Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3f070797 | ||
|
|
62a339e5b9 | ||
|
|
f1fd40176f | ||
|
|
e5d8629ba3 | ||
|
|
3375f94848 | ||
|
|
9f7206abfe | ||
|
|
334c7426c2 | ||
|
|
bd7f4a433c | ||
|
|
27287238aa | ||
|
|
486767fca9 | ||
|
|
e22fe7679a | ||
|
|
7c2e526c49 | ||
|
|
2f5776d1e0 | ||
|
|
7901fc7e82 | ||
|
|
612800bef2 | ||
|
|
41b58a6f70 | ||
|
|
1a8e26a847 | ||
|
|
0bcddddc37 | ||
|
|
99163865e2 | ||
|
|
40113a2b53 | ||
|
|
75c8b19f31 | ||
|
|
2a28b0db85 | ||
|
|
ca4b851ba4 | ||
|
|
0742dac057 | ||
|
|
9d2e35c322 | ||
|
|
526bd994d8 | ||
|
|
37c1f22aee | ||
|
|
10a97b1f4a | ||
|
|
11eaaf0f88 | ||
|
|
512b9d1175 | ||
|
|
a1307bdd0b | ||
|
|
06132a0704 | ||
|
|
a7e4eb865b | ||
|
|
5246ab8369 | ||
|
|
baa887da2e | ||
|
|
be40f087ad | ||
|
|
ad62bd07aa | ||
|
|
365b9c0660 | ||
|
|
a1f5e0480c | ||
|
|
00a7950e6a | ||
|
|
598ed9c964 | ||
|
|
2b3edab4eb | ||
|
|
d0bdfce803 | ||
|
|
17a4568492 | ||
|
|
840a432b7f | ||
|
|
cc17e29fbe | ||
|
|
dd5f0a729b | ||
|
|
9d72caee44 | ||
|
|
661165bd65 | ||
|
|
e8cd89d7e4 | ||
|
|
4caf8efa72 | ||
|
|
ed707df837 | ||
|
|
0821aea72b | ||
|
|
4dfac6bb34 | ||
|
|
8ad41eebe2 |
133
.github/workflows/build.yml
vendored
Normal file
133
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload_artifacts:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows
|
||||
os: windows-latest
|
||||
build_script: pnpm run build:win
|
||||
ytdlp_asset: yt-dlp.exe
|
||||
ytdlp_output: yt-dlp.exe
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
|
||||
ffmpeg_output: ffmpeg.exe
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
build_script: pnpm run build:mac
|
||||
ytdlp_asset: yt-dlp_macos
|
||||
ytdlp_output: yt-dlp_macos
|
||||
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
|
||||
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
|
||||
ffmpeg_inner_path: ffmpeg/ffmpeg
|
||||
ffmpeg_output: ffmpeg_macos
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
build_script: pnpm run build:linux
|
||||
ytdlp_asset: yt-dlp
|
||||
ytdlp_output: yt-dlp_linux
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
|
||||
ffmpeg_output: ffmpeg_linux
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download ffmpeg binary (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ffmpegUrl = '${{ matrix.ffmpeg_url }}'
|
||||
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
|
||||
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
|
||||
- name: Download ffmpeg binary (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||
|
||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$x86_bin" ]]; then
|
||||
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
|
||||
chmod +x "resources/$FFMPEG_OUTPUT"
|
||||
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
|
||||
|
||||
- name: Download ffmpeg binary (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||
mkdir ffmpeg
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||
fi
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check && pnpm run typecheck
|
||||
|
||||
- name: Build application
|
||||
run: ${{ matrix.build_script }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: inputs.upload_artifacts == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -5,31 +5,5 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build check
|
||||
run: pnpm run build
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
|
||||
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -6,65 +6,29 @@ on:
|
||||
- v*.*.*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: 20
|
||||
pattern: dist-*
|
||||
merge-multiple: true
|
||||
path: dist/
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download yt-dlp binaries
|
||||
run: |
|
||||
# Download yt-dlp.exe for Windows
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
||||
|
||||
# Download yt-dlp for macOS (for cross-platform builds)
|
||||
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
|
||||
# chmod +x resources/yt-dlp_macos
|
||||
|
||||
# Download yt-dlp for Linux (for cross-platform builds)
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
||||
chmod +x resources/yt-dlp_linux
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
|
||||
# - name: build-linux
|
||||
# if: matrix.os == 'ubuntu-latest'
|
||||
# run: pnpm run build:linux
|
||||
|
||||
# - name: build-mac
|
||||
# if: matrix.os == 'macos-latest'
|
||||
# run: pnpm run build:mac
|
||||
|
||||
- name: build-win
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: pnpm run build:win
|
||||
|
||||
- name: release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
@@ -78,4 +42,3 @@ jobs:
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
|
||||
29
.github/workflows/translator.yaml
vendored
Normal file
29
.github/workflows/translator.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: 'translator'
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
discussion:
|
||||
types: [created, edited]
|
||||
discussion_comment:
|
||||
types: [created, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
IS_MODIFY_TITLE: true
|
||||
78
CONTRIBUTING.md
Normal file
78
CONTRIBUTING.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Contributing to VidBee
|
||||
|
||||
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
|
||||
|
||||
## Getting Ready
|
||||
- Use Node.js 18+ and pnpm 8+.
|
||||
- Install dependencies with `pnpm install`.
|
||||
- Run `pnpm dev` to test changes locally.
|
||||
|
||||
## Tech Stack
|
||||
- Runtime: Electron 38, electron-vite, electron-builder.
|
||||
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
|
||||
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
|
||||
|
||||
## Local Development
|
||||
- Use `pnpm install` to pull dependencies after cloning.
|
||||
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
|
||||
- Preview the production build locally with `pnpm start`.
|
||||
|
||||
## Useful Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `pnpm run typecheck` | Type-check the main and renderer projects. |
|
||||
| `pnpm build` | Run type checks and produce production bundles. |
|
||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
|
||||
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
|
||||
| `pnpm run check` | Format and lint the codebase with Biome. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
|-- main/ # Electron main process, IPC services, configuration
|
||||
|-- preload/ # Context bridge and preload helpers
|
||||
`-- renderer/
|
||||
|-- src/
|
||||
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
|
||||
| |-- components/ # UI components, download views, shared controls
|
||||
| |-- data/ # Static datasets such as popularSites.ts
|
||||
| |-- hooks/ # Custom hooks and global atoms
|
||||
| |-- lib/ # Utilities shared across the renderer
|
||||
| `-- assets/ # Global styles and icons
|
||||
`-- index.html
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
|
||||
- Only update strings in `src/renderer/src/locales/en.json`; maintainers handle the other locales.
|
||||
- Keep copy edits focused and avoid removing translation keys without discussion.
|
||||
|
||||
## Configuration and Storage
|
||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
|
||||
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
|
||||
- Logs are recorded with `electron-log` to simplify troubleshooting.
|
||||
|
||||
## Packaging
|
||||
- Build production bundles with `pnpm build`.
|
||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
||||
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
|
||||
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
|
||||
|
||||
## Working on Changes
|
||||
- Keep each pull request focused on a single problem or feature.
|
||||
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
|
||||
- Write comments and console messages in English only.
|
||||
- When updating copy in the app, adjust strings in `src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
|
||||
|
||||
## Opening Issues
|
||||
- Search existing issues to avoid duplicates.
|
||||
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
|
||||
|
||||
## Submitting Pull Requests
|
||||
- Explain the motivation and impact of the change in the description.
|
||||
- Mention any user facing updates or migrations.
|
||||
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
|
||||
|
||||
We appreciate every contribution that keeps VidBee simple and reliable.
|
||||
176
README.md
176
README.md
@@ -1,97 +1,111 @@
|
||||
# VidBee
|
||||
|
||||
<div align="center">
|
||||
<img src="build/icon.png" alt="VidBee icon" width="120" />
|
||||
<h3>A minimal Electron downloader for video and audio</h3>
|
||||
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
|
||||
<a href="https://github.com/nexmoe/VidBee">
|
||||
<img src="build/icon.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3>VidBee</h3>
|
||||
<p>
|
||||
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&style=flat-square&label=Latest%20Release" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
|
||||
<br />
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Features
|
||||
- Download single videos, audio-only tracks, or entire playlists through a unified flow.
|
||||
- Queue multiple jobs with progress tracking, pause, resume, retry, and download history.
|
||||
- Detect platform-friendly formats automatically and store files in custom locations.
|
||||
- Quick actions for popular sites plus manual URL entry for anything yt-dlp supports.
|
||||
- Theme toggle (system, light, dark) and localized interface in English and Simplified Chinese.
|
||||
- Desktop-native touches such as tray integration, update checks, and persistent settings.
|
||||
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs.
|
||||
|
||||
## Tech Stack
|
||||
- **Runtime:** Electron 38, electron-vite, electron-builder.
|
||||
- **Frontend:** React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
|
||||
- **Tooling:** TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
## Getting Started
|
||||
### Prerequisites
|
||||
- Node.js 18 or newer
|
||||
- pnpm 8 or newer
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
Feel free to try it using the following methods:
|
||||
|
||||
### Run the app in development
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
| Operating System | Source |
|
||||
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
|
||||
|
||||
The Electron app and Vite dev server launch together with hot module replacement.
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
## Useful Scripts
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Run the Electron and Vite development environment. |
|
||||
| `pnpm start` | Preview the production build locally. |
|
||||
| `pnpm run typecheck` | Type-check the main and renderer projects. |
|
||||
| `pnpm build` | Run type checks and produce production bundles. |
|
||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
|
||||
| `pnpm build:unpack` | Produce unpacked output directories. |
|
||||
| `pnpm run check` | Format and lint the codebase with Biome. |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
├─ main/ # Electron main process, IPC services, configuration
|
||||
├─ preload/ # Context bridge and preload helpers
|
||||
└─ renderer/
|
||||
├─ src/
|
||||
│ ├─ pages/ # Application routes (Home, Settings, Playlist, etc.)
|
||||
│ ├─ components/ # UI components, download views, shared controls
|
||||
│ ├─ data/ # Static datasets such as popularSites.ts
|
||||
│ ├─ hooks/ # Custom hooks and global atoms
|
||||
│ ├─ lib/ # Utilities shared across the renderer
|
||||
│ └─ assets/ # Global styles and icons
|
||||
└─ index.html
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
|
||||
|
||||
## Configuration and Storage
|
||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
|
||||
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
|
||||
- Logs are recorded with `electron-log` to simplify troubleshooting.
|
||||
|
||||
## Packaging
|
||||
Run one of the following commands after a successful build:
|
||||
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
|
||||
|
||||
```bash
|
||||
pnpm build:win
|
||||
pnpm build:mac
|
||||
pnpm build:linux
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
|
||||
Artifacts are generated under `dist/`. Use `pnpm build:unpack` to create unpacked directories for manual inspection.
|
||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||
|
||||
## Contributing
|
||||
Issues and pull requests are welcome. Keep changes focused, document user facing updates, and run `pnpm run check` before opening a PR.
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
## License
|
||||
This project is distributed under the MIT License. See `LICENSE` for details.
|
||||
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
|
||||
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Thanks
|
||||
- [Electron](https://www.electronjs.org/)
|
||||
- [React](https://react.dev/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🌍 Global Video Download Support
|
||||
|
||||
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
|
||||
|
||||

|
||||
|
||||
### 🎨 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.
|
||||
|
||||

|
||||
|
||||
## 🌐 Supported Sites
|
||||
|
||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||
|
||||
| Video Platforms | Audio & Other Platforms |
|
||||
| :--------------- | :---------------------- |
|
||||
| YouTube | YouTube Music |
|
||||
| TikTok | SoundCloud |
|
||||
| Facebook | Mixcloud |
|
||||
| Instagram | Bandcamp |
|
||||
| X (Twitter) | Reddit |
|
||||
| Vimeo | |
|
||||
| Dailymotion | |
|
||||
| Twitch | |
|
||||
| LinkedIn | |
|
||||
| Pinterest | |
|
||||
| Tumblr | |
|
||||
| Niconico | |
|
||||
| Kick | |
|
||||
|
||||
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
|
||||
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
|
||||
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
|
||||
- [React](https://react.dev/) - The UI library
|
||||
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.icns
Binary file not shown.
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'
|
||||
})
|
||||
@@ -21,14 +21,21 @@ nsis:
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: yourname@example.com
|
||||
maintainer: nexmoex@gmail.com
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
@@ -11,14 +11,16 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "pnpm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"setup": "node scripts/setup-dev-binaries.js",
|
||||
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||
"build:win": "pnpm run build && electron-builder --win",
|
||||
"build:mac": "pnpm run build && electron-builder --mac",
|
||||
"build:linux": "pnpm run build && electron-builder --linux",
|
||||
"release": "pnpm run check && bumpp"
|
||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -26,8 +28,10 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -40,13 +44,16 @@
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/plugin-jsx": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"electron-ipc-decorator": "^0.2.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.5.3",
|
||||
"jotai": "^2.15.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
@@ -54,9 +61,11 @@
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router": "^7.9.4",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yt-dlp-wrap-plus": "^2.3.20",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
@@ -69,6 +78,7 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"bumpp": "^10.3.1",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"electron": "^38.1.2",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
652
pnpm-lock.yaml
generated
652
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
resources/.gitignore
vendored
5
resources/.gitignore
vendored
@@ -2,8 +2,11 @@
|
||||
yt-dlp.exe
|
||||
yt-dlp_macos
|
||||
yt-dlp_linux
|
||||
ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
|
||||
# But keep the README
|
||||
!README.md
|
||||
!.gitignore
|
||||
|
||||
|
||||
@@ -48,8 +48,24 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
|
||||
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
|
||||
```
|
||||
|
||||
## ffmpeg Binaries
|
||||
|
||||
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
|
||||
|
||||
### Required Files
|
||||
|
||||
1. **Windows**: `ffmpeg.exe`
|
||||
2. **macOS**: `ffmpeg_macos`
|
||||
3. **Linux**: `ffmpeg_linux`
|
||||
|
||||
### How to Download
|
||||
|
||||
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
|
||||
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
|
||||
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
|
||||
|
||||
### Note
|
||||
|
||||
- If you don't place binaries here, the app will attempt to download them at runtime
|
||||
- The app will automatically use the bundled version if available
|
||||
- File sizes: ~10-15 MB per binary
|
||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
|
||||
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
|
||||
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
|
||||
|
||||
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
resources/tray-icon.png
Normal file
BIN
resources/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 924 B |
BIN
screenshots/download-queue.png
Normal file
BIN
screenshots/download-queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshots/main-interface.png
Normal file
BIN
screenshots/main-interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
72
scripts/check-ytdlp.js
Normal file
72
scripts/check-ytdlp.js
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
// Get platform from command line arguments
|
||||
const platform = process.argv[2]
|
||||
|
||||
if (!platform) {
|
||||
console.error('❌ Error: Platform argument is required!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supportedPlatforms = ['win', 'mac', 'linux']
|
||||
|
||||
if (!supportedPlatforms.includes(platform)) {
|
||||
console.error('❌ Error: Invalid platform specified!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const binaries = [
|
||||
{
|
||||
label: 'yt-dlp',
|
||||
filenameMap: {
|
||||
win: 'yt-dlp.exe',
|
||||
mac: 'yt-dlp_macos',
|
||||
linux: 'yt-dlp_linux'
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'ffmpeg',
|
||||
filenameMap: {
|
||||
win: 'ffmpeg.exe',
|
||||
mac: 'ffmpeg_macos',
|
||||
linux: 'ffmpeg_linux'
|
||||
},
|
||||
help: {
|
||||
win: 'https://ffmpeg.org/download.html',
|
||||
linux: 'https://ffmpeg.org/download.html',
|
||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
let hasMissingBinary = false
|
||||
|
||||
for (const binary of binaries) {
|
||||
const filename = binary.filenameMap[platform]
|
||||
const binaryPath = path.join(__dirname, '..', 'resources', filename)
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
console.error(`❌ Error: resources/${filename} not found!`)
|
||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
||||
const help =
|
||||
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
|
||||
if (help) {
|
||||
console.error(`See ${help}`)
|
||||
}
|
||||
hasMissingBinary = true
|
||||
} else {
|
||||
console.log(`✅ ${filename} found in resources/ directory`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMissingBinary) {
|
||||
process.exit(1)
|
||||
}
|
||||
28
scripts/set-console-encoding.js
Normal file
28
scripts/set-console-encoding.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 设置控制台编码为 UTF-8,解决中文乱码问题
|
||||
* 这个脚本在 Windows 上设置控制台代码页为 UTF-8
|
||||
*/
|
||||
|
||||
const { exec } = require('node:child_process')
|
||||
const os = require('node:os')
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
console.log('Setting console encoding to UTF-8...')
|
||||
|
||||
// 设置控制台代码页为 UTF-8 (65001)
|
||||
exec('chcp 65001', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.warn('Failed to set console code page:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.warn('Console code page setting warning:', stderr)
|
||||
}
|
||||
|
||||
console.log('Console encoding set to UTF-8')
|
||||
console.log('Output:', stdout)
|
||||
})
|
||||
} else {
|
||||
console.log('Not on Windows, no console encoding change needed')
|
||||
}
|
||||
343
scripts/setup-dev-binaries.js
Executable file
343
scripts/setup-dev-binaries.js
Executable file
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Development environment setup script
|
||||
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { execSync } = require('node:child_process')
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
|
||||
// Configuration
|
||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||
|
||||
// Platform configuration
|
||||
const PLATFORM_CONFIG = {
|
||||
win32: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp.exe',
|
||||
output: 'yt-dlp.exe'
|
||||
},
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
||||
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||
output: 'ffmpeg.exe',
|
||||
extract: 'unzip'
|
||||
}
|
||||
},
|
||||
darwin: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp_macos',
|
||||
output: 'yt-dlp_macos'
|
||||
},
|
||||
ffmpeg: {
|
||||
// For development, download only the architecture matching current system
|
||||
arm64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip'
|
||||
},
|
||||
x64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip'
|
||||
}
|
||||
}
|
||||
},
|
||||
linux: {
|
||||
ytdlp: {
|
||||
asset: 'yt-dlp',
|
||||
output: 'yt-dlp_linux'
|
||||
},
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
||||
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||
output: 'ffmpeg_linux',
|
||||
extract: 'tar'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function log(message, type = 'info') {
|
||||
const icons = {
|
||||
info: '📦',
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warn: '⚠️',
|
||||
download: '⬇️'
|
||||
}
|
||||
console.log(`${icons[type] || 'ℹ️'} ${message}`)
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
|
||||
protocol
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
file.close()
|
||||
fs.unlinkSync(dest)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function extractZip(zipPath, extractDir) {
|
||||
const platform = os.platform()
|
||||
ensureDir(extractDir)
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Use PowerShell Expand-Archive on Windows
|
||||
try {
|
||||
const zipAbsPath = path.resolve(zipPath)
|
||||
const extractAbsDir = path.resolve(extractDir)
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to extract zip: ${error.message}`)
|
||||
}
|
||||
} else {
|
||||
// Use unzip command on macOS/Linux
|
||||
try {
|
||||
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to extract zip: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractTarXz(tarPath, extractDir) {
|
||||
ensureDir(extractDir)
|
||||
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
function setExecutable(filePath) {
|
||||
if (os.platform() !== 'win32') {
|
||||
fs.chmodSync(filePath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
// Main download functions
|
||||
async function downloadYtDlp(config) {
|
||||
const { asset, output } = config.ytdlp
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ${asset}...`, 'download')
|
||||
const url = `${YTDLP_BASE_URL}/${asset}`
|
||||
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempPath)
|
||||
fs.renameSync(tempPath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegWindows(config) {
|
||||
const { url, innerPath, output } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegMac(config) {
|
||||
const arch = os.arch()
|
||||
const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64']
|
||||
|
||||
if (!ffmpegConfig) {
|
||||
throw new Error(`Unsupported architecture: ${arch}`)
|
||||
}
|
||||
|
||||
const { url, innerPath, output } = ffmpegConfig
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempZip)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpegLinux(config) {
|
||||
const { url, innerPath, output } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
|
||||
try {
|
||||
await downloadFile(url, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
const sourcePath = path.join(extractDir, innerPath)
|
||||
if (!fileExists(sourcePath)) {
|
||||
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempTar)
|
||||
fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar)
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Main setup function
|
||||
async function setup() {
|
||||
const platform = os.platform()
|
||||
const config = PLATFORM_CONFIG[platform]
|
||||
|
||||
if (!config) {
|
||||
log(`Unsupported platform: ${platform}`, 'error')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log(`Setting up development binaries for ${platform}...`, 'info')
|
||||
ensureDir(RESOURCES_DIR)
|
||||
|
||||
try {
|
||||
// Download yt-dlp
|
||||
await downloadYtDlp(config)
|
||||
|
||||
// Download ffmpeg
|
||||
if (platform === 'win32') {
|
||||
await downloadFfmpegWindows(config)
|
||||
} else if (platform === 'darwin') {
|
||||
await downloadFfmpegMac(config)
|
||||
} else if (platform === 'linux') {
|
||||
await downloadFfmpegLinux(config)
|
||||
}
|
||||
|
||||
log('Development environment setup completed!', 'success')
|
||||
} catch (error) {
|
||||
log(`Setup failed: ${error.message}`, 'error')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run setup
|
||||
if (require.main === module) {
|
||||
setup()
|
||||
}
|
||||
|
||||
module.exports = { setup }
|
||||
44
src/main/config/logger-config.ts
Normal file
44
src/main/config/logger-config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import log from 'electron-log/main'
|
||||
|
||||
/**
|
||||
* Configure electron-log
|
||||
* Set log format, file path, transport methods, etc.
|
||||
*/
|
||||
export function configureLogger() {
|
||||
// Configure console output format - support colors and scope, time in gray
|
||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
||||
|
||||
// Enable console colors
|
||||
log.transports.console.useStyles = true
|
||||
|
||||
// Configure file output format - include scope information
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
||||
|
||||
// Set log levels
|
||||
// Development: show all logs
|
||||
// Production: show info level and above only
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
log.transports.console.level = isDev ? 'silly' : 'info'
|
||||
log.transports.file.level = isDev ? 'silly' : 'info'
|
||||
|
||||
// Enable IPC transport in development environment to show renderer process logs in main process console
|
||||
if (isDev) {
|
||||
log.transports.ipc.level = 'silly'
|
||||
} else {
|
||||
log.transports.ipc.level = false
|
||||
}
|
||||
|
||||
// Set maximum log file size (10MB)
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024
|
||||
|
||||
// Enable error catching - catch unhandled errors and rejected promises
|
||||
log.errorHandler.startCatching({
|
||||
showDialog: false, // Don't show error dialog, only log to file
|
||||
onError: (options) => {
|
||||
log.error('Unhandled error caught by electron-log:', options.error)
|
||||
log.error('App versions:', options.versions)
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Log file location:', log.transports.file.getFile().path)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
125
src/main/download-engine/args-builder.ts
Normal file
125
src/main/download-engine/args-builder.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
||||
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
||||
}
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
|
||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
return format
|
||||
}
|
||||
|
||||
if (!format || format === 'best') {
|
||||
if (audioFormat === 'none') {
|
||||
return 'bestvideo+none'
|
||||
}
|
||||
if (!audioFormat || audioFormat === 'best') {
|
||||
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
|
||||
return 'bestvideo+bestaudio'
|
||||
}
|
||||
return `bestvideo+${audioFormat}`
|
||||
}
|
||||
|
||||
if (audioFormat === 'none') {
|
||||
return `${format}+none`
|
||||
}
|
||||
|
||||
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
|
||||
return `${format}+${audio}`
|
||||
}
|
||||
|
||||
export const resolveAudioFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
|
||||
if (!format) {
|
||||
return 'bestaudio'
|
||||
}
|
||||
|
||||
if (format.includes('/') || format.includes('+') || format.includes('[')) {
|
||||
return format
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
export const buildDownloadArgs = (
|
||||
options: DownloadOptions,
|
||||
downloadPath: string,
|
||||
settings: AppSettings
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Format selection
|
||||
if (options.type === 'video') {
|
||||
const formatSelector = resolveVideoFormatSelector(options)
|
||||
args.push('-f', formatSelector)
|
||||
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
|
||||
// based on codec compatibility. Forcing MP4 can cause failures
|
||||
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
|
||||
} else if (options.type === 'audio') {
|
||||
args.push('-f', resolveAudioFormatSelector(options))
|
||||
} else if (options.type === 'extract') {
|
||||
args.push('-x')
|
||||
args.push('--audio-format', options.extractFormat || 'mp3')
|
||||
args.push('--audio-quality', options.extractQuality || '5')
|
||||
}
|
||||
|
||||
// Time range
|
||||
if (options.startTime || options.endTime) {
|
||||
const start = options.startTime || '0'
|
||||
const end = options.endTime || ''
|
||||
args.push('--download-sections', `*${start}-${end || ''}`)
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs', '--sub-langs', 'all')
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||
const filenameTemplate = sanitizeFilenameTemplate(
|
||||
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
|
||||
)
|
||||
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
|
||||
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
args.push('--no-part')
|
||||
args.push('--no-playlist-reverse')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
args.push('--windows-filenames')
|
||||
}
|
||||
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', settings.configPath)
|
||||
}
|
||||
|
||||
args.push(options.url)
|
||||
|
||||
return args
|
||||
}
|
||||
208
src/main/download-engine/format-utils.ts
Normal file
208
src/main/download-engine/format-utils.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
DownloadOptions,
|
||||
OneClickQualityPreset,
|
||||
VideoFormat
|
||||
} from '../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
bad: 128,
|
||||
worst: 96
|
||||
}
|
||||
|
||||
const selectVideoFormatForPreset = (
|
||||
formats: VideoFormat[],
|
||||
preset: OneClickQualityPreset
|
||||
): VideoFormat | undefined => {
|
||||
if (formats.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...formats].sort((a, b) => {
|
||||
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
|
||||
if (heightDiff !== 0) return heightDiff
|
||||
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
|
||||
if (fpsDiff !== 0) return fpsDiff
|
||||
return (b.tbr ?? 0) - (a.tbr ?? 0)
|
||||
})
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
const heightLimit = qualityPresetToVideoHeight[preset]
|
||||
if (!heightLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const withinLimit = sorted.find((format) => {
|
||||
const height = format.height ?? 0
|
||||
return height > 0 && height <= heightLimit
|
||||
})
|
||||
|
||||
return withinLimit ?? sorted[0]
|
||||
}
|
||||
|
||||
const selectAudioFormatForPreset = (
|
||||
formats: VideoFormat[],
|
||||
preset: OneClickQualityPreset
|
||||
): VideoFormat | undefined => {
|
||||
if (formats.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...formats].sort((a, b) => {
|
||||
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
|
||||
if (bitrateDiff !== 0) return bitrateDiff
|
||||
const sizeA = a.filesize ?? a.filesize_approx ?? 0
|
||||
const sizeB = b.filesize ?? b.filesize_approx ?? 0
|
||||
if (sizeB !== sizeA) return sizeB - sizeA
|
||||
return 0
|
||||
})
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||
if (!abrLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const withinLimit = sorted.find((format) => {
|
||||
const bitrate = format.tbr ?? 0
|
||||
return bitrate > 0 && bitrate <= abrLimit
|
||||
})
|
||||
|
||||
return withinLimit ?? sorted[0]
|
||||
}
|
||||
|
||||
const findFormatBySelector = (
|
||||
formats: VideoFormat[],
|
||||
selector?: string
|
||||
): VideoFormat | undefined => {
|
||||
if (!selector) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const candidateIds = selector
|
||||
.split('/')
|
||||
.map((option) => option.split('+')[0].trim())
|
||||
.filter((option) => option.length > 0)
|
||||
|
||||
for (const candidateId of candidateIds) {
|
||||
const match = formats.find((format) => format.format_id === candidateId)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const findFormatByIdCandidates = (
|
||||
formats: VideoFormat[],
|
||||
rawFormatId: string | undefined
|
||||
): VideoFormat | undefined => {
|
||||
if (!rawFormatId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parts = rawFormatId
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
for (const part of parts) {
|
||||
const match = formats.find((format) => format.format_id === part)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const parseSizeToBytes = (value?: string): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleaned = value.trim().replace(/^~\s*/, '')
|
||||
if (!cleaned) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const amount = Number(match[1].replace(/,/g, ''))
|
||||
if (Number.isNaN(amount)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const unit = match[2].toUpperCase()
|
||||
const multipliers: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1_000,
|
||||
KIB: 1_024,
|
||||
MB: 1_000_000,
|
||||
MIB: 1_048_576,
|
||||
GB: 1_000_000_000,
|
||||
GIB: 1_073_741_824,
|
||||
TB: 1_000_000_000_000,
|
||||
TIB: 1_099_511_627_776
|
||||
}
|
||||
|
||||
const multiplier = multipliers[unit]
|
||||
if (!multiplier) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.round(amount * multiplier)
|
||||
}
|
||||
|
||||
export const resolveSelectedFormat = (
|
||||
formats: VideoFormat[],
|
||||
options: DownloadOptions,
|
||||
settings: AppSettings
|
||||
): VideoFormat | undefined => {
|
||||
const directMatch = findFormatBySelector(formats, options.format)
|
||||
if (directMatch) {
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const preset = settings.oneClickQuality ?? 'best'
|
||||
|
||||
if (options.type === 'video') {
|
||||
const videoFormats = formats.filter(
|
||||
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
|
||||
)
|
||||
return selectVideoFormatForPreset(videoFormats, preset)
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
const audioFormats = formats.filter(
|
||||
(format) =>
|
||||
!!format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(!format.video_ext || format.video_ext === 'none')
|
||||
)
|
||||
return selectAudioFormatForPreset(audioFormats, preset)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -1,30 +1,44 @@
|
||||
import { join } from 'node:path'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, shell } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||
import { subscriptionManager } from './lib/subscription-manager'
|
||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyDockVisibility } from './utils/dock'
|
||||
|
||||
// Initialize electron-log for main process
|
||||
log.initialize()
|
||||
|
||||
// Configure logger settings
|
||||
configureLogger()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
// Create the browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
titleBarStyle: 'hidden', // Hide title bar on macOS
|
||||
autoHideMenuBar: true,
|
||||
icon: appIcon, // Set application icon
|
||||
frame: false,
|
||||
vibrancy: 'fullscreen-ui', // on MacOS
|
||||
backgroundMaterial: 'acrylic', // on Windows 11
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -32,7 +46,20 @@ export function createWindow(): void {
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // Allow drag regions to work
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (isMac) {
|
||||
windowOptions.titleBarStyle = 'hidden'
|
||||
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
|
||||
windowOptions.vibrancy = 'fullscreen-ui'
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
windowOptions.backgroundMaterial = 'acrylic'
|
||||
}
|
||||
|
||||
// Create the browser window
|
||||
mainWindow = new BrowserWindow(windowOptions)
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
const closeToTray = settingsManager.get('closeToTray')
|
||||
@@ -59,6 +86,10 @@ export function createWindow(): void {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -85,6 +116,71 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
try {
|
||||
log.info('Initializing auto-updater...')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
autoUpdater.logger = log
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
|
||||
// If auto-update is enabled, the update will be downloaded automatically
|
||||
// because autoDownload is set to true
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, update will be downloaded automatically')
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log.info('Update not available:', info.version)
|
||||
mainWindow?.webContents.send('update:not-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Update error:', err)
|
||||
mainWindow?.webContents.send('update:error', err.message)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
log.info('Download progress:', progressObj.percent)
|
||||
mainWindow?.webContents.send('update:download-progress', progressObj)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('update:show-notification', {
|
||||
title: 'Update Ready',
|
||||
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
|
||||
icon: 'app-icon'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
|
||||
// Check for updates immediately if auto-update is enabled
|
||||
const autoUpdateEnabled = settingsManager.get('autoUpdate')
|
||||
if (autoUpdateEnabled) {
|
||||
log.info('Auto-update is enabled, checking for updates immediately...')
|
||||
// Use checkForUpdates instead of checkForUpdatesAndNotify
|
||||
// because we have our own notification system and want to ensure immediate download
|
||||
void autoUpdater.checkForUpdates()
|
||||
} else {
|
||||
log.info('Auto-update is disabled, skipping automatic update check')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize auto-updater:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -99,98 +195,37 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
||||
console.log('IPC services available:', Object.keys(services))
|
||||
log.info('IPC services available:', Object.keys(services))
|
||||
|
||||
// Initialize ffmpeg
|
||||
try {
|
||||
log.info('Initializing ffmpeg...')
|
||||
await ffmpegManager.initialize()
|
||||
log.info('ffmpeg initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize ffmpeg:', error)
|
||||
}
|
||||
|
||||
// Initialize yt-dlp
|
||||
try {
|
||||
console.log('Initializing yt-dlp...')
|
||||
log.info('Initializing yt-dlp...')
|
||||
await ytdlpManager.initialize()
|
||||
console.log('yt-dlp initialized successfully')
|
||||
log.info('yt-dlp initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize yt-dlp:', error)
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
// Initialize auto-updater (only in production)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
console.log('Initializing auto-updater...')
|
||||
|
||||
// Configure logging
|
||||
log.transports.file.level = 'info'
|
||||
autoUpdater.logger = log
|
||||
|
||||
// Configure auto-updater for production
|
||||
autoUpdater.autoDownload = true // Auto download updates
|
||||
autoUpdater.autoInstallOnAppQuit = true // Auto install on quit
|
||||
|
||||
// Set up event listeners
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
console.log('Update available:', info.version)
|
||||
// Send notification to renderer
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log.info('Update not available:', info.version)
|
||||
console.log('Update not available:', info.version)
|
||||
// Send notification to renderer
|
||||
mainWindow?.webContents.send('update:not-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Update error:', err)
|
||||
console.error('Update error:', err)
|
||||
// Send error to renderer
|
||||
mainWindow?.webContents.send('update:error', err.message)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
log.info('Download progress:', progressObj.percent)
|
||||
console.log('Download progress:', progressObj.percent)
|
||||
// Send progress to renderer
|
||||
mainWindow?.webContents.send('update:download-progress', progressObj)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
console.log('Update downloaded:', info.version)
|
||||
// Send notification to renderer
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
// Show system notification
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('update:show-notification', {
|
||||
title: 'Update Ready',
|
||||
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
|
||||
icon: 'app-icon'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check for updates on startup if auto-update is enabled
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, checking for updates...')
|
||||
console.log('Auto-update is enabled, checking for updates...')
|
||||
// Use checkForUpdatesAndNotify for automatic notifications
|
||||
await autoUpdater.checkForUpdatesAndNotify()
|
||||
}
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
console.log('Auto-updater initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize auto-updater:', error)
|
||||
console.error('Failed to initialize auto-updater:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping auto-updater initialization in development mode')
|
||||
}
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
|
||||
createWindow()
|
||||
|
||||
initAutoUpdater()
|
||||
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,26 @@ class AppService extends IpcService {
|
||||
|
||||
return dialog.showMessageBox(options)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getSiteIcon(_context: IpcContext, domain: string): Promise<string | null> {
|
||||
try {
|
||||
const iconUrl = `https://unavatar.io/${domain}`
|
||||
const response = await fetch(iconUrl)
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const contentType = response.headers.get('content-type') || 'image/png'
|
||||
const base64 = buffer.toString('base64')
|
||||
return `data:${contentType};base64,${base64}`
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch site icon:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AppService }
|
||||
|
||||
@@ -3,10 +3,11 @@ import type {
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
} from '../../../shared/types'
|
||||
import { downloadEngine } from '../../download-engine'
|
||||
import { downloadEngine } from '../../lib/download-engine'
|
||||
|
||||
class DownloadService extends IpcService {
|
||||
static readonly groupName = 'download'
|
||||
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
|
||||
async startPlaylistDownload(
|
||||
_context: IpcContext,
|
||||
options: PlaylistDownloadOptions
|
||||
): Promise<string[]> {
|
||||
): Promise<PlaylistDownloadResult> {
|
||||
return downloadEngine.startPlaylistDownload(options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { execFile, execSync } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { dialog, shell } from 'electron'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { clipboard, dialog, shell } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
class FileSystemService extends IpcService {
|
||||
static readonly groupName = 'fs'
|
||||
|
||||
@@ -36,7 +40,20 @@ class FileSystemService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
getDefaultDownloadPath(_context: IpcContext): string {
|
||||
return `${os.homedir()}/Downloads`
|
||||
const fallbackPath = path.join(os.homedir(), 'Downloads')
|
||||
|
||||
if (process.platform === 'linux' || process.platform === 'freebsd') {
|
||||
try {
|
||||
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
|
||||
if (xdgPath) {
|
||||
return xdgPath
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackPath
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -56,12 +73,6 @@ class FileSystemService extends IpcService {
|
||||
}
|
||||
|
||||
if (stats?.isDirectory()) {
|
||||
const candidate = await this.findLikelyFile(normalizedPath, normalizedPath)
|
||||
if (candidate) {
|
||||
shell.showItemInFolder(candidate)
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await shell.openPath(normalizedPath)
|
||||
if (result) {
|
||||
console.error('Failed to open directory:', result)
|
||||
@@ -70,94 +81,66 @@ class FileSystemService extends IpcService {
|
||||
return true
|
||||
}
|
||||
|
||||
const fallbackDirectory = path.dirname(normalizedPath)
|
||||
const fallbackCandidate = await this.findLikelyFile(fallbackDirectory, normalizedPath)
|
||||
if (fallbackCandidate) {
|
||||
shell.showItemInFolder(fallbackCandidate)
|
||||
// If the exact path doesn't exist, try to open the parent directory
|
||||
const parentDirectory = path.dirname(normalizedPath)
|
||||
const parentStats = await fs.stat(parentDirectory).catch(() => null)
|
||||
|
||||
if (parentStats?.isDirectory()) {
|
||||
const result = await shell.openPath(parentDirectory)
|
||||
if (result) {
|
||||
console.error('Failed to open parent directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await shell.openPath(fallbackDirectory)
|
||||
if (!result) {
|
||||
return true
|
||||
}
|
||||
console.error('Failed to open directory:', result)
|
||||
console.error('File or directory does not exist:', normalizedPath)
|
||||
return false
|
||||
} catch (error) {
|
||||
try {
|
||||
const directory = path.dirname(path.normalize(this.sanitizePath(filePath)))
|
||||
const dirStats = await fs.stat(directory)
|
||||
if (dirStats.isDirectory()) {
|
||||
const fallbackCandidate = await this.findLikelyFile(directory, directory)
|
||||
if (fallbackCandidate) {
|
||||
shell.showItemInFolder(fallbackCandidate)
|
||||
return true
|
||||
}
|
||||
const result = await shell.openPath(directory)
|
||||
if (result) {
|
||||
console.error('Failed to open directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.error('Failed to open parent directory:', dirError)
|
||||
}
|
||||
console.error('Failed to open file location:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@IpcMethod()
|
||||
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
const stats = await fs.stat(normalizedPath)
|
||||
if (!stats.isFile()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(normalizedPath)
|
||||
|
||||
await this.copyFileToClipboardByPlatform(resolvedPath)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePath(target: string): string {
|
||||
return target.trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
|
||||
try {
|
||||
const dirStats = await fs.stat(directory)
|
||||
if (!dirStats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const files = entries.filter((entry: Dirent) => entry.isFile())
|
||||
if (files.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expectedBase = path.basename(expectedPath).toLowerCase()
|
||||
const expectedName = path.parse(expectedPath).name.toLowerCase()
|
||||
|
||||
const exactMatch = files.find((entry) => entry.name.toLowerCase() === expectedBase)
|
||||
if (exactMatch) {
|
||||
return path.join(directory, exactMatch.name)
|
||||
}
|
||||
|
||||
if (expectedName) {
|
||||
const partialMatch = files.find((entry) => entry.name.toLowerCase().includes(expectedName))
|
||||
if (partialMatch) {
|
||||
return path.join(directory, partialMatch.name)
|
||||
}
|
||||
}
|
||||
|
||||
let latestMatch: { filePath: string; mtimeMs: number } | null = null
|
||||
for (const entry of files) {
|
||||
const candidatePath = path.join(directory, entry.name)
|
||||
try {
|
||||
const candidateStats = await fs.stat(candidatePath)
|
||||
if (!latestMatch || candidateStats.mtimeMs > latestMatch.mtimeMs) {
|
||||
latestMatch = { filePath: candidatePath, mtimeMs: candidateStats.mtimeMs }
|
||||
}
|
||||
} catch (statError) {
|
||||
console.error('Failed to stat candidate file:', statError)
|
||||
}
|
||||
}
|
||||
|
||||
return latestMatch?.filePath ?? null
|
||||
} catch (error) {
|
||||
console.error('Failed to search for matching file:', error)
|
||||
return null
|
||||
private async copyFileToClipboardByPlatform(resolvedPath: string): Promise<void> {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
await this.copyFileToClipboardWindows(resolvedPath)
|
||||
return
|
||||
case 'darwin':
|
||||
await this.copyFileToClipboardMac(resolvedPath)
|
||||
return
|
||||
default:
|
||||
await this.copyFileToClipboardLinux(resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +154,151 @@ class FileSystemService extends IpcService {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async copyFileToClipboardWindows(resolvedPath: string): Promise<void> {
|
||||
const escaped = resolvedPath.replace(/'/g, "''")
|
||||
try {
|
||||
await execFileAsync('powershell.exe', [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Set-Clipboard -Path '${escaped}'`
|
||||
])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
|
||||
}
|
||||
|
||||
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||
const fileList = `${winPath}\u0000\u0000`
|
||||
const encodedList = Buffer.from(fileList, 'ucs2')
|
||||
|
||||
const dropFilesStructSize = 20
|
||||
const buffer = Buffer.alloc(dropFilesStructSize + encodedList.length)
|
||||
buffer.writeUInt32LE(dropFilesStructSize, 0)
|
||||
buffer.writeInt32LE(0, 4)
|
||||
buffer.writeInt32LE(0, 8)
|
||||
buffer.writeUInt32LE(0, 12)
|
||||
buffer.writeUInt32LE(1, 16)
|
||||
encodedList.copy(buffer, dropFilesStructSize)
|
||||
|
||||
clipboard.writeBuffer('CF_HDROP', buffer)
|
||||
clipboard.writeBuffer('Preferred DropEffect', Buffer.from([1, 0, 0, 0]))
|
||||
clipboard.writeBuffer('FileNameW', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ucs2'))
|
||||
clipboard.writeBuffer('FileName', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ascii'))
|
||||
}
|
||||
|
||||
private async copyFileToClipboardMac(resolvedPath: string): Promise<void> {
|
||||
const escaped = resolvedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
try {
|
||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
|
||||
}
|
||||
|
||||
const entries = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<array>',
|
||||
` <string>${this.escapeForPlist(resolvedPath)}</string>`,
|
||||
'</array>',
|
||||
'</plist>'
|
||||
]
|
||||
const plist = Buffer.from(entries.join('\n'), 'utf8')
|
||||
clipboard.writeBuffer('NSFilenamesPboardType', plist)
|
||||
|
||||
const fileUrl = pathToFileURL(resolvedPath).toString()
|
||||
clipboard.writeBuffer('public.file-url', Buffer.from(`${fileUrl}\n`, 'utf8'))
|
||||
}
|
||||
|
||||
private async copyFileToClipboardLinux(resolvedPath: string): Promise<void> {
|
||||
const fileUrl = pathToFileURL(resolvedPath).toString()
|
||||
const content = `copy\n${fileUrl}`
|
||||
clipboard.writeBuffer('x-special/gnome-copied-files', Buffer.from(content, 'utf8'))
|
||||
clipboard.writeBuffer('text/uri-list', Buffer.from(`${fileUrl}\n`, 'utf8'))
|
||||
}
|
||||
|
||||
private escapeForPlist(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
|
||||
const stats = await fs.stat(normalizedPath).catch(() => null)
|
||||
|
||||
return stats?.isFile() ?? false
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
|
||||
const stats = await fs.stat(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stats) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await fs.unlink(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(normalizedPath)
|
||||
if (entries.length === 0) {
|
||||
await fs.rmdir(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FileSystemService }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { DownloadHistoryItem } from '../../../shared/types'
|
||||
import { historyManager } from '../../lib/history-manager'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
class HistoryService extends IpcService {
|
||||
static readonly groupName = 'history'
|
||||
@@ -24,282 +21,10 @@ class HistoryService extends IpcService {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async removeHistoryItem(_context: IpcContext, id: string, outputPath?: string): Promise<boolean> {
|
||||
const record = historyManager.getHistoryById(id)
|
||||
|
||||
await this.deleteOutputResource(record, outputPath)
|
||||
|
||||
removeHistoryItem(_context: IpcContext, id: string): boolean {
|
||||
return historyManager.removeHistoryItem(id)
|
||||
}
|
||||
|
||||
private async deleteOutputResource(
|
||||
record: DownloadHistoryItem | undefined,
|
||||
fallbackOutputPath?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const candidatePaths = new Set<string>()
|
||||
if (record?.outputPath) {
|
||||
candidatePaths.add(record.outputPath)
|
||||
}
|
||||
if (fallbackOutputPath) {
|
||||
candidatePaths.add(fallbackOutputPath)
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (await this.tryDeletePath(candidate)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const directories = this.collectCandidateDirectories(candidatePaths)
|
||||
if (directories.length === 0) {
|
||||
const defaultDownloadPath = settingsManager.get('downloadPath')
|
||||
if (defaultDownloadPath) {
|
||||
directories.push(defaultDownloadPath)
|
||||
}
|
||||
}
|
||||
|
||||
const matchKeys = this.collectMatchKeys(record, candidatePaths)
|
||||
if (matchKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const extensions = this.collectExtensions(candidatePaths, record)
|
||||
for (const directory of directories) {
|
||||
const matchedPath = await this.findMatchingFile(directory, matchKeys, extensions, record)
|
||||
if (matchedPath && (await this.tryDeletePath(matchedPath))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete output resource:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePath(target: string): string {
|
||||
return target.trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
private normalizeMatchString(value?: string): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
}
|
||||
|
||||
private collectCandidateDirectories(candidatePaths: Set<string>): string[] {
|
||||
const directories = new Set<string>()
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
const normalized = path.normalize(sanitized)
|
||||
if (!path.isAbsolute(normalized)) continue
|
||||
const directory = path.dirname(normalized)
|
||||
if (directory) {
|
||||
directories.add(directory)
|
||||
}
|
||||
}
|
||||
return Array.from(directories)
|
||||
}
|
||||
|
||||
private collectExtensions(
|
||||
candidatePaths: Set<string>,
|
||||
record: DownloadHistoryItem | undefined
|
||||
): Set<string> {
|
||||
const extensions = new Set<string>()
|
||||
|
||||
const addExtension = (ext?: string) => {
|
||||
if (!ext) {
|
||||
return
|
||||
}
|
||||
const trimmed = ext.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
const normalized = trimmed.startsWith('.')
|
||||
? trimmed.toLowerCase()
|
||||
: `.${trimmed.toLowerCase()}`
|
||||
extensions.add(normalized)
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
addExtension(path.extname(sanitized))
|
||||
}
|
||||
|
||||
addExtension(record?.outputPath ? path.extname(record.outputPath) : undefined)
|
||||
addExtension(record?.selectedFormat?.ext)
|
||||
addExtension(record?.selectedFormat?.video_ext)
|
||||
addExtension(record?.selectedFormat?.audio_ext)
|
||||
|
||||
if (extensions.size === 0) {
|
||||
const fallback =
|
||||
record?.type === 'audio'
|
||||
? ['.mp3', '.m4a', '.aac', '.ogg', '.opus', '.flac', '.wav']
|
||||
: ['.mp4', '.mkv', '.webm', '.mov', '.avi']
|
||||
for (const ext of fallback) {
|
||||
extensions.add(ext)
|
||||
}
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
private collectMatchKeys(
|
||||
record: DownloadHistoryItem | undefined,
|
||||
candidatePaths: Set<string>
|
||||
): string[] {
|
||||
const keys = new Set<string>()
|
||||
const fallbackKeys: string[] = []
|
||||
|
||||
const tryAddKey = (value?: string) => {
|
||||
if (!value) return
|
||||
const normalized = this.normalizeMatchString(value)
|
||||
if (!normalized) return
|
||||
if (normalized.length >= 3) {
|
||||
keys.add(normalized)
|
||||
} else {
|
||||
fallbackKeys.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
const baseName = path.parse(sanitized).name
|
||||
tryAddKey(baseName)
|
||||
}
|
||||
|
||||
tryAddKey(record?.title)
|
||||
tryAddKey(record?.id)
|
||||
|
||||
if (keys.size === 0) {
|
||||
for (const key of fallbackKeys) {
|
||||
keys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
private async findMatchingFile(
|
||||
directory: string,
|
||||
matchKeys: string[],
|
||||
extensions: Set<string>,
|
||||
record: DownloadHistoryItem | undefined
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const dirStats = await fs.stat(directory).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!dirStats || !dirStats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const matches: Array<{ path: string; diff: number }> = []
|
||||
const targetTimestamp = record?.completedAt ?? record?.downloadedAt
|
||||
const maxDiff = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
const entryExt = path.extname(entry.name).toLowerCase()
|
||||
if (extensions.size > 0 && entryExt && !extensions.has(entryExt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedName = this.normalizeMatchString(entry.name)
|
||||
if (!normalizedName && matchKeys.length > 0) continue
|
||||
|
||||
const hasMatchKeys = matchKeys.length > 0
|
||||
const isMatch = hasMatchKeys
|
||||
? matchKeys.some((key) => key && normalizedName.includes(key))
|
||||
: true
|
||||
if (!isMatch) continue
|
||||
|
||||
const stats = await fs.stat(entryPath).catch(() => null)
|
||||
if (!stats) continue
|
||||
|
||||
if (targetTimestamp) {
|
||||
const diff = Math.abs(stats.mtimeMs - targetTimestamp)
|
||||
if (diff > maxDiff) {
|
||||
continue
|
||||
}
|
||||
matches.push({ path: entryPath, diff })
|
||||
} else {
|
||||
if (!hasMatchKeys) {
|
||||
continue
|
||||
}
|
||||
matches.push({ path: entryPath, diff: Number.POSITIVE_INFINITY })
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.diff - b.diff)
|
||||
return matches[0]?.path ?? null
|
||||
} catch (error) {
|
||||
console.error('Failed to search for matching file:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async tryDeletePath(rawPath: string): Promise<boolean> {
|
||||
const sanitizedPath = this.sanitizePath(rawPath)
|
||||
if (!sanitizedPath) return false
|
||||
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
const stats = await fs.stat(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stats) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await fs.unlink(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(normalizedPath)
|
||||
if (entries.length === 0) {
|
||||
await fs.rmdir(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getHistoryCount(_context: IpcContext): {
|
||||
active: number
|
||||
|
||||
@@ -1,42 +1,82 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
|
||||
class SettingsService extends IpcService {
|
||||
static readonly groupName = 'settings'
|
||||
|
||||
@IpcMethod()
|
||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||
return settingsManager.get(key)
|
||||
const value = settingsManager.get(key)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||
settingsManager.set(key, value)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
||||
} else {
|
||||
settingsManager.set(key, value)
|
||||
}
|
||||
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAll(_context: IpcContext): AppSettings {
|
||||
return settingsManager.getAll()
|
||||
const settings = settingsManager.getAll()
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
settingsManager.setAll(settings)
|
||||
|
||||
if (settings.language) {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
src/main/ipc/services/subscription-service.ts
Normal file
164
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
interface CreateSubscriptionOptions {
|
||||
url: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const ensureUrlHasProtocol = (value: string): string => {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return `https://${value}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
|
||||
const normalized = ensureUrlHasProtocol(rawUrl.trim())
|
||||
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
|
||||
if (youTubeChannelMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
|
||||
if (youTubeUserMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
|
||||
if (youTubeHandleMatch) {
|
||||
const handle = youTubeHandleMatch[1].replace('@', '')
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
|
||||
if (biliSpaceMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
if (/rsshub\.app\/bilibili/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionService extends IpcService {
|
||||
static readonly groupName = 'subscriptions'
|
||||
|
||||
@IpcMethod()
|
||||
list(_context: IpcContext): SubscriptionRule[] {
|
||||
return subscriptionManager.getAll()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
|
||||
return resolveFeedFromInput(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async create(
|
||||
_context: IpcContext,
|
||||
options: CreateSubscriptionOptions
|
||||
): Promise<SubscriptionRule> {
|
||||
const resolved = resolveFeedFromInput(options.url)
|
||||
const settings = settingsManager.getAll()
|
||||
const payload: SubscriptionCreatePayload = {
|
||||
sourceUrl: resolved.sourceUrl,
|
||||
feedUrl: resolved.feedUrl,
|
||||
platform: resolved.platform,
|
||||
keywords: options.keywords,
|
||||
tags: options.tags,
|
||||
onlyDownloadLatest:
|
||||
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||
downloadDirectory: options.downloadDirectory || settings.downloadPath,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || settings.subscriptionFilenameTemplate
|
||||
),
|
||||
enabled: options.enabled ?? true
|
||||
}
|
||||
|
||||
const created = subscriptionManager.add(payload)
|
||||
void subscriptionScheduler.runNow(created.id)
|
||||
return created
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
update(
|
||||
_context: IpcContext,
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload
|
||||
): SubscriptionRule | undefined {
|
||||
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||
if (typeof normalized.namingTemplate === 'string') {
|
||||
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||
}
|
||||
return subscriptionManager.update(id, normalized)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
remove(_context: IpcContext, id: string): boolean {
|
||||
return subscriptionManager.remove(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async refresh(_context: IpcContext, id?: string): Promise<void> {
|
||||
await subscriptionScheduler.runNow(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
|
||||
return subscriptionScheduler.queueItem(id, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
export { SubscriptionService }
|
||||
@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
const isNewerVersion = (latest: string, current: string): boolean => {
|
||||
const toSegments = (version: string) =>
|
||||
version.split(/[.-]/).map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const latestSegments = toSegments(latest)
|
||||
const currentSegments = toSegments(current)
|
||||
const maxLength = Math.max(latestSegments.length, currentSegments.length)
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const latestValue = latestSegments[index] ?? 0
|
||||
const currentValue = currentSegments[index] ?? 0
|
||||
|
||||
if (latestValue > currentValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (latestValue < currentValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class UpdateService extends IpcService {
|
||||
static readonly groupName = 'update'
|
||||
|
||||
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
|
||||
_context: IpcContext
|
||||
): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
// In production, use checkForUpdatesAndNotify for automatic notifications
|
||||
const result = await autoUpdater.checkForUpdatesAndNotify()
|
||||
const currentVersion = app.getVersion()
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const latestVersion = result?.updateInfo?.version
|
||||
|
||||
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
|
||||
return {
|
||||
available: true,
|
||||
version: latestVersion
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: result !== null,
|
||||
version: result?.updateInfo?.version
|
||||
available: false,
|
||||
version: latestVersion ?? currentVersion
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
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
|
||||
997
src/main/lib/download-engine.ts
Normal file
997
src/main/lib/download-engine.ts
Normal file
@@ -0,0 +1,997 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import path from 'node:path'
|
||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||
import type {
|
||||
DownloadHistoryItem,
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
DownloadProgress,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} from '../../shared/types'
|
||||
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
|
||||
import {
|
||||
findFormatByIdCandidates,
|
||||
parseSizeToBytes,
|
||||
resolveSelectedFormat
|
||||
} from '../download-engine/format-utils'
|
||||
import { settingsManager } from '../settings'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
import { DownloadQueue } from './download-queue'
|
||||
import { ffmpegManager } from './ffmpeg-manager'
|
||||
import { historyManager } from './history-manager'
|
||||
import { ytdlpManager } from './ytdlp-manager'
|
||||
|
||||
interface DownloadProcess {
|
||||
controller: AbortController
|
||||
process: YTDlpEventEmitter
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
const maxConcurrent = settingsManager.get('maxConcurrentDownloads')
|
||||
this.queue = new DownloadQueue(maxConcurrent)
|
||||
|
||||
this.queue.on('start-download', async (item) => {
|
||||
await this.executeDownload(item.id, item.options)
|
||||
})
|
||||
}
|
||||
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const args = ['-j', '--no-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
}
|
||||
|
||||
args.push(url)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = ytdlp.exec(args)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const info = JSON.parse(stdout)
|
||||
|
||||
// Calculate estimated file size for formats missing filesize information
|
||||
// Using tbr (total bitrate in kbps) and duration (in seconds)
|
||||
// Formula: (tbr * 1000) / 8 * duration = size in bytes
|
||||
if (info.formats && Array.isArray(info.formats) && info.duration) {
|
||||
const duration = info.duration
|
||||
for (const format of info.formats) {
|
||||
if (
|
||||
!format.filesize &&
|
||||
!format.filesize_approx &&
|
||||
format.tbr &&
|
||||
typeof format.tbr === 'number' &&
|
||||
duration > 0
|
||||
) {
|
||||
// Calculate estimated size: tbr (kbps) * 1000 / 8 bits per byte * duration (seconds)
|
||||
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
|
||||
format.filesize_approx = estimatedSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopedLoggers.download.info('Successfully retrieved video info for:', url)
|
||||
resolve(info)
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to parse video info for:', url, error)
|
||||
reject(new Error(`Failed to parse video info: ${error}`))
|
||||
}
|
||||
} else {
|
||||
scopedLoggers.download.error(
|
||||
'Failed to fetch video info for:',
|
||||
url,
|
||||
'Exit code:',
|
||||
code,
|
||||
'Error:',
|
||||
stderr
|
||||
)
|
||||
reject(new Error(stderr || 'Failed to fetch video info'))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
scopedLoggers.download.error('yt-dlp process error for:', url, error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getPlaylistInfo(url: string): Promise<PlaylistInfo> {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const args = ['-J', '--flat-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
}
|
||||
|
||||
args.push(url)
|
||||
|
||||
type RawPlaylistEntry = {
|
||||
id?: string
|
||||
title?: string
|
||||
url?: string
|
||||
webpage_url?: string
|
||||
original_url?: string
|
||||
ie_key?: string
|
||||
}
|
||||
|
||||
const resolveEntryUrl = (entry: RawPlaylistEntry): string => {
|
||||
if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) {
|
||||
return entry.url
|
||||
}
|
||||
if (entry.webpage_url && typeof entry.webpage_url === 'string') {
|
||||
return entry.webpage_url
|
||||
}
|
||||
if (entry.original_url && typeof entry.original_url === 'string') {
|
||||
return entry.original_url
|
||||
}
|
||||
if (entry.url && typeof entry.url === 'string') {
|
||||
if (entry.ie_key && typeof entry.ie_key === 'string') {
|
||||
const extractor = entry.ie_key.toLowerCase()
|
||||
if (extractor.includes('youtube')) {
|
||||
return `https://www.youtube.com/watch?v=${entry.url}`
|
||||
}
|
||||
if (extractor.includes('youtubemusic')) {
|
||||
return `https://music.youtube.com/watch?v=${entry.url}`
|
||||
}
|
||||
}
|
||||
if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) {
|
||||
return entry.url
|
||||
}
|
||||
}
|
||||
if (entry.id && typeof entry.id === 'string') {
|
||||
return entry.id
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = ytdlp.exec(args)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
id?: string
|
||||
title?: string
|
||||
entries?: RawPlaylistEntry[]
|
||||
}
|
||||
const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : []
|
||||
const entries = rawEntries
|
||||
.map((entry, index) => {
|
||||
const resolvedUrl = resolveEntryUrl(entry)
|
||||
return {
|
||||
id: entry.id || `${index}`,
|
||||
title: entry.title || `Entry ${index + 1}`,
|
||||
url: resolvedUrl,
|
||||
index: index + 1
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.url)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Successfully retrieved playlist info for:',
|
||||
url,
|
||||
'entries:',
|
||||
entries.length
|
||||
)
|
||||
resolve({
|
||||
id: parsed.id || url,
|
||||
title: parsed.title || 'Playlist',
|
||||
entries,
|
||||
entryCount: entries.length
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to parse playlist info for:', url, error)
|
||||
reject(new Error(`Failed to parse playlist info: ${error}`))
|
||||
}
|
||||
} else {
|
||||
scopedLoggers.download.error(
|
||||
'Failed to fetch playlist info for:',
|
||||
url,
|
||||
'Exit code:',
|
||||
code,
|
||||
'Error:',
|
||||
stderr
|
||||
)
|
||||
reject(new Error(stderr || 'Failed to fetch playlist info'))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
|
||||
const playlistInfo = await this.getPlaylistInfo(options.url)
|
||||
const downloadEntries: PlaylistDownloadResult['entries'] = []
|
||||
const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
// Calculate the range of entries to download
|
||||
const totalEntries = playlistInfo.entries.length
|
||||
if (totalEntries === 0) {
|
||||
scopedLoggers.download.warn('Playlist has no entries:', options.url)
|
||||
return {
|
||||
groupId,
|
||||
playlistId: playlistInfo.id,
|
||||
playlistTitle: playlistInfo.title,
|
||||
type: options.type,
|
||||
totalCount: 0,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
entries: []
|
||||
}
|
||||
}
|
||||
|
||||
const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0)
|
||||
const requestedEnd = options.endIndex
|
||||
? Math.min(options.endIndex - 1, totalEntries - 1)
|
||||
: totalEntries - 1
|
||||
const rangeStart = Math.min(requestedStart, requestedEnd)
|
||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const selectedEntries = rawEntries.filter((entry) => {
|
||||
if (!entry.url) {
|
||||
scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const selectionSize = selectedEntries.length
|
||||
|
||||
scopedLoggers.download.info(
|
||||
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
|
||||
)
|
||||
|
||||
// Create download items for each video in the playlist
|
||||
for (const entry of selectedEntries) {
|
||||
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
||||
|
||||
const downloadOptions: DownloadOptions = {
|
||||
url: entry.url,
|
||||
type: options.type,
|
||||
format: options.format,
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
downloadEntries.push({
|
||||
downloadId,
|
||||
entryId: entry.id,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
index: entry.index
|
||||
})
|
||||
|
||||
// Add to queue
|
||||
this.queue.add(downloadId, downloadOptions, {
|
||||
id: downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
type: options.type,
|
||||
status: 'pending',
|
||||
progress: { percent: 0 },
|
||||
createdAt,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
|
||||
this.upsertHistoryEntry(downloadId, downloadOptions, {
|
||||
title: entry.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
groupId,
|
||||
playlistId: playlistInfo.id,
|
||||
playlistTitle: playlistInfo.title,
|
||||
type: options.type,
|
||||
totalCount: selectionSize,
|
||||
startIndex: selectedEntries[0]?.index ?? rangeStart + 1,
|
||||
endIndex: selectedEntries[selectedEntries.length - 1]?.index ?? rangeEnd + 1,
|
||||
entries: downloadEntries
|
||||
}
|
||||
}
|
||||
|
||||
startDownload(id: string, options: DownloadOptions): void {
|
||||
if (this.activeDownloads.has(id)) {
|
||||
console.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 item: DownloadItem = {
|
||||
id,
|
||||
url: options.url,
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: targetDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
}
|
||||
|
||||
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
|
||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
process.env.PYTHONIOENCODING = 'utf-8'
|
||||
process.env.LC_ALL = 'C.UTF-8'
|
||||
}
|
||||
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
const info = await this.getVideoInfo(options.url)
|
||||
videoInfo = info
|
||||
|
||||
availableFormats = Array.isArray(info.formats) ? info.formats : []
|
||||
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
|
||||
|
||||
if (selectedFormat) {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
description: info.description,
|
||||
uploader: info.uploader,
|
||||
viewCount: info.view_count,
|
||||
// Store only essential download info
|
||||
selectedFormat
|
||||
})
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
description: info.description,
|
||||
uploader: info.uploader,
|
||||
viewCount: info.view_count,
|
||||
// Store only essential download info
|
||||
selectedFormat
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
|
||||
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
||||
if (!formatId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = findFormatByIdCandidates(availableFormats, formatId)
|
||||
if (!candidate) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedFormat?.format_id === candidate.format_id) {
|
||||
return true
|
||||
}
|
||||
|
||||
selectedFormat = candidate
|
||||
actualFormat = candidate.ext || actualFormat
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
||||
|
||||
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 =
|
||||
options.type === 'video' ? resolveVideoFormatSelector(options) : undefined
|
||||
const willMerge = formatSelector?.includes('+') ?? false
|
||||
|
||||
const urlArg = args.pop()
|
||||
if (!urlArg) {
|
||||
const missingUrlError = new Error('Download arguments missing URL.')
|
||||
scopedLoggers.download.error('Missing URL argument for download ID:', id)
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'error',
|
||||
completedAt: Date.now(),
|
||||
error: missingUrlError.message
|
||||
})
|
||||
this.queue.downloadCompleted(id)
|
||||
this.emit('download-error', id, missingUrlError)
|
||||
this.addToHistory(id, options, 'error', missingUrlError.message)
|
||||
return
|
||||
}
|
||||
|
||||
let ffmpegPath: string
|
||||
try {
|
||||
ffmpegPath = ffmpegManager.getPath()
|
||||
} catch (error) {
|
||||
const ffmpegError = error instanceof Error ? error : new Error(String(error))
|
||||
scopedLoggers.download.error('Failed to resolve ffmpeg for download ID:', id, ffmpegError)
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'error',
|
||||
completedAt: Date.now(),
|
||||
error: ffmpegError.message
|
||||
})
|
||||
this.queue.downloadCompleted(id)
|
||||
this.emit('download-error', id, ffmpegError)
|
||||
this.addToHistory(id, options, 'error', ffmpegError.message)
|
||||
return
|
||||
}
|
||||
|
||||
args.push('--ffmpeg-location', ffmpegPath)
|
||||
args.push(urlArg)
|
||||
|
||||
const controller = new AbortController()
|
||||
const ytdlpProcess = ytdlp.exec(args, {
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
||||
|
||||
this.emit('download-started', id)
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
status: 'downloading'
|
||||
})
|
||||
|
||||
let latestKnownSizeBytes: number | undefined
|
||||
|
||||
// Handle progress
|
||||
ytdlpProcess.on(
|
||||
'progress',
|
||||
(progress: {
|
||||
percent?: number
|
||||
currentSpeed?: string
|
||||
eta?: string
|
||||
downloaded?: string
|
||||
total?: string
|
||||
}) => {
|
||||
const totalBytes = parseSizeToBytes(progress.total)
|
||||
if (totalBytes !== undefined) {
|
||||
latestKnownSizeBytes = totalBytes
|
||||
}
|
||||
|
||||
const downloadedBytes = parseSizeToBytes(progress.downloaded)
|
||||
if (downloadedBytes !== undefined) {
|
||||
latestKnownSizeBytes =
|
||||
latestKnownSizeBytes !== undefined
|
||||
? Math.max(latestKnownSizeBytes, downloadedBytes)
|
||||
: downloadedBytes
|
||||
}
|
||||
|
||||
const downloadProgress: DownloadProgress = {
|
||||
percent: progress.percent || 0,
|
||||
currentSpeed: progress.currentSpeed || '',
|
||||
eta: progress.eta || '',
|
||||
downloaded: progress.downloaded || '',
|
||||
total: progress.total || ''
|
||||
}
|
||||
this.emit('download-progress', id, downloadProgress)
|
||||
}
|
||||
)
|
||||
|
||||
// Handle yt-dlp events to capture format info
|
||||
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
||||
// Look for format selection messages
|
||||
if (eventType === 'info' && eventData.includes('format')) {
|
||||
// Extract format info from yt-dlp output
|
||||
const formatMatch = eventData.match(/\[info\]\s*([^\s:]+):\s*(.+)/)
|
||||
if (formatMatch) {
|
||||
const formatId = formatMatch[1]
|
||||
const formatInfo = formatMatch[2]
|
||||
|
||||
applySelectedFormat(formatId)
|
||||
|
||||
// Extract format details with better regex patterns
|
||||
const extMatch = formatInfo.match(/(\w+)(?:\s|$)/)
|
||||
if (extMatch && !actualFormat) {
|
||||
actualFormat = extMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for download progress messages that might contain format info
|
||||
if (eventType === 'download' && eventData.includes('format')) {
|
||||
const formatMatch = eventData.match(/format\s*([0-9A-Za-z+-]+)/)
|
||||
if (formatMatch) {
|
||||
applySelectedFormat(formatMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
ytdlpProcess.on('close', async (code: number | null) => {
|
||||
this.activeDownloads.delete(id)
|
||||
this.queue.downloadCompleted(id)
|
||||
|
||||
if (code === 0) {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const title = videoInfo?.title || 'Unknown'
|
||||
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
|
||||
|
||||
// Determine file extension based on download type and format
|
||||
// yt-dlp automatically chooses the best merge format (mkv/webm/mp4)
|
||||
// based on codec compatibility, so we should use actualFormat when available
|
||||
let extension: string
|
||||
if (options.type === 'audio') {
|
||||
extension = options.extractFormat || 'mp3'
|
||||
} else if (willMerge) {
|
||||
// For merged files, yt-dlp auto-selects format (mkv/webm/mp4)
|
||||
// Use actualFormat if available, otherwise default to mkv (most compatible)
|
||||
extension = actualFormat || 'mkv'
|
||||
} else {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
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()
|
||||
return (
|
||||
(baseName === sanitizedTitle || baseName.startsWith(sanitizedTitle)) &&
|
||||
fileExt === extension.toLowerCase()
|
||||
)
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
if (!located) {
|
||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
if (fileSize === undefined && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
|
||||
const savedFileName = path.basename(actualFilePath)
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
this.emit('download-completed', id)
|
||||
this.addToHistory(id, options, 'completed', undefined)
|
||||
} else {
|
||||
scopedLoggers.download.error(
|
||||
'Download failed with exit code for ID:',
|
||||
id,
|
||||
'Exit code:',
|
||||
code
|
||||
)
|
||||
this.emit('download-error', id, new Error(`Download exited with code ${code}`))
|
||||
this.addToHistory(id, options, 'error', `Download exited with code ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle errors
|
||||
ytdlpProcess.on('error', (error: Error) => {
|
||||
scopedLoggers.download.error('Download process error for ID:', id, error)
|
||||
this.activeDownloads.delete(id)
|
||||
this.queue.downloadCompleted(id)
|
||||
this.emit('download-error', id, error)
|
||||
this.addToHistory(id, options, 'error', error.message)
|
||||
})
|
||||
}
|
||||
|
||||
cancelDownload(id: string): boolean {
|
||||
scopedLoggers.download.info('Cancelling download for ID:', id)
|
||||
const snapshot = this.queue.getItemDetails(id)
|
||||
|
||||
const download = this.activeDownloads.get(id)
|
||||
if (download) {
|
||||
download.controller.abort()
|
||||
const removedFromQueue = this.queue.remove(id)
|
||||
this.activeDownloads.delete(id)
|
||||
scopedLoggers.download.info('Download cancelled successfully for ID:', id)
|
||||
this.emit('download-cancelled', id)
|
||||
if (snapshot) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, {
|
||||
status: 'cancelled',
|
||||
completedAt: Date.now()
|
||||
})
|
||||
}
|
||||
return removedFromQueue
|
||||
}
|
||||
const removed = this.queue.remove(id)
|
||||
if (removed && snapshot) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, {
|
||||
status: 'cancelled',
|
||||
completedAt: Date.now()
|
||||
})
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
updateMaxConcurrent(max: number): void {
|
||||
this.queue.setMaxConcurrent(max)
|
||||
}
|
||||
|
||||
getQueueStatus() {
|
||||
return this.queue.getQueueStatus()
|
||||
}
|
||||
|
||||
updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {
|
||||
this.queue.updateItemInfo(id, updates)
|
||||
|
||||
const snapshot = this.queue.getItemDetails(id)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const historyUpdates: Partial<DownloadHistoryItem> = {}
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
historyUpdates.title = updates.title
|
||||
}
|
||||
if (updates.thumbnail !== undefined) {
|
||||
historyUpdates.thumbnail = updates.thumbnail
|
||||
}
|
||||
if (updates.duration !== undefined) {
|
||||
historyUpdates.duration = updates.duration
|
||||
}
|
||||
if (updates.fileSize !== undefined) {
|
||||
historyUpdates.fileSize = updates.fileSize
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
if (updates.channel !== undefined) {
|
||||
historyUpdates.channel = updates.channel
|
||||
}
|
||||
if (updates.uploader !== undefined) {
|
||||
historyUpdates.uploader = updates.uploader
|
||||
}
|
||||
if (updates.viewCount !== undefined) {
|
||||
historyUpdates.viewCount = updates.viewCount
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
historyUpdates.tags = updates.tags
|
||||
}
|
||||
if (updates.playlistId !== undefined) {
|
||||
historyUpdates.playlistId = updates.playlistId
|
||||
}
|
||||
if (updates.playlistTitle !== undefined) {
|
||||
historyUpdates.playlistTitle = updates.playlistTitle
|
||||
}
|
||||
if (updates.playlistIndex !== undefined) {
|
||||
historyUpdates.playlistIndex = updates.playlistIndex
|
||||
}
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
if (updates.completedAt !== undefined) {
|
||||
historyUpdates.completedAt = updates.completedAt
|
||||
}
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
private addToHistory(
|
||||
id: string,
|
||||
options: DownloadOptions,
|
||||
status: DownloadHistoryItem['status'],
|
||||
error?: string
|
||||
): void {
|
||||
// Get the download item from the queue to get additional info
|
||||
const completedDownload = this.queue.getCompletedDownload(id)
|
||||
scopedLoggers.download.info('Completed download:', completedDownload)
|
||||
const completedAt = Date.now()
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
title: completedDownload?.item.title || `Download ${id}`,
|
||||
thumbnail: completedDownload?.item.thumbnail,
|
||||
status,
|
||||
completedAt,
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
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,
|
||||
playlistSize: completedDownload?.item.playlistSize
|
||||
})
|
||||
}
|
||||
|
||||
private upsertHistoryEntry(
|
||||
id: string,
|
||||
options: DownloadOptions,
|
||||
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,
|
||||
title: updates.title || `Download ${id}`,
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
viewCount: updates.viewCount,
|
||||
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,
|
||||
playlistTitle: updates.playlistTitle,
|
||||
playlistIndex: updates.playlistIndex,
|
||||
playlistSize: updates.playlistSize
|
||||
}
|
||||
|
||||
const merged: DownloadHistoryItem = {
|
||||
...base,
|
||||
...updates,
|
||||
id,
|
||||
url: updates.url ?? base.url,
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
status: updates.status ?? base.status,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadEngine = new DownloadEngine()
|
||||
101
src/main/lib/ffmpeg-manager.ts
Normal file
101
src/main/lib/ffmpeg-manager.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
class FfmpegManager {
|
||||
private ffmpegPath: string | null = null
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ffmpegPath = await this.findFfmpegBinary()
|
||||
console.log('ffmpeg initialized at:', this.ffmpegPath)
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
if (!this.ffmpegPath) {
|
||||
throw new Error('ffmpeg not initialized. Call initialize() first.')
|
||||
}
|
||||
return this.ffmpegPath
|
||||
}
|
||||
|
||||
private getResourcesPath(): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return path.join(process.cwd(), 'resources')
|
||||
}
|
||||
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
|
||||
}
|
||||
|
||||
private async findFfmpegBinary(): Promise<string> {
|
||||
const platform = os.platform()
|
||||
const resourceCandidates: string[] = []
|
||||
|
||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
||||
console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
||||
return process.env.FFMPEG_PATH
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
resourceCandidates.push('ffmpeg.exe')
|
||||
} else if (platform === 'darwin') {
|
||||
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
|
||||
} else {
|
||||
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
|
||||
}
|
||||
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
for (const candidate of resourceCandidates) {
|
||||
const fullPath = path.join(resourcesPath, candidate)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
console.warn('Failed to set executable permission on ffmpeg binary:', error)
|
||||
}
|
||||
}
|
||||
console.log('Using bundled ffmpeg:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
||||
for (const candidate of commonPaths) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
console.log('Using system ffmpeg:', candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux' || platform === 'freebsd') {
|
||||
try {
|
||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
console.log('Using system ffmpeg:', systemPath)
|
||||
return systemPath
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
try {
|
||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
console.log('Using system ffmpeg:', output)
|
||||
return output
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const ffmpegManager = new FfmpegManager()
|
||||
@@ -1,52 +1,476 @@
|
||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||
const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
import type { DownloadHistoryItem } from '../../shared/types'
|
||||
import { runMigrations } from './database/migrate'
|
||||
import {
|
||||
type DownloadHistoryInsert,
|
||||
type DownloadHistoryRow,
|
||||
downloadHistoryTable
|
||||
} from './database/schema'
|
||||
import { getDatabaseFilePath } from './database-path'
|
||||
|
||||
const logger = log.scope('history-manager')
|
||||
|
||||
const TAG_SEPARATOR = '\n'
|
||||
|
||||
const createDownloadHistoryTableSql = sql`
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
thumbnail TEXT,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
download_path TEXT,
|
||||
saved_file_name TEXT,
|
||||
file_size INTEGER,
|
||||
duration INTEGER,
|
||||
downloaded_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
sort_key INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
description TEXT,
|
||||
channel TEXT,
|
||||
uploader TEXT,
|
||||
view_count INTEGER,
|
||||
tags TEXT,
|
||||
origin TEXT,
|
||||
subscription_id TEXT,
|
||||
selected_format TEXT,
|
||||
playlist_id TEXT,
|
||||
playlist_title TEXT,
|
||||
playlist_index INTEGER,
|
||||
playlist_size INTEGER
|
||||
)
|
||||
`
|
||||
|
||||
const renameDownloadHistoryTableSql = sql`
|
||||
ALTER TABLE download_history RENAME TO download_history_legacy
|
||||
`
|
||||
|
||||
const dropLegacyDownloadHistoryTableSql = sql`
|
||||
DROP TABLE download_history_legacy
|
||||
`
|
||||
|
||||
const copyDownloadHistoryFromLegacySql = sql`
|
||||
INSERT INTO download_history (
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
type,
|
||||
status,
|
||||
download_path,
|
||||
saved_file_name,
|
||||
file_size,
|
||||
duration,
|
||||
downloaded_at,
|
||||
completed_at,
|
||||
sort_key,
|
||||
error,
|
||||
description,
|
||||
channel,
|
||||
uploader,
|
||||
view_count,
|
||||
tags,
|
||||
origin,
|
||||
subscription_id,
|
||||
selected_format,
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
playlist_index,
|
||||
playlist_size
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
type,
|
||||
status,
|
||||
download_path,
|
||||
saved_file_name,
|
||||
file_size,
|
||||
duration,
|
||||
downloaded_at,
|
||||
completed_at,
|
||||
sort_key,
|
||||
error,
|
||||
description,
|
||||
channel,
|
||||
uploader,
|
||||
view_count,
|
||||
tags,
|
||||
origin,
|
||||
subscription_id,
|
||||
selected_format,
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
playlist_index,
|
||||
playlist_size
|
||||
FROM download_history_legacy
|
||||
`
|
||||
|
||||
const sanitizeList = (values?: string[]): string[] => {
|
||||
if (!values || values.length === 0) {
|
||||
return []
|
||||
}
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||
}
|
||||
|
||||
const serializeTags = (values?: string[]): string | null => {
|
||||
const sanitized = sanitizeList(values)
|
||||
return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null
|
||||
}
|
||||
|
||||
const parseTags = (value: string | null): string[] | undefined => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = value
|
||||
.split(TAG_SEPARATOR)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)
|
||||
return parsed.length > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
|
||||
id: text('id').primaryKey(),
|
||||
status: text('status').notNull(),
|
||||
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
payload: text('payload').notNull()
|
||||
})
|
||||
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
|
||||
|
||||
class HistoryManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
private db: BetterSQLite3Database | null = null
|
||||
private history: Map<string, DownloadHistoryItem> = new Map()
|
||||
private schemaChecked = false
|
||||
private migrationChecked = false
|
||||
|
||||
constructor() {
|
||||
this.store = new Store({
|
||||
name: 'download-history',
|
||||
defaults: {
|
||||
items: []
|
||||
}
|
||||
})
|
||||
this.loadHistory()
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
private initialize(): void {
|
||||
try {
|
||||
const historyArray = this.store.get('items', [])
|
||||
this.history = new Map(historyArray.map((item) => [item.id, item]))
|
||||
this.getDatabase()
|
||||
this.ensureStructuredSchema()
|
||||
this.ensureLegacyMigration()
|
||||
this.loadHistoryFromDatabase()
|
||||
} catch (error) {
|
||||
console.error('Failed to load download history:', error)
|
||||
logger.error('history-db failed to initialize', error)
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
private getDatabase(): BetterSQLite3Database {
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
|
||||
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
const database = drizzle(sqlite)
|
||||
runMigrations(database)
|
||||
|
||||
this.db = database
|
||||
logger.info(`history-db initialized at ${databasePath}`)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private getLegacyStorePath(): string {
|
||||
return join(app.getPath('userData'), 'download-history.json')
|
||||
}
|
||||
|
||||
private ensureStructuredSchema(): void {
|
||||
if (this.schemaChecked) {
|
||||
return
|
||||
}
|
||||
this.schemaChecked = true
|
||||
|
||||
try {
|
||||
const historyArray = Array.from(this.history.values())
|
||||
this.store.set('items', historyArray)
|
||||
const database = this.getDatabase()
|
||||
const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`)
|
||||
const hasPayloadColumn = columns.some((column) => column.name === 'payload')
|
||||
const hasUrlColumn = columns.some((column) => column.name === 'url')
|
||||
if (hasPayloadColumn || !hasUrlColumn) {
|
||||
this.migrateLegacyPayloadTable()
|
||||
return
|
||||
}
|
||||
|
||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
if (needsRebuild) {
|
||||
this.rebuildDownloadHistoryTable()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save download history:', error)
|
||||
logger.error('history-db failed to inspect schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private migrateLegacyPayloadTable(): void {
|
||||
const database = this.getDatabase()
|
||||
logger.info('history-db migrating legacy payload schema to structured columns')
|
||||
|
||||
try {
|
||||
let migratedCount = 0
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
tx.run(renameDownloadHistoryTableSql)
|
||||
tx.run(createDownloadHistoryTableSql)
|
||||
|
||||
const legacyRows = tx.select().from(legacyDownloadHistoryTable).all()
|
||||
migratedCount = legacyRows.length
|
||||
for (const legacyRow of legacyRows) {
|
||||
const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow))
|
||||
tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run()
|
||||
}
|
||||
|
||||
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||
},
|
||||
{ behavior: 'immediate' }
|
||||
)
|
||||
logger.info(`history-db migrated ${migratedCount} rows to new schema`)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to migrate legacy payload rows', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildDownloadHistoryTable(): void {
|
||||
const database = this.getDatabase()
|
||||
logger.info('history-db rebuilding download_history table to latest schema')
|
||||
|
||||
try {
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
tx.run(renameDownloadHistoryTableSql)
|
||||
tx.run(createDownloadHistoryTableSql)
|
||||
tx.run(copyDownloadHistoryFromLegacySql)
|
||||
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||
},
|
||||
{ behavior: 'immediate' }
|
||||
)
|
||||
logger.info('history-db rebuilt download_history table')
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to rebuild download_history schema', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem {
|
||||
try {
|
||||
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
|
||||
return {
|
||||
...parsed,
|
||||
status: (parsed.status ?? row.status) as DownloadHistoryItem['status'],
|
||||
downloadedAt: parsed.downloadedAt ?? row.downloadedAt,
|
||||
completedAt: parsed.completedAt ?? row.completedAt ?? undefined
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('history-db falling back while migrating payload row', { id: row.id, error })
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.id,
|
||||
title: `Download ${row.id}`,
|
||||
type: 'video',
|
||||
status: row.status as DownloadHistoryItem['status'],
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLegacyMigration(): void {
|
||||
if (this.migrationChecked) {
|
||||
return
|
||||
}
|
||||
this.migrationChecked = true
|
||||
|
||||
const legacyPath = this.getLegacyStorePath()
|
||||
if (!existsSync(legacyPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(legacyPath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as { items?: DownloadHistoryItem[] }
|
||||
const items = parsed.items ?? []
|
||||
if (items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
for (const legacyItem of items) {
|
||||
const normalized = this.normalizeItem(legacyItem)
|
||||
const insertPayload = this.mapItemToInsert(normalized)
|
||||
database
|
||||
.insert(downloadHistoryTable)
|
||||
.values(insertPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: downloadHistoryTable.id,
|
||||
set: this.mapItemToUpdate(insertPayload)
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(legacyPath, `${legacyPath}.bak`)
|
||||
} catch (renameError) {
|
||||
logger.warn(
|
||||
'history-db migrated legacy store but could not rename original file',
|
||||
renameError
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`history-db migrated ${items.length} entries from legacy electron-store data`)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to migrate legacy store', error)
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistoryFromDatabase(): void {
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const rows = database.select().from(downloadHistoryTable).all()
|
||||
this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to load rows', error)
|
||||
this.history = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {
|
||||
const fallbackTimestamp = Date.now()
|
||||
const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp
|
||||
const status = item.status ?? 'pending'
|
||||
|
||||
return {
|
||||
...item,
|
||||
status,
|
||||
downloadedAt
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
type: item.type,
|
||||
status: item.status,
|
||||
downloadPath: item.downloadPath ?? null,
|
||||
savedFileName: item.savedFileName ?? null,
|
||||
fileSize: item.fileSize ?? null,
|
||||
duration: item.duration ?? null,
|
||||
downloadedAt: item.downloadedAt,
|
||||
completedAt: item.completedAt ?? null,
|
||||
sortKey: item.completedAt ?? item.downloadedAt,
|
||||
error: item.error ?? null,
|
||||
description: item.description ?? null,
|
||||
channel: item.channel ?? null,
|
||||
uploader: item.uploader ?? null,
|
||||
viewCount: item.viewCount ?? null,
|
||||
tags: serializeTags(item.tags) ?? null,
|
||||
origin: item.origin ?? null,
|
||||
subscriptionId: item.subscriptionId ?? null,
|
||||
selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,
|
||||
playlistId: item.playlistId ?? null,
|
||||
playlistTitle: item.playlistTitle ?? null,
|
||||
playlistIndex: item.playlistIndex ?? null,
|
||||
playlistSize: item.playlistSize ?? null
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {
|
||||
const { id: _id, ...rest } = payload
|
||||
return rest
|
||||
}
|
||||
|
||||
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
|
||||
let selectedFormat: DownloadHistoryItem['selectedFormat']
|
||||
if (row.selectedFormat) {
|
||||
try {
|
||||
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
|
||||
} catch (error) {
|
||||
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
const tags = parseTags(row.tags ?? null)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
type: row.type as DownloadHistoryItem['type'],
|
||||
status: row.status as DownloadHistoryItem['status'],
|
||||
downloadPath: row.downloadPath ?? undefined,
|
||||
savedFileName: row.savedFileName ?? undefined,
|
||||
fileSize: row.fileSize ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
viewCount: row.viewCount ?? undefined,
|
||||
tags,
|
||||
origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,
|
||||
subscriptionId: row.subscriptionId ?? undefined,
|
||||
selectedFormat,
|
||||
playlistId: row.playlistId ?? undefined,
|
||||
playlistTitle: row.playlistTitle ?? undefined,
|
||||
playlistIndex: row.playlistIndex ?? undefined,
|
||||
playlistSize: row.playlistSize ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
addHistoryItem(item: DownloadHistoryItem): void {
|
||||
this.history.set(item.id, item)
|
||||
this.saveHistory()
|
||||
const normalized = this.normalizeItem(item)
|
||||
const insertPayload = this.mapItemToInsert(normalized)
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
database
|
||||
.insert(downloadHistoryTable)
|
||||
.values(insertPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: downloadHistoryTable.id,
|
||||
set: this.mapItemToUpdate(insertPayload)
|
||||
})
|
||||
.run()
|
||||
this.history.set(normalized.id, normalized)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to upsert item', { id: normalized.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
getHistory(): DownloadHistoryItem[] {
|
||||
return Array.from(this.history.values()).sort((a, b) => {
|
||||
const aTime = a.completedAt || a.downloadedAt
|
||||
const bTime = b.completedAt || b.downloadedAt
|
||||
const aTime = a.completedAt ?? a.downloadedAt
|
||||
const bTime = b.completedAt ?? b.downloadedAt
|
||||
return bTime - aTime
|
||||
})
|
||||
}
|
||||
@@ -56,30 +480,52 @@ class HistoryManager {
|
||||
}
|
||||
|
||||
removeHistoryItem(id: string): boolean {
|
||||
const deleted = this.history.delete(id)
|
||||
if (deleted) {
|
||||
this.saveHistory()
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.id, id))
|
||||
.run()
|
||||
const removedFromMap = this.history.delete(id)
|
||||
return result.changes > 0 || removedFromMap
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to delete item', { id, error })
|
||||
return false
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.history.clear()
|
||||
this.saveHistory()
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
database.delete(downloadHistoryTable).run()
|
||||
this.history.clear()
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to clear items', error)
|
||||
}
|
||||
}
|
||||
|
||||
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
|
||||
let removedCount = 0
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.status === status) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.delete(downloadHistoryTable)
|
||||
.where(eq(downloadHistoryTable.status, status))
|
||||
.run()
|
||||
for (const [id, item] of this.history.entries()) {
|
||||
if (item.status === status) {
|
||||
this.history.delete(id)
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
if ((result.changes ?? 0) > removedCount) {
|
||||
removedCount = result.changes ?? removedCount
|
||||
}
|
||||
return removedCount
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to clear items by status', { status, error })
|
||||
return removedCount
|
||||
}
|
||||
if (removedCount > 0) {
|
||||
this.saveHistory()
|
||||
}
|
||||
return removedCount
|
||||
}
|
||||
|
||||
getHistoryCount(): {
|
||||
@@ -111,6 +557,15 @@ class HistoryManager {
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
hasHistoryForUrl(url: string): boolean {
|
||||
for (const item of this.history.values()) {
|
||||
if (item.url === url) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const historyManager = new HistoryManager()
|
||||
|
||||
641
src/main/lib/subscription-manager.ts
Normal file
641
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import log from 'electron-log/main'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionFeedItem,
|
||||
SubscriptionRule,
|
||||
SubscriptionStatus,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
|
||||
import { runMigrations } from './database/migrate'
|
||||
import {
|
||||
type SubscriptionInsert,
|
||||
type SubscriptionItemRow,
|
||||
type SubscriptionRow,
|
||||
subscriptionItemsTable,
|
||||
subscriptionsTable
|
||||
} from './database/schema'
|
||||
import { getDatabaseFilePath } from './database-path'
|
||||
|
||||
const sanitizeList = (values?: string[]): string[] => {
|
||||
if (!values || values.length === 0) {
|
||||
return []
|
||||
}
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
log.error('Failed to ensure subscription directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
|
||||
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
|
||||
|
||||
const parseStringArray = (value: string | null | undefined): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
|
||||
|
||||
export class SubscriptionManager extends EventEmitter {
|
||||
private sqlite: BetterSqlite3Instance | null = null
|
||||
private db: BetterSQLite3Database | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
try {
|
||||
this.getDatabase()
|
||||
this.migrateLegacyStore()
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to initialize database', error)
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): SubscriptionRule[] {
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.orderBy(desc(subscriptionsTable.updatedAt))
|
||||
.all()
|
||||
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
|
||||
}
|
||||
|
||||
getById(id: string): SubscriptionRule | undefined {
|
||||
const database = this.getDatabase()
|
||||
const row = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.where(eq(subscriptionsTable.id, id))
|
||||
.get()
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||
}
|
||||
|
||||
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||
const timestamp = Date.now()
|
||||
const keywords = sanitizeList(payload.keywords)
|
||||
const tags = sanitizeList(payload.tags)
|
||||
const record: SubscriptionRule = {
|
||||
id: randomUUID(),
|
||||
title: payload.sourceUrl,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
feedUrl: payload.feedUrl,
|
||||
platform: payload.platform,
|
||||
keywords,
|
||||
tags,
|
||||
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
|
||||
enabled: payload.enabled ?? true,
|
||||
coverUrl: undefined,
|
||||
latestVideoTitle: undefined,
|
||||
latestVideoPublishedAt: undefined,
|
||||
lastCheckedAt: undefined,
|
||||
lastSuccessAt: undefined,
|
||||
status: 'idle',
|
||||
lastError: undefined,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
downloadDirectory: payload.downloadDirectory,
|
||||
namingTemplate: payload.namingTemplate
|
||||
? sanitizeFilenameTemplate(payload.namingTemplate)
|
||||
: undefined,
|
||||
items: []
|
||||
}
|
||||
|
||||
ensureDirectoryExists(payload.downloadDirectory)
|
||||
this.insertRecord(record)
|
||||
this.emitUpdates()
|
||||
return record
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
|
||||
): SubscriptionRule | undefined {
|
||||
const existing = this.getById(id)
|
||||
if (!existing) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
|
||||
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
|
||||
const next: SubscriptionRule = {
|
||||
...existing,
|
||||
...updates,
|
||||
keywords: keywords ?? existing.keywords,
|
||||
tags: tags ?? existing.tags,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
|
||||
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
|
||||
next.title = updates.sourceUrl
|
||||
}
|
||||
|
||||
if (updates.namingTemplate) {
|
||||
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
|
||||
}
|
||||
|
||||
ensureDirectoryExists(next.downloadDirectory)
|
||||
this.updateRecord(next)
|
||||
this.emitUpdates()
|
||||
return next
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const database = this.getDatabase()
|
||||
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
database
|
||||
.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, id))
|
||||
.run()
|
||||
this.emitUpdates()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
replaceFeedItems(
|
||||
subscriptionId: string,
|
||||
items: SubscriptionFeedItem[],
|
||||
silent: boolean = false
|
||||
): void {
|
||||
const database = this.getDatabase()
|
||||
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
const now = Date.now()
|
||||
database.transaction((tx) => {
|
||||
tx.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
|
||||
.run()
|
||||
for (const item of orderedItems) {
|
||||
tx.insert(subscriptionItemsTable)
|
||||
.values({
|
||||
subscriptionId,
|
||||
itemId: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
added: booleanToNumber(item.addedToQueue),
|
||||
downloadId: item.downloadId ?? null,
|
||||
createdAt: item.publishedAt,
|
||||
updatedAt: now
|
||||
})
|
||||
.run()
|
||||
}
|
||||
})
|
||||
if (!silent) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
updateFeedItemQueueState(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
updates: { added?: boolean; downloadId?: string | null }
|
||||
): void {
|
||||
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
|
||||
return
|
||||
}
|
||||
|
||||
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
if (updates.added !== undefined) {
|
||||
setPayload.added = booleanToNumber(updates.added)
|
||||
}
|
||||
if (Object.hasOwn(updates, 'downloadId')) {
|
||||
setPayload.downloadId = updates.downloadId ?? null
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.update(subscriptionItemsTable)
|
||||
.set(setPayload)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
|
||||
eq(subscriptionItemsTable.itemId, itemId)
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
|
||||
if (records.length === 0) {
|
||||
return records
|
||||
}
|
||||
const ids = records.map((record) => record.id)
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionItemsTable)
|
||||
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
|
||||
.orderBy(desc(subscriptionItemsTable.publishedAt))
|
||||
.all()
|
||||
|
||||
const grouped = new Map<string, SubscriptionFeedItem[]>()
|
||||
for (const row of rows) {
|
||||
const item = this.mapItemRowToFeedItem(row)
|
||||
const list = grouped.get(row.subscriptionId)
|
||||
if (list) {
|
||||
list.push(item)
|
||||
} else {
|
||||
grouped.set(row.subscriptionId, [item])
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
items: grouped.get(record.id) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
private getDatabase(): BetterSQLite3Database {
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
this.sqlite.pragma('journal_mode = WAL')
|
||||
this.sqlite.pragma('foreign_keys = ON')
|
||||
this.db = drizzle(this.sqlite)
|
||||
runMigrations(this.db)
|
||||
this.ensureSubscriptionsSchema()
|
||||
this.ensureItemsSchema()
|
||||
log.info('subscriptions: database initialized at', databasePath)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private ensureItemsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasAdded = columns.some((column) => column.name === 'added')
|
||||
if (!hasAdded) {
|
||||
this.sqlite
|
||||
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
|
||||
.run()
|
||||
}
|
||||
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
|
||||
const hasLegacyStatus = columns.some((column) => column.name === 'status')
|
||||
const needsMigration = hasDownloaded || hasLegacyStatus
|
||||
if (needsMigration) {
|
||||
if (hasDownloaded) {
|
||||
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscription_items_new (
|
||||
subscription_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
published_at INTEGER NOT NULL,
|
||||
thumbnail TEXT,
|
||||
added INTEGER NOT NULL,
|
||||
download_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (subscription_id, item_id)
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscription_items_new (
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM subscription_items`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscription_items`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
|
||||
)
|
||||
.run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy columns from subscription_items')
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscription_items schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSubscriptionsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasLegacyColumns = columns.some((column) =>
|
||||
['seen_item_ids', 'last_item_id'].includes(column.name)
|
||||
)
|
||||
if (!hasLegacyColumns) {
|
||||
return
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscriptions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
keywords TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
only_latest INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
cover_url TEXT,
|
||||
latest_video_title TEXT,
|
||||
latest_video_published_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
last_success_at INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
download_directory TEXT,
|
||||
naming_template TEXT
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscriptions_new (
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
FROM subscriptions`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscriptions`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscriptions schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private migrateLegacyStore(): void {
|
||||
try {
|
||||
const LegacyStore = require('electron-store')
|
||||
const store = new LegacyStore({
|
||||
name: 'subscriptions',
|
||||
defaults: {
|
||||
subscriptions: []
|
||||
}
|
||||
})
|
||||
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
|
||||
if (!legacyData || legacyData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.getAll().length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const legacyItem of legacyData) {
|
||||
try {
|
||||
const normalized: SubscriptionRule = {
|
||||
...legacyItem,
|
||||
keywords: sanitizeList(legacyItem.keywords),
|
||||
tags: sanitizeList(legacyItem.tags),
|
||||
items: []
|
||||
}
|
||||
this.insertRecord(normalized)
|
||||
const legacyFeedItemsRaw = Array.isArray(
|
||||
(legacyItem as { items?: SubscriptionFeedItem[] }).items
|
||||
)
|
||||
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
|
||||
: []
|
||||
if (legacyFeedItemsRaw.length > 0) {
|
||||
const converted = legacyFeedItemsRaw.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
|
||||
downloadId: item.downloadId
|
||||
}))
|
||||
this.replaceFeedItems(normalized.id, converted, true)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to migrate legacy record', error)
|
||||
}
|
||||
}
|
||||
|
||||
store.clear()
|
||||
this.emitUpdates()
|
||||
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: legacy migration skipped', error)
|
||||
}
|
||||
}
|
||||
|
||||
private insertRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private updateRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
sourceUrl: record.sourceUrl,
|
||||
feedUrl: record.feedUrl,
|
||||
platform: record.platform,
|
||||
keywords: stringifyArray(record.keywords),
|
||||
tags: stringifyArray(record.tags),
|
||||
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
|
||||
enabled: booleanToNumber(record.enabled),
|
||||
coverUrl: record.coverUrl,
|
||||
latestVideoTitle: record.latestVideoTitle,
|
||||
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
|
||||
lastCheckedAt: record.lastCheckedAt ?? null,
|
||||
lastSuccessAt: record.lastSuccessAt ?? null,
|
||||
status: record.status,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
downloadDirectory: record.downloadDirectory,
|
||||
namingTemplate: record.namingTemplate
|
||||
? sanitizeFilenameTemplate(record.namingTemplate)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
sourceUrl: row.sourceUrl,
|
||||
feedUrl: row.feedUrl,
|
||||
platform: row.platform as SubscriptionRule['platform'],
|
||||
keywords: parseStringArray(row.keywords),
|
||||
tags: parseStringArray(row.tags),
|
||||
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
|
||||
enabled: numberToBoolean(row.enabled),
|
||||
coverUrl: row.coverUrl ?? undefined,
|
||||
latestVideoTitle: row.latestVideoTitle ?? undefined,
|
||||
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
|
||||
lastCheckedAt: row.lastCheckedAt ?? undefined,
|
||||
lastSuccessAt: row.lastSuccessAt ?? undefined,
|
||||
status: row.status as SubscriptionStatus,
|
||||
lastError: row.lastError ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
downloadDirectory: row.downloadDirectory ?? undefined,
|
||||
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
|
||||
return {
|
||||
id: row.itemId,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
publishedAt: row.publishedAt,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
addedToQueue: numberToBoolean(row.added),
|
||||
downloadId: row.downloadId ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdates(): void {
|
||||
this.emit('subscriptions:updated', this.getAll())
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionManager = new SubscriptionManager()
|
||||
458
src/main/lib/subscription-scheduler.ts
Normal file
458
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import log from 'electron-log/main'
|
||||
import Parser from 'rss-parser'
|
||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||
import { settingsManager } from '../settings'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { historyManager } from './history-manager'
|
||||
import { subscriptionManager } from './subscription-manager'
|
||||
|
||||
const logger = log.scope('subscriptions')
|
||||
|
||||
type ParserItem = {
|
||||
title?: string
|
||||
link?: string
|
||||
guid?: string
|
||||
id?: string
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TrackedDownload = {
|
||||
subscriptionId: string
|
||||
itemId: string
|
||||
url: string
|
||||
retries: number
|
||||
downloadId: string
|
||||
}
|
||||
|
||||
type FeedItem = {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||
return `sub_${base}`
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to ensure subscription download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionScheduler extends EventEmitter {
|
||||
private timer?: NodeJS.Timeout
|
||||
private checking = false
|
||||
private pendingRun = false
|
||||
private downloads: Map<string, TrackedDownload> = new Map()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
downloadEngine.on('download-completed', (id: string) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: id
|
||||
})
|
||||
})
|
||||
|
||||
downloadEngine.on('download-error', (id: string, error: Error) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
const currentRetries = tracked.retries ?? 0
|
||||
if (currentRetries < 1) {
|
||||
logger.warn('Retrying failed subscription download', { id, error })
|
||||
this.queueDownload(
|
||||
tracked.subscriptionId,
|
||||
tracked.itemId,
|
||||
tracked.url,
|
||||
currentRetries + 1
|
||||
).catch((queueError) => {
|
||||
logger.error('Retry queue failed:', queueError)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'failed',
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.scheduleNextRun(0)
|
||||
}
|
||||
|
||||
refreshInterval(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.scheduleNextRun()
|
||||
}
|
||||
|
||||
async runNow(subscriptionId?: string): Promise<void> {
|
||||
if (subscriptionId) {
|
||||
const target = subscriptionManager.getById(subscriptionId)
|
||||
if (target?.enabled) {
|
||||
await this.checkSubscription(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.checkAll()
|
||||
}
|
||||
|
||||
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return false
|
||||
}
|
||||
const item = subscription.items.find((entry) => entry.id === itemId)
|
||||
if (!item || item.addedToQueue) {
|
||||
return false
|
||||
}
|
||||
await this.queueDownload(subscriptionId, itemId, item.url)
|
||||
return true
|
||||
}
|
||||
|
||||
private scheduleNextRun(initialDelay?: number): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||
this.timer = setTimeout(() => {
|
||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async checkAll(): Promise<void> {
|
||||
if (this.checking) {
|
||||
this.pendingRun = true
|
||||
return
|
||||
}
|
||||
this.checking = true
|
||||
try {
|
||||
const subscriptions = subscriptionManager
|
||||
.getAll()
|
||||
.filter((subscription) => subscription.enabled)
|
||||
for (const subscription of subscriptions) {
|
||||
await this.checkSubscription(subscription)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to run subscription sync', error)
|
||||
} finally {
|
||||
this.checking = false
|
||||
if (this.pendingRun) {
|
||||
this.pendingRun = false
|
||||
void this.checkAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'checking',
|
||||
lastCheckedAt: startedAt,
|
||||
lastError: undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const feed = await parser.parseURL(subscription.feedUrl)
|
||||
const feedItems = Array.isArray(feed.items) ? feed.items : []
|
||||
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
|
||||
const recentItems = this.filterRecentItems(subscription, normalizedItems)
|
||||
const unseenItems = this.filterNewItems(subscription, recentItems)
|
||||
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
|
||||
const keywordFiltered =
|
||||
keywords.length > 0
|
||||
? unseenItems.filter((item) => {
|
||||
const lowered = item.title.toLowerCase()
|
||||
return keywords.some((keyword) => lowered.includes(keyword))
|
||||
})
|
||||
: unseenItems
|
||||
|
||||
const deduped = keywordFiltered
|
||||
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
|
||||
const itemsToDownload =
|
||||
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
|
||||
|
||||
subscriptionManager.replaceFeedItems(
|
||||
subscription.id,
|
||||
this.buildFeedItems(normalizedItems, subscription)
|
||||
)
|
||||
|
||||
if (itemsToDownload.length > 0) {
|
||||
for (const item of itemsToDownload) {
|
||||
await this.queueDownload(subscription.id, item.id, item.url)
|
||||
}
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
: subscription.title,
|
||||
sourceUrl:
|
||||
typeof feed.link === 'string' && feed.link.trim().length > 0
|
||||
? feed.link.trim()
|
||||
: subscription.sourceUrl
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown RSS error'
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'failed',
|
||||
lastError: message,
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
logger.error('Subscription check failed:', { id: subscription.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
|
||||
const normalized: FeedItem[] = []
|
||||
for (const item of items) {
|
||||
const id = this.resolveItemId(item)
|
||||
if (!id || !item.link || !item.title) {
|
||||
continue
|
||||
}
|
||||
normalized.push({
|
||||
id,
|
||||
url: item.link,
|
||||
title: item.title,
|
||||
publishedAt: this.resolvePublishedAt(item),
|
||||
thumbnail: this.resolveThumbnail(item)
|
||||
})
|
||||
}
|
||||
|
||||
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
}
|
||||
|
||||
private buildFeedItems(
|
||||
items: FeedItem[],
|
||||
subscription: SubscriptionRule
|
||||
): SubscriptionFeedItem[] {
|
||||
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
|
||||
return items.map((item) => {
|
||||
const tracked = this.getTrackedDownloadByUrl(item.url)
|
||||
const existing = existingItems.get(item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue:
|
||||
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
|
||||
downloadId: tracked?.downloadId ?? existing?.downloadId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private resolveItemId(item: ParserItem): string | null {
|
||||
const idCandidate =
|
||||
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
|
||||
if (!idCandidate) {
|
||||
return null
|
||||
}
|
||||
return idCandidate.trim()
|
||||
}
|
||||
|
||||
private resolvePublishedAt(item: ParserItem): number {
|
||||
const candidates = [item.isoDate, item.pubDate]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const timestamp = Date.parse(candidate)
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
private resolveThumbnail(item: ParserItem): string | undefined {
|
||||
// Try media:thumbnail first
|
||||
const thumbnail = item.mediaThumbnail
|
||||
if (Array.isArray(thumbnail)) {
|
||||
const found = thumbnail.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
|
||||
return thumbnail.url as string | undefined
|
||||
}
|
||||
|
||||
// Try enclosure (for RSS feeds with image/jpeg type)
|
||||
const enclosure = item.enclosure
|
||||
if (Array.isArray(enclosure)) {
|
||||
const imageEnclosure = enclosure.find(
|
||||
(entry) => entry?.url && entry?.type?.startsWith('image/')
|
||||
)
|
||||
if (imageEnclosure?.url) return imageEnclosure.url
|
||||
}
|
||||
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
|
||||
const enc = enclosure as { url?: string; type?: string }
|
||||
if (enc.url && enc.type?.startsWith('image/')) {
|
||||
return enc.url
|
||||
}
|
||||
}
|
||||
|
||||
// Try media:content as fallback
|
||||
const mediaContent = item.mediaContent
|
||||
if (Array.isArray(mediaContent)) {
|
||||
const found = mediaContent.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
|
||||
if (lastKnownPublishedAt === 0) {
|
||||
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
|
||||
}
|
||||
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
|
||||
}
|
||||
|
||||
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
|
||||
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
|
||||
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
|
||||
}
|
||||
|
||||
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const seenIds = new Set(subscription.items.map((item) => item.id))
|
||||
return items.filter((item) => !seenIds.has(item.id))
|
||||
}
|
||||
|
||||
private async queueDownload(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
url: string,
|
||||
retryCount = 0
|
||||
): Promise<void> {
|
||||
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
|
||||
const isRetry = retryCount > 0
|
||||
if (this.downloads.has(downloadId) && !isRetry) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||
const namingTemplate =
|
||||
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
customDownloadPath: downloadDirectory,
|
||||
customFilenameTemplate: namingTemplate,
|
||||
tags,
|
||||
origin: 'subscription',
|
||||
subscriptionId
|
||||
})
|
||||
|
||||
this.downloads.set(downloadId, {
|
||||
subscriptionId,
|
||||
itemId,
|
||||
url,
|
||||
retries: retryCount,
|
||||
downloadId
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: true,
|
||||
downloadId
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
|
||||
subscriptionManager.update(subscriptionId, {
|
||||
status: 'failed'
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: false,
|
||||
downloadId: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
|
||||
for (const tracked of this.downloads.values()) {
|
||||
if (tracked.url === url) {
|
||||
return tracked
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionScheduler = new SubscriptionScheduler()
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AppSettings } from '../shared/types'
|
||||
@@ -8,6 +9,24 @@ const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
||||
|
||||
const ensureDirectoryExists = (dir: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
|
||||
class SettingsManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
@@ -16,9 +35,10 @@ class SettingsManager {
|
||||
this.store = new Store({
|
||||
defaults: {
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
}
|
||||
})
|
||||
this.ensureDownloadDirectory()
|
||||
}
|
||||
|
||||
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||
@@ -26,6 +46,9 @@ class SettingsManager {
|
||||
}
|
||||
|
||||
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
@@ -35,6 +58,9 @@ class SettingsManager {
|
||||
|
||||
setAll(settings: Partial<AppSettings>): void {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
|
||||
}
|
||||
}
|
||||
@@ -43,8 +69,25 @@ class SettingsManager {
|
||||
this.store.clear()
|
||||
this.store.set({
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
try {
|
||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||
const normalizedDownloadPath =
|
||||
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
|
||||
? DEFAULT_DOWNLOAD_PATH
|
||||
: currentPath
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to verify download directory:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { normalizeLanguageCode } from '@shared/languages'
|
||||
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
|
||||
import appIcon from '../../resources/icon.png?asset'
|
||||
import trayIcon from '../../resources/tray-icon.png?asset'
|
||||
import { settingsManager } from './settings'
|
||||
|
||||
let tray: Tray | null = null
|
||||
@@ -8,7 +10,7 @@ let tray: Tray | null = null
|
||||
* Get translated text based on current language setting
|
||||
*/
|
||||
function t(key: 'showHome' | 'quit'): string {
|
||||
const language = settingsManager.get('language') || 'en'
|
||||
const language = normalizeLanguageCode(settingsManager.get('language'))
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
@@ -16,12 +18,12 @@ function t(key: 'showHome' | 'quit'): string {
|
||||
quit: 'Quit'
|
||||
},
|
||||
zh: {
|
||||
showHome: '打开首页',
|
||||
quit: '关闭应用'
|
||||
showHome: '显示主页',
|
||||
quit: '退出应用'
|
||||
}
|
||||
}
|
||||
|
||||
const displayLang = language.startsWith('en') ? 'en' : 'zh'
|
||||
const displayLang = language.startsWith('zh') ? 'zh' : 'en'
|
||||
return translations[displayLang][key]
|
||||
}
|
||||
|
||||
@@ -85,9 +87,17 @@ export function createTray(): void {
|
||||
return
|
||||
}
|
||||
|
||||
const trayIcon = nativeImage.createFromPath(appIcon)
|
||||
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
|
||||
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
|
||||
const trayIconImage = nativeImage.createFromPath(iconPath)
|
||||
|
||||
tray = new Tray(trayIcon)
|
||||
// For macOS, ensure the icon is properly sized
|
||||
if (process.platform === 'darwin') {
|
||||
// Resize to 16x16 for macOS tray
|
||||
trayIconImage.setTemplateImage(true)
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconImage)
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('VidBee')
|
||||
|
||||
16
src/main/utils/dock.ts
Normal file
16
src/main/utils/dock.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Apply Dock visibility preference on macOS.
|
||||
*/
|
||||
export function applyDockVisibility(hideDockIcon: boolean): void {
|
||||
if (process.platform !== 'darwin' || !app.dock) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hideDockIcon) {
|
||||
app.dock.hide()
|
||||
} else {
|
||||
app.dock.show()
|
||||
}
|
||||
}
|
||||
25
src/main/utils/logger.ts
Normal file
25
src/main/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Main process logger utility
|
||||
* Directly use electron-log/main
|
||||
*/
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
// Export electron-log instance
|
||||
export default log
|
||||
|
||||
// Export commonly used logging methods
|
||||
export const logger = log
|
||||
|
||||
// Predefined scoped loggers
|
||||
export const scopedLoggers = {
|
||||
main: log.scope('main'),
|
||||
ipc: log.scope('ipc'),
|
||||
window: log.scope('window'),
|
||||
download: log.scope('download'),
|
||||
engine: log.scope('engine'),
|
||||
system: log.scope('system'),
|
||||
storage: log.scope('storage'),
|
||||
thumbnail: log.scope('thumbnail'),
|
||||
history: log.scope('history')
|
||||
}
|
||||
@@ -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: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -2,30 +2,213 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import { Sidebar } from '@renderer/components/ui/sidebar'
|
||||
import { Toaster } from '@renderer/components/ui/sonner'
|
||||
import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { SupportedSites } from './pages/SupportedSites'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const { t } = useTranslation()
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptions()
|
||||
|
||||
const handleSubscriptions = (...args: unknown[]) => {
|
||||
const list = args[0]
|
||||
if (Array.isArray(list)) {
|
||||
setSubscriptions(list as SubscriptionRule[])
|
||||
}
|
||||
}
|
||||
|
||||
ipcEvents.on('subscriptions:updated', handleSubscriptions)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)
|
||||
}
|
||||
}, [loadSubscriptions, setSubscriptions])
|
||||
|
||||
// Load or remove analytics script based on settings
|
||||
useEffect(() => {
|
||||
const scriptId = 'analytics-script'
|
||||
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
||||
|
||||
if (settings.enableAnalytics) {
|
||||
// Remove existing script if it exists
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
}
|
||||
|
||||
// Create and append new script
|
||||
const script = document.createElement('script')
|
||||
script.id = scriptId
|
||||
script.src = 'https://rybbit.102417.xyz/api/script.js'
|
||||
script.setAttribute('data-site-id', '7bc6f6d625a4')
|
||||
script.defer = true
|
||||
script.async = true
|
||||
document.head.appendChild(script)
|
||||
analyticsScriptRef.current = script
|
||||
} else {
|
||||
// Remove script if analytics is disabled
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
analyticsScriptRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
const script = document.getElementById(scriptId)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
}
|
||||
}, [settings.enableAnalytics])
|
||||
|
||||
useEffect(() => {
|
||||
// Get platform info to determine if we should show title bar
|
||||
const getPlatform = async () => {
|
||||
try {
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
// Default to showing title bar if platform detection fails
|
||||
setPlatform('unknown')
|
||||
}
|
||||
}
|
||||
getPlatform()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window?.api) {
|
||||
return
|
||||
}
|
||||
|
||||
const showRestartPrompt = () => {
|
||||
toast.info(t('about.notifications.restartToUpdate'), {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetDownloadState = () => {
|
||||
if (updateDownloadInProgressRef.current) {
|
||||
updateDownloadInProgressRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
resetDownloadState()
|
||||
|
||||
const versionLabel = info?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
toast.success(downloadedMessage)
|
||||
|
||||
showRestartPrompt()
|
||||
}
|
||||
|
||||
const handleUpdateError = (rawMessage: unknown) => {
|
||||
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
||||
resetDownloadState()
|
||||
|
||||
const errorMessage = message || t('about.notifications.unknownErrorFallback')
|
||||
toast.error(t('about.notifications.updateError', { error: errorMessage }))
|
||||
}
|
||||
|
||||
const handleDownloadProgress = (rawProgress: unknown) => {
|
||||
const progress = (rawProgress ?? {}) as { percent?: number }
|
||||
if (typeof progress?.percent === 'number') {
|
||||
console.info('Update download progress:', progress.percent.toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
|
||||
const versionLabel = payload.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
|
||||
toast.info(payload?.body ?? downloadedMessage, {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only listen to update events that should be shown globally
|
||||
// update:available is handled in About page only
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.on('update:error', handleUpdateError)
|
||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'subscriptions':
|
||||
return <Subscriptions />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
return <SupportedSites />
|
||||
default:
|
||||
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
|
||||
return (
|
||||
<Home
|
||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
||||
onOpenSettings={() => setCurrentPage('settings')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +220,7 @@ function AppContent() {
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{/* Custom Title Bar */}
|
||||
<TitleBar />
|
||||
<TitleBar platform={platform} />
|
||||
|
||||
<ScrollArea
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
@@ -49,7 +232,7 @@ function AppContent() {
|
||||
</ScrollArea>
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
<Toaster richColors={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,32 +60,32 @@
|
||||
--card-foreground: oklch(0.8853 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.9328 0.0025 228.7857);
|
||||
--primary: oklch(0.6692 0.1607 245.0110);
|
||||
--primary: oklch(0.8223 0.1704 79.8747);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9622 0.0035 219.5331);
|
||||
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--muted: oklch(0.2090 0 0);
|
||||
--muted: oklch(0.3485 0 0);
|
||||
--muted-foreground: oklch(0.5637 0.0078 247.9662);
|
||||
--accent: oklch(0.1928 0.0331 242.5459);
|
||||
--accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.2674 0.0047 248.0045);
|
||||
--input: oklch(0.3020 0.0288 244.8244);
|
||||
--ring: oklch(0.6818 0.1584 243.3540);
|
||||
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||
--ring: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-1: oklch(0.8223 0.1704 79.8747);
|
||||
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||
--sidebar: oklch(0.2097 0.0080 274.5332);
|
||||
--sidebar-foreground: oklch(0.8853 0 0);
|
||||
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
|
||||
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
|
||||
--sidebar-border: oklch(0.3795 0.0220 240.5943);
|
||||
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
|
||||
@@ -1,33 +1,162 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import {
|
||||
type DownloadRecord,
|
||||
removeDownloadAtom,
|
||||
removeHistoryRecordAtom
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
const normalizeSavedFileName = (fileName?: string): string | undefined => {
|
||||
if (!fileName) {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = fileName.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
|
||||
}
|
||||
|
||||
const generateFilePathCandidates = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
const safeTitle = title.trim() || 'Unknown'
|
||||
|
||||
const savedNameCandidates: string[] = []
|
||||
const trimmedSavedFileName = savedFileName?.trim()
|
||||
if (trimmedSavedFileName) {
|
||||
const normalized = normalizeSavedFileName(trimmedSavedFileName)
|
||||
if (normalized) {
|
||||
savedNameCandidates.push(normalized)
|
||||
}
|
||||
if (!normalized || normalized !== trimmedSavedFileName) {
|
||||
savedNameCandidates.push(trimmedSavedFileName)
|
||||
}
|
||||
}
|
||||
|
||||
const candidateFileNames =
|
||||
savedNameCandidates.length > 0
|
||||
? savedNameCandidates
|
||||
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
|
||||
return Array.from(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
}
|
||||
|
||||
const tryFileOperation = async (
|
||||
paths: string[],
|
||||
operation: (filePath: string) => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
for (const filePath of paths) {
|
||||
const success = await operation(filePath)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||
const normalized = normalizeSavedFileName(fileName)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
if (!normalized.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
const ext = normalized.split('.').pop()
|
||||
return ext?.toLowerCase()
|
||||
}
|
||||
|
||||
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
if (savedExt) {
|
||||
return savedExt
|
||||
}
|
||||
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||
if (selectedExt) {
|
||||
return selectedExt
|
||||
}
|
||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||
}
|
||||
|
||||
const getFormatLabel = (download: DownloadRecord): string | undefined => {
|
||||
if (download.selectedFormat?.ext) {
|
||||
return download.selectedFormat.ext.toUpperCase()
|
||||
}
|
||||
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||
return savedExt ? savedExt.toUpperCase() : undefined
|
||||
}
|
||||
|
||||
const getQualityLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitizeCodec = (codec?: string | null): string | undefined => {
|
||||
if (!codec || codec === 'none') {
|
||||
return undefined
|
||||
}
|
||||
return codec
|
||||
}
|
||||
|
||||
const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||
const format = download.selectedFormat
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (download.type === 'audio' || download.type === 'extract') {
|
||||
return sanitizeCodec(format.acodec)
|
||||
}
|
||||
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
label: string
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
@@ -51,19 +180,72 @@ const formatDate = (timestamp?: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateShort = (timestamp?: number) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
const isHistory = download.entryType === 'history'
|
||||
const isSubscriptionDownload = download.origin === 'subscription'
|
||||
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
|
||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||
const actionsContainerBaseClass =
|
||||
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
|
||||
const actionsContainerClass = showActionsWithoutHover
|
||||
? actionsContainerBaseClass
|
||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
const checkFileExists = async () => {
|
||||
if (!download.title || !download.downloadPath) {
|
||||
setFileExists(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formatForPath = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
download.downloadPath,
|
||||
download.title,
|
||||
formatForPath,
|
||||
download.savedFileName
|
||||
)
|
||||
for (const filePath of filePaths) {
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
if (exists) {
|
||||
setFileExists(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
setFileExists(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
setFileExists(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -75,14 +257,20 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFileLocation = async () => {
|
||||
if (!download.outputPath) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
const success = await ipcServices.fs.openFileLocation(download.outputPath)
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.openFileLocation(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -91,29 +279,75 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return Boolean(download.title && download.downloadPath && fileExists)
|
||||
}
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
if (!download.outputPath) {
|
||||
toast.error(t('notifications.openFileFailed'))
|
||||
// need title, downloadPath, format
|
||||
const handleCopyToClipboard = async () => {
|
||||
if (!canCopyToClipboard()) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||
const downloadPath = download.downloadPath
|
||||
const format = resolvedExtension
|
||||
const title = download.title
|
||||
|
||||
if (!downloadPath || !title) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ipcServices.fs.openFileLocation(download.outputPath)
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.copyFileToClipboard(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
toast.success(t('notifications.videoCopied'))
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error)
|
||||
toast.error(t('notifications.openFileFailed'))
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
await handleOpenFileLocation()
|
||||
}
|
||||
|
||||
// need id
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
// Remove from history first
|
||||
await ipcServices.history.removeHistoryItem(download.id)
|
||||
|
||||
// Then try to delete the file
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
console.warn('Failed to delete download file for history item:', download.id)
|
||||
}
|
||||
|
||||
removeHistory(download.id)
|
||||
toast.success(t('notifications.itemRemoved'))
|
||||
} catch (error) {
|
||||
@@ -132,7 +366,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
case 'processing':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
case 'pending':
|
||||
return <Loader2 className="h-4 w-4 text-muted-foreground" />
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
case 'cancelled':
|
||||
return <X className="h-4 w-4 text-muted-foreground" />
|
||||
default:
|
||||
@@ -161,16 +395,258 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const statusIcon = getStatusIcon()
|
||||
const statusText = getStatusText()
|
||||
const sourceDisplay =
|
||||
download.uploader && download.channel && download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader || download.channel || ''
|
||||
|
||||
const metadataDetails: MetadataDetail[] = []
|
||||
|
||||
if (timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('history.date'),
|
||||
value: formatDate(timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceDisplay) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.source'),
|
||||
value: sourceDisplay
|
||||
})
|
||||
}
|
||||
|
||||
if (download.playlistId) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.playlist'),
|
||||
value: (
|
||||
<span>
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined && download.playlistSize !== undefined ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{` ${t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.duration) {
|
||||
metadataDetails.push({
|
||||
label: t('history.duration'),
|
||||
value: formatDuration(download.duration)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFormatSize =
|
||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||
|
||||
const formatLabelValue = getFormatLabel(download)
|
||||
|
||||
if (formatLabelValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.format'),
|
||||
value: formatLabelValue
|
||||
})
|
||||
}
|
||||
|
||||
const qualityLabel = getQualityLabel(download)
|
||||
|
||||
if (qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.quality'),
|
||||
value: qualityLabel
|
||||
})
|
||||
}
|
||||
|
||||
if (inlineFileSize) {
|
||||
metadataDetails.push({
|
||||
label: t('history.fileSize'),
|
||||
value: inlineFileSize
|
||||
})
|
||||
}
|
||||
|
||||
const codecValue = getCodecLabel(download)
|
||||
if (codecValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.codec'),
|
||||
value: codecValue
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedSavedFileName || download.savedFileName) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.savedFile'),
|
||||
value: normalizedSavedFileName ?? download.savedFileName
|
||||
})
|
||||
}
|
||||
|
||||
if (download.url) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.url'),
|
||||
value: (
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="wrap-break-word text-primary hover:underline"
|
||||
>
|
||||
{download.url}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Additional metadata fields
|
||||
if (download.description) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.description'),
|
||||
value: <span className="wrap-break-word">{download.description}</span>
|
||||
})
|
||||
}
|
||||
|
||||
if (download.viewCount !== undefined && download.viewCount !== null) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.views'),
|
||||
value: download.viewCount.toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
if (download.tags && download.tags.length > 0) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.tags'),
|
||||
value: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{download.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.downloadPath) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.downloadPath'),
|
||||
value: <span className="wrap-break-word font-mono text-xs">{download.downloadPath}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if (download.createdAt && download.createdAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.createdAt'),
|
||||
value: formatDate(download.createdAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.startedAt) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.startedAt'),
|
||||
value: formatDate(download.startedAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.completedAt && download.completedAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.completedAt'),
|
||||
value: formatDate(download.completedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// Speed
|
||||
if (download.speed) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.speed'),
|
||||
value: download.speed
|
||||
})
|
||||
}
|
||||
|
||||
// File size (if different from inlineFileSize)
|
||||
if (download.fileSize && download.fileSize !== selectedFormatSize) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fileSize'),
|
||||
value: formatFileSize(download.fileSize)
|
||||
})
|
||||
}
|
||||
|
||||
// Selected format details
|
||||
if (download.selectedFormat) {
|
||||
if (download.selectedFormat.width) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.width'),
|
||||
value: `${download.selectedFormat.width}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.height && !qualityLabel) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.height'),
|
||||
value: `${download.selectedFormat.height}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.fps) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fps'),
|
||||
value: `${download.selectedFormat.fps}`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.vcodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.videoCodec'),
|
||||
value: download.selectedFormat.vcodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.acodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.audioCodec'),
|
||||
value: download.selectedFormat.acodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.format_note) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.formatNote'),
|
||||
value: download.selectedFormat.format_note
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.protocol) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.protocol'),
|
||||
value: download.selectedFormat.protocol.toUpperCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSubscriptionDownload) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.subscription'),
|
||||
value: subscriptionLabel
|
||||
})
|
||||
}
|
||||
|
||||
const hasMetadataDetails = metadataDetails.length > 0
|
||||
|
||||
return (
|
||||
<div className="group relative w-full max-w-full overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60">
|
||||
<ImageWithPlaceholder
|
||||
src={thumbnailSrc}
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
||||
<RemoteImage
|
||||
src={download.thumbnail}
|
||||
alt={download.title}
|
||||
className="w-32 h-18 object-cover aspect-video"
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -179,105 +655,116 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
{download.title}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs wrap-break-word">{download.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
{download.title}
|
||||
</p>
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
|
||||
{download.type}
|
||||
</Badge>
|
||||
{(statusIcon || statusText) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{statusIcon}
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{timestamp ? (
|
||||
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
|
||||
<span className="truncate text-muted-foreground/80">
|
||||
{formatDateShort(timestamp)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{/* Source link */}
|
||||
{(sourceDisplay || download.url) &&
|
||||
(download.url ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-[180px] truncate hover:text-primary transition-colors"
|
||||
>
|
||||
{sourceDisplay || download.url}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate">{sourceDisplay}</span>
|
||||
))}
|
||||
|
||||
<span className="truncate max-w-[120px] text-left">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{download.uploader &&
|
||||
download.channel &&
|
||||
download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader
|
||||
? `${download.uploader}`
|
||||
: download.channel
|
||||
? `${download.channel}`
|
||||
: ''}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
|
||||
{download.selectedFormat ? (
|
||||
{/* Playlist info */}
|
||||
{download.playlistId && (
|
||||
<>
|
||||
{download.selectedFormat.height ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.height}p
|
||||
{download.selectedFormat.fps === 60 ? '60' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.ext ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.ext.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
|
||||
<span className="text-[10px] opacity-75">
|
||||
{formatFileSize(
|
||||
download.selectedFormat.filesize ||
|
||||
download.selectedFormat.filesize_approx
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.quality ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.quality}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.format ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.format.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.codec ? (
|
||||
<span className="text-[10px] opacity-75">{download.codec}</span>
|
||||
) : null}
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="max-w-[200px] truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined &&
|
||||
` (${download.playlistIndex}/${download.playlistSize})`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality badge */}
|
||||
{qualityLabel && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{qualityLabel}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* File size */}
|
||||
{inlineFileSize && <span>{inlineFileSize}</span>}
|
||||
|
||||
{/* Details toggle */}
|
||||
{hasMetadataDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={detailsOpen ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((prev) => !prev)}
|
||||
>
|
||||
{detailsOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{detailsOpen && hasMetadataDetails && (
|
||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-3 text-xs">
|
||||
{metadataDetails.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{item.label}</span>
|
||||
<span className="flex-1 wrap-break-word text-foreground">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={actionsContainerClass}>
|
||||
{isHistory ? (
|
||||
<>
|
||||
{download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -285,13 +772,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFile}
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('history.openFile')}</p>
|
||||
<p>{t('history.copyToClipboard')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -329,7 +817,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.status === 'completed' && download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -337,13 +825,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFile}
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('history.openFile')}</p>
|
||||
<p>{t('history.copyToClipboard')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
|
||||
interface PlaylistDownloadGroupProps {
|
||||
groupId: string
|
||||
title: string
|
||||
records: DownloadRecord[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function PlaylistDownloadGroup({
|
||||
groupId,
|
||||
title,
|
||||
records,
|
||||
totalCount
|
||||
}: PlaylistDownloadGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const completedCount = records.filter((record) => record.status === 'completed').length
|
||||
const errorCount = records.filter((record) => record.status === 'error').length
|
||||
const activeCount = records.filter((record) =>
|
||||
['downloading', 'processing', 'pending'].includes(record.status)
|
||||
).length
|
||||
|
||||
const displayTitle = title || t('playlist.untitled')
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">
|
||||
{t('playlist.groupErrors', { count: errorCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{records.map((record) => (
|
||||
<div
|
||||
key={`${groupId}:${record.entryType}:${record.id}`}
|
||||
className="border-l border-border/50 pl-3"
|
||||
>
|
||||
<DownloadItem download={record} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { History as HistoryIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||
|
||||
@@ -14,7 +17,6 @@ export function UnifiedDownloadHistory() {
|
||||
const { t } = useTranslation()
|
||||
const allRecords = useAtomValue(downloadsArrayAtom)
|
||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||
const clearCompleted = useSetAtom(clearCompletedAtom)
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
|
||||
useHistorySync()
|
||||
@@ -46,33 +48,59 @@ export function UnifiedDownloadHistory() {
|
||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||
]
|
||||
|
||||
const hasCompletedActive = allRecords.some(
|
||||
(item) => item.entryType === 'active' && item.status === 'completed'
|
||||
)
|
||||
const handleClearCompleted = () => {
|
||||
clearCompleted()
|
||||
}
|
||||
const groupedView = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ id: string; title: string; totalCount: number; records: DownloadRecord[] }
|
||||
>()
|
||||
const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =
|
||||
[]
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
if (record.playlistId) {
|
||||
let group = groups.get(record.playlistId)
|
||||
if (!group) {
|
||||
group = {
|
||||
id: record.playlistId,
|
||||
title: record.playlistTitle || record.title,
|
||||
totalCount: record.playlistSize || 0,
|
||||
records: []
|
||||
}
|
||||
groups.set(record.playlistId, group)
|
||||
order.push({ type: 'group', id: record.playlistId })
|
||||
}
|
||||
group.records.push(record)
|
||||
if (!group.title && record.playlistTitle) {
|
||||
group.title = record.playlistTitle
|
||||
}
|
||||
if (!group.totalCount && record.playlistSize) {
|
||||
group.totalCount = record.playlistSize
|
||||
}
|
||||
} else {
|
||||
order.push({ type: 'single', record })
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
group.records.sort((a, b) => {
|
||||
const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex
|
||||
}
|
||||
return b.createdAt - a.createdAt
|
||||
})
|
||||
if (!group.totalCount) {
|
||||
group.totalCount = group.records.length
|
||||
}
|
||||
}
|
||||
|
||||
return { order, groups }
|
||||
}, [filteredRecords])
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('download.downloadQueue')}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{hasCompletedActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 border border-border/60 px-3"
|
||||
onClick={handleClearCompleted}
|
||||
>
|
||||
{t('download.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<CardHeader className="gap-4 p-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
@@ -89,26 +117,55 @@ export function UnifiedDownloadHistory() {
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
<span>{filter.label}</span>
|
||||
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
|
||||
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
|
||||
)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 overflow-hidden w-full">
|
||||
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||
<HistoryIcon className="h-10 w-10 opacity-50" />
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
|
||||
{filteredRecords.map((record) => (
|
||||
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
|
||||
))}
|
||||
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
|
||||
{groupedView.order.map((item) => {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={`${item.record.entryType}:${item.record.id}`}
|
||||
download={item.record}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const group = groupedView.groups.get(item.id)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistDownloadGroup
|
||||
key={`group:${group.id}`}
|
||||
groupId={group.id}
|
||||
title={group.title}
|
||||
totalCount={group.totalCount}
|
||||
records={group.records}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import type { PlaylistEntry, PlaylistInfo } from '@shared/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PlaylistPreviewCardProps {
|
||||
playlist: PlaylistInfo
|
||||
entries: PlaylistEntry[]
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const totalCount = playlist.entryCount
|
||||
const selectedCount = entries.length
|
||||
const firstIndex = entries[0]?.index ?? null
|
||||
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<CardTitle
|
||||
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
|
||||
title={playlist.title}
|
||||
>
|
||||
{playlist.title || t('playlist.untitled')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
|
||||
{firstIndex !== null && lastIndex !== null ? (
|
||||
<span className="truncate">
|
||||
{t('playlist.selectedRange', {
|
||||
start: firstIndex,
|
||||
end: lastIndex
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{t('playlist.noRangeSelected')}</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{t('playlist.showingCount', { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
{onClear && (
|
||||
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
|
||||
{t('playlist.clearPreview')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20">
|
||||
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
|
||||
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
|
||||
{entries.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('playlist.noEntriesInRange')}
|
||||
</li>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<li
|
||||
key={`${entry.index}-${entry.id}`}
|
||||
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
|
||||
#{entry.index}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
|
||||
title={entry.title}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ol>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@renderer/components/ui/dialog'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { settingsAtom } from '@renderer/store/settings'
|
||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const sanitizeCommaList = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||
|
||||
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
|
||||
|
||||
export interface SubscriptionFormData {
|
||||
url?: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface SubscriptionFormDialogProps {
|
||||
mode: 'add' | 'edit'
|
||||
subscription?: SubscriptionRule
|
||||
open: boolean
|
||||
onSave: (data: SubscriptionFormData) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SubscriptionFormDialog({
|
||||
mode,
|
||||
subscription,
|
||||
open,
|
||||
onSave,
|
||||
onClose
|
||||
}: SubscriptionFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||
|
||||
// Form state
|
||||
const [url, setUrl] = useState('')
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [onlyLatest, setOnlyLatest] = useState(false)
|
||||
const [downloadDirectory, setDownloadDirectory] = useState('')
|
||||
const [namingTemplate, setNamingTemplate] = useState('')
|
||||
|
||||
// Feed detection state
|
||||
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||
|
||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevDefaultPathRef = useRef(settings.downloadPath)
|
||||
const urlInputId = useId()
|
||||
|
||||
// Initialize form values based on mode
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'edit' && subscription) {
|
||||
setUrl(subscription.feedUrl)
|
||||
setKeywords(subscription.keywords.join(', '))
|
||||
setTags(subscription.tags.join(', '))
|
||||
setOnlyLatest(subscription.onlyDownloadLatest)
|
||||
setDownloadDirectory(subscription.downloadDirectory || '')
|
||||
setNamingTemplate(subscription.namingTemplate || '')
|
||||
} else {
|
||||
// Add mode - use defaults from settings
|
||||
setUrl('')
|
||||
setKeywords('')
|
||||
setTags('')
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
setDownloadDirectory(settings.downloadPath)
|
||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
subscription,
|
||||
settings.subscriptionOnlyLatestDefault,
|
||||
settings.downloadPath,
|
||||
settings.subscriptionFilenameTemplate
|
||||
])
|
||||
|
||||
// Sync download directory with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
const newPath = settings.downloadPath
|
||||
setDownloadDirectory((prev) => {
|
||||
if (!prev || prev === prevDefaultPathRef.current) {
|
||||
return newPath
|
||||
}
|
||||
return prev
|
||||
})
|
||||
prevDefaultPathRef.current = newPath
|
||||
}
|
||||
}, [settings.downloadPath, mode])
|
||||
|
||||
// Sync naming template with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||
}
|
||||
}, [settings.subscriptionFilenameTemplate, mode])
|
||||
|
||||
// Sync onlyLatest with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
}
|
||||
}, [settings.subscriptionOnlyLatestDefault, mode])
|
||||
|
||||
// Feed detection logic
|
||||
useEffect(() => {
|
||||
if (!url.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// In edit mode, don't detect if URL hasn't changed
|
||||
if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
|
||||
detectTimeout.current = setTimeout(async () => {
|
||||
setDetectingFeed(true)
|
||||
try {
|
||||
await resolveFeed(url.trim())
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
} finally {
|
||||
setDetectingFeed(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (detectTimeout.current) {
|
||||
clearTimeout(detectTimeout.current)
|
||||
}
|
||||
}
|
||||
}, [url, resolveFeed, mode, subscription])
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setDownloadDirectory(path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('subscriptions.notifications.directoryError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRSSHubDocs = async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate URL for add mode
|
||||
if (mode === 'add' && !url.trim()) {
|
||||
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
const formData: SubscriptionFormData = {
|
||||
keywords: sanitizeCommaList(keywords),
|
||||
tags: sanitizeCommaList(tags),
|
||||
onlyDownloadLatest: onlyLatest,
|
||||
downloadDirectory: downloadDirectory || undefined,
|
||||
namingTemplate: namingTemplate || undefined
|
||||
}
|
||||
|
||||
// Include URL if it's provided and different from current (for edit mode)
|
||||
if (url.trim()) {
|
||||
if (
|
||||
mode === 'add' ||
|
||||
(mode === 'edit' && subscription && url.trim() !== subscription.feedUrl)
|
||||
) {
|
||||
try {
|
||||
await resolveFeed(url.trim())
|
||||
formData.url = url.trim()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed:', error)
|
||||
toast.error(t('subscriptions.notifications.resolveError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(formData)
|
||||
}
|
||||
|
||||
const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'
|
||||
const descriptionKey =
|
||||
mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'
|
||||
const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' && subscription
|
||||
? t(titleKey, { name: subscription.title })
|
||||
: t(titleKey)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t(descriptionKey)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
|
||||
<Input
|
||||
id={urlInputId}
|
||||
value={url}
|
||||
placeholder={t('subscriptions.placeholders.url')}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
{detectingFeed && (
|
||||
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
|
||||
)}
|
||||
{mode === 'add' && !url.trim() && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground flex-1">
|
||||
{t('subscriptions.rssHub.hint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="h-5 w-5 p-0 shrink-0"
|
||||
title={t('subscriptions.rssHub.openDocs')}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.tags')}</Label>
|
||||
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={downloadDirectory} readOnly />
|
||||
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
|
||||
{t('subscriptions.actions.selectDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
||||
<Input
|
||||
value={namingTemplate}
|
||||
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{mode === 'add' && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('download.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -44,7 +47,7 @@ export function ImageWithPlaceholder({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className={cn('relative w-full h-full', className)}>
|
||||
{isLoading && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
121
src/renderer/src/components/ui/remote-image.tsx
Normal file
121
src/renderer/src/components/ui/remote-image.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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 (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 (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="file:///path/to/image.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('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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
} from '@renderer/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { saveSettingAtom } from '@renderer/store/settings'
|
||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import '../../assets/title-bar.css'
|
||||
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
||||
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
||||
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
||||
@@ -17,10 +19,12 @@ import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||
import MingcuteRssLine from '~icons/mingcute/rss-line'
|
||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
interface NavigationItem {
|
||||
id: Page
|
||||
@@ -40,10 +44,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文' }
|
||||
]
|
||||
const languageOptions = languageList
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
@@ -54,6 +55,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
},
|
||||
label: t('menu.download')
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
icon: {
|
||||
active: MingcuteRssFill,
|
||||
inactive: MingcuteRssLine
|
||||
},
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
icon: {
|
||||
@@ -83,11 +92,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
]
|
||||
|
||||
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
|
||||
const activeLanguageCode = normalizeLanguageCode(i18n.language)
|
||||
const currentLanguage =
|
||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
const handleLanguageChange = async (value: LanguageCode) => {
|
||||
if (activeLanguageCode === value) {
|
||||
return
|
||||
}
|
||||
@@ -103,21 +112,15 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
||||
{item.label}
|
||||
@@ -128,9 +131,9 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||
{/* App Logo */}
|
||||
<div className="flex flex-col items-center gap-1 py-4 mb-1">
|
||||
<div className="flex flex-col items-center gap-1 py-3 mt-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center">
|
||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||
</div>
|
||||
@@ -150,7 +153,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-12 h-12">
|
||||
<Button variant="ghost" size="icon" className="no-drag w-12 h-12">
|
||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -167,9 +170,13 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => void handleLanguageChange(option.value)}
|
||||
className={isActive ? 'font-semibold' : undefined}
|
||||
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
|
||||
aria-current={isActive}
|
||||
>
|
||||
{option.label}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
|
||||
<span lang={option.hreflang}>{option.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
@@ -190,7 +197,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
|
||||
@@ -7,11 +7,15 @@ import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
|
||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||
import '../../assets/title-bar.css'
|
||||
|
||||
export function TitleBar() {
|
||||
interface TitleBarProps {
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export function TitleBar({ platform }: TitleBarProps) {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听窗口最大化状态变化
|
||||
// Listen for window maximize state changes
|
||||
const handleMaximized = () => {
|
||||
setIsMaximized(true)
|
||||
}
|
||||
@@ -41,8 +45,17 @@ export function TitleBar() {
|
||||
ipcServices.window.close()
|
||||
}
|
||||
|
||||
const isMac = platform === 'darwin'
|
||||
const containerClass = `flex drag-region bg-background select-none ${
|
||||
isMac ? 'h-10 items-center px-4' : 'justify-end pt-4 px-5'
|
||||
}`
|
||||
|
||||
if (isMac) {
|
||||
return <div className={containerClass} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
|
||||
<div className={containerClass}>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-1 no-drag">
|
||||
<Button
|
||||
|
||||
@@ -41,44 +41,46 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('audioExtract.title')}</CardTitle>
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectFormat')}</Label>
|
||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onExtract('extract')} className="w-full">
|
||||
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
|
||||
{t('audioExtract.extract')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
@@ -73,9 +72,22 @@ export function FormatSelector({
|
||||
|
||||
useEffect(() => {
|
||||
// Filter and sort formats
|
||||
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
|
||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||
const videos = formats.filter(
|
||||
(f) =>
|
||||
f.video_ext !== 'none' &&
|
||||
f.vcodec &&
|
||||
f.vcodec !== 'none' &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
const audios = formats.filter(
|
||||
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
|
||||
(f) =>
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext) &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
|
||||
// Apply showMoreFormats filter
|
||||
@@ -87,6 +99,48 @@ export function FormatSelector({
|
||||
? audios
|
||||
: audios.filter((f) => f.ext !== 'webm')
|
||||
|
||||
// Sort formats by quality (best first)
|
||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by height (higher is better)
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
// If same height, sort by fps (higher is better)
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by bitrate/quality if available
|
||||
const aQuality = a.tbr ?? a.quality ?? 0
|
||||
const bQuality = b.tbr ?? b.quality ?? 0
|
||||
if (aQuality !== bQuality) {
|
||||
return bQuality - aQuality
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(filteredVideos)
|
||||
setAudioFormats(filteredAudios)
|
||||
|
||||
@@ -121,25 +175,50 @@ export function FormatSelector({
|
||||
}
|
||||
|
||||
const formatVideoLabel = (format: VideoFormat) => {
|
||||
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
|
||||
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
|
||||
const parts: string[] = []
|
||||
// Resolution
|
||||
if (format.height) {
|
||||
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
|
||||
}
|
||||
// Format extension
|
||||
parts.push(format.ext.toUpperCase())
|
||||
// Codec (if showMoreFormats is enabled)
|
||||
if (settings.showMoreFormats && format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0])
|
||||
}
|
||||
// Audio indicator
|
||||
if (format.acodec !== 'none') {
|
||||
parts.push('🔊')
|
||||
}
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
|
||||
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioLabel = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Quality
|
||||
const quality = format.format_note || t('download.unknownQuality')
|
||||
parts.push(quality)
|
||||
// Format extension
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
return `${quality} | ${ext} | ${size}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectVideoFormat')}</Label>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||
<Select
|
||||
value={selectedVideo}
|
||||
onValueChange={(value) => {
|
||||
@@ -147,25 +226,25 @@ export function FormatSelector({
|
||||
onVideoFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{videoFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatVideoLabel(format)}
|
||||
<span className="text-sm">{formatVideoLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectAudioFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -173,18 +252,20 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
<SelectItem value="none" className="cursor-pointer py-2.5">
|
||||
<span className="text-sm">{t('download.noAudio')}</span>
|
||||
</SelectItem>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -196,8 +277,8 @@ export function FormatSelector({
|
||||
|
||||
// Audio only
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -205,17 +286,17 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -115,53 +115,59 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
|
||||
<div className="space-y-5">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('download.back')}
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full md:w-80 rounded-lg aspect-video"
|
||||
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
|
||||
fallbackIcon={<Play className="h-12 w-12" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2 items-center">
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(videoInfo.duration)}
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{formatViews(videoInfo.view_count)}
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && (
|
||||
<Badge variant="outline" className="px-2.5 py-1">
|
||||
{videoInfo.uploader}
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={titleId}>{t('download.title')}</Label>
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor={titleId} className="text-sm font-semibold">
|
||||
{t('download.title')}
|
||||
</Label>
|
||||
<Input
|
||||
id={titleId}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="font-medium"
|
||||
className="font-medium h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,14 +176,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
|
||||
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="video" className="text-sm font-medium">
|
||||
{t('download.video')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-sm font-medium">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="video" className="space-y-4 mt-4">
|
||||
<TabsContent value="video" className="space-y-5 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="video"
|
||||
@@ -194,13 +204,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
|
||||
<Button
|
||||
onClick={() => handleDownload('video')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadVideo')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audio" className="space-y-4 mt-4">
|
||||
<TabsContent value="audio" className="space-y-5 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
@@ -218,7 +233,12 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
|
||||
<Button
|
||||
onClick={() => handleDownload('audio')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
|
||||
@@ -1,98 +1,26 @@
|
||||
export interface PopularSite {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
url: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
export const popularSites: PopularSite[] = [
|
||||
{
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
description: 'Long-form and livestream video from creators worldwide.'
|
||||
},
|
||||
{
|
||||
id: 'youtubemusic',
|
||||
label: 'YouTube Music',
|
||||
description: 'Official music videos, albums, and live performances.'
|
||||
},
|
||||
{
|
||||
id: 'tiktok',
|
||||
label: 'TikTok',
|
||||
description: 'Short-form mobile videos, effects, and live streams.'
|
||||
},
|
||||
{
|
||||
id: 'facebook',
|
||||
label: 'Facebook',
|
||||
description: 'Feed, Watch, and Reels videos from public pages.'
|
||||
},
|
||||
{
|
||||
id: 'instagram',
|
||||
label: 'Instagram',
|
||||
description: 'Feed, Stories, Reels, and Highlights content.'
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
label: 'X (Twitter)',
|
||||
description: 'Timeline posts, Spaces recordings, and broadcasts.'
|
||||
},
|
||||
{
|
||||
id: 'soundcloud',
|
||||
label: 'SoundCloud',
|
||||
description: 'Music tracks, playlists, and DJ sets.'
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
label: 'Reddit',
|
||||
description: 'Embedded clips and hosted videos from communities.'
|
||||
},
|
||||
{
|
||||
id: 'vimeo',
|
||||
label: 'Vimeo',
|
||||
description: 'High-quality creator and business video hosting.'
|
||||
},
|
||||
{
|
||||
id: 'dailymotion',
|
||||
label: 'Dailymotion',
|
||||
description: 'Global news, sports, and entertainment clips.'
|
||||
},
|
||||
{
|
||||
id: 'twitch',
|
||||
label: 'Twitch',
|
||||
description: 'Gaming, music, and IRL live streams and VODs.'
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
description: 'Professional talks, webinars, and learning videos.'
|
||||
},
|
||||
{
|
||||
id: 'pinterest',
|
||||
label: 'Pinterest',
|
||||
description: 'Idea pins, how-to reels, and lifestyle inspiration videos.'
|
||||
},
|
||||
{
|
||||
id: 'tumblr',
|
||||
label: 'Tumblr',
|
||||
description: 'Creative short-form media and fan edits.'
|
||||
},
|
||||
{
|
||||
id: 'mixcloud',
|
||||
label: 'Mixcloud',
|
||||
description: 'DJ mixes, radio shows, and long-form audio.'
|
||||
},
|
||||
{
|
||||
id: 'niconico',
|
||||
label: 'Niconico',
|
||||
description: 'Japanese animation, music, and live broadcast archive.'
|
||||
},
|
||||
{
|
||||
id: 'kick',
|
||||
label: 'Kick',
|
||||
description: 'Creator live streams and replays on the Kick platform.'
|
||||
},
|
||||
{
|
||||
id: 'bandcamp',
|
||||
label: 'Bandcamp',
|
||||
description: 'Independent artist albums and community releases.'
|
||||
}
|
||||
{ id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },
|
||||
{ id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },
|
||||
{ id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },
|
||||
{ id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },
|
||||
{ id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },
|
||||
{ id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },
|
||||
{ id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },
|
||||
{ id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },
|
||||
{ id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },
|
||||
{ id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },
|
||||
{ id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },
|
||||
{ id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },
|
||||
{ id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },
|
||||
{ id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },
|
||||
{ id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },
|
||||
{ id: 'kick', url: 'https://kick.com', domain: 'kick.com' },
|
||||
{ id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }
|
||||
]
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { defaultLanguageCode, supportedLanguageCodes } from '@shared/languages'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
type TranslationDictionary = typeof en
|
||||
|
||||
const localeModules = import.meta.glob<{ default: TranslationDictionary }>('./locales/*.json', {
|
||||
eager: true
|
||||
})
|
||||
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(localeModules).map(([path, module]) => {
|
||||
const code = path.replace('./locales/', '').replace('.json', '')
|
||||
return [code, module.default]
|
||||
})
|
||||
) as Record<string, TranslationDictionary>
|
||||
|
||||
const resources = Object.fromEntries(
|
||||
supportedLanguageCodes.map((code) => [
|
||||
code,
|
||||
{
|
||||
translation: translations[code] ?? en
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh }
|
||||
},
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources,
|
||||
lng: defaultLanguageCode,
|
||||
fallbackLng: defaultLanguageCode,
|
||||
supportedLngs: supportedLanguageCodes,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Check updates",
|
||||
"download": "Download",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"goToDownload": "Go to download page",
|
||||
"openRepo": "Open GitHub repository",
|
||||
"view": "View",
|
||||
"visit": "Visit"
|
||||
@@ -14,18 +16,29 @@
|
||||
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
||||
"betaProgramTitle": "Preview channel",
|
||||
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Follow @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
|
||||
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
|
||||
"followAuthorTitle": "Follow the Developer",
|
||||
"here": "here",
|
||||
"homepage": "Homepage",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Looking for updates...",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"noUpdatesAvailable": "You're using the latest version",
|
||||
"updateError": "Failed to check for updates: {{error}}",
|
||||
"downloadUpdate": "Download and install update {{version}}?",
|
||||
"downloadStarted": "Download started...",
|
||||
"downloadError": "Failed to download update",
|
||||
"downloadStarted": "Download started...",
|
||||
"downloadUpdate": "Download and install update {{version}}?",
|
||||
"manualDownloadAction": "Download now",
|
||||
"noUpdatesAvailable": "You're using the latest version",
|
||||
"restartToUpdate": "Restart now to install update?",
|
||||
"restartNowAction": "Restart now",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"updateAvailableMessage": "A new version {{version}} is available. Please download it from the official website.",
|
||||
"updateDownloaded": "Update downloaded, restart to install",
|
||||
"restartToUpdate": "Restart now to install update?"
|
||||
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
|
||||
"updateError": "Failed to check for updates: {{error}}",
|
||||
"unknownErrorFallback": "Unknown error"
|
||||
},
|
||||
"preferencesDescription": "Tune update settings without leaving this page.",
|
||||
"preferencesTitle": "Quick Toggles",
|
||||
@@ -45,19 +58,25 @@
|
||||
},
|
||||
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
|
||||
"resourcesTitle": "Resources",
|
||||
"shareActions": {
|
||||
"copy": "Copy link",
|
||||
"facebook": "Share on Facebook",
|
||||
"twitter": "Share on X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Share VidBee with your community in one click.",
|
||||
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
|
||||
"shareTitle": "Spread the word",
|
||||
"shareDescription": "Share VidBee with your community in one click.",
|
||||
"shareActions": {
|
||||
"twitter": "Share on X (Twitter)",
|
||||
"facebook": "Share on Facebook",
|
||||
"copy": "Copy link"
|
||||
},
|
||||
"sourceCode": "Source Code is available",
|
||||
"tagline": "An AI-friendly download helper for every creator",
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Latest: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "New version available",
|
||||
"uptodate": "You're up to date",
|
||||
"error": "Unable to fetch the latest version"
|
||||
},
|
||||
"downloadingUpdate": "Downloading update"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Close app when download finishes",
|
||||
@@ -91,7 +110,7 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"download": {
|
||||
"active": "active",
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
"audio": "Audio",
|
||||
"back": "Back",
|
||||
@@ -121,8 +140,10 @@
|
||||
"noAudio": "No Audio",
|
||||
"noHistory": "No download history",
|
||||
"noItems": "No items found",
|
||||
"goToSettings": "Go to Settings",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
|
||||
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
|
||||
"oneClickDownloadNow": "Download Now",
|
||||
"oneClickDownloadStarted": "Download started with default settings",
|
||||
"paste": "Paste",
|
||||
@@ -131,6 +152,8 @@
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectFormat": "Select Format",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
@@ -143,7 +166,33 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Video Information",
|
||||
"videoInfoUpdated": "Video information updated"
|
||||
"videoInfoUpdated": "Video information updated",
|
||||
"metadata": {
|
||||
"source": "Source",
|
||||
"playlist": "Playlist",
|
||||
"format": "Format",
|
||||
"quality": "Quality",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Saved file",
|
||||
"url": "Source URL",
|
||||
"description": "Description",
|
||||
"views": "Views",
|
||||
"tags": "Tags",
|
||||
"downloadPath": "Download path",
|
||||
"createdAt": "Created at",
|
||||
"startedAt": "Started at",
|
||||
"completedAt": "Completed at",
|
||||
"speed": "Speed",
|
||||
"fileSize": "File size",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Video codec",
|
||||
"audioCodec": "Audio codec",
|
||||
"formatNote": "Format note",
|
||||
"protocol": "Protocol",
|
||||
"subscription": "Subscription"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Click to copy details",
|
||||
@@ -160,8 +209,8 @@
|
||||
"clearCancelled": "Clear Cancelled",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"clearErrors": "Clear Errors",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyUrl": "Copy URL",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"date": "Date",
|
||||
"description": "View and manage your download history",
|
||||
"duration": "Duration",
|
||||
@@ -174,11 +223,11 @@
|
||||
},
|
||||
"noHistory": "No download history yet",
|
||||
"noHistoryDescription": "Your completed downloads will appear here",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"openFile": "Open File",
|
||||
"openFileLocation": "Open File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"outputPath": "Output Path",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeItem": "Remove Item",
|
||||
"stats": {
|
||||
"cancelled": "Cancelled",
|
||||
@@ -197,6 +246,8 @@
|
||||
"about": "About",
|
||||
"download": "Download",
|
||||
"playlist": "Download Playlist",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Subscriptions",
|
||||
"preferences": "Preferences",
|
||||
"supportedSites": "Supported Sites",
|
||||
"theme": "Theme:"
|
||||
@@ -211,9 +262,12 @@
|
||||
"openFolderFailed": "Failed to open folder",
|
||||
"removeFailed": "Failed to remove item",
|
||||
"settingsSaved": "Settings saved",
|
||||
"urlCopied": "URL copied to clipboard"
|
||||
"urlCopied": "URL copied to clipboard",
|
||||
"videoCopied": "Video copied to clipboard"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -228,12 +282,27 @@
|
||||
"filenameFormat": "Filename format for playlists",
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"linkLabel": "Playlist URL",
|
||||
"noEntries": "No videos were found in this playlist",
|
||||
"noEntriesInRange": "No videos in the selected range",
|
||||
"noRangeSelected": "No end set - full playlist selected",
|
||||
"playlistUrlDescription": "Download all videos from a playlist in bulk",
|
||||
"positionLabel": "Item {{index}} of {{total}}",
|
||||
"previewButton": "Preview playlist",
|
||||
"previewFailed": "Failed to preview playlist",
|
||||
"previewSummary": "Preview playlist items before downloading.",
|
||||
"previewRequired": "Preview the playlist before downloading.",
|
||||
"range": "Range (Optional)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist"
|
||||
"title": "Download Playlist",
|
||||
"totalVideos": "Total videos: {{count}}",
|
||||
"untitled": "Untitled playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "About",
|
||||
@@ -241,23 +310,47 @@
|
||||
"app": "App Settings",
|
||||
"audio": "Audio Preferences",
|
||||
"browserForCookies": "Select browser to use cookies from",
|
||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
|
||||
"cookiesFile": "Cookies file",
|
||||
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
||||
"clearCookiesFile": "Clear",
|
||||
"cookiesHelpTitle": "Using cookies",
|
||||
"cookiesHelpBrowser": "Pick your browser above to reuse its signed-in session automatically.",
|
||||
"cookiesHelpFile": "Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.",
|
||||
"cookiesHelpFaq": "Open yt-dlp cookies FAQ",
|
||||
"openLinkError": "Failed to open link",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Use configuration file",
|
||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||
"clearConfigFile": "Clear",
|
||||
"dark": "Dark",
|
||||
"description": "Configure your download preferences and application settings",
|
||||
"directorySelectError": "Failed to select directory",
|
||||
"downloadPath": "Download location",
|
||||
"downloadPathDescription": "Choose where to save downloaded files",
|
||||
"fileSelectError": "Failed to select file",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"languageOptions": {
|
||||
"chinese": "Chinese (Simplified)",
|
||||
"english": "English"
|
||||
},
|
||||
"light": "Light",
|
||||
"hideDockIcon": "Hide Dock icon",
|
||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||
"enableAnalytics": "Help improve VidBee",
|
||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Enable one-click download with default settings",
|
||||
"oneClickDownloadType": "Default download type",
|
||||
"oneClickDownloadTypeDescription": "Choose the default download type for one-click downloads. Quality uses the preset below.",
|
||||
"oneClickQuality": "Preferred quality",
|
||||
"oneClickQualityDescription": "Select the quality preset used for one-click downloads",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Bad",
|
||||
@@ -267,11 +360,19 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Proxy server for network requests",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Select config file",
|
||||
"selectPath": "Select",
|
||||
"showMoreFormats": "Show more format options",
|
||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
||||
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||
"title": "Settings",
|
||||
"tray": {
|
||||
"quit": "Quit",
|
||||
@@ -279,6 +380,120 @@
|
||||
},
|
||||
"video": "Video Preferences"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Subscriptions",
|
||||
"subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}",
|
||||
"description": "Automatically monitor RSS feeds and queue new downloads without manual work.",
|
||||
"defaults": {
|
||||
"title": "Automation defaults",
|
||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||
"downloadDirectory": "Download directory",
|
||||
"filenameTemplate": "Filename template (file only)",
|
||||
"checkInterval": "Check interval (hours)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||
},
|
||||
"add": {
|
||||
"title": "Add RSS",
|
||||
"description": "Paste an RSS feed link. VidBee will detect the feed automatically."
|
||||
},
|
||||
"fields": {
|
||||
"url": "Feed URL",
|
||||
"keywords": "Keyword filter (comma separated)",
|
||||
"tags": "Auto tags",
|
||||
"customDirectory": "Custom directory",
|
||||
"namingTemplate": "Custom filename template (file only)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"onlyLatestShort": "Only latest"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"save": "Save changes",
|
||||
"selectDirectory": "Browse",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"items": {
|
||||
"title": "Latest uploads ({{count}})",
|
||||
"count": "{{count}} items",
|
||||
"empty": "No recent feed items found.",
|
||||
"status": {
|
||||
"queued": "Queued",
|
||||
"notQueued": "Not queued",
|
||||
"pending": "Pending",
|
||||
"downloading": "Downloading",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"error": "Failed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"fromChannel": "From {{channel}}",
|
||||
"tooltip": {
|
||||
"downloadStatus": "Download status: {{status}}",
|
||||
"downloadPending": "Waiting for download details...",
|
||||
"notQueued": "Not in the download queue yet"
|
||||
},
|
||||
"actions": {
|
||||
"open": "Open in browser",
|
||||
"queue": "Add to download queue"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"subscription": "Subscription",
|
||||
"unknown": "Unknown subscription",
|
||||
"noThumbnail": "No thumbnail"
|
||||
},
|
||||
"notifications": {
|
||||
"directoryError": "Failed to open the directory picker.",
|
||||
"missingUrl": "Please paste a channel link first.",
|
||||
"created": "Subscription added",
|
||||
"createError": "Failed to add subscription.",
|
||||
"refreshStarted": "Refresh started",
|
||||
"removed": "Subscription removed",
|
||||
"updated": "Subscription updated",
|
||||
"itemQueued": "Added to download queue",
|
||||
"itemAlreadyQueued": "This video is already queued",
|
||||
"queueError": "Failed to add to download queue.",
|
||||
"openLinkError": "Failed to open the video link.",
|
||||
"resolveError": "Failed to resolve RSS feed URL."
|
||||
},
|
||||
"detectedFeed": "Detected {{platform}} feed -> {{feed}}",
|
||||
"detecting": "Detecting feed...",
|
||||
"latestVideo": "Latest video: {{title}}",
|
||||
"lastChecked": "Last checked: {{time}}",
|
||||
"never": "Never",
|
||||
"empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.",
|
||||
"edit": {
|
||||
"title": "Edit {{name}}",
|
||||
"description": "Tweak filters, tags, and overrides for this feed."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"up-to-date": "Up to date",
|
||||
"checking": "Checking",
|
||||
"failed": "Failed",
|
||||
"idle": "Idle",
|
||||
"tooltip": {
|
||||
"updatedAt": "Updated: {{time}}"
|
||||
}
|
||||
},
|
||||
"rssHub": {
|
||||
"title": "Automated Subscriptions with RSSHub",
|
||||
"description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.",
|
||||
"learnMore": "Learn more about RSSHub",
|
||||
"openDocs": "Open RSSHub Documentation",
|
||||
"hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supports {{sites}} and more.",
|
||||
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
|
||||
@@ -287,6 +502,80 @@
|
||||
"pageDescription": "VidBee uses yt-dlp under the hood to reach hundreds of sources.",
|
||||
"pageIntro": "Here are the mainstream services people download from most frequently.",
|
||||
"pageTitle": "Supported Sites",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Independent artist albums and community releases.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Global news, sports, and entertainment clips.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Feed, Watch, and Reels videos from public pages.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Feed, Stories, Reels, and Highlights content.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Creator live streams and replays on the Kick platform.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Professional talks, webinars, and learning videos.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ mixes, radio shows, and long-form audio.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Japanese animation, music, and live broadcast archive.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Idea pins, how-to reels, and lifestyle inspiration videos.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Embedded clips and hosted videos from communities.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Music tracks, playlists, and DJ sets.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Short-form mobile videos, effects, and live streams.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Creative short-form media and fan edits.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Gaming, music, and IRL live streams and VODs.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Timeline posts, Spaces recordings, and broadcasts.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "High-quality creator and business video hosting.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Long-form and livestream video from creators worldwide.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Official music videos, albums, and live performances.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Main platforms",
|
||||
"viewAll": "View all supported sites"
|
||||
}
|
||||
|
||||
394
src/renderer/src/locales/fr.json
Normal file
394
src/renderer/src/locales/fr.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"email": "Email",
|
||||
"feedback": "Commentaires",
|
||||
"openRepo": "Ouvrir le dépôt GitHub",
|
||||
"view": "Voir",
|
||||
"visit": "Visiter"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Télécharger et installer automatiquement les nouvelles versions en arrière-plan.",
|
||||
"autoUpdateTitle": "Mises à jour automatiques",
|
||||
"betaProgramDescription": "Recevez les versions préliminaires et les prochaines fonctionnalités avant tout le monde.",
|
||||
"betaProgramTitle": "Canal de prévisualisation",
|
||||
"description": "VidBee est un téléchargeur gratuit et open-source construit avec Electron et alimenté par yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Suivre @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Restez à jour avec les dernières nouvelles et mises à jour de VidBee.",
|
||||
"followAuthorSupport": "Suivez le développeur sur X (Twitter) pour obtenir les dernières mises à jour et nouvelles sur VidBee.",
|
||||
"followAuthorTitle": "Suivre le Développeur",
|
||||
"here": "ici",
|
||||
"homepage": "Page d'accueil",
|
||||
"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}} ?",
|
||||
"noUpdatesAvailable": "Vous utilisez la dernière version",
|
||||
"restartToUpdate": "Redémarrer maintenant pour installer la mise à jour ?",
|
||||
"updateAvailable": "Mise à jour disponible : {{version}}",
|
||||
"updateDownloaded": "Mise à jour téléchargée, redémarrez pour installer",
|
||||
"updateError": "Échec de la vérification des mises à jour : {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Ajustez les paramètres de mise à jour sans quitter cette page.",
|
||||
"preferencesTitle": "Basculements Rapides",
|
||||
"resources": {
|
||||
"changelog": "Notes de version",
|
||||
"changelogDescription": "Suivez ce qui a changé dans chaque version.",
|
||||
"contact": "Support par email",
|
||||
"contactDescription": "Contactez directement pour de l'aide ou collaboration.",
|
||||
"documentation": "Centre d'aide",
|
||||
"documentationDescription": "Guides, FAQ et flux de travail courants.",
|
||||
"feedback": "Commentaires et problèmes",
|
||||
"feedbackDescription": "Partagez des idées ou signalez des problèmes sur GitHub.",
|
||||
"license": "Licence",
|
||||
"licenseDescription": "Consultez les termes de la licence open-source.",
|
||||
"website": "Site web officiel",
|
||||
"websiteDescription": "Points forts du produit, feuille de route et actualités de la communauté."
|
||||
},
|
||||
"resourcesDescription": "Liens utiles pour en savoir plus sur VidBee et rester connecté.",
|
||||
"resourcesTitle": "Ressources",
|
||||
"shareActions": {
|
||||
"copy": "Copier le lien",
|
||||
"facebook": "Partager sur Facebook",
|
||||
"twitter": "Partager sur X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Partagez VidBee avec votre communauté en un clic.",
|
||||
"shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.",
|
||||
"shareTitle": "Faites passer le mot",
|
||||
"sourceCode": "Le code source est disponible",
|
||||
"title": "À propos",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Dernière : v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nouvelle version disponible",
|
||||
"uptodate": "Vous êtes à jour",
|
||||
"error": "Impossible de récupérer la dernière version"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fermer l'application quand le téléchargement se termine",
|
||||
"currentLocation": "Emplacement de téléchargement actuel - ",
|
||||
"downloadLocation": "Emplacement de téléchargement",
|
||||
"downloadSubs": "Télécharger les sous-titres si disponibles",
|
||||
"end": "Fin",
|
||||
"endHint": "Si laissé vide, sera téléchargé jusqu'à la fin",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Sélectionner l'Emplacement de Téléchargement",
|
||||
"start": "Début",
|
||||
"startHint": "Si laissé vide, commencera du début",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Sous-titres",
|
||||
"timeRange": "Télécharger une plage de temps spécifique",
|
||||
"title": "Options Avancées"
|
||||
},
|
||||
"app": {
|
||||
"description": "Télécharger des vidéos et audios depuis des centaines de sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Mauvais",
|
||||
"best": "Meilleur",
|
||||
"extract": "Extraire",
|
||||
"good": "Bon",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectQuality": "Sélectionner la Qualité",
|
||||
"title": "Extraire l'Audio",
|
||||
"worst": "Pire"
|
||||
},
|
||||
"download": {
|
||||
"active": "Actif",
|
||||
"all": "Tout",
|
||||
"audio": "Audio",
|
||||
"back": "Retour",
|
||||
"cancel": "Annuler",
|
||||
"cancelled": "Annulé",
|
||||
"clearCompleted": "Effacer les Terminés",
|
||||
"clearDownloads": "Effacer les Téléchargements",
|
||||
"completed": "Terminé",
|
||||
"downloadAudio": "Télécharger l'Audio",
|
||||
"downloadBtn": "Télécharger",
|
||||
"downloadPending": "En attente",
|
||||
"downloadQueue": "File de Téléchargement",
|
||||
"downloadVideo": "Télécharger la Vidéo",
|
||||
"downloading": "Téléchargement en cours...",
|
||||
"enterUrl": "Entrer l'URL de la Vidéo",
|
||||
"enterUrlDescription": "Collez ou tapez une URL de vidéo. ",
|
||||
"error": "Erreur",
|
||||
"fetch": "Récupérer",
|
||||
"fetchingVideoInfo": "Récupération des informations vidéo...",
|
||||
"history": "Historique",
|
||||
"imageLoadError": "Échec du chargement de l'image",
|
||||
"imagePlaceholder": "Aucune image disponible",
|
||||
"infoUnavailable": "Téléchargement en Un Clic (Info indisponible)",
|
||||
"loading": "Chargement",
|
||||
"moreOptions": "Plus d'options",
|
||||
"noActiveDownloads": "Aucun téléchargement actif",
|
||||
"noAudio": "Pas d'Audio",
|
||||
"noHistory": "Aucun historique de téléchargement",
|
||||
"noItems": "Aucun élément trouvé",
|
||||
"oneClickDownload": "Téléchargement en Un Clic",
|
||||
"oneClickDownloadDescription": "Télécharger directement avec les paramètres par défaut sans confirmation",
|
||||
"oneClickDownloadNow": "Télécharger Maintenant",
|
||||
"oneClickDownloadStarted": "Téléchargement démarré avec les paramètres par défaut",
|
||||
"paste": "Coller",
|
||||
"pastePlaylistUrl": "Cliquez pour coller le lien de la playlist depuis le presse-papiers [Ctrl + V]",
|
||||
"pasteUrl": "Cliquez pour coller l'URL de la vidéo ou l'ID [Ctrl + V]",
|
||||
"preparing": "Préparation...",
|
||||
"processing": "Traitement",
|
||||
"progress": "Progrès",
|
||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectVideoFormat": "Sélectionner le Format Vidéo",
|
||||
"singleVideo": "Vidéo Unique",
|
||||
"speed": "Vitesse",
|
||||
"title": "Titre",
|
||||
"total": "Total",
|
||||
"unknownQuality": "Qualité inconnue",
|
||||
"unknownSize": "Taille inconnue",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Vidéo",
|
||||
"videoInfo": "Informations Vidéo",
|
||||
"videoInfoUpdated": "Informations vidéo mises à jour"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Cliquez pour copier les détails",
|
||||
"clipboardEmpty": "Le presse-papiers est vide",
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadNecessaryFilesFailed": "Échec du téléchargement des fichiers nécessaires. Veuillez vérifier votre réseau et réessayer",
|
||||
"emptyUrl": "Veuillez entrer une URL",
|
||||
"errorDetails": "Détails de l'Erreur",
|
||||
"fetchInfoFailed": "Échec de la récupération des informations vidéo",
|
||||
"networkError": "Une erreur s'est produite. Vérifiez votre réseau et utilisez une URL correcte",
|
||||
"pasteFromClipboard": "Échec du collage depuis le presse-papiers"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Effacer les Annulés",
|
||||
"clearCompleted": "Effacer les Terminés",
|
||||
"clearErrors": "Effacer les Erreurs",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"copyUrl": "Copier l'URL",
|
||||
"date": "Date",
|
||||
"description": "Voir et gérer votre historique de téléchargements",
|
||||
"duration": "Durée",
|
||||
"fileSize": "Taille du Fichier",
|
||||
"filters": {
|
||||
"all": "Tout",
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"errors": "Erreurs"
|
||||
},
|
||||
"noHistory": "Aucun historique de téléchargement encore",
|
||||
"noHistoryDescription": "Vos téléchargements terminés apparaîtront ici",
|
||||
"openDownloadFolder": "Ouvrir le Dossier de Téléchargement",
|
||||
"openFile": "Ouvrir le Fichier",
|
||||
"openFileLocation": "Ouvrir l'Emplacement du Fichier",
|
||||
"openFolder": "Ouvrir le Dossier",
|
||||
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
|
||||
"removeItem": "Supprimer l'Élément",
|
||||
"stats": {
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"errors": "Erreurs",
|
||||
"total": "Total"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"error": "Erreur"
|
||||
},
|
||||
"title": "Historique de Téléchargement"
|
||||
},
|
||||
"menu": {
|
||||
"about": "À propos",
|
||||
"download": "Télécharger",
|
||||
"playlist": "Télécharger la Playlist",
|
||||
"preferences": "Préférences",
|
||||
"supportedSites": "Sites Supportés",
|
||||
"theme": "Thème :"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Échec de la copie dans le presse-papiers",
|
||||
"downloadCompleted": "Téléchargement terminé",
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"itemRemoved": "Élément supprimé",
|
||||
"openFileFailed": "Échec de l'ouverture du fichier",
|
||||
"openFolderFailed": "Échec de l'ouverture du dossier",
|
||||
"removeFailed": "Échec de la suppression de l'élément",
|
||||
"settingsSaved": "Paramètres sauvegardés",
|
||||
"urlCopied": "URL copiée dans le presse-papiers",
|
||||
"videoCopied": "Vidéo copiée dans le presse-papiers"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "La fonctionnalité de téléchargement de playlist arrive bientôt !",
|
||||
"completed": "Playlist téléchargée",
|
||||
"description": "Télécharger toutes les vidéos d'une playlist ou chaîne YouTube",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement de playlist",
|
||||
"downloadPlaylist": "Télécharger la Playlist",
|
||||
"downloadStarted": "Démarré le téléchargement de {{count}} vidéos de la playlist",
|
||||
"downloadType": "Type de Téléchargement",
|
||||
"downloading": "Téléchargement de la playlist :",
|
||||
"endIndex": "Fin",
|
||||
"enterPlaylistUrl": "Entrer l'URL de la Playlist",
|
||||
"fetchFailed": "Échec de la récupération des informations de playlist",
|
||||
"filenameFormat": "Format de nom de fichier pour les playlists",
|
||||
"folderFormat": "Format de nom de dossier pour les playlists",
|
||||
"foundVideos": "Trouvé {{count}} vidéos dans la playlist",
|
||||
"linkLabel": "URL de la Playlist",
|
||||
"playlistUrlDescription": "Télécharger toutes les vidéos d'une playlist en lot",
|
||||
"range": "Plage (Optionnel)",
|
||||
"resetToDefault": "Réinitialiser par défaut",
|
||||
"startIndex": "Début (1)",
|
||||
"title": "Télécharger la Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "À propos",
|
||||
"advanced": "Avancé",
|
||||
"app": "Paramètres de l'App",
|
||||
"audio": "Préférences Audio",
|
||||
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
||||
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Utiliser le fichier de configuration",
|
||||
"configFileDescription": "Fichier de configuration personnalisé pour yt-dlp",
|
||||
"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",
|
||||
"fileSelectError": "Échec de la sélection du fichier",
|
||||
"general": "Général",
|
||||
"language": "Langue",
|
||||
"light": "Clair",
|
||||
"maxConcurrentDownloads": "Nombre maximum de téléchargements actifs",
|
||||
"maxConcurrentDownloadsDescription": "Nombre maximum de téléchargements simultanés",
|
||||
"none": "Aucun",
|
||||
"oneClickDownload": "Téléchargement en Un Clic",
|
||||
"oneClickDownloadDescription": "Activer le téléchargement en un clic avec les paramètres par défaut",
|
||||
"oneClickDownloadType": "Type de téléchargement par défaut",
|
||||
"oneClickDownloadTypeDescription": "Choisissez le type de téléchargement par défaut pour les téléchargements en un clic. La qualité utilise le préréglage ci-dessous.",
|
||||
"oneClickQuality": "Qualité préférée",
|
||||
"oneClickQualityDescription": "Sélectionnez le préréglage de qualité utilisé pour les téléchargements en un clic",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Mauvais",
|
||||
"best": "Meilleur",
|
||||
"good": "Bon",
|
||||
"normal": "Normal",
|
||||
"worst": "Pire"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Serveur proxy pour les requêtes réseau",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Sélectionner le fichier de configuration",
|
||||
"selectPath": "Sélectionner",
|
||||
"showMoreFormats": "Afficher plus d'options de format",
|
||||
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
||||
"system": "Système",
|
||||
"theme": "Thème",
|
||||
"themeDescription": "Choisissez un thème clair, sombre ou système pour VidBee",
|
||||
"title": "Paramètres",
|
||||
"tray": {
|
||||
"quit": "Quitter",
|
||||
"showHome": "Afficher l'Accueil"
|
||||
},
|
||||
"video": "Préférences Vidéo"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supporte {{sites}} et plus.",
|
||||
"moreDescription": "La liste complète yt-dlp est mise à jour constamment par la communauté.",
|
||||
"moreTitle": "Besoin d'un autre site ?",
|
||||
"openFullList": "Ouvrir la liste complète des sites supportés",
|
||||
"pageDescription": "VidBee utilise yt-dlp en arrière-plan pour atteindre des centaines de sources.",
|
||||
"pageIntro": "Voici les services principaux que les gens téléchargent le plus souvent.",
|
||||
"pageTitle": "Sites Supportés",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Albums d'artistes indépendants et sorties communautaires.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Actualités mondiales, sports et clips de divertissement.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Vidéos de flux, Watch et Reels des pages publiques.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Contenu de flux, Stories, Reels et Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Streams en direct et replays de créateurs sur la plateforme Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Conférences professionnelles, webinaires et vidéos d'apprentissage.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mixes DJ, émissions radio et audio long format.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animation japonaise, musique et archives de diffusion en direct.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Épingles d'idées, Reels de tutoriels et vidéos d'inspiration lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clips intégrés et vidéos hébergées des communautés.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Pistes musicales, playlists et sets DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Vidéos courtes mobiles, effets et streams en direct.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Médias courts créatifs et montages de fans.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Streams en direct de gaming, musique et IRL et VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Publications de timeline, enregistrements Spaces et diffusions.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hébergement vidéo de haute qualité pour créateurs et entreprises.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Vidéo long format et livestream de créateurs du monde entier.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Vidéos musicales officielles, albums et performances live.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Plateformes principales",
|
||||
"viewAll": "Voir tous les sites supportés"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/it.json
Normal file
394
src/renderer/src/locales/it.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Controlla aggiornamenti",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"openRepo": "Apri repository GitHub",
|
||||
"view": "Visualizza",
|
||||
"visit": "Visita"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Scarica e installa automaticamente le nuove versioni in background.",
|
||||
"autoUpdateTitle": "Aggiornamenti automatici",
|
||||
"betaProgramDescription": "Ricevi build anticipate e prossime funzionalità prima di tutti.",
|
||||
"betaProgramTitle": "Canale anteprima",
|
||||
"description": "VidBee è un downloader gratuito e open-source costruito con Electron e alimentato da yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Segui @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Rimani aggiornato con le ultime notizie e aggiornamenti di VidBee.",
|
||||
"followAuthorSupport": "Segui lo sviluppatore su X (Twitter) per ottenere gli ultimi aggiornamenti e notizie su VidBee.",
|
||||
"followAuthorTitle": "Segui lo Sviluppatore",
|
||||
"here": "qui",
|
||||
"homepage": "Homepage",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Ricerca aggiornamenti...",
|
||||
"downloadError": "Errore nel download dell'aggiornamento",
|
||||
"downloadStarted": "Download iniziato...",
|
||||
"downloadUpdate": "Scarica e installa l'aggiornamento {{version}}?",
|
||||
"noUpdatesAvailable": "Stai usando l'ultima versione",
|
||||
"restartToUpdate": "Riavvia ora per installare l'aggiornamento?",
|
||||
"updateAvailable": "Aggiornamento disponibile: {{version}}",
|
||||
"updateDownloaded": "Aggiornamento scaricato, riavvia per installare",
|
||||
"updateError": "Errore nel controllo degli aggiornamenti: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Regola le impostazioni di aggiornamento senza lasciare questa pagina.",
|
||||
"preferencesTitle": "Toggle Rapidi",
|
||||
"resources": {
|
||||
"changelog": "Note di rilascio",
|
||||
"changelogDescription": "Tieni traccia di cosa è cambiato in ogni versione.",
|
||||
"contact": "Supporto email",
|
||||
"contactDescription": "Contatta direttamente per aiuto o collaborazione.",
|
||||
"documentation": "Centro assistenza",
|
||||
"documentationDescription": "Guide, FAQ e flussi di lavoro comuni.",
|
||||
"feedback": "Feedback e problemi",
|
||||
"feedbackDescription": "Condividi idee o segnala problemi su GitHub.",
|
||||
"license": "Licenza",
|
||||
"licenseDescription": "Rivedi i termini della licenza open-source.",
|
||||
"website": "Sito web ufficiale",
|
||||
"websiteDescription": "Punti salienti del prodotto, roadmap e notizie della comunità."
|
||||
},
|
||||
"resourcesDescription": "Link utili per saperne di più su VidBee e rimanere connessi.",
|
||||
"resourcesTitle": "Risorse",
|
||||
"shareActions": {
|
||||
"copy": "Copia link",
|
||||
"facebook": "Condividi su Facebook",
|
||||
"twitter": "Condividi su X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Condividi VidBee con la tua comunità in un clic.",
|
||||
"shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.",
|
||||
"shareTitle": "Passa parola",
|
||||
"sourceCode": "Il codice sorgente è disponibile",
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Ultima: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nuova versione disponibile",
|
||||
"uptodate": "Sei aggiornato",
|
||||
"error": "Impossibile recuperare l'ultima versione"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Chiudi app quando il download finisce",
|
||||
"currentLocation": "Posizione download attuale - ",
|
||||
"downloadLocation": "Posizione download",
|
||||
"downloadSubs": "Scarica sottotitoli se disponibili",
|
||||
"end": "Fine",
|
||||
"endHint": "Se lasciato vuoto, verrà scaricato fino alla fine",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Seleziona Posizione Download",
|
||||
"start": "Inizio",
|
||||
"startHint": "Se lasciato vuoto, inizierà dall'inizio",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Sottotitoli",
|
||||
"timeRange": "Scarica intervallo di tempo specifico",
|
||||
"title": "Opzioni Avanzate"
|
||||
},
|
||||
"app": {
|
||||
"description": "Scarica video e audio da centinaia di siti",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Cattivo",
|
||||
"best": "Migliore",
|
||||
"extract": "Estrai",
|
||||
"good": "Buono",
|
||||
"normal": "Normale",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectQuality": "Seleziona Qualità",
|
||||
"title": "Estrai Audio",
|
||||
"worst": "Peggiore"
|
||||
},
|
||||
"download": {
|
||||
"active": "Attivo",
|
||||
"all": "Tutto",
|
||||
"audio": "Audio",
|
||||
"back": "Indietro",
|
||||
"cancel": "Annulla",
|
||||
"cancelled": "Annullato",
|
||||
"clearCompleted": "Cancella Completati",
|
||||
"clearDownloads": "Cancella Download",
|
||||
"completed": "Completato",
|
||||
"downloadAudio": "Scarica Audio",
|
||||
"downloadBtn": "Scarica",
|
||||
"downloadPending": "In attesa",
|
||||
"downloadQueue": "Coda Download",
|
||||
"downloadVideo": "Scarica Video",
|
||||
"downloading": "Scaricando...",
|
||||
"enterUrl": "Inserisci URL Video",
|
||||
"enterUrlDescription": "Incolla o digita un URL video. ",
|
||||
"error": "Errore",
|
||||
"fetch": "Recupera",
|
||||
"fetchingVideoInfo": "Recupero informazioni video...",
|
||||
"history": "Cronologia",
|
||||
"imageLoadError": "Errore nel caricamento dell'immagine",
|
||||
"imagePlaceholder": "Nessuna immagine disponibile",
|
||||
"infoUnavailable": "Download con Un Clic (Info non disponibile)",
|
||||
"loading": "Caricamento",
|
||||
"moreOptions": "Più opzioni",
|
||||
"noActiveDownloads": "Nessun download attivo",
|
||||
"noAudio": "Nessun Audio",
|
||||
"noHistory": "Nessuna cronologia download",
|
||||
"noItems": "Nessun elemento trovato",
|
||||
"oneClickDownload": "Download con Un Clic",
|
||||
"oneClickDownloadDescription": "Scarica direttamente con impostazioni predefinite senza conferma",
|
||||
"oneClickDownloadNow": "Scarica Ora",
|
||||
"oneClickDownloadStarted": "Download iniziato con impostazioni predefinite",
|
||||
"paste": "Incolla",
|
||||
"pastePlaylistUrl": "Clicca per incollare link playlist dagli appunti [Ctrl + V]",
|
||||
"pasteUrl": "Clicca per incollare URL video o ID [Ctrl + V]",
|
||||
"preparing": "Preparazione...",
|
||||
"processing": "Elaborazione",
|
||||
"progress": "Progresso",
|
||||
"selectAudioFormat": "Seleziona Formato Audio",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectVideoFormat": "Seleziona Formato Video",
|
||||
"singleVideo": "Video Singolo",
|
||||
"speed": "Velocità",
|
||||
"title": "Titolo",
|
||||
"total": "Totale",
|
||||
"unknownQuality": "Qualità sconosciuta",
|
||||
"unknownSize": "Dimensione sconosciuta",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Informazioni Video",
|
||||
"videoInfoUpdated": "Informazioni video aggiornate"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Clicca per copiare i dettagli",
|
||||
"clipboardEmpty": "Gli appunti sono vuoti",
|
||||
"downloadFailed": "Download fallito",
|
||||
"downloadNecessaryFilesFailed": "Errore nel download dei file necessari. Controlla la tua rete e riprova",
|
||||
"emptyUrl": "Inserisci un URL",
|
||||
"errorDetails": "Dettagli Errore",
|
||||
"fetchInfoFailed": "Errore nel recupero delle informazioni video",
|
||||
"networkError": "Si è verificato un errore. Controlla la tua rete e usa un URL corretto",
|
||||
"pasteFromClipboard": "Errore nell'incollare dagli appunti"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Cancella Annullati",
|
||||
"clearCompleted": "Cancella Completati",
|
||||
"clearErrors": "Cancella Errori",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"copyUrl": "Copia URL",
|
||||
"date": "Data",
|
||||
"description": "Visualizza e gestisci la tua cronologia download",
|
||||
"duration": "Durata",
|
||||
"fileSize": "Dimensione File",
|
||||
"filters": {
|
||||
"all": "Tutto",
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"errors": "Errori"
|
||||
},
|
||||
"noHistory": "Nessuna cronologia download ancora",
|
||||
"noHistoryDescription": "I tuoi download completati appariranno qui",
|
||||
"openDownloadFolder": "Apri Cartella Download",
|
||||
"openFile": "Apri File",
|
||||
"openFileLocation": "Apri Posizione File",
|
||||
"openFolder": "Apri Cartella",
|
||||
"openInBrowser": "Clicca per aprire nel browser",
|
||||
"removeItem": "Rimuovi Elemento",
|
||||
"stats": {
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"errors": "Errori",
|
||||
"total": "Totale"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"error": "Errore"
|
||||
},
|
||||
"title": "Cronologia Download"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Informazioni",
|
||||
"download": "Scarica",
|
||||
"playlist": "Scarica Playlist",
|
||||
"preferences": "Preferenze",
|
||||
"supportedSites": "Siti Supportati",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Errore nella copia negli appunti",
|
||||
"downloadCompleted": "Download completato",
|
||||
"downloadFailed": "Download fallito",
|
||||
"downloadStarted": "Download iniziato",
|
||||
"itemRemoved": "Elemento rimosso",
|
||||
"openFileFailed": "Errore nell'apertura del file",
|
||||
"openFolderFailed": "Errore nell'apertura della cartella",
|
||||
"removeFailed": "Errore nella rimozione dell'elemento",
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"urlCopied": "URL copiato negli appunti",
|
||||
"videoCopied": "Video copiato negli appunti"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "La funzionalità di download playlist arriverà presto!",
|
||||
"completed": "Playlist scaricata",
|
||||
"description": "Scarica tutti i video da una playlist o canale YouTube",
|
||||
"downloadFailed": "Errore nell'avvio del download playlist",
|
||||
"downloadPlaylist": "Scarica Playlist",
|
||||
"downloadStarted": "Iniziato download di {{count}} video dalla playlist",
|
||||
"downloadType": "Tipo Download",
|
||||
"downloading": "Scaricando playlist:",
|
||||
"endIndex": "Fine",
|
||||
"enterPlaylistUrl": "Inserisci URL Playlist",
|
||||
"fetchFailed": "Errore nel recupero delle informazioni playlist",
|
||||
"filenameFormat": "Formato nome file per playlist",
|
||||
"folderFormat": "Formato nome cartella per playlist",
|
||||
"foundVideos": "Trovati {{count}} video nella playlist",
|
||||
"linkLabel": "URL Playlist",
|
||||
"playlistUrlDescription": "Scarica tutti i video da una playlist in blocco",
|
||||
"range": "Intervallo (Opzionale)",
|
||||
"resetToDefault": "Ripristina predefinito",
|
||||
"startIndex": "Inizio (1)",
|
||||
"title": "Scarica Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Informazioni",
|
||||
"advanced": "Avanzato",
|
||||
"app": "Impostazioni App",
|
||||
"audio": "Preferenze Audio",
|
||||
"browserForCookies": "Seleziona browser per usare i cookie",
|
||||
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Usa file di configurazione",
|
||||
"configFileDescription": "File di configurazione personalizzato per yt-dlp",
|
||||
"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",
|
||||
"fileSelectError": "Errore nella selezione del file",
|
||||
"general": "Generale",
|
||||
"language": "Lingua",
|
||||
"light": "Chiaro",
|
||||
"maxConcurrentDownloads": "Numero massimo di download attivi",
|
||||
"maxConcurrentDownloadsDescription": "Numero massimo di download simultanei",
|
||||
"none": "Nessuno",
|
||||
"oneClickDownload": "Download con Un Clic",
|
||||
"oneClickDownloadDescription": "Abilita download con un clic con impostazioni predefinite",
|
||||
"oneClickDownloadType": "Tipo download predefinito",
|
||||
"oneClickDownloadTypeDescription": "Scegli il tipo di download predefinito per i download con un clic. La qualità usa il preset qui sotto.",
|
||||
"oneClickQuality": "Qualità preferita",
|
||||
"oneClickQualityDescription": "Seleziona il preset di qualità utilizzato per i download con un clic",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Cattivo",
|
||||
"best": "Migliore",
|
||||
"good": "Buono",
|
||||
"normal": "Normale",
|
||||
"worst": "Peggiore"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Server proxy per le richieste di rete",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Seleziona file di configurazione",
|
||||
"selectPath": "Seleziona",
|
||||
"showMoreFormats": "Mostra più opzioni formato",
|
||||
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
"themeDescription": "Scegli un tema chiaro, scuro o sistema per VidBee",
|
||||
"title": "Impostazioni",
|
||||
"tray": {
|
||||
"quit": "Esci",
|
||||
"showHome": "Mostra Home"
|
||||
},
|
||||
"video": "Preferenze Video"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supporta {{sites}} e altro.",
|
||||
"moreDescription": "La lista completa yt-dlp viene aggiornata costantemente dalla comunità.",
|
||||
"moreTitle": "Hai bisogno di un altro sito?",
|
||||
"openFullList": "Apri lista completa siti supportati",
|
||||
"pageDescription": "VidBee usa yt-dlp sotto il cofano per raggiungere centinaia di fonti.",
|
||||
"pageIntro": "Ecco i servizi principali da cui le persone scaricano più spesso.",
|
||||
"pageTitle": "Siti Supportati",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Album di artisti indipendenti e uscite della comunità.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Notizie globali, sport e clip di intrattenimento.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Video di feed, Watch e Reels da pagine pubbliche.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Contenuto di feed, Stories, Reels e Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Stream live e replay di creatori sulla piattaforma Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Talk professionali, webinar e video di apprendimento.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mix DJ, programmi radio e audio long-form.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animazione giapponese, musica e archivio di trasmissioni live.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Pin di idee, Reels tutorial e video di ispirazione lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clip incorporati e video ospitati dalle comunità.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Traccia musicali, playlist e set DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Video brevi mobili, effetti e stream live.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Media brevi creativi e montaggi di fan.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Stream live di gaming, musica e IRL e VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Post di timeline, registrazioni Spaces e trasmissioni.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hosting video di alta qualità per creatori e aziende.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Video long-form e livestream da creatori di tutto il mondo.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Video musicali ufficiali, album e performance live.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Piattaforme principali",
|
||||
"viewAll": "Visualizza tutti i siti supportati"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/ja.json
Normal file
394
src/renderer/src/locales/ja.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "アップデートを確認",
|
||||
"email": "メール",
|
||||
"feedback": "フィードバック",
|
||||
"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}}をダウンロードしてインストールしますか?",
|
||||
"noUpdatesAvailable": "最新バージョンを使用しています",
|
||||
"restartToUpdate": "今すぐ再起動してアップデートをインストールしますか?",
|
||||
"updateAvailable": "利用可能なアップデート:{{version}}",
|
||||
"updateDownloaded": "アップデートがダウンロードされました。再起動してインストールしてください",
|
||||
"updateError": "アップデートの確認に失敗:{{error}}"
|
||||
},
|
||||
"preferencesDescription": "このページを離れることなくアップデート設定を調整します。",
|
||||
"preferencesTitle": "クイックトグル",
|
||||
"resources": {
|
||||
"changelog": "リリースノート",
|
||||
"changelogDescription": "各バージョンで何が変更されたかを追跡します。",
|
||||
"contact": "メールサポート",
|
||||
"contactDescription": "ヘルプやコラボレーションのために直接連絡してください。",
|
||||
"documentation": "ヘルプセンター",
|
||||
"documentationDescription": "ガイド、FAQ、一般的なワークフロー。",
|
||||
"feedback": "フィードバックと問題",
|
||||
"feedbackDescription": "GitHubでアイデアを共有したり問題を報告したりしてください。",
|
||||
"license": "ライセンス",
|
||||
"licenseDescription": "オープンソースライセンス条項を確認してください。",
|
||||
"website": "公式ウェブサイト",
|
||||
"websiteDescription": "製品のハイライト、ロードマップ、コミュニティニュース。"
|
||||
},
|
||||
"resourcesDescription": "VidBeeについてもっと学び、つながりを保つための有用なリンク。",
|
||||
"resourcesTitle": "リソース",
|
||||
"shareActions": {
|
||||
"copy": "リンクをコピー",
|
||||
"facebook": "Facebookで共有",
|
||||
"twitter": "X (Twitter)で共有"
|
||||
},
|
||||
"shareDescription": "ワンクリックでコミュニティとVidBeeを共有してください。",
|
||||
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
|
||||
"shareTitle": "口コミを広める",
|
||||
"sourceCode": "ソースコードが利用可能",
|
||||
"title": "について",
|
||||
"version": "バージョン",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "新しいバージョンが利用可能",
|
||||
"uptodate": "最新バージョンを使用中",
|
||||
"error": "最新バージョンを取得できません"
|
||||
}
|
||||
},
|
||||
"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": "アイテムが見つかりません",
|
||||
"oneClickDownload": "ワンクリックダウンロード",
|
||||
"oneClickDownloadDescription": "確認なしでデフォルト設定で直接ダウンロード",
|
||||
"oneClickDownloadNow": "今すぐダウンロード",
|
||||
"oneClickDownloadStarted": "デフォルト設定でダウンロード開始",
|
||||
"paste": "貼り付け",
|
||||
"pastePlaylistUrl": "クリップボードからプレイリストリンクを貼り付け [Ctrl + V]",
|
||||
"pasteUrl": "ビデオURLまたはIDを貼り付け [Ctrl + V]",
|
||||
"preparing": "準備中...",
|
||||
"processing": "処理中",
|
||||
"progress": "進行状況",
|
||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||
"selectFormat": "フォーマットを選択",
|
||||
"selectVideoFormat": "ビデオフォーマットを選択",
|
||||
"singleVideo": "単一ビデオ",
|
||||
"speed": "速度",
|
||||
"title": "タイトル",
|
||||
"total": "合計",
|
||||
"unknownQuality": "不明な品質",
|
||||
"unknownSize": "不明なサイズ",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "ビデオ",
|
||||
"videoInfo": "ビデオ情報",
|
||||
"videoInfoUpdated": "ビデオ情報が更新されました"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "詳細をコピーするにはクリック",
|
||||
"clipboardEmpty": "クリップボードが空です",
|
||||
"downloadFailed": "ダウンロードに失敗",
|
||||
"downloadNecessaryFilesFailed": "必要なファイルのダウンロードに失敗しました。ネットワークを確認して再試行してください",
|
||||
"emptyUrl": "URLを入力してください",
|
||||
"errorDetails": "エラーの詳細",
|
||||
"fetchInfoFailed": "ビデオ情報の取得に失敗",
|
||||
"networkError": "エラーが発生しました。ネットワークを確認し、正しいURLを使用してください",
|
||||
"pasteFromClipboard": "クリップボードからの貼り付けに失敗"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "キャンセル済みをクリア",
|
||||
"clearCompleted": "完了をクリア",
|
||||
"clearErrors": "エラーをクリア",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyUrl": "URLをコピー",
|
||||
"date": "日付",
|
||||
"description": "ダウンロード履歴を表示および管理",
|
||||
"duration": "期間",
|
||||
"fileSize": "ファイルサイズ",
|
||||
"filters": {
|
||||
"all": "すべて",
|
||||
"cancelled": "キャンセル済み",
|
||||
"completed": "完了",
|
||||
"errors": "エラー"
|
||||
},
|
||||
"noHistory": "ダウンロード履歴はまだありません",
|
||||
"noHistoryDescription": "完了したダウンロードがここに表示されます",
|
||||
"openDownloadFolder": "ダウンロードフォルダを開く",
|
||||
"openFile": "ファイルを開く",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"openFolder": "フォルダを開く",
|
||||
"openInBrowser": "ブラウザで開くにはクリック",
|
||||
"removeItem": "アイテムを削除",
|
||||
"stats": {
|
||||
"cancelled": "キャンセル済み",
|
||||
"completed": "完了",
|
||||
"errors": "エラー",
|
||||
"total": "合計"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "キャンセル済み",
|
||||
"completed": "完了",
|
||||
"error": "エラー"
|
||||
},
|
||||
"title": "ダウンロード履歴"
|
||||
},
|
||||
"menu": {
|
||||
"about": "について",
|
||||
"download": "ダウンロード",
|
||||
"playlist": "プレイリストをダウンロード",
|
||||
"preferences": "設定",
|
||||
"supportedSites": "サポートされているサイト",
|
||||
"theme": "テーマ:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "クリップボードへのコピーに失敗",
|
||||
"downloadCompleted": "ダウンロード完了",
|
||||
"downloadFailed": "ダウンロードに失敗",
|
||||
"downloadStarted": "ダウンロード開始",
|
||||
"itemRemoved": "アイテムが削除されました",
|
||||
"openFileFailed": "ファイルの開封に失敗",
|
||||
"openFolderFailed": "フォルダの開封に失敗",
|
||||
"removeFailed": "アイテムの削除に失敗",
|
||||
"settingsSaved": "設定が保存されました",
|
||||
"urlCopied": "URLがクリップボードにコピーされました",
|
||||
"videoCopied": "ビデオがクリップボードにコピーされました"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
|
||||
"completed": "プレイリストがダウンロードされました",
|
||||
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
||||
"downloadFailed": "プレイリストダウンロードの開始に失敗",
|
||||
"downloadPlaylist": "プレイリストをダウンロード",
|
||||
"downloadStarted": "プレイリストから{{count}}個のビデオのダウンロードを開始",
|
||||
"downloadType": "ダウンロードタイプ",
|
||||
"downloading": "プレイリストをダウンロード中:",
|
||||
"endIndex": "終了",
|
||||
"enterPlaylistUrl": "プレイリストURLを入力",
|
||||
"fetchFailed": "プレイリスト情報の取得に失敗",
|
||||
"filenameFormat": "プレイリスト用ファイル名フォーマット",
|
||||
"folderFormat": "プレイリスト用フォルダ名フォーマット",
|
||||
"foundVideos": "プレイリストで{{count}}個のビデオを発見",
|
||||
"linkLabel": "プレイリストURL",
|
||||
"playlistUrlDescription": "プレイリストからすべてのビデオを一括ダウンロード",
|
||||
"range": "範囲(オプション)",
|
||||
"resetToDefault": "デフォルトにリセット",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "プレイリストをダウンロード"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "について",
|
||||
"advanced": "高度",
|
||||
"app": "アプリ設定",
|
||||
"audio": "オーディオ設定",
|
||||
"browserForCookies": "Cookieに使用するブラウザを選択",
|
||||
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "設定ファイルを使用",
|
||||
"configFileDescription": "yt-dlp用のカスタム設定ファイル",
|
||||
"dark": "ダーク",
|
||||
"description": "ダウンロード設定とアプリ設定を構成",
|
||||
"directorySelectError": "ディレクトリの選択に失敗",
|
||||
"downloadPath": "ダウンロード場所",
|
||||
"downloadPathDescription": "ダウンロードファイルの保存場所を選択",
|
||||
"fileSelectError": "ファイルの選択に失敗",
|
||||
"general": "一般",
|
||||
"language": "言語",
|
||||
"light": "ライト",
|
||||
"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": "インターフェースに追加のフォーマットオプションを表示",
|
||||
"system": "システム",
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "VidBeeのライト、ダーク、またはシステムテーマを選択",
|
||||
"title": "設定",
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
"showHome": "ホームを表示"
|
||||
},
|
||||
"video": "ビデオ設定"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "{{sites}}およびその他のサイトをサポートしています。",
|
||||
"moreDescription": "完全なyt-dlpリストはコミュニティによって継続的に更新されています。",
|
||||
"moreTitle": "他のサイトが必要ですか?",
|
||||
"openFullList": "サポートされているすべてのサイトリストを開く",
|
||||
"pageDescription": "VidBeeは数百のソースに到達するためにyt-dlpをバックグラウンドで使用します。",
|
||||
"pageIntro": "人々が最も頻繁にダウンロードする主要サービスです。",
|
||||
"pageTitle": "サポートされているサイト",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "インディペンデントアーティストのアルバムとコミュニティリリース。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "グローバルニュース、スポーツ、エンターテイメントクリップ。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "パブリックページからのフィード、Watch、Reels動画。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "フィード、Stories、Reels、ハイライトコンテンツ。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kickプラットフォームでのクリエイターライブストリームとリプレイ。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "プロフェッショナルトーク、ウェビナー、学習動画。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJミックス、ラジオ番組、ロングフォームオーディオ。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本のアニメーション、音楽、ライブ放送アーカイブ。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "アイデアピン、ハウツーReels、ライフスタイルインスピレーション動画。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "コミュニティからの埋め込みクリップとホスト動画。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音楽トラック、プレイリスト、DJセット。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "ショートフォームモバイル動画、エフェクト、ライブストリーム。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "クリエイティブショートフォームメディアとファン編集。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "ゲーミング、音楽、IRLライブストリームとVOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "タイムラインポスト、Spaces録音、放送。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "クリエイターとビジネス向け高品質動画ホスティング。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "世界中のクリエイターからのロングフォームとライブストリーム動画。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "公式ミュージックビデオ、アルバム、ライブパフォーマンス。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主要プラットフォーム",
|
||||
"viewAll": "サポートされているすべてのサイトを表示"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/ko.json
Normal file
394
src/renderer/src/locales/ko.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"email": "이메일",
|
||||
"feedback": "피드백",
|
||||
"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}}을(를) 다운로드하고 설치하시겠습니까?",
|
||||
"noUpdatesAvailable": "최신 버전을 사용 중입니다",
|
||||
"restartToUpdate": "지금 재시작하여 업데이트를 설치하시겠습니까?",
|
||||
"updateAvailable": "사용 가능한 업데이트: {{version}}",
|
||||
"updateDownloaded": "업데이트가 다운로드되었습니다. 재시작하여 설치하세요",
|
||||
"updateError": "업데이트 확인 실패: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "이 페이지를 떠나지 않고 업데이트 설정을 조정하세요.",
|
||||
"preferencesTitle": "빠른 토글",
|
||||
"resources": {
|
||||
"changelog": "릴리스 노트",
|
||||
"changelogDescription": "각 버전에서 변경된 내용을 확인하세요.",
|
||||
"contact": "이메일 지원",
|
||||
"contactDescription": "도움이나 협업을 위해 직접 연락하세요.",
|
||||
"documentation": "도움말 센터",
|
||||
"documentationDescription": "가이드, FAQ 및 일반적인 워크플로우.",
|
||||
"feedback": "피드백 및 문제",
|
||||
"feedbackDescription": "GitHub에서 아이디어를 공유하거나 문제를 신고하세요.",
|
||||
"license": "라이선스",
|
||||
"licenseDescription": "오픈소스 라이선스 조건을 검토하세요.",
|
||||
"website": "공식 웹사이트",
|
||||
"websiteDescription": "제품 하이라이트, 로드맵 및 커뮤니티 뉴스."
|
||||
},
|
||||
"resourcesDescription": "VidBee에 대해 더 알아보고 연결을 유지하는 유용한 링크입니다.",
|
||||
"resourcesTitle": "리소스",
|
||||
"shareActions": {
|
||||
"copy": "링크 복사",
|
||||
"facebook": "Facebook에서 공유",
|
||||
"twitter": "X (Twitter)에서 공유"
|
||||
},
|
||||
"shareDescription": "한 번의 클릭으로 커뮤니티와 VidBee를 공유하세요.",
|
||||
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
|
||||
"shareTitle": "소문을 퍼뜨리세요",
|
||||
"sourceCode": "소스 코드 사용 가능",
|
||||
"title": "정보",
|
||||
"version": "버전",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "최신: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "새 버전 사용 가능",
|
||||
"uptodate": "최신 버전 사용 중",
|
||||
"error": "최신 버전을 가져올 수 없음"
|
||||
}
|
||||
},
|
||||
"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": "항목을 찾을 수 없음",
|
||||
"oneClickDownload": "원클릭 다운로드",
|
||||
"oneClickDownloadDescription": "확인 없이 기본 설정으로 직접 다운로드",
|
||||
"oneClickDownloadNow": "지금 다운로드",
|
||||
"oneClickDownloadStarted": "기본 설정으로 다운로드 시작됨",
|
||||
"paste": "붙여넣기",
|
||||
"pastePlaylistUrl": "클립보드에서 재생목록 링크 붙여넣기 [Ctrl + V]",
|
||||
"pasteUrl": "비디오 URL 또는 ID 붙여넣기 [Ctrl + V]",
|
||||
"preparing": "준비 중...",
|
||||
"processing": "처리 중",
|
||||
"progress": "진행률",
|
||||
"selectAudioFormat": "오디오 형식 선택",
|
||||
"selectFormat": "형식 선택",
|
||||
"selectVideoFormat": "비디오 형식 선택",
|
||||
"singleVideo": "단일 비디오",
|
||||
"speed": "속도",
|
||||
"title": "제목",
|
||||
"total": "총계",
|
||||
"unknownQuality": "알 수 없는 품질",
|
||||
"unknownSize": "알 수 없는 크기",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "비디오",
|
||||
"videoInfo": "비디오 정보",
|
||||
"videoInfoUpdated": "비디오 정보 업데이트됨"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "세부 정보 복사하려면 클릭",
|
||||
"clipboardEmpty": "클립보드가 비어있음",
|
||||
"downloadFailed": "다운로드 실패",
|
||||
"downloadNecessaryFilesFailed": "필수 파일 다운로드 실패. 네트워크를 확인하고 다시 시도하세요",
|
||||
"emptyUrl": "URL을 입력하세요",
|
||||
"errorDetails": "오류 세부 정보",
|
||||
"fetchInfoFailed": "비디오 정보 가져오기 실패",
|
||||
"networkError": "오류가 발생했습니다. 네트워크를 확인하고 올바른 URL을 사용하세요",
|
||||
"pasteFromClipboard": "클립보드에서 붙여넣기 실패"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "취소된 항목 지우기",
|
||||
"clearCompleted": "완료된 항목 지우기",
|
||||
"clearErrors": "오류 지우기",
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"copyUrl": "URL 복사",
|
||||
"date": "날짜",
|
||||
"description": "다운로드 기록 보기 및 관리",
|
||||
"duration": "지속 시간",
|
||||
"fileSize": "파일 크기",
|
||||
"filters": {
|
||||
"all": "모두",
|
||||
"cancelled": "취소됨",
|
||||
"completed": "완료",
|
||||
"errors": "오류"
|
||||
},
|
||||
"noHistory": "다운로드 기록이 아직 없습니다",
|
||||
"noHistoryDescription": "완료된 다운로드가 여기에 표시됩니다",
|
||||
"openDownloadFolder": "다운로드 폴더 열기",
|
||||
"openFile": "파일 열기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"openFolder": "폴더 열기",
|
||||
"openInBrowser": "브라우저에서 열려면 클릭",
|
||||
"removeItem": "항목 제거",
|
||||
"stats": {
|
||||
"cancelled": "취소됨",
|
||||
"completed": "완료",
|
||||
"errors": "오류",
|
||||
"total": "총계"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "취소됨",
|
||||
"completed": "완료",
|
||||
"error": "오류"
|
||||
},
|
||||
"title": "다운로드 기록"
|
||||
},
|
||||
"menu": {
|
||||
"about": "정보",
|
||||
"download": "다운로드",
|
||||
"playlist": "재생목록 다운로드",
|
||||
"preferences": "환경설정",
|
||||
"supportedSites": "지원되는 사이트",
|
||||
"theme": "테마:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "클립보드에 복사 실패",
|
||||
"downloadCompleted": "다운로드 완료",
|
||||
"downloadFailed": "다운로드 실패",
|
||||
"downloadStarted": "다운로드 시작됨",
|
||||
"itemRemoved": "항목 제거됨",
|
||||
"openFileFailed": "파일 열기 실패",
|
||||
"openFolderFailed": "폴더 열기 실패",
|
||||
"removeFailed": "항목 제거 실패",
|
||||
"settingsSaved": "설정 저장됨",
|
||||
"urlCopied": "URL이 클립보드에 복사됨",
|
||||
"videoCopied": "비디오가 클립보드에 복사됨"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "재생목록 다운로드 기능이 곧 출시됩니다!",
|
||||
"completed": "재생목록 다운로드됨",
|
||||
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
||||
"downloadFailed": "재생목록 다운로드 시작 실패",
|
||||
"downloadPlaylist": "재생목록 다운로드",
|
||||
"downloadStarted": "재생목록에서 {{count}}개 비디오 다운로드 시작됨",
|
||||
"downloadType": "다운로드 유형",
|
||||
"downloading": "재생목록 다운로드 중:",
|
||||
"endIndex": "끝",
|
||||
"enterPlaylistUrl": "재생목록 URL 입력",
|
||||
"fetchFailed": "재생목록 정보 가져오기 실패",
|
||||
"filenameFormat": "재생목록용 파일명 형식",
|
||||
"folderFormat": "재생목록용 폴더명 형식",
|
||||
"foundVideos": "재생목록에서 {{count}}개 비디오 발견",
|
||||
"linkLabel": "재생목록 URL",
|
||||
"playlistUrlDescription": "재생목록의 모든 비디오를 일괄 다운로드",
|
||||
"range": "범위 (선택사항)",
|
||||
"resetToDefault": "기본값으로 재설정",
|
||||
"startIndex": "시작 (1)",
|
||||
"title": "재생목록 다운로드"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "정보",
|
||||
"advanced": "고급",
|
||||
"app": "앱 설정",
|
||||
"audio": "오디오 환경설정",
|
||||
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
||||
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "설정 파일 사용",
|
||||
"configFileDescription": "yt-dlp용 사용자 정의 설정 파일",
|
||||
"dark": "다크",
|
||||
"description": "다운로드 환경설정 및 앱 설정 구성",
|
||||
"directorySelectError": "디렉토리 선택 실패",
|
||||
"downloadPath": "다운로드 위치",
|
||||
"downloadPathDescription": "다운로드 파일을 저장할 위치 선택",
|
||||
"fileSelectError": "파일 선택 실패",
|
||||
"general": "일반",
|
||||
"language": "언어",
|
||||
"light": "라이트",
|
||||
"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": "인터페이스에 추가 형식 옵션 표시",
|
||||
"system": "시스템",
|
||||
"theme": "테마",
|
||||
"themeDescription": "VidBee용 라이트, 다크 또는 시스템 테마 선택",
|
||||
"title": "설정",
|
||||
"tray": {
|
||||
"quit": "종료",
|
||||
"showHome": "홈 표시"
|
||||
},
|
||||
"video": "비디오 환경설정"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "{{sites}} 및 더 많은 사이트를 지원합니다.",
|
||||
"moreDescription": "완전한 yt-dlp 목록은 커뮤니티에 의해 지속적으로 업데이트됩니다.",
|
||||
"moreTitle": "다른 사이트가 필요하신가요?",
|
||||
"openFullList": "지원되는 모든 사이트 목록 열기",
|
||||
"pageDescription": "VidBee는 수백 개의 소스에 도달하기 위해 yt-dlp를 백그라운드에서 사용합니다.",
|
||||
"pageIntro": "사람들이 가장 자주 다운로드하는 주요 서비스들입니다.",
|
||||
"pageTitle": "지원되는 사이트",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "인디 아티스트 앨범 및 커뮤니티 릴리스.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "글로벌 뉴스, 스포츠 및 엔터테인먼트 클립.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "공개 페이지의 피드, Watch 및 Reels 비디오.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "피드, Stories, Reels 및 Highlights 콘텐츠.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 플랫폼의 크리에이터 라이브 스트림 및 리플레이.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "전문 강연, 웨비나 및 학습 비디오.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 믹스, 라디오 쇼 및 롱폼 오디오.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "일본 애니메이션, 음악 및 라이브 방송 아카이브.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "아이디어 핀, 하우투 Reels 및 라이프스타일 영감 비디오.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "커뮤니티의 임베드 클립 및 호스팅 비디오.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "음악 트랙, 플레이리스트 및 DJ 세트.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "짧은 모바일 비디오, 효과 및 라이브 스트림.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "창의적인 짧은 미디어 및 팬 편집.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "게임, 음악 및 IRL 라이브 스트림 및 VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "타임라인 포스트, Spaces 녹음 및 방송.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "크리에이터 및 비즈니스용 고품질 비디오 호스팅.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "전 세계 크리에이터의 롱폼 및 라이브스트림 비디오.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "공식 뮤직 비디오, 앨범 및 라이브 공연.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "주요 플랫폼",
|
||||
"viewAll": "지원되는 모든 사이트 보기"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/pt.json
Normal file
394
src/renderer/src/locales/pt.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Verificar atualizações",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"openRepo": "Abrir repositório GitHub",
|
||||
"view": "Ver",
|
||||
"visit": "Visitar"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Baixar e instalar novas versões automaticamente em segundo plano.",
|
||||
"autoUpdateTitle": "Atualizações automáticas",
|
||||
"betaProgramDescription": "Receba builds antecipados e próximos recursos antes de todos.",
|
||||
"betaProgramTitle": "Canal de visualização",
|
||||
"description": "VidBee é um baixador gratuito e de código aberto construído com Electron e alimentado por yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Seguir @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Mantenha-se atualizado com as últimas notícias e atualizações do VidBee.",
|
||||
"followAuthorSupport": "Siga o desenvolvedor no X (Twitter) para obter as últimas atualizações e notícias sobre VidBee.",
|
||||
"followAuthorTitle": "Seguir o Desenvolvedor",
|
||||
"here": "aqui",
|
||||
"homepage": "Página inicial",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Procurando atualizações...",
|
||||
"downloadError": "Falha ao baixar atualização",
|
||||
"downloadStarted": "Download iniciado...",
|
||||
"downloadUpdate": "Baixar e instalar atualização {{version}}?",
|
||||
"noUpdatesAvailable": "Você está usando a versão mais recente",
|
||||
"restartToUpdate": "Reiniciar agora para instalar atualização?",
|
||||
"updateAvailable": "Atualização disponível: {{version}}",
|
||||
"updateDownloaded": "Atualização baixada, reinicie para instalar",
|
||||
"updateError": "Falha ao verificar atualizações: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Ajuste configurações de atualização sem sair desta página.",
|
||||
"preferencesTitle": "Alternâncias Rápidas",
|
||||
"resources": {
|
||||
"changelog": "Notas da versão",
|
||||
"changelogDescription": "Acompanhe o que mudou em cada versão.",
|
||||
"contact": "Suporte por email",
|
||||
"contactDescription": "Entre em contato diretamente para ajuda ou colaboração.",
|
||||
"documentation": "Central de ajuda",
|
||||
"documentationDescription": "Guias, FAQs e fluxos de trabalho comuns.",
|
||||
"feedback": "Feedback e problemas",
|
||||
"feedbackDescription": "Compartilhe ideias ou reporte problemas no GitHub.",
|
||||
"license": "Licença",
|
||||
"licenseDescription": "Revise os termos da licença de código aberto.",
|
||||
"website": "Site oficial",
|
||||
"websiteDescription": "Destaques do produto, roadmap e notícias da comunidade."
|
||||
},
|
||||
"resourcesDescription": "Links úteis para aprender mais sobre VidBee e manter-se conectado.",
|
||||
"resourcesTitle": "Recursos",
|
||||
"shareActions": {
|
||||
"copy": "Copiar link",
|
||||
"facebook": "Compartilhar no Facebook",
|
||||
"twitter": "Compartilhar no X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Compartilhe VidBee com sua comunidade em um clique.",
|
||||
"shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.",
|
||||
"shareTitle": "Espalhe a palavra",
|
||||
"sourceCode": "Código fonte disponível",
|
||||
"title": "Sobre",
|
||||
"version": "Versão",
|
||||
"versionLabel": "v{{version}}",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fechar aplicativo quando download terminar",
|
||||
"currentLocation": "Local de download atual - ",
|
||||
"downloadLocation": "Local de download",
|
||||
"downloadSubs": "Baixar legendas se disponíveis",
|
||||
"end": "Fim",
|
||||
"endHint": "Se deixado vazio, será baixado até o final",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Selecionar Local de Download",
|
||||
"start": "Início",
|
||||
"startHint": "Se deixado vazio, começará do início",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Legendas",
|
||||
"timeRange": "Baixar intervalo de tempo específico",
|
||||
"title": "Opções Avançadas"
|
||||
},
|
||||
"app": {
|
||||
"description": "Baixar vídeos e áudios de centenas de sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Ruim",
|
||||
"best": "Melhor",
|
||||
"extract": "Extrair",
|
||||
"good": "Bom",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectQuality": "Selecionar Qualidade",
|
||||
"title": "Extrair Áudio",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"download": {
|
||||
"active": "Ativo",
|
||||
"all": "Todos",
|
||||
"audio": "Áudio",
|
||||
"back": "Voltar",
|
||||
"cancel": "Cancelar",
|
||||
"cancelled": "Cancelado",
|
||||
"clearCompleted": "Limpar Concluídos",
|
||||
"clearDownloads": "Limpar Downloads",
|
||||
"completed": "Concluído",
|
||||
"downloadAudio": "Baixar Áudio",
|
||||
"downloadBtn": "Baixar",
|
||||
"downloadPending": "Pendente",
|
||||
"downloadQueue": "Fila de Download",
|
||||
"downloadVideo": "Baixar Vídeo",
|
||||
"downloading": "Baixando...",
|
||||
"enterUrl": "Inserir URL do Vídeo",
|
||||
"enterUrlDescription": "Cole ou digite uma URL de vídeo. ",
|
||||
"error": "Erro",
|
||||
"fetch": "Buscar",
|
||||
"fetchingVideoInfo": "Buscando informações do vídeo...",
|
||||
"history": "Histórico",
|
||||
"imageLoadError": "Falha ao carregar imagem",
|
||||
"imagePlaceholder": "Nenhuma imagem disponível",
|
||||
"infoUnavailable": "Download de Um Clique (Info indisponível)",
|
||||
"loading": "Carregando",
|
||||
"moreOptions": "Mais opções",
|
||||
"noActiveDownloads": "Nenhum download ativo",
|
||||
"noAudio": "Sem Áudio",
|
||||
"noHistory": "Nenhum histórico de download",
|
||||
"noItems": "Nenhum item encontrado",
|
||||
"oneClickDownload": "Download de Um Clique",
|
||||
"oneClickDownloadDescription": "Baixar diretamente com configurações padrão sem confirmação",
|
||||
"oneClickDownloadNow": "Baixar Agora",
|
||||
"oneClickDownloadStarted": "Download iniciado com configurações padrão",
|
||||
"paste": "Colar",
|
||||
"pastePlaylistUrl": "Clique para colar link da playlist da área de transferência [Ctrl + V]",
|
||||
"pasteUrl": "Clique para colar URL do vídeo ou ID [Ctrl + V]",
|
||||
"preparing": "Preparando...",
|
||||
"processing": "Processando",
|
||||
"progress": "Progresso",
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"singleVideo": "Vídeo Único",
|
||||
"speed": "Velocidade",
|
||||
"title": "Título",
|
||||
"total": "Total",
|
||||
"unknownQuality": "Qualidade desconhecida",
|
||||
"unknownSize": "Tamanho desconhecido",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Vídeo",
|
||||
"videoInfo": "Informações do Vídeo",
|
||||
"videoInfoUpdated": "Informações do vídeo atualizadas"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Clique para copiar detalhes",
|
||||
"clipboardEmpty": "Área de transferência vazia",
|
||||
"downloadFailed": "Download falhou",
|
||||
"downloadNecessaryFilesFailed": "Falha ao baixar arquivos necessários. Verifique sua rede e tente novamente",
|
||||
"emptyUrl": "Por favor, insira uma URL",
|
||||
"errorDetails": "Detalhes do Erro",
|
||||
"fetchInfoFailed": "Falha ao buscar informações do vídeo",
|
||||
"networkError": "Algum erro ocorreu. Verifique sua rede e use uma URL correta",
|
||||
"pasteFromClipboard": "Falha ao colar da área de transferência"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Limpar Cancelados",
|
||||
"clearCompleted": "Limpar Concluídos",
|
||||
"clearErrors": "Limpar Erros",
|
||||
"copyToClipboard": "Copiar para área de transferência",
|
||||
"copyUrl": "Copiar URL",
|
||||
"date": "Data",
|
||||
"description": "Ver e gerenciar seu histórico de downloads",
|
||||
"duration": "Duração",
|
||||
"fileSize": "Tamanho do Arquivo",
|
||||
"filters": {
|
||||
"all": "Todos",
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"errors": "Erros"
|
||||
},
|
||||
"noHistory": "Nenhum histórico de download ainda",
|
||||
"noHistoryDescription": "Seus downloads concluídos aparecerão aqui",
|
||||
"openDownloadFolder": "Abrir Pasta de Downloads",
|
||||
"openFile": "Abrir Arquivo",
|
||||
"openFileLocation": "Abrir Localização do Arquivo",
|
||||
"openFolder": "Abrir Pasta",
|
||||
"openInBrowser": "Clique para abrir no navegador",
|
||||
"removeItem": "Remover Item",
|
||||
"stats": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"errors": "Erros",
|
||||
"total": "Total"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"error": "Erro"
|
||||
},
|
||||
"title": "Histórico de Downloads"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Sobre",
|
||||
"download": "Download",
|
||||
"playlist": "Baixar Playlist",
|
||||
"preferences": "Preferências",
|
||||
"supportedSites": "Sites Suportados",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Falha ao copiar para área de transferência",
|
||||
"downloadCompleted": "Download concluído",
|
||||
"downloadFailed": "Download falhou",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"itemRemoved": "Item removido",
|
||||
"openFileFailed": "Falha ao abrir arquivo",
|
||||
"openFolderFailed": "Falha ao abrir pasta",
|
||||
"removeFailed": "Falha ao remover item",
|
||||
"settingsSaved": "Configurações salvas",
|
||||
"urlCopied": "URL copiada para área de transferência",
|
||||
"videoCopied": "Vídeo copiado para área de transferência"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "Recurso de download de playlist em breve!",
|
||||
"completed": "Playlist baixada",
|
||||
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
|
||||
"downloadFailed": "Falha ao iniciar download da playlist",
|
||||
"downloadPlaylist": "Baixar Playlist",
|
||||
"downloadStarted": "Iniciado download de {{count}} vídeos da playlist",
|
||||
"downloadType": "Tipo de Download",
|
||||
"downloading": "Baixando playlist:",
|
||||
"endIndex": "Fim",
|
||||
"enterPlaylistUrl": "Inserir URL da Playlist",
|
||||
"fetchFailed": "Falha ao buscar informações da playlist",
|
||||
"filenameFormat": "Formato de nome de arquivo para playlists",
|
||||
"folderFormat": "Formato de nome de pasta para playlists",
|
||||
"foundVideos": "Encontrados {{count}} vídeos na playlist",
|
||||
"linkLabel": "URL da Playlist",
|
||||
"playlistUrlDescription": "Baixar todos os vídeos de uma playlist em lote",
|
||||
"range": "Intervalo (Opcional)",
|
||||
"resetToDefault": "Redefinir para padrão",
|
||||
"startIndex": "Início (1)",
|
||||
"title": "Baixar Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Sobre",
|
||||
"advanced": "Avançado",
|
||||
"app": "Configurações do App",
|
||||
"audio": "Preferências de Áudio",
|
||||
"browserForCookies": "Selecionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Usar arquivo de configuração",
|
||||
"configFileDescription": "Arquivo de configuração personalizado para yt-dlp",
|
||||
"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",
|
||||
"fileSelectError": "Falha ao selecionar arquivo",
|
||||
"general": "Geral",
|
||||
"language": "Idioma",
|
||||
"light": "Claro",
|
||||
"maxConcurrentDownloads": "Número máximo de downloads ativos",
|
||||
"maxConcurrentDownloadsDescription": "Número máximo de downloads simultâneos",
|
||||
"none": "Nenhum",
|
||||
"oneClickDownload": "Download de Um Clique",
|
||||
"oneClickDownloadDescription": "Habilitar download de um clique com configurações padrão",
|
||||
"oneClickDownloadType": "Tipo de download padrão",
|
||||
"oneClickDownloadTypeDescription": "Escolha o tipo de download padrão para downloads de um clique. A qualidade usa o preset abaixo.",
|
||||
"oneClickQuality": "Qualidade preferida",
|
||||
"oneClickQualityDescription": "Selecione o preset de qualidade usado para downloads de um clique",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Automático",
|
||||
"bad": "Ruim",
|
||||
"best": "Melhor",
|
||||
"good": "Bom",
|
||||
"normal": "Normal",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Servidor proxy para requisições de rede",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Selecionar arquivo de configuração",
|
||||
"selectPath": "Selecionar",
|
||||
"showMoreFormats": "Mostrar mais opções de formato",
|
||||
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
"themeDescription": "Escolha um tema claro, escuro ou sistema para VidBee",
|
||||
"title": "Configurações",
|
||||
"tray": {
|
||||
"quit": "Sair",
|
||||
"showHome": "Mostrar Início"
|
||||
},
|
||||
"video": "Preferências de Vídeo"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Suporta {{sites}} e mais.",
|
||||
"moreDescription": "A lista completa do yt-dlp é atualizada constantemente pela comunidade.",
|
||||
"moreTitle": "Precisa de outro site?",
|
||||
"openFullList": "Abrir lista completa de sites suportados",
|
||||
"pageDescription": "VidBee usa yt-dlp nos bastidores para alcançar centenas de fontes.",
|
||||
"pageIntro": "Aqui estão os serviços principais que as pessoas mais baixam.",
|
||||
"pageTitle": "Sites Suportados",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Álbuns de artistas independentes e lançamentos da comunidade.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Notícias globais, esportes e clipes de entretenimento.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Vídeos de feed, Watch e Reels de páginas públicas.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Conteúdo de feed, Stories, Reels e Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Streams ao vivo e replays de criadores na plataforma Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Palestras profissionais, webinars e vídeos de aprendizado.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mixes de DJ, programas de rádio e áudio long-form.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animação japonesa, música e arquivo de transmissão ao vivo.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Pins de ideias, Reels de tutoriais e vídeos de inspiração lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clipes incorporados e vídeos hospedados das comunidades.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Faixas musicais, playlists e sets de DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Vídeos curtos móveis, efeitos e streams ao vivo.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Mídia curta criativa e edições de fãs.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Streams ao vivo de gaming, música e IRL e VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Posts de timeline, gravações Spaces e transmissões.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hospedagem de vídeo de alta qualidade para criadores e empresas.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Vídeo long-form e livestream de criadores em todo o mundo.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Vídeos musicais oficiais, álbuns e performances ao vivo.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Plataformas principais",
|
||||
"viewAll": "Ver todos os sites suportados"
|
||||
}
|
||||
}
|
||||
418
src/renderer/src/locales/zh-TW.json
Normal file
418
src/renderer/src/locales/zh-TW.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"email": "電子郵件",
|
||||
"feedback": "意見回饋",
|
||||
"openRepo": "開啟 GitHub 儲存庫",
|
||||
"view": "檢視",
|
||||
"visit": "造訪"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "在背景自動下載並安裝新版本。",
|
||||
"autoUpdateTitle": "自動更新",
|
||||
"betaProgramDescription": "搶先獲得預覽版和即將發布的新功能。",
|
||||
"betaProgramTitle": "預覽通道",
|
||||
"description": "VidBee 是一個基於 Node.js 和 Electron 構建的自由開源應用,使用 yt-dlp 來完成下載。",
|
||||
"followAuthorActions": {
|
||||
"follow": "關注 @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "獲取 VidBee 的最新消息和更新。",
|
||||
"followAuthorSupport": "在 X (Twitter) 上關注開發者,獲取 VidBee 的最新更新和消息。",
|
||||
"followAuthorTitle": "關注開發者",
|
||||
"here": "此處",
|
||||
"homepage": "首頁",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"error": "無法取得最新版本",
|
||||
"uptodate": "您已是最新版本"
|
||||
},
|
||||
"notifications": {
|
||||
"checkingUpdates": "正在搜尋更新...",
|
||||
"downloadError": "下載更新失敗",
|
||||
"downloadStarted": "開始下載...",
|
||||
"downloadUpdate": "下載並安裝更新 {{version}}?",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"restartToUpdate": "立即重新啟動以安裝更新?",
|
||||
"updateAvailable": "發現新版本:{{version}}",
|
||||
"updateDownloaded": "更新已下載,重新啟動以安裝",
|
||||
"updateError": "檢查更新失敗:{{error}}"
|
||||
},
|
||||
"preferencesDescription": "無需離開此頁即可調整更新設定。",
|
||||
"preferencesTitle": "快速切換",
|
||||
"resources": {
|
||||
"changelog": "發行說明",
|
||||
"changelogDescription": "了解每個版本的變更內容。",
|
||||
"contact": "電子郵件支援",
|
||||
"contactDescription": "直接聯繫我們以獲取協助或開展合作。",
|
||||
"documentation": "說明中心",
|
||||
"documentationDescription": "指南、常見問題和常見流程。",
|
||||
"feedback": "意見回饋與問題",
|
||||
"feedbackDescription": "在 GitHub 上分享想法或回報問題。",
|
||||
"license": "授權條款",
|
||||
"licenseDescription": "查閱開源授權條款。",
|
||||
"website": "官方網站",
|
||||
"websiteDescription": "產品亮點、路線圖與社群動態。"
|
||||
},
|
||||
"resourcesDescription": "了解 VidBee 並保持關注的實用連結。",
|
||||
"resourcesTitle": "資源",
|
||||
"shareActions": {
|
||||
"copy": "複製連結",
|
||||
"facebook": "在 Facebook 上分享",
|
||||
"twitter": "在 X (Twitter) 上分享"
|
||||
},
|
||||
"shareDescription": "一鍵與您的社群分享 VidBee。",
|
||||
"shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。",
|
||||
"shareTitle": "廣為宣傳",
|
||||
"sourceCode": "原始碼已開放",
|
||||
"title": "關於",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "下載完成後關閉應用程式",
|
||||
"currentLocation": "目前下載位置 - ",
|
||||
"downloadLocation": "下載位置",
|
||||
"downloadSubs": "若有字幕則下載",
|
||||
"end": "結束",
|
||||
"endHint": "如果留空,將下載到結尾",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "選擇下載位置",
|
||||
"start": "開始",
|
||||
"startHint": "如果留空,將從開頭開始",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "字幕",
|
||||
"timeRange": "下載指定時間範圍",
|
||||
"title": "進階選項"
|
||||
},
|
||||
"app": {
|
||||
"description": "從數百個網站下載影片和音訊",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "較差",
|
||||
"best": "最佳",
|
||||
"extract": "擷取",
|
||||
"good": "良好",
|
||||
"normal": "標準",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectQuality": "選擇品質",
|
||||
"title": "擷取音訊",
|
||||
"worst": "最差"
|
||||
},
|
||||
"download": {
|
||||
"active": "進行中",
|
||||
"all": "全部",
|
||||
"audio": "音訊",
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"cancelled": "已取消",
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearDownloads": "清除下載",
|
||||
"completed": "已完成",
|
||||
"downloadAudio": "下載音訊",
|
||||
"downloadBtn": "下載",
|
||||
"downloadPending": "待處理",
|
||||
"downloadQueue": "下載佇列",
|
||||
"downloadVideo": "下載影片",
|
||||
"downloading": "正在下載...",
|
||||
"enterUrl": "輸入影片連結",
|
||||
"enterUrlDescription": "貼上或輸入一個影片連結。",
|
||||
"error": "錯誤",
|
||||
"fetch": "取得",
|
||||
"fetchingVideoInfo": "正在取得影片資訊...",
|
||||
"history": "歷史",
|
||||
"imageLoadError": "圖片載入失敗",
|
||||
"imagePlaceholder": "暫無圖片",
|
||||
"infoUnavailable": "一鍵下載(資訊不可用)",
|
||||
"loading": "載入中",
|
||||
"moreOptions": "更多選項",
|
||||
"noActiveDownloads": "暫無進行中的下載",
|
||||
"noAudio": "無音訊",
|
||||
"noHistory": "暫無下載歷史",
|
||||
"noItems": "未找到項目",
|
||||
"oneClickDownload": "一鍵下載",
|
||||
"oneClickDownloadDescription": "使用預設設定直接下載,無需確認",
|
||||
"oneClickDownloadNow": "立即下載",
|
||||
"oneClickDownloadStarted": "已使用預設設定開始下載",
|
||||
"paste": "貼上",
|
||||
"pastePlaylistUrl": "點擊從剪貼簿貼上播放清單連結 [Ctrl + V]",
|
||||
"pasteUrl": "點擊貼上影片連結或 ID [Ctrl + V]",
|
||||
"preparing": "正在準備...",
|
||||
"processing": "處理中",
|
||||
"progress": "進度",
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"singleVideo": "單個影片",
|
||||
"speed": "速度",
|
||||
"title": "標題",
|
||||
"total": "總計",
|
||||
"unknownQuality": "未知品質",
|
||||
"unknownSize": "未知大小",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "影片",
|
||||
"videoInfo": "影片資訊",
|
||||
"videoInfoUpdated": "影片資訊已更新"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "點擊複製詳情",
|
||||
"clipboardEmpty": "剪貼簿為空",
|
||||
"downloadFailed": "下載失敗",
|
||||
"downloadNecessaryFilesFailed": "必要檔案下載失敗。請檢查網路後再試",
|
||||
"emptyUrl": "請輸入連結",
|
||||
"errorDetails": "錯誤詳情",
|
||||
"fetchInfoFailed": "取得影片資訊失敗",
|
||||
"networkError": "發生錯誤。請檢查網路並確認連結正確",
|
||||
"pasteFromClipboard": "從剪貼簿貼上失敗"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "清除已取消",
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除錯誤",
|
||||
"copyToClipboard": "複製到剪貼簿",
|
||||
"copyUrl": "複製連結",
|
||||
"date": "日期",
|
||||
"description": "檢視並管理下載歷史",
|
||||
"duration": "時長",
|
||||
"fileSize": "檔案大小",
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"errors": "錯誤"
|
||||
},
|
||||
"noHistory": "暫無下載歷史",
|
||||
"noHistoryDescription": "完成的下載會顯示在這裡",
|
||||
"openDownloadFolder": "開啟下載資料夾",
|
||||
"openFile": "開啟檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"openFolder": "開啟資料夾",
|
||||
"openInBrowser": "點擊在瀏覽器中開啟",
|
||||
"removeItem": "移除項目",
|
||||
"stats": {
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"errors": "錯誤",
|
||||
"total": "總計"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"error": "錯誤"
|
||||
},
|
||||
"title": "下載歷史"
|
||||
},
|
||||
"menu": {
|
||||
"about": "關於",
|
||||
"download": "下載",
|
||||
"playlist": "下載播放清單",
|
||||
"preferences": "偏好設定",
|
||||
"supportedSites": "支援的網站",
|
||||
"theme": "主題:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "複製到剪貼簿失敗",
|
||||
"downloadCompleted": "下載完成",
|
||||
"downloadFailed": "下載失敗",
|
||||
"downloadStarted": "下載已開始",
|
||||
"itemRemoved": "項目已移除",
|
||||
"openFileFailed": "開啟檔案失敗",
|
||||
"openFolderFailed": "開啟資料夾失敗",
|
||||
"removeFailed": "移除項目失敗",
|
||||
"settingsSaved": "設定已儲存",
|
||||
"urlCopied": "連結已複製到剪貼簿",
|
||||
"videoCopied": "影片已複製到剪貼簿"
|
||||
},
|
||||
"playlist": {
|
||||
"clearPreview": "清晰預覽",
|
||||
"comingSoon": "播放清單下載功能即將推出!",
|
||||
"completed": "播放清單已下載",
|
||||
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
||||
"downloadFailed": "啟動播放清單下載失敗",
|
||||
"downloadPlaylist": "下載播放清單",
|
||||
"downloadStarted": "已開始下載播放清單中的 {{count}} 個影片",
|
||||
"downloadType": "下載類型",
|
||||
"downloading": "正在下載播放清單:",
|
||||
"endIndex": "結束",
|
||||
"enterPlaylistUrl": "輸入播放清單連結",
|
||||
"fetchFailed": "取得播放清單資訊失敗",
|
||||
"filenameFormat": "播放清單檔案名稱格式",
|
||||
"folderFormat": "播放清單資料夾命名格式",
|
||||
"foundVideos": "在播放清單中找到 {{count}} 個影片",
|
||||
"groupActive": "{{count}} 個活躍",
|
||||
"groupErrors": "{{count}} 失敗",
|
||||
"groupSummary": "{{已完成}} / {{總計}}已完成",
|
||||
"linkLabel": "播放清單連結",
|
||||
"noEntries": "在此播放列表中找不到視頻",
|
||||
"noEntriesInRange": "所選範圍內沒有視頻",
|
||||
"noRangeSelected": "沒有結束設置 - 已選擇完整播放列表",
|
||||
"playlistUrlDescription": "批量下載播放清單中的所有影片",
|
||||
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
|
||||
"previewButton": "預覽播放列表",
|
||||
"previewFailed": "預覽播放列表失敗",
|
||||
"previewRequired": "下載前預覽播放列表。",
|
||||
"previewSummary": "下載前預覽播放列表項目。",
|
||||
"range": "範圍(可選)",
|
||||
"resetToDefault": "恢復預設",
|
||||
"selectedRange": "範圍:{{開始}}-{{結束}}",
|
||||
"showingCount": "顯示 {{count}} 個視頻",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "下載播放清單",
|
||||
"totalVideos": "視頻總數:{{count}}",
|
||||
"untitled": "無標題播放列表"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "關於",
|
||||
"advanced": "進階",
|
||||
"app": "應用程式設定",
|
||||
"audio": "音訊偏好",
|
||||
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
||||
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"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": "選擇儲存下載檔案的位置",
|
||||
"fileSelectError": "選擇檔案失敗",
|
||||
"general": "一般",
|
||||
"language": "語言",
|
||||
"light": "淺色",
|
||||
"maxConcurrentDownloads": "最大活動下載數",
|
||||
"maxConcurrentDownloadsDescription": "最大同時下載數量",
|
||||
"none": "無",
|
||||
"oneClickDownload": "一鍵下載",
|
||||
"oneClickDownloadDescription": "啟用使用預設設定的一鍵下載",
|
||||
"oneClickDownloadType": "預設下載類型",
|
||||
"oneClickDownloadTypeDescription": "選擇一鍵下載的預設下載類型。品質使用下面的預設。",
|
||||
"oneClickQuality": "首選品質",
|
||||
"oneClickQualityDescription": "選擇用於一鍵下載的品質預設",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "自動",
|
||||
"bad": "較差",
|
||||
"best": "最佳",
|
||||
"good": "良好",
|
||||
"normal": "標準",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "無法打開鏈接",
|
||||
"proxy": "代理伺服器",
|
||||
"proxyDescription": "網路請求的代理伺服器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "選擇設定檔",
|
||||
"selectPath": "選擇",
|
||||
"showMoreFormats": "顯示更多格式選項",
|
||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||
"system": "系統",
|
||||
"theme": "主題",
|
||||
"themeDescription": "為 VidBee 選擇淺色、深色或系統主題",
|
||||
"title": "設定",
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
"showHome": "顯示首頁"
|
||||
},
|
||||
"video": "影片偏好"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "支援 {{sites}} 等更多網站。",
|
||||
"moreDescription": "完整的 yt-dlp 清單由社群持續更新。",
|
||||
"moreTitle": "需要其他網站?",
|
||||
"openFullList": "開啟全部支援網站清單",
|
||||
"pageDescription": "VidBee 使用 yt-dlp 覆蓋數百個資源。",
|
||||
"pageIntro": "以下是大家最常下載的主流服務。",
|
||||
"pageTitle": "支援的網站",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "獨立藝術家專輯和社群發布。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "全球新聞、體育和娛樂片段。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "來自公開頁面的動態、觀看和 Reels 影片。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "動態、故事、Reels 和精選內容。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 平台上的創作者直播和回放。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "專業演講、網路研討會和學習影片。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 混音、廣播節目和長音訊。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本動畫、音樂和直播檔案。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "創意圖釘、教學 Reels 和生活方式靈感影片。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "來自社群的嵌入片段和託管影片。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音樂曲目、播放清單和 DJ 套裝。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "短影片、特效和直播。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "創意短影片和粉絲編輯。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "遊戲、音樂和 IRL 直播和 VOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "時間軸貼文、Spaces 錄音和廣播。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "高品質創作者和商業影片託管。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "來自全球創作者的長影片和直播。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "官方音樂影片、專輯和現場表演。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "檢視全部支援的網站"
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,24 @@
|
||||
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
|
||||
"betaProgramTitle": "预览通道",
|
||||
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
|
||||
"followAuthorActions": {
|
||||
"follow": "关注 @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
|
||||
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
|
||||
"followAuthorTitle": "关注开发者",
|
||||
"here": "此处",
|
||||
"homepage": "主页",
|
||||
"notifications": {
|
||||
"checkingUpdates": "正在查找更新...",
|
||||
"updateAvailable": "发现新版本: {{version}}",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"updateError": "检查更新失败: {{error}}",
|
||||
"downloadUpdate": "下载并安装更新 {{version}}?",
|
||||
"downloadStarted": "开始下载...",
|
||||
"downloadError": "下载更新失败",
|
||||
"downloadStarted": "开始下载...",
|
||||
"downloadUpdate": "下载并安装更新 {{version}}?",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"restartToUpdate": "立即重启以安装更新?",
|
||||
"updateAvailable": "发现新版本: {{version}}",
|
||||
"updateDownloaded": "更新已下载,重启以安装",
|
||||
"restartToUpdate": "立即重启以安装更新?"
|
||||
"updateError": "检查更新失败: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "无需离开此页即可调整更新设置。",
|
||||
"preferencesTitle": "快速切换",
|
||||
@@ -46,10 +52,15 @@
|
||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||
"resourcesTitle": "资源",
|
||||
"sourceCode": "源代码已开放",
|
||||
"tagline": "面向每位创作者的 AI 友好下载助手",
|
||||
"title": "关于",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"uptodate": "您已是最新版本",
|
||||
"error": "无法获取最新版本"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "下载完成后关闭应用",
|
||||
@@ -153,7 +164,6 @@
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除错误",
|
||||
"copyUrl": "复制链接",
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"date": "日期",
|
||||
"description": "查看并管理下载历史",
|
||||
"duration": "时长",
|
||||
@@ -166,11 +176,11 @@
|
||||
},
|
||||
"noHistory": "暂无下载历史",
|
||||
"noHistoryDescription": "完成的下载会显示在这里",
|
||||
"openDownloadFolder": "打开下载文件夹",
|
||||
"openFile": "打开文件",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"openFolder": "打开文件夹",
|
||||
"openDownloadFolder": "打开下载文件夹",
|
||||
"outputPath": "输出路径",
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"removeItem": "移除项目",
|
||||
"stats": {
|
||||
"cancelled": "已取消",
|
||||
@@ -233,34 +243,52 @@
|
||||
"app": "应用设置",
|
||||
"audio": "音频偏好",
|
||||
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
||||
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "使用配置文件",
|
||||
"configFileDescription": "yt-dlp 的自定义配置文件",
|
||||
"dark": "深色",
|
||||
"description": "配置下载偏好和应用设置",
|
||||
"directorySelectError": "选择目录失败",
|
||||
"downloadPath": "下载位置",
|
||||
"downloadPathDescription": "选择保存下载文件的位置",
|
||||
"fileSelectError": "选择文件失败",
|
||||
"general": "通用",
|
||||
"language": "语言",
|
||||
"languageOptions": {
|
||||
"chinese": "简体中文",
|
||||
"english": "英语"
|
||||
},
|
||||
"light": "浅色",
|
||||
"maxConcurrentDownloads": "最大活动下载数",
|
||||
"maxConcurrentDownloadsDescription": "最大同时下载数量",
|
||||
"none": "无",
|
||||
"oneClickAudioForVideo": "视频默认音频",
|
||||
"oneClickAudioFormat": "默认音频格式",
|
||||
"oneClickDownload": "一键下载",
|
||||
"oneClickDownloadDescription": "启用使用默认设置的一键下载",
|
||||
"oneClickDownloadType": "默认下载类型",
|
||||
"oneClickVideoFormat": "默认视频格式",
|
||||
"preferredAudioQuality": "首选音频质量",
|
||||
"preferredVideoCodec": "首选视频编码",
|
||||
"preferredVideoQuality": "首选视频质量",
|
||||
"oneClickDownloadTypeDescription": "选择一键下载的默认下载类型。质量使用下面的预设。",
|
||||
"oneClickQuality": "首选质量",
|
||||
"oneClickQualityDescription": "选择用于一键下载的质量预设",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "自动",
|
||||
"bad": "较差",
|
||||
"best": "最佳",
|
||||
"good": "良好",
|
||||
"normal": "标准",
|
||||
"worst": "最差"
|
||||
},
|
||||
"proxy": "代理",
|
||||
"proxyDescription": "网络请求的代理服务器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "选择配置文件",
|
||||
"selectPath": "选择",
|
||||
"showMoreFormats": "显示更多格式选项",
|
||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||
"system": "系统",
|
||||
"theme": "主题",
|
||||
"themeDescription": "为 VidBee 选择浅色、深色或系统主题",
|
||||
"title": "设置",
|
||||
"tray": {
|
||||
"quit": "退出",
|
||||
@@ -276,6 +304,80 @@
|
||||
"pageDescription": "VidBee 使用 yt-dlp 覆盖数百个资源。",
|
||||
"pageIntro": "以下是大家最常下载的主流服务。",
|
||||
"pageTitle": "支持的网站",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "独立艺术家专辑和社区发布。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "全球新闻、体育和娱乐片段。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "来自公共页面的动态、观看和 Reels 视频。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "动态、故事、Reels 和精选内容。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 平台上的创作者直播和回放。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "专业演讲、网络研讨会和学习视频。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 混音、广播节目和长音频。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本动画、音乐和直播档案。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "创意图钉、教程 Reels 和生活方式灵感视频。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "来自社区的嵌入片段和托管视频。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音乐曲目、播放列表和 DJ 套装。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "短视频、特效和直播。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "创意短视频和粉丝编辑。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "游戏、音乐和 IRL 直播和 VOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "时间线帖子、Spaces 录音和广播。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "高质量创作者和商业视频托管。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "来自全球创作者的长视频和直播。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "官方音乐视频、专辑和现场表演。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "查看全部支持的网站"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './assets/main.css'
|
||||
import './assets/global.css'
|
||||
import 'flag-icons/css/flag-icons.min.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
FileText,
|
||||
Github,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
interface AboutResource {
|
||||
@@ -36,12 +38,20 @@ interface AboutResource {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type LatestVersionState =
|
||||
| { status: 'available'; version: string }
|
||||
| { status: 'uptodate'; version: string }
|
||||
| { status: 'error'; error?: string }
|
||||
| null
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
@@ -64,12 +74,91 @@ export function About() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for update events only in About page
|
||||
useEffect(() => {
|
||||
if (!window?.api) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: versionLabel
|
||||
})
|
||||
// Reset download progress when new update is available
|
||||
setUpdateDownloadProgress(0)
|
||||
}
|
||||
|
||||
const handleUpdateDownloadProgress = (rawProgress: unknown) => {
|
||||
const progress = (rawProgress ?? {}) as { percent?: number }
|
||||
if (typeof progress?.percent === 'number') {
|
||||
setUpdateDownloadProgress(progress.percent)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDownloaded = () => {
|
||||
// Clear progress when download is complete
|
||||
setUpdateDownloadProgress(null)
|
||||
}
|
||||
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
ipcEvents.on('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
) => {
|
||||
await saveSetting({ key, value })
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
|
||||
// If auto-update is enabled, check for updates immediately
|
||||
if (key === 'autoUpdate' && value === true) {
|
||||
try {
|
||||
toast.info(t('about.notifications.checkingUpdates'))
|
||||
const result = await ipcServices.update.checkForUpdates()
|
||||
|
||||
if (result.available) {
|
||||
// The update will be downloaded automatically because autoDownload is enabled
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
status: 'error',
|
||||
error: result.error
|
||||
})
|
||||
} else {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoToDownload = () => {
|
||||
openShareUrl('https://vidbee.org/download/')
|
||||
}
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
@@ -79,20 +168,35 @@ export function About() {
|
||||
|
||||
if (result.available) {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
status: 'error',
|
||||
error: result.error
|
||||
})
|
||||
} else {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
|
||||
setLatestVersionState({
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const shareLinks = useMemo(() => {
|
||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||
const encodedText = encodeURIComponent(t('about.tagline'))
|
||||
const encodedText = encodeURIComponent(t('about.description'))
|
||||
|
||||
return {
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
@@ -126,8 +230,30 @@ export function About() {
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersionBadgeText =
|
||||
latestVersionState && latestVersionState.status !== 'error' && latestVersionState.version
|
||||
? t('about.latestVersionBadge', { version: latestVersionState.version })
|
||||
: null
|
||||
const latestVersionStatusKey = latestVersionState
|
||||
? `about.latestVersionStatus.${latestVersionState.status}`
|
||||
: null
|
||||
const latestVersionStatusClass =
|
||||
latestVersionState?.status === 'available'
|
||||
? 'text-primary'
|
||||
: latestVersionState?.status === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||
|
||||
const aboutResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
{
|
||||
icon: LinkIcon,
|
||||
label: t('about.resources.website'),
|
||||
description: t('about.resources.websiteDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://vidbee.org/'
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: t('about.resources.changelog'),
|
||||
@@ -163,11 +289,6 @@ export function About() {
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -176,11 +297,38 @@ export function About() {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('about.tagline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
</Badge>
|
||||
{latestVersionState ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{latestVersionBadgeText ? (
|
||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||
) : null}
|
||||
{latestVersionStatusText ? (
|
||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||
{latestVersionStatusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{updateDownloadProgress !== null && (
|
||||
<div className="space-y-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('about.downloadingUpdate')}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{updateDownloadProgress.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={updateDownloadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -194,6 +342,12 @@ export function About() {
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{latestVersionState?.status === 'available' ? (
|
||||
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('about.actions.goToDownload')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleCheckForUpdates} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
@@ -218,21 +372,44 @@ export function About() {
|
||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.shareDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.shareActions.twitter')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
|
||||
<Facebook className="h-4 w-4" />
|
||||
{t('about.shareActions.facebook')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
{t('about.shareActions.copy')}
|
||||
</Button>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.shareActions.twitter')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
|
||||
<Facebook className="h-4 w-4" />
|
||||
{t('about.shareActions.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyShareLink}
|
||||
className="gap-2"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
{t('about.shareActions.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-muted-foreground md:max-w-md">
|
||||
{t('about.followAuthorSupport')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openShareUrl('https://x.com/nexmoex')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.followAuthorActions.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -15,15 +15,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Tabs, TabsContent } from '@renderer/components/ui/tabs'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import type { AppSettings, OneClickQualityPreset } from '@shared/types'
|
||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
|
||||
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
|
||||
import { VideoInfoCard } from '../components/video/VideoInfoCard'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import {
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from '../store/video'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
@@ -50,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
|
||||
}
|
||||
|
||||
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
|
||||
auto: null,
|
||||
best: 320,
|
||||
good: 256,
|
||||
normal: 192,
|
||||
@@ -71,22 +70,24 @@ const dedupe = (candidates: Array<string | undefined>): string[] => {
|
||||
}
|
||||
|
||||
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
||||
settings.oneClickQuality ?? 'auto'
|
||||
settings.oneClickQuality ?? 'best'
|
||||
|
||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||
if (preset === 'worst') {
|
||||
return ['worstaudio', 'worst']
|
||||
return ['worstaudio']
|
||||
}
|
||||
|
||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best'])
|
||||
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
|
||||
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
||||
}
|
||||
|
||||
const buildVideoFormatPreference = (settings: AppSettings): string => {
|
||||
const preset = getQualityPreset(settings)
|
||||
|
||||
if (preset === 'worst') {
|
||||
return 'worstvideo+worstaudio/worst'
|
||||
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
|
||||
return 'worstvideo+worstaudio'
|
||||
}
|
||||
|
||||
const maxHeight = qualityPresetToVideoHeight[preset]
|
||||
@@ -109,7 +110,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
|
||||
combinations.push(video)
|
||||
}
|
||||
} else {
|
||||
combinations.push('best')
|
||||
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
|
||||
combinations.push('bestvideo+bestaudio')
|
||||
}
|
||||
|
||||
return dedupe(combinations).join('/')
|
||||
@@ -122,9 +124,10 @@ const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||
|
||||
interface HomeProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
}
|
||||
|
||||
export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
||||
const { t } = useTranslation()
|
||||
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
|
||||
const [loading] = useAtom(videoInfoLoadingAtom)
|
||||
@@ -139,19 +142,51 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||
const inlinePreviewSites = popularSites
|
||||
.slice(0, 3)
|
||||
.map((site) => site.label)
|
||||
.map((site) => t(`sites.popular.${site.id}.label`))
|
||||
.join(', ')
|
||||
|
||||
// Playlist states
|
||||
const playlistUrlId = useId()
|
||||
const downloadTypeId = useId()
|
||||
const [playlistUrl, setPlaylistUrl] = useState('')
|
||||
const [playlistLoading, setPlaylistLoading] = useState(false)
|
||||
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
|
||||
const [startIndex, setStartIndex] = useState('1')
|
||||
const [endIndex, setEndIndex] = useState('')
|
||||
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
|
||||
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
|
||||
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
|
||||
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
|
||||
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
|
||||
|
||||
const computePlaylistRange = useCallback(
|
||||
(info: PlaylistInfo) => {
|
||||
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
|
||||
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
|
||||
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
|
||||
const endValue =
|
||||
rawEnd !== undefined
|
||||
? info.entryCount > 0
|
||||
? Math.min(rawEnd, info.entryCount)
|
||||
: rawEnd
|
||||
: undefined
|
||||
return { start, end: endValue }
|
||||
},
|
||||
[startIndex, endIndex]
|
||||
)
|
||||
|
||||
const selectedPlaylistEntries = useMemo(() => {
|
||||
if (!playlistInfo) {
|
||||
return []
|
||||
}
|
||||
const range = computePlaylistRange(playlistInfo)
|
||||
const previewEnd = range.end ?? playlistInfo.entryCount
|
||||
return playlistInfo.entries.filter(
|
||||
(entry) => entry.index >= range.start && entry.index <= previewEnd
|
||||
)
|
||||
}, [playlistInfo, computePlaylistRange])
|
||||
|
||||
const syncHistoryItem = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -366,70 +401,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
|
||||
// Playlist handlers
|
||||
const handlePastePlaylistUrl = useCallback(async () => {
|
||||
if (playlistBusy) return
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (!text.trim()) {
|
||||
toast.error(t('errors.clipboardEmpty'))
|
||||
return
|
||||
}
|
||||
setPlaylistUrl(text.trim())
|
||||
const trimmed = text.trim()
|
||||
setPlaylistUrl(trimmed)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to paste URL:', error)
|
||||
toast.error(t('errors.pasteFromClipboard'))
|
||||
}
|
||||
}, [t])
|
||||
}, [playlistBusy, t])
|
||||
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
const handleClearPlaylistPreview = useCallback(() => {
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}, [])
|
||||
|
||||
const handlePreviewPlaylist = useCallback(async () => {
|
||||
if (!playlistUrl.trim()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
setPlaylistLoading(true)
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistPreviewLoading(true)
|
||||
try {
|
||||
// Get playlist info first to show user what will be downloaded
|
||||
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
|
||||
const trimmedUrl = playlistUrl.trim()
|
||||
const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)
|
||||
setPlaylistInfo(info)
|
||||
if (info.entryCount === 0) {
|
||||
toast.error(t('playlist.noEntries'))
|
||||
return
|
||||
}
|
||||
toast.success(t('playlist.foundVideos', { count: info.entryCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlist info:', error)
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : t('playlist.previewFailed')
|
||||
setPlaylistPreviewError(message)
|
||||
setPlaylistInfo(null)
|
||||
toast.error(t('playlist.previewFailed'))
|
||||
} finally {
|
||||
setPlaylistPreviewLoading(false)
|
||||
}
|
||||
}, [playlistUrl, t])
|
||||
|
||||
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount }))
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
const trimmedUrl = playlistUrl.trim()
|
||||
if (!trimmedUrl) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!playlistInfo) {
|
||||
toast.error(t('playlist.previewRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistDownloadLoading(true)
|
||||
try {
|
||||
const info = playlistInfo
|
||||
setPlaylistInfo(info)
|
||||
|
||||
if (info.entryCount === 0) {
|
||||
toast.error(t('playlist.noEntries'))
|
||||
return
|
||||
}
|
||||
|
||||
const range = computePlaylistRange(info)
|
||||
const previewEnd = range.end ?? info.entryCount
|
||||
|
||||
if (previewEnd < range.start || previewEnd === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
// Build format preference based on settings
|
||||
const format =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
// Start playlist download
|
||||
const downloadIds = await ipcServices.download.startPlaylistDownload({
|
||||
url: playlistUrl.trim(),
|
||||
const result = await ipcServices.download.startPlaylistDownload({
|
||||
url: trimmedUrl,
|
||||
type: downloadType,
|
||||
format,
|
||||
startIndex: parseInt(startIndex, 10) || 1,
|
||||
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
|
||||
startIndex: range.start,
|
||||
endIndex: range.end
|
||||
})
|
||||
|
||||
// Add all downloads to the renderer state
|
||||
for (const id of downloadIds) {
|
||||
if (result.totalCount === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
const baseCreatedAt = Date.now()
|
||||
result.entries.forEach((entry, index) => {
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: playlistUrl.trim(),
|
||||
title: t('download.fetchingVideoInfo'),
|
||||
id: entry.downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title || t('download.fetchingVideoInfo'),
|
||||
type: downloadType,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt: Date.now()
|
||||
createdAt: baseCreatedAt + index,
|
||||
playlistId: result.groupId,
|
||||
playlistTitle: result.playlistTitle,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: result.totalCount
|
||||
}
|
||||
addDownload(downloadItem)
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
|
||||
setPlaylistUrl('') // Clear the URL after starting download
|
||||
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to start playlist download:', error)
|
||||
toast.error(t('playlist.downloadFailed'))
|
||||
} finally {
|
||||
setPlaylistLoading(false)
|
||||
setPlaylistDownloadLoading(false)
|
||||
}
|
||||
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
|
||||
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
|
||||
|
||||
// Auto-focus input on mount
|
||||
useEffect(() => {
|
||||
@@ -441,219 +537,275 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Tabs defaultValue="single" className="w-full">
|
||||
{/* <TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||
<ListVideo className="h-4 w-4" />
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList> */}
|
||||
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6">
|
||||
{/* URL Input Card */}
|
||||
{!videoInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('download.enterUrl')}</CardTitle>
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||
className="w-full gap-0"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="px-0"
|
||||
onClick={() => onOpenSupportedSites?.()}
|
||||
>
|
||||
{t('sites.viewAll')}
|
||||
</Button>
|
||||
</div>
|
||||
{activeTab === 'single' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 h-auto"
|
||||
onClick={() => onOpenSupportedSites?.()}
|
||||
>
|
||||
{t('sites.viewAll')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
t('playlist.playlistUrlDescription')
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('download.urlPlaceholder')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6 mt-0">
|
||||
{/* URL Input Card */}
|
||||
{!videoInfo && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('download.urlPlaceholder')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pr-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
{settings.oneClickDownload ? (
|
||||
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download.oneClickDownloadNow')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{t('download.fetch')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
{settings.oneClickDownload ? (
|
||||
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download.oneClickDownloadNow')}
|
||||
</>
|
||||
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||
</div>
|
||||
{onOpenSettings && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-2 py-0 text-xs"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
{t('download.goToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Info and Download Options */}
|
||||
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="space-y-6 mt-0">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => {
|
||||
setPlaylistUrl(e.target.value)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
||||
{loading ? (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.startIndex')}
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.endIndex')}
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{playlistInfo && (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{t('download.fetch')}
|
||||
</>
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t('download.oneClickDownload')}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
{t('download.oneClickDownloadDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{playlistPreviewError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Video Info and Download Options */}
|
||||
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
|
||||
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => setPlaylistUrl(e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={playlistLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistLoading}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistLoading}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.startIndex')}
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('playlist.endIndex')}
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={playlistLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Playlist Preview Card (outside main card) */}
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory />
|
||||
|
||||
@@ -21,22 +21,45 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||
import type { OneClickQualityPreset } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
const clampSubscriptionInterval = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, parsed))
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlatform = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlatform()
|
||||
}, [])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
@@ -54,7 +77,7 @@ export function Settings() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error('Failed to select directory')
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +90,32 @@ export function Settings() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select file:', error)
|
||||
toast.error('Failed to select file')
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCookiesFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('cookiesPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select cookies file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCookiesFaq = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
await ipcServices.fs.openExternal(
|
||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to open cookies FAQ:', error)
|
||||
toast.error(t('settings.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +148,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.downloadPath')}</ItemTitle>
|
||||
<ItemDescription>Choose where to save downloaded files</ItemDescription>
|
||||
<ItemDescription>{t('settings.downloadPathDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
@@ -115,9 +163,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.theme')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Choose a light, dark, or system theme for VidBee
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.themeDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -160,8 +206,7 @@ export function Settings() {
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Choose the default download type for one-click downloads. Quality uses the
|
||||
preset below.
|
||||
{t('settings.oneClickDownloadTypeDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
@@ -185,9 +230,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Select the quality preset used for one-click downloads
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.oneClickQualityDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -200,9 +243,6 @@ export function Settings() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t('settings.oneClickQualityOptions.auto')}
|
||||
</SelectItem>
|
||||
<SelectItem value="best">
|
||||
{t('settings.oneClickQualityOptions.best')}
|
||||
</SelectItem>
|
||||
@@ -230,9 +270,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Display additional format options in the interface
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.showMoreFormatsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
@@ -245,11 +283,57 @@ export function Settings() {
|
||||
</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>
|
||||
<ItemDescription>Maximum number of simultaneous downloads</ItemDescription>
|
||||
<ItemDescription>
|
||||
{t('settings.maxConcurrentDownloadsDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -274,43 +358,14 @@ export function Settings() {
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Browser to extract cookies from for authentication
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">Chrome</SelectItem>
|
||||
<SelectItem value="firefox">Firefox</SelectItem>
|
||||
<SelectItem value="edge">Edge</SelectItem>
|
||||
<SelectItem value="safari">Safari</SelectItem>
|
||||
<SelectItem value="brave">Brave</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.proxy')}</ItemTitle>
|
||||
<ItemDescription>Proxy server for network requests</ItemDescription>
|
||||
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
placeholder="http://proxy:port"
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={settings.proxy}
|
||||
onChange={(e) => handleSettingChange('proxy', e.target.value)}
|
||||
className="w-64"
|
||||
@@ -323,16 +378,108 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
||||
<ItemDescription>Custom configuration file for yt-dlp</ItemDescription>
|
||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('configPath', '')}
|
||||
disabled={!settings.configPath}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesFile')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
||||
disabled={!settings.cookiesPath}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||
{t('settings.cookiesHelpFaq')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.enableAnalytics}
|
||||
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
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-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
|
||||
isActive && 'bg-muted/45'
|
||||
)}
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden transition-colors">
|
||||
<RemoteImage
|
||||
src={subscription.coverUrl}
|
||||
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||
className="h-full w-full object-cover rounded-full overflow-hidden"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',
|
||||
statusMeta.dotClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</HoverCardTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<HoverCardContent className="max-w-xs space-y-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</p>
|
||||
<p className="text-xs">{statusDescription}</p>
|
||||
<p className="text-xs">
|
||||
{t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('subscriptions.actions.refresh')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4" />
|
||||
{t('subscriptions.actions.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>
|
||||
<Power className="h-4 w-4" />
|
||||
{subscription.enabled
|
||||
? t('subscriptions.actions.disable')
|
||||
: t('subscriptions.actions.enable')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('subscriptions.actions.remove')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<SubscriptionFormDialog
|
||||
mode="edit"
|
||||
subscription={subscription}
|
||||
open={editOpen}
|
||||
onSave={async (data) => {
|
||||
await onUpdate(data)
|
||||
toast.success(t('subscriptions.notifications.updated'))
|
||||
setEditOpen(false)
|
||||
}}
|
||||
onClose={() => setEditOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Subscriptions() {
|
||||
const { t } = useTranslation()
|
||||
const [subscriptions] = useAtom(subscriptionsAtom)
|
||||
const updateSubscription = useSetAtom(updateSubscriptionAtom)
|
||||
const removeSubscription = useSetAtom(removeSubscriptionAtom)
|
||||
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
|
||||
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [selectedTab, setSelectedTab] = useState<string>('')
|
||||
|
||||
const sortedSubscriptions = useMemo(
|
||||
() =>
|
||||
[...subscriptions].sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)
|
||||
),
|
||||
[subscriptions]
|
||||
)
|
||||
|
||||
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||
const handleUpdateSubscription = useCallback(
|
||||
async (id: string, data: SubscriptionRuleUpdateForm) => {
|
||||
const updatePayload: Parameters<typeof updateSubscription>[0]['data'] = {
|
||||
keywords: data.keywords,
|
||||
tags: data.tags,
|
||||
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||
downloadDirectory: data.downloadDirectory,
|
||||
namingTemplate: data.namingTemplate,
|
||||
enabled: data.enabled
|
||||
}
|
||||
|
||||
// If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform
|
||||
if (data.url) {
|
||||
try {
|
||||
const resolved = await resolveFeed(data.url)
|
||||
updatePayload.sourceUrl = resolved.sourceUrl
|
||||
updatePayload.feedUrl = resolved.feedUrl
|
||||
updatePayload.platform = resolved.platform
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve feed URL:', error)
|
||||
toast.error(t('subscriptions.notifications.resolveError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateSubscription({ id, data: updatePayload })
|
||||
await refreshSubscription(id)
|
||||
},
|
||||
[refreshSubscription, updateSubscription, resolveFeed, t]
|
||||
)
|
||||
|
||||
const createSubscription = useSetAtom(createSubscriptionAtom)
|
||||
|
||||
const handleCreateSubscription = useCallback(
|
||||
async (data: SubscriptionFormData) => {
|
||||
if (!data.url) {
|
||||
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSubscription({
|
||||
url: data.url,
|
||||
keywords: data.keywords?.join(', '),
|
||||
tags: data.tags?.join(', '),
|
||||
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||
downloadDirectory: data.downloadDirectory,
|
||||
namingTemplate: data.namingTemplate,
|
||||
enabled: data.enabled
|
||||
})
|
||||
toast.success(t('subscriptions.notifications.created'))
|
||||
setAddDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create subscription:', error)
|
||||
toast.error(t('subscriptions.notifications.createError'))
|
||||
}
|
||||
},
|
||||
[createSubscription, t]
|
||||
)
|
||||
|
||||
const handleOpenRSSHubDocs = useCallback(async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Filter subscriptions based on selected tab
|
||||
const displayedSubscriptions = useMemo(() => {
|
||||
if (!selectedTab) {
|
||||
return []
|
||||
}
|
||||
return sortedSubscriptions.filter((sub) => sub.id === selectedTab)
|
||||
}, [selectedTab, sortedSubscriptions])
|
||||
|
||||
// Set default tab to first subscription if available
|
||||
useEffect(() => {
|
||||
if (!selectedTab && sortedSubscriptions.length > 0) {
|
||||
// Set to first subscription if no tab is selected
|
||||
setSelectedTab(sortedSubscriptions[0].id)
|
||||
} else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {
|
||||
// If selected subscription no longer exists, switch to first available
|
||||
if (sortedSubscriptions.length > 0) {
|
||||
setSelectedTab(sortedSubscriptions[0].id)
|
||||
} else {
|
||||
setSelectedTab('')
|
||||
}
|
||||
}
|
||||
}, [selectedTab, sortedSubscriptions])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Channel Tabs Header */}
|
||||
<div className="">
|
||||
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-auto">
|
||||
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0 px-6">
|
||||
{/* Subscription Channel Tabs */}
|
||||
{sortedSubscriptions.map((subscription) => (
|
||||
<SubscriptionTab
|
||||
key={subscription.id}
|
||||
subscription={subscription}
|
||||
isActive={subscription.id === selectedTab}
|
||||
onRefresh={() => refreshSubscription(subscription.id)}
|
||||
onRemove={() => removeSubscription(subscription.id)}
|
||||
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
|
||||
/>
|
||||
))}
|
||||
{/* Add RSS Button */}
|
||||
<Button
|
||||
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||
variant="ghost"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
<span className="w-full truncate text-xs font-medium">
|
||||
{t('subscriptions.add.title')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative space-y-8 p-6">
|
||||
<section className="space-y-4">
|
||||
{sortedSubscriptions.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : !selectedTab ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayedSubscriptions.map((subscription) => (
|
||||
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RSSHub Info Card */}
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t('subscriptions.rssHub.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenRSSHubDocs()}
|
||||
className="gap-2"
|
||||
>
|
||||
{t('subscriptions.rssHub.openDocs')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SubscriptionFormDialog
|
||||
mode="add"
|
||||
open={addDialogOpen}
|
||||
onSave={handleCreateSubscription}
|
||||
onClose={() => setAddDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubscriptionTabProps {
|
||||
subscription: SubscriptionRule
|
||||
isActive: boolean
|
||||
onRefresh: () => Promise<void>
|
||||
onRemove: () => Promise<void>
|
||||
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
|
||||
}
|
||||
|
||||
type SubscriptionRuleUpdateForm = SubscriptionFormData
|
||||
|
||||
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
|
||||
const { t } = useTranslation()
|
||||
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
|
||||
const downloads = useAtomValue(downloadsArrayAtom)
|
||||
const [historyStatusMap, setHistoryStatusMap] = useState<Record<string, DownloadStatus | null>>(
|
||||
{}
|
||||
)
|
||||
const downloadLookup = useMemo(() => {
|
||||
const map = new Map<string, DownloadRecord>()
|
||||
downloads.forEach((record) => {
|
||||
map.set(record.id, record)
|
||||
})
|
||||
return map
|
||||
}, [downloads])
|
||||
|
||||
useEffect(() => {
|
||||
const queuedDownloadIds = Array.from(
|
||||
new Set(
|
||||
feedItems
|
||||
.filter((item) => item.addedToQueue && item.downloadId)
|
||||
.map((item) => item.downloadId as string)
|
||||
)
|
||||
)
|
||||
|
||||
const missingIds = queuedDownloadIds.filter(
|
||||
(downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined
|
||||
)
|
||||
|
||||
if (missingIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const fetchHistoryStatuses = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
missingIds.map(async (downloadId) => {
|
||||
try {
|
||||
const historyItem = await ipcServices.history.getHistoryById(downloadId)
|
||||
return { downloadId, status: historyItem?.status ?? null }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch download history entry:', error)
|
||||
return { downloadId, status: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setHistoryStatusMap((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
|
||||
for (const { downloadId, status } of results) {
|
||||
if (next[downloadId] === status) {
|
||||
continue
|
||||
}
|
||||
next[downloadId] = status
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? next : prev
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve download history statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchHistoryStatuses()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [feedItems, downloadLookup, historyStatusMap])
|
||||
|
||||
const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {
|
||||
if (!item.addedToQueue) {
|
||||
return 'notQueued'
|
||||
}
|
||||
if (!item.downloadId) {
|
||||
return 'queued'
|
||||
}
|
||||
const matchedDownload = downloadLookup.get(item.downloadId)
|
||||
if (!matchedDownload) {
|
||||
const cachedHistoryStatus = historyStatusMap[item.downloadId]
|
||||
if (cachedHistoryStatus) {
|
||||
return cachedHistoryStatus
|
||||
}
|
||||
return 'queued'
|
||||
}
|
||||
return matchedDownload.status
|
||||
}
|
||||
|
||||
const handleOpenItem = async (url: string) => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to open subscription item link:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueueItem = useCallback(
|
||||
async (item: SubscriptionFeedItem) => {
|
||||
if (item.addedToQueue) {
|
||||
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const queued = await ipcServices.subscriptions.queueItem(subscription.id, item.id)
|
||||
if (queued) {
|
||||
toast.success(t('subscriptions.notifications.itemQueued'))
|
||||
return
|
||||
}
|
||||
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||
} catch (error) {
|
||||
console.error('Failed to queue subscription item:', error)
|
||||
toast.error(t('subscriptions.notifications.queueError'))
|
||||
}
|
||||
},
|
||||
[subscription.id, t]
|
||||
)
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.items.empty')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{feedItems.map((item) => {
|
||||
const itemStatus = resolveItemStatus(item)
|
||||
const hasResolvedDownloadStatus =
|
||||
item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued'
|
||||
const badgeLabel = item.addedToQueue
|
||||
? t('subscriptions.items.status.queued')
|
||||
: t('subscriptions.items.status.notQueued')
|
||||
const tooltipLabel = item.addedToQueue
|
||||
? hasResolvedDownloadStatus
|
||||
? t('subscriptions.items.tooltip.downloadStatus', {
|
||||
status: t(subscriptionItemStatusLabels[itemStatus])
|
||||
})
|
||||
: t('subscriptions.items.tooltip.downloadPending')
|
||||
: t('subscriptions.items.tooltip.notQueued')
|
||||
const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
|
||||
return (
|
||||
<ContextMenu key={`${subscription.id}-${item.id}`}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<article className="group transition-all">
|
||||
<div className="relative w-full overflow-hidden bg-muted aspect-video rounded-2xl">
|
||||
{item.thumbnail ? (
|
||||
<RemoteImage
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t('subscriptions.labels.noThumbnail')}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-black/5 to-transparent" />
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
|
||||
{subscription.coverUrl ? (
|
||||
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
|
||||
<RemoteImage
|
||||
src={subscription.coverUrl}
|
||||
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
|
||||
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-40 truncate text-xs">
|
||||
{subscription.title || t('subscriptions.labels.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
|
||||
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
|
||||
badgeClass
|
||||
)}
|
||||
>
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className="text-base font-semibold leading-snug text-card-foreground"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="rounded-full px-4"
|
||||
onClick={() => void handleOpenItem(item.url)}
|
||||
title={t('subscriptions.items.actions.open')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleQueueItem(item)}
|
||||
disabled={item.addedToQueue}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('subscriptions.items.actions.queue')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleOpenItem(item.url)}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t('subscriptions.items.actions.open')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,45 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
|
||||
|
||||
function SiteIcon({ domain, alt }: { domain: string; alt: string }) {
|
||||
const [iconUrl, setIconUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadIcon = async () => {
|
||||
try {
|
||||
const dataUrl = await ipcServices.app.getSiteIcon(domain)
|
||||
if (!cancelled) {
|
||||
setIconUrl(dataUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
if (!cancelled) {
|
||||
setIconUrl(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadIcon()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [domain])
|
||||
|
||||
return <RemoteImage src={iconUrl} alt={alt} className="w-full h-full" />
|
||||
}
|
||||
|
||||
export function SupportedSites() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -28,15 +62,40 @@ export function SupportedSites() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{popularSites.map((site) => (
|
||||
<li key={site.id} className="rounded-md border border-border px-6 py-5">
|
||||
<p className="text-sm font-medium">{site.label}</p>
|
||||
{site.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{site.description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{popularSites.map((site) => {
|
||||
const labelKey = `sites.popular.${site.id}.label`
|
||||
const descriptionKey = `sites.popular.${site.id}.description`
|
||||
const label = t(labelKey)
|
||||
const description = t(descriptionKey)
|
||||
const hasDescription = description !== descriptionKey
|
||||
|
||||
return (
|
||||
<li key={site.id}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-muted/50 hover:border-primary/50 group"
|
||||
>
|
||||
<div className="shrink-0 w-10 h-10 rounded-xs overflow-hidden">
|
||||
<SiteIcon domain={site.domain} alt={label} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{label}</p>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</div>
|
||||
{hasDescription ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
|
||||
export type DownloadRecord = DownloadItem & {
|
||||
entryType: 'active' | 'history'
|
||||
downloadedAt?: number
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
}
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
@@ -22,13 +24,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
status: item.status,
|
||||
progress: undefined,
|
||||
error: item.error,
|
||||
outputPath: item.outputPath,
|
||||
downloadPath: item.downloadPath,
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
fileSize: item.fileSize,
|
||||
format: item.format,
|
||||
quality: item.quality,
|
||||
codec: item.codec,
|
||||
createdAt: item.downloadedAt,
|
||||
startedAt: item.downloadedAt,
|
||||
completedAt: item.completedAt ?? item.downloadedAt,
|
||||
@@ -38,6 +37,11 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
viewCount: item.viewCount,
|
||||
tags: item.tags,
|
||||
selectedFormat: item.selectedFormat,
|
||||
playlistId: item.playlistId,
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
savedFileName: item.savedFileName,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { atom } from 'jotai'
|
||||
import { normalizeLanguageCode } from '../../../shared/languages'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { defaultSettings } from '../../../shared/types'
|
||||
import i18n from '../i18n'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
// Settings atom
|
||||
@@ -10,7 +12,18 @@ export const settingsAtom = atom<AppSettings>(defaultSettings)
|
||||
export const loadSettingsAtom = atom(null, async (_get, set) => {
|
||||
try {
|
||||
const settings = await ipcServices.settings.getAll()
|
||||
set(settingsAtom, settings)
|
||||
const savedLanguage = normalizeLanguageCode(settings.language)
|
||||
const currentLanguage = normalizeLanguageCode(i18n.language)
|
||||
|
||||
if (currentLanguage !== savedLanguage) {
|
||||
try {
|
||||
await i18n.changeLanguage(savedLanguage)
|
||||
} catch (error) {
|
||||
console.error('Failed to apply saved language:', error)
|
||||
}
|
||||
}
|
||||
|
||||
set(settingsAtom, { ...settings, language: savedLanguage })
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
}
|
||||
|
||||
79
src/renderer/src/store/subscriptions.ts
Normal file
79
src/renderer/src/store/subscriptions.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '@shared/types'
|
||||
import { atom } from 'jotai'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
const normalizeCommaList = (value?: string): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||
}
|
||||
|
||||
export const subscriptionsAtom = atom<SubscriptionRule[]>([])
|
||||
|
||||
export const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => {
|
||||
set(subscriptionsAtom, subscriptions)
|
||||
})
|
||||
|
||||
export const loadSubscriptionsAtom = atom(null, async (_get, set) => {
|
||||
try {
|
||||
const subscriptions = await ipcServices.subscriptions.list()
|
||||
set(subscriptionsAtom, subscriptions)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscriptions:', error)
|
||||
}
|
||||
})
|
||||
|
||||
export interface CreateSubscriptionForm {
|
||||
url: string
|
||||
keywords?: string
|
||||
tags?: string
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const createSubscriptionAtom = atom(
|
||||
null,
|
||||
async (_get, _set, payload: CreateSubscriptionForm) => {
|
||||
await ipcServices.subscriptions.create({
|
||||
url: payload.url,
|
||||
keywords: normalizeCommaList(payload.keywords),
|
||||
tags: normalizeCommaList(payload.tags),
|
||||
onlyDownloadLatest: payload.onlyDownloadLatest,
|
||||
downloadDirectory: payload.downloadDirectory,
|
||||
namingTemplate: payload.namingTemplate,
|
||||
enabled: payload.enabled
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const updateSubscriptionAtom = atom(
|
||||
null,
|
||||
async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => {
|
||||
await ipcServices.subscriptions.update(update.id, update.data)
|
||||
}
|
||||
)
|
||||
|
||||
export const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => {
|
||||
await ipcServices.subscriptions.remove(id)
|
||||
})
|
||||
|
||||
export const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => {
|
||||
await ipcServices.subscriptions.refresh(id)
|
||||
})
|
||||
|
||||
export const resolveFeedAtom = atom(
|
||||
null,
|
||||
async (_get, _set, url: string): Promise<SubscriptionResolvedFeed> => {
|
||||
return ipcServices.subscriptions.resolve(url)
|
||||
}
|
||||
)
|
||||
105
src/shared/languages.ts
Normal file
105
src/shared/languages.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface LanguageDefinition {
|
||||
flag: string
|
||||
name: string
|
||||
hreflang: string
|
||||
}
|
||||
|
||||
export const languages = {
|
||||
en: {
|
||||
flag: 'fi fi-us',
|
||||
name: 'English',
|
||||
hreflang: 'en'
|
||||
},
|
||||
es: {
|
||||
flag: 'fi fi-es',
|
||||
name: 'Español',
|
||||
hreflang: 'es'
|
||||
},
|
||||
ar: {
|
||||
flag: 'fi fi-sa',
|
||||
name: 'العربية',
|
||||
hreflang: 'ar'
|
||||
},
|
||||
id: {
|
||||
flag: 'fi fi-id',
|
||||
name: 'Bahasa Indonesia',
|
||||
hreflang: 'id'
|
||||
},
|
||||
pt: {
|
||||
flag: 'fi fi-br',
|
||||
name: 'Português',
|
||||
hreflang: 'pt-BR'
|
||||
},
|
||||
fr: {
|
||||
flag: 'fi fi-fr',
|
||||
name: 'Français',
|
||||
hreflang: 'fr'
|
||||
},
|
||||
it: {
|
||||
flag: 'fi fi-it',
|
||||
name: 'Italiano',
|
||||
hreflang: 'it'
|
||||
},
|
||||
zh: {
|
||||
flag: 'fi fi-cn',
|
||||
name: '中文',
|
||||
hreflang: 'zh-CN'
|
||||
},
|
||||
'zh-TW': {
|
||||
flag: 'fi fi-tw',
|
||||
name: '繁體中文',
|
||||
hreflang: 'zh-TW'
|
||||
},
|
||||
ko: {
|
||||
flag: 'fi fi-kr',
|
||||
name: '한국어',
|
||||
hreflang: 'ko'
|
||||
},
|
||||
ja: {
|
||||
flag: 'fi fi-jp',
|
||||
name: '日本語',
|
||||
hreflang: 'ja'
|
||||
},
|
||||
ru: {
|
||||
flag: 'fi fi-ru',
|
||||
name: 'Русский',
|
||||
hreflang: 'ru'
|
||||
},
|
||||
de: {
|
||||
flag: 'fi fi-de',
|
||||
name: 'Deutsch',
|
||||
hreflang: 'de'
|
||||
}
|
||||
} as const satisfies Record<string, LanguageDefinition>
|
||||
|
||||
export type LanguageCode = keyof typeof languages
|
||||
|
||||
export const defaultLanguageCode: LanguageCode = 'en'
|
||||
|
||||
export const languageList = Object.entries(languages).map(([code, definition]) => ({
|
||||
value: code as LanguageCode,
|
||||
...definition
|
||||
}))
|
||||
|
||||
export const supportedLanguageCodes = languageList.map((language) => language.value)
|
||||
|
||||
export function normalizeLanguageCode(code: string | null | undefined): LanguageCode {
|
||||
if (!code) {
|
||||
return defaultLanguageCode
|
||||
}
|
||||
|
||||
const normalizedInput = code.toLowerCase()
|
||||
const directMatch = supportedLanguageCodes.find(
|
||||
(languageCode) => languageCode.toLowerCase() === normalizedInput
|
||||
)
|
||||
if (directMatch) {
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const base = normalizedInput.split('-')[0] ?? ''
|
||||
const baseMatch = supportedLanguageCodes.find(
|
||||
(languageCode) => languageCode.split('-')[0]?.toLowerCase() === base
|
||||
)
|
||||
|
||||
return baseMatch ?? defaultLanguageCode
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { LanguageCode } from '../languages'
|
||||
import { defaultLanguageCode } from '../languages'
|
||||
|
||||
// Download related types
|
||||
export interface VideoFormat {
|
||||
format_id: string
|
||||
@@ -14,6 +17,7 @@ export interface VideoFormat {
|
||||
audio_ext?: string
|
||||
tbr?: number
|
||||
quality?: number
|
||||
protocol?: string // http, https, m3u8, m3u8_native, etc.
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
@@ -54,14 +58,11 @@ export interface DownloadItem {
|
||||
status: DownloadStatus
|
||||
progress?: DownloadProgress
|
||||
error?: string
|
||||
outputPath?: string
|
||||
speed?: string
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
savedFileName?: string
|
||||
// Timestamps
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
@@ -72,8 +73,25 @@ export interface DownloadItem {
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface SubscriptionFeedItem {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
addedToQueue: boolean
|
||||
downloadId?: string
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
@@ -83,24 +101,28 @@ export interface DownloadHistoryItem {
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
status: DownloadStatus
|
||||
outputPath?: string
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
fileSize?: number
|
||||
duration?: number
|
||||
downloadedAt: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
// Enhanced video information
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
@@ -113,17 +135,24 @@ export interface DownloadOptions {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
outputPath?: string
|
||||
customDownloadPath?: string
|
||||
customFilenameTemplate?: string
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
id: string
|
||||
title: string
|
||||
entries: Array<{
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
}>
|
||||
entries: PlaylistEntry[]
|
||||
entryCount: number
|
||||
}
|
||||
|
||||
@@ -137,24 +166,110 @@ export interface PlaylistDownloadOptions {
|
||||
folderFormat?: string
|
||||
}
|
||||
|
||||
export interface PlaylistDownloadEntry {
|
||||
downloadId: string
|
||||
entryId: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PlaylistDownloadResult {
|
||||
groupId: string
|
||||
playlistId: string
|
||||
playlistTitle: string
|
||||
type: 'video' | 'audio'
|
||||
totalCount: number
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
entries: PlaylistDownloadEntry[]
|
||||
}
|
||||
|
||||
// Subscription types
|
||||
export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'
|
||||
|
||||
export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'
|
||||
|
||||
export interface SubscriptionRule {
|
||||
id: string
|
||||
title: string
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
keywords: string[]
|
||||
tags: string[]
|
||||
onlyDownloadLatest: boolean
|
||||
enabled: boolean
|
||||
coverUrl?: string
|
||||
latestVideoTitle?: string
|
||||
latestVideoPublishedAt?: number
|
||||
lastCheckedAt?: number
|
||||
lastSuccessAt?: number
|
||||
status: SubscriptionStatus
|
||||
lastError?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
items: SubscriptionFeedItem[]
|
||||
}
|
||||
|
||||
export interface SubscriptionResolvedFeed {
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
}
|
||||
|
||||
export interface SubscriptionCreatePayload {
|
||||
sourceUrl: string
|
||||
feedUrl: string
|
||||
platform: SubscriptionPlatform
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionUpdatePayload {
|
||||
title?: string
|
||||
sourceUrl?: string
|
||||
feedUrl?: string
|
||||
platform?: SubscriptionPlatform
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
enabled?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
items?: SubscriptionFeedItem[]
|
||||
}
|
||||
|
||||
// Settings types
|
||||
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
export interface AppSettings {
|
||||
downloadPath: string
|
||||
showMoreFormats: boolean
|
||||
maxConcurrentDownloads: number
|
||||
browserForCookies: string
|
||||
cookiesPath: string
|
||||
proxy: string
|
||||
configPath: string
|
||||
betaProgram: boolean
|
||||
language: string
|
||||
language: LanguageCode
|
||||
theme: string
|
||||
oneClickDownload: boolean
|
||||
oneClickDownloadType: 'video' | 'audio'
|
||||
oneClickQuality: OneClickQualityPreset
|
||||
closeToTray: boolean
|
||||
hideDockIcon: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionFilenameTemplate: string
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
@@ -162,14 +277,20 @@ export const defaultSettings: AppSettings = {
|
||||
showMoreFormats: false,
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
proxy: '',
|
||||
configPath: '',
|
||||
betaProgram: false,
|
||||
language: 'en',
|
||||
language: defaultLanguageCode,
|
||||
theme: 'system',
|
||||
oneClickDownload: false,
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'auto',
|
||||
oneClickQuality: 'best',
|
||||
closeToTray: false,
|
||||
autoUpdate: true
|
||||
hideDockIcon: false,
|
||||
autoUpdate: true,
|
||||
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
subscriptionCheckIntervalHours: 3,
|
||||
enableAnalytics: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user