Compare commits

..

28 Commits

Author SHA1 Message Date
Nexmoe
6c3f070797 chore: release v1.0.0 2025-11-15 12:30:16 +08:00
Nexmoe
62a339e5b9 feat: rss-subscriptions (#28)
* feat(subscriptions): add subscription atoms and types for feeds and rules

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Run CI on Linux builds

* Update release workflow for Linux builds
2025-10-30 21:36:00 +08:00
Nexmoe
10a97b1f4a support of macOS x64 packing (#7) 2025-10-30 21:31:42 +08:00
60 changed files with 7645 additions and 1019 deletions

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

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

View File

@@ -5,33 +5,5 @@ on:
branches: [ main ]
jobs:
test:
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: 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
- name: Lint and format check
run: pnpm run check
- name: build-win
run: pnpm run build:win
build:
uses: ./.github/workflows/build.yml

View File

@@ -6,59 +6,26 @@ on:
- v*.*.*
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, macos-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: 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:
generate_release_notes: true
@@ -75,4 +42,3 @@ jobs:
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

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

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

View File

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

125
README.md
View File

@@ -1,36 +1,37 @@
# 🐝 VidBee
<div align="center">
<img src="build/icon.png" alt="VidBee icon" width="120" />
<h3>Download videos from almost any website worldwide</h3>
<p>Best-in-class UI interface - Clean, intuitive, and powerful</p>
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
<a href="https://github.com/nexmoe/VidBee">
<img src="build/icon.png" alt="Logo" width="80" height="80">
</a>
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&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>
## ✨ Core Features
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs.
### 🌍 Global Video Download Support
## 👋🏻 Getting Started
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
- Localized interface support in many languages
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
### 🎨 Best-in-class UI Experience
Feel free to try it using the following methods:
- **Modern Design** - Clean and beautiful interface
- **Intuitive Operations** - One-click pause/resume/retry
- **Real-time Progress** - Detailed download progress tracking and status management
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
## 📥 Download & Install
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
2. **Choose your platform**:
- **Windows**: Download `vidbee-x.x.x-setup.exe`
- **macOS**: Download `vidbee-x.x.x.dmg`
- **Linux**: Download `vidbee-x.x.x.AppImage`
3. **Install and run** the application
| 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> |
### 🍎 macOS Installation Notes
@@ -42,49 +43,69 @@ xattr -rd com.apple.quarantine /Applications/VidBee.app/
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
## 📸 Screenshots
> [!IMPORTANT]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
## ✨ Features
### 🌍 Global Video Download Support
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
![VidBee Main Interface](screenshots/main-interface.png)
*Clean and intuitive interface with download queue management*
### 🎨 Best-in-class UI Experience
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
![VidBee Download Queue](screenshots/download-queue.png)
*Comprehensive download queue with progress tracking and status 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 | |
| :--------------- | :---------------------- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
## ?? Contributing
## 🤝 Contributing
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
You are welcome to join the open source community to build together. Please check our [Contributing Guide](./CONTRIBUTING.md) for more details.
## 📄 License
This project is distributed under the MIT License. See `LICENSE` for details.
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
## 🙏 Thanks
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [Electron](https://www.electronjs.org/)
- [React](https://react.dev/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
- [React](https://react.dev/) - The UI library
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components

7
drizzle.config.ts Normal file
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "0.3.0",
"version": "1.0.0",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -12,13 +12,15 @@
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac",
"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": "pnpm run check && bumpp"
"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,9 +44,11 @@
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@tailwindcss/vite": "^4.1.13",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.7",
"electron-ipc-decorator": "^0.2.0",
"electron-log": "^5.4.3",
"electron-store": "^11.0.2",
@@ -55,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"
},
@@ -70,6 +78,7 @@
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"bumpp": "^10.3.1",
"drizzle-kit": "^0.31.7",
"electron": "^38.1.2",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",

644
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,38 +3,6 @@
const fs = require('node:fs')
const path = require('node:path')
/**
* Check if yt-dlp binary exists in resources directory
* Usage: node scripts/check-ytdlp.js [platform]
* Platform options: win, mac, linux
* Exit with error code 1 if not found
*/
function checkYtDlpExists(platform) {
const platformMap = {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
}
const filename = platformMap[platform]
if (!filename) {
console.error('❌ Error: Invalid platform specified!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const ytdlpPath = path.join(__dirname, '..', 'resources', filename)
if (!fs.existsSync(ytdlpPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
console.error('You can download it from: https://github.com/yt-dlp/yt-dlp/releases/latest')
process.exit(1)
}
console.log(`${filename} found in resources/ directory`)
}
// Get platform from command line arguments
const platform = process.argv[2]
@@ -44,5 +12,61 @@ if (!platform) {
process.exit(1)
}
// Run the check
checkYtDlpExists(platform)
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)
}

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

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

View File

@@ -1,6 +1,12 @@
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
@@ -14,7 +20,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
return 'bestvideo+bestaudio'
}
return `bestvideo+${audioFormat}`
}
@@ -53,7 +60,11 @@ export const buildDownloadArgs = (
// Format selection
if (options.type === 'video') {
args.push('-f', resolveVideoFormatSelector(options))
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') {
@@ -75,7 +86,12 @@ export const buildDownloadArgs = (
}
// Output path with proper encoding handling
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
const filenameTemplate = sanitizeFilenameTemplate(
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
)
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
args.push('-o', outputTemplate)
// Add options for better filename handling

View File

@@ -6,7 +6,6 @@ import type {
} from '../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -15,7 +14,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -187,7 +185,7 @@ export const resolveSelectedFormat = (
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
const preset = settings.oneClickQuality ?? 'best'
if (options.type === 'video') {
const videoFormats = formats.filter(

View File

@@ -1,12 +1,15 @@
import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
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 { 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'
@@ -21,19 +24,21 @@ 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
trafficLightPosition: { x: 12.5, y: 10 },
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,
@@ -41,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')
@@ -68,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()
}
@@ -95,11 +117,6 @@ function setupDownloadEvents(): void {
}
function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') {
log.info('Skipping auto-updater initialization in development mode')
return
}
try {
log.info('Initializing auto-updater...')
@@ -111,6 +128,12 @@ function initAutoUpdater(): void {
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) => {
@@ -141,12 +164,18 @@ function initAutoUpdater(): void {
}
})
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify()
}
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)
}
@@ -168,6 +197,15 @@ app.whenReady().then(async () => {
// IPC services are automatically registered by electron-ipc-decorator when imported
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 {
log.info('Initializing yt-dlp...')
@@ -186,6 +224,8 @@ app.whenReady().then(async () => {
// Create system tray
createTray()
subscriptionScheduler.start()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { execFile, execSync } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -40,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()
@@ -216,6 +229,25 @@ class FileSystemService extends IpcService {
.replace(/'/g, '&apos;')
}
@IpcMethod()
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
return false
}
}
@IpcMethod()
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
try {

View File

@@ -1,5 +1,7 @@
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'
@@ -9,12 +11,20 @@ class SettingsService extends IpcService {
@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()
@@ -23,15 +33,30 @@ class SettingsService extends IpcService {
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) {
@@ -41,12 +66,17 @@ class SettingsService extends IpcService {
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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import type {
VideoFormat,
VideoInfo
} from '../../shared/types'
import { buildDownloadArgs } from '../download-engine/args-builder'
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
@@ -21,6 +21,7 @@ import {
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'
@@ -52,6 +53,11 @@ class DownloadEngine extends EventEmitter {
// 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)
@@ -91,6 +97,27 @@ class DownloadEngine extends EventEmitter {
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) {
@@ -368,6 +395,8 @@ class DownloadEngine extends EventEmitter {
const createdAt = Date.now()
const settings = settingsManager.getAll()
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
const origin = options.origin ?? 'manual'
const item: DownloadItem = {
id,
@@ -375,7 +404,10 @@ class DownloadEngine extends EventEmitter {
title: 'Downloading...',
type: options.type,
status: 'pending' as const,
createdAt
createdAt,
tags: options.tags,
origin,
subscriptionId: options.subscriptionId
}
this.queue.add(id, options, item)
@@ -384,7 +416,10 @@ class DownloadEngine extends EventEmitter {
title: item.title,
status: 'pending',
downloadedAt: createdAt,
downloadPath: settings.downloadPath
downloadPath: targetDownloadPath,
tags: options.tags,
origin,
subscriptionId: options.subscriptionId
})
}
@@ -392,7 +427,8 @@ class DownloadEngine extends EventEmitter {
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const downloadPath = settings.downloadPath
const defaultDownloadPath = settings.downloadPath
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
// Set environment variables for proper encoding on Windows
if (process.platform === 'win32') {
@@ -403,9 +439,8 @@ class DownloadEngine extends EventEmitter {
let availableFormats: VideoFormat[] = []
let selectedFormat: VideoFormat | undefined
let actualFormat: string | null = null
let actualQuality: string | null = null
let actualCodec: string | null = null
let videoInfo: VideoInfo | undefined
let lastKnownOutputPath: string | undefined
// First, get detailed video info to capture basic metadata and formats
try {
@@ -417,20 +452,6 @@ class DownloadEngine extends EventEmitter {
if (selectedFormat) {
actualFormat = selectedFormat.ext || actualFormat
if (selectedFormat.height) {
actualQuality = `${selectedFormat.height}p${
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
}`
} else if (selectedFormat.format_note) {
actualQuality = selectedFormat.format_note
}
if (options.type === 'audio' || options.type === 'extract') {
actualCodec = selectedFormat.acodec || actualCodec
} else {
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
}
}
this.updateDownloadInfo(id, {
@@ -475,18 +496,6 @@ class DownloadEngine extends EventEmitter {
selectedFormat = candidate
actualFormat = candidate.ext || actualFormat
if (candidate.height) {
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
} else if (candidate.format_note) {
actualQuality = candidate.format_note
}
if (options.type === 'audio' || options.type === 'extract') {
actualCodec = candidate.acodec || actualCodec
} else {
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
}
this.updateDownloadInfo(id, {
selectedFormat: candidate
})
@@ -494,7 +503,79 @@ class DownloadEngine extends EventEmitter {
return true
}
const args = buildDownloadArgs(options, downloadPath, settings)
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, {
@@ -562,16 +643,6 @@ class DownloadEngine extends EventEmitter {
if (extMatch && !actualFormat) {
actualFormat = extMatch[1]
}
const heightMatch = formatInfo.match(/(\d+)p/)
if (heightMatch && !actualQuality) {
actualQuality = `${heightMatch[1]}p`
}
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
if (codecMatch && !actualCodec) {
actualCodec = codecMatch[1]
}
}
}
@@ -582,6 +653,10 @@ class DownloadEngine extends EventEmitter {
applySelectedFormat(formatMatch[1])
}
}
if (eventType === 'download' || eventType === 'info') {
extractOutputPathFromLog(eventData)
}
})
// Handle completion
@@ -593,23 +668,96 @@ class DownloadEngine extends EventEmitter {
// Generate file path using downloadPath + title + ext
const title = videoInfo?.title || 'Unknown'
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
const extension =
options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4'
const fileName = `${sanitizedTitle}.${extension}`
const finalOutputPath = path.join(downloadPath, fileName)
scopedLoggers.download.info('Generated file path for ID:', id, 'Path:', finalOutputPath)
// 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')
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
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
} else {
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
}
}
@@ -617,13 +765,13 @@ class DownloadEngine extends EventEmitter {
fileSize = latestKnownSizeBytes
}
const savedFileName = path.basename(actualFilePath)
this.updateDownloadInfo(id, {
status: 'completed',
completedAt: Date.now(),
fileSize,
format: actualFormat || undefined,
quality: actualQuality || undefined,
codec: actualCodec || undefined
savedFileName
})
scopedLoggers.download.info('Download completed successfully for ID:', id)
this.emit('download-completed', id)
@@ -709,15 +857,6 @@ class DownloadEngine extends EventEmitter {
if (updates.fileSize !== undefined) {
historyUpdates.fileSize = updates.fileSize
}
if (updates.format !== undefined) {
historyUpdates.format = updates.format
}
if (updates.quality !== undefined) {
historyUpdates.quality = updates.quality
}
if (updates.codec !== undefined) {
historyUpdates.codec = updates.codec
}
if (updates.description !== undefined) {
historyUpdates.description = updates.description
}
@@ -745,6 +884,9 @@ class DownloadEngine extends EventEmitter {
if (updates.playlistSize !== undefined) {
historyUpdates.playlistSize = updates.playlistSize
}
if (updates.selectedFormat !== undefined) {
historyUpdates.selectedFormat = updates.selectedFormat
}
if (updates.status !== undefined) {
historyUpdates.status = updates.status
}
@@ -754,8 +896,8 @@ class DownloadEngine extends EventEmitter {
if (updates.error !== undefined) {
historyUpdates.error = updates.error
}
if (updates.selectedFormat !== undefined) {
historyUpdates.selectedFormat = updates.selectedFormat
if (updates.savedFileName !== undefined) {
historyUpdates.savedFileName = updates.savedFileName
}
if (Object.keys(historyUpdates).length > 0) {
@@ -782,14 +924,13 @@ class DownloadEngine extends EventEmitter {
error,
duration: completedDownload?.item.duration,
fileSize: completedDownload?.item.fileSize,
format: completedDownload?.item.format,
quality: completedDownload?.item.quality,
codec: completedDownload?.item.codec,
description: completedDownload?.item.description,
channel: completedDownload?.item.channel,
uploader: completedDownload?.item.uploader,
viewCount: completedDownload?.item.viewCount,
tags: completedDownload?.item.tags,
origin: completedDownload?.item.origin,
subscriptionId: completedDownload?.item.subscriptionId,
playlistId: completedDownload?.item.playlistId,
playlistTitle: completedDownload?.item.playlistTitle,
playlistIndex: completedDownload?.item.playlistIndex,
@@ -803,6 +944,8 @@ class DownloadEngine extends EventEmitter {
updates: Partial<DownloadHistoryItem>
): void {
const existing = historyManager.getHistoryById(id)
const resolvedDownloadPath =
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
const base: DownloadHistoryItem = existing ?? {
id,
url: options.url,
@@ -810,20 +953,20 @@ class DownloadEngine extends EventEmitter {
thumbnail: updates.thumbnail,
type: options.type,
status: updates.status || 'pending',
downloadPath: updates.downloadPath,
downloadPath: resolvedDownloadPath,
savedFileName: updates.savedFileName,
fileSize: updates.fileSize,
duration: updates.duration,
downloadedAt: updates.downloadedAt ?? Date.now(),
completedAt: updates.completedAt,
error: updates.error,
format: updates.format,
quality: updates.quality,
codec: updates.codec,
description: updates.description,
channel: updates.channel,
uploader: updates.uploader,
viewCount: updates.viewCount,
tags: updates.tags,
tags: updates.tags ?? options.tags,
origin: updates.origin ?? options.origin,
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
// Download-specific format info
selectedFormat: updates.selectedFormat,
playlistId: updates.playlistId,
@@ -840,7 +983,11 @@ class DownloadEngine extends EventEmitter {
type: updates.type ?? base.type,
title: updates.title ?? base.title,
status: updates.status ?? base.status,
downloadedAt: updates.downloadedAt ?? base.downloadedAt
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
downloadPath: resolvedDownloadPath ?? base.downloadPath,
tags: updates.tags ?? base.tags,
origin: updates.origin ?? base.origin,
subscriptionId: updates.subscriptionId ?? base.subscriptionId
}
historyManager.addHistoryItem(merged)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,19 +2,91 @@ 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 { useEffect, useState } from 'react'
import { ipcServices } from './lib/ipc'
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
@@ -31,18 +103,112 @@ function AppContent() {
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')}
/>
)
}
}

View File

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

View File

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

View File

@@ -1,13 +1,24 @@
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 { useAtomValue, useSetAtom } from 'jotai'
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
import {
AlertCircle,
CheckCircle2,
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,
@@ -16,19 +27,136 @@ import {
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
// Helper function to generate file path with proper path separators
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
const fileName = `${title}.${format}`
// Use proper path joining for cross-platform compatibility
// Handle both forward and backward slashes for cross-platform compatibility
const normalizeSavedFileName = (fileName?: string): string | undefined => {
if (!fileName) {
return undefined
}
const trimmed = fileName.trim()
if (!trimmed) {
return undefined
}
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
}
const generateFilePathCandidates = (
downloadPath: string,
title: string,
format: string,
savedFileName?: string
): string[] => {
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
return `${normalizedDownloadPath}/${fileName}`
const safeTitle = title.trim() || 'Unknown'
const savedNameCandidates: string[] = []
const trimmedSavedFileName = savedFileName?.trim()
if (trimmedSavedFileName) {
const normalized = normalizeSavedFileName(trimmedSavedFileName)
if (normalized) {
savedNameCandidates.push(normalized)
}
if (!normalized || normalized !== trimmedSavedFileName) {
savedNameCandidates.push(trimmedSavedFileName)
}
}
const candidateFileNames =
savedNameCandidates.length > 0
? savedNameCandidates
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
return Array.from(
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
)
}
const tryFileOperation = async (
paths: string[],
operation: (filePath: string) => Promise<boolean>
): Promise<boolean> => {
for (const filePath of paths) {
const success = await operation(filePath)
if (success) {
return true
}
}
return false
}
const getSavedFileExtension = (fileName?: string): string | undefined => {
const normalized = normalizeSavedFileName(fileName)
if (!normalized) {
return undefined
}
if (!normalized.includes('.')) {
return undefined
}
const ext = normalized.split('.').pop()
return ext?.toLowerCase()
}
const resolveDownloadExtension = (download: DownloadRecord): string => {
const savedExt = getSavedFileExtension(download.savedFileName)
if (savedExt) {
return savedExt
}
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
if (selectedExt) {
return selectedExt
}
return download.type === 'audio' ? 'mp3' : 'mp4'
}
const getFormatLabel = (download: DownloadRecord): string | undefined => {
if (download.selectedFormat?.ext) {
return download.selectedFormat.ext.toUpperCase()
}
const savedExt = getSavedFileExtension(download.savedFileName)
return savedExt ? savedExt.toUpperCase() : undefined
}
const getQualityLabel = (download: DownloadRecord): string | undefined => {
const format = download.selectedFormat
if (!format) {
return undefined
}
if (format.height) {
return `${format.height}p${format.fps === 60 ? '60' : ''}`
}
if (format.format_note) {
return format.format_note
}
if (typeof format.quality === 'number') {
return format.quality.toString()
}
return undefined
}
const sanitizeCodec = (codec?: string | null): string | undefined => {
if (!codec || codec === 'none') {
return undefined
}
return codec
}
const getCodecLabel = (download: DownloadRecord): string | undefined => {
const format = download.selectedFormat
if (!format) {
return undefined
}
if (download.type === 'audio' || download.type === 'extract') {
return sanitizeCodec(format.acodec)
}
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
}
interface DownloadItemProps {
download: DownloadRecord
}
type MetadataDetail = {
label: string
value: ReactNode
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
@@ -52,20 +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
@@ -79,12 +259,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
const handleOpenFolder = async () => {
try {
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath || settings.downloadPath
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
const filePath = generateFilePath(downloadPath, download.title, format)
const format = resolvedExtension
const filePaths = generateFilePathCandidates(
downloadPath,
download.title,
format,
download.savedFileName
)
const success = await ipcServices.fs.openFileLocation(filePath)
const success = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.openFileLocation(filePath)
)
if (!success) {
toast.error(t('notifications.openFolderFailed'))
}
@@ -93,20 +279,40 @@ 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)
}
// need title, downloadPath, format
const handleCopyToClipboard = async () => {
if (!download.title || !download.downloadPath || !download.format) {
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 {
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath
const format = download.format
const filePath = generateFilePath(downloadPath, download.title, format)
const filePaths = generateFilePathCandidates(
downloadPath,
title,
format,
download.savedFileName
)
const success = await ipcServices.fs.copyFileToClipboard(filePath)
const success = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.copyFileToClipboard(filePath)
)
if (!success) {
toast.error(t('notifications.copyFailed'))
return
@@ -122,16 +328,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath || settings.downloadPath
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
const filePath = generateFilePath(downloadPath, download.title, format)
const format = resolvedExtension
const filePaths = generateFilePathCandidates(
downloadPath,
download.title,
format,
download.savedFileName
)
// Remove from history first
await ipcServices.history.removeHistoryItem(download.id)
// Then try to delete the file
await ipcServices.fs.deleteFile(filePath)
const deleted = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.deleteFile(filePath)
)
if (!deleted) {
console.warn('Failed to delete download file for history item:', download.id)
}
removeHistory(download.id)
toast.success(t('notifications.itemRemoved'))
@@ -180,14 +395,256 @@ 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 w-32 h-20">
<ImageWithPlaceholder
src={thumbnailSrc}
<RemoteImage
src={download.thumbnail}
alt={download.title}
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
@@ -198,115 +655,111 @@ 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">
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
<div className="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>
{download.playlistId && (
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="secondary"
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
>
{t('playlist.badgeLabel')}
</Badge>
<span className="truncate">
{download.playlistTitle || t('playlist.untitled')}
{download.playlistIndex !== undefined &&
download.playlistSize !== undefined && (
<span className="ml-1 text-muted-foreground/80">
{t('playlist.positionLabel', {
index: download.playlistIndex,
total: download.playlistSize
})}
</span>
)}
</span>
</div>
)}
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
<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 ? (
@@ -320,7 +773,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!download.title || !download.downloadPath || !download.format}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>
@@ -373,6 +826,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>

View File

@@ -1,13 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import { cn } from '@renderer/lib/utils'
import { useAtomValue, useSetAtom } from 'jotai'
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 type { DownloadRecord } from '../../store/downloads'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
@@ -17,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()
@@ -99,33 +98,9 @@ export function UnifiedDownloadHistory() {
return { order, groups }
}, [filteredRecords])
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
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
@@ -155,7 +130,7 @@ export function UnifiedDownloadHistory() {
})}
</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" />
@@ -191,6 +166,6 @@ export function UnifiedDownloadHistory() {
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -11,6 +11,7 @@ import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import '../../assets/title-bar.css'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
@@ -18,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
@@ -52,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: {
@@ -101,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}
@@ -126,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>
@@ -148,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>
@@ -192,7 +197,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>

View File

@@ -41,44 +41,46 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
]
return (
<Card>
<CardHeader>
<CardTitle>{t('audioExtract.title')}</CardTitle>
<Card className="border-2 border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => onExtract('extract')} className="w-full">
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
{t('audioExtract.extract')}
</Button>
</CardContent>

View File

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

View File

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

View File

@@ -1,24 +1,26 @@
export interface PopularSite {
id: string
url: string
domain: string
}
export const popularSites: PopularSite[] = [
{ id: 'youtube' },
{ id: 'youtubemusic' },
{ id: 'tiktok' },
{ id: 'facebook' },
{ id: 'instagram' },
{ id: 'twitter' },
{ id: 'soundcloud' },
{ id: 'reddit' },
{ id: 'vimeo' },
{ id: 'dailymotion' },
{ id: 'twitch' },
{ id: 'linkedin' },
{ id: 'pinterest' },
{ id: 'tumblr' },
{ id: 'mixcloud' },
{ id: 'niconico' },
{ id: 'kick' },
{ id: 'bandcamp' }
{ id: 'youtube', url: 'https://www.youtube.com', domain: 'youtube.com' },
{ id: 'youtubemusic', url: 'https://music.youtube.com', domain: 'youtube.com' },
{ id: 'tiktok', url: 'https://www.tiktok.com', domain: 'tiktok.com' },
{ id: 'facebook', url: 'https://www.facebook.com', domain: 'facebook.com' },
{ id: 'instagram', url: 'https://www.instagram.com', domain: 'instagram.com' },
{ id: 'twitter', url: 'https://twitter.com', domain: 'twitter.com' },
{ id: 'soundcloud', url: 'https://soundcloud.com', domain: 'soundcloud.com' },
{ id: 'reddit', url: 'https://www.reddit.com', domain: 'reddit.com' },
{ id: 'vimeo', url: 'https://vimeo.com', domain: 'vimeo.com' },
{ id: 'dailymotion', url: 'https://www.dailymotion.com', domain: 'dailymotion.com' },
{ id: 'twitch', url: 'https://www.twitch.tv', domain: 'twitch.tv' },
{ id: 'linkedin', url: 'https://www.linkedin.com', domain: 'linkedin.com' },
{ id: 'pinterest', url: 'https://www.pinterest.com', domain: 'pinterest.com' },
{ id: 'tumblr', url: 'https://www.tumblr.com', domain: 'tumblr.com' },
{ id: 'mixcloud', url: 'https://www.mixcloud.com', domain: 'mixcloud.com' },
{ id: 'niconico', url: 'https://www.nicovideo.jp', domain: 'nicovideo.jp' },
{ id: 'kick', url: 'https://kick.com', domain: 'kick.com' },
{ id: 'bandcamp', url: 'https://bandcamp.com', domain: 'bandcamp.com' }
]

View File

@@ -2,8 +2,10 @@
"about": {
"actions": {
"checkUpdates": "Check updates",
"download": "Download",
"email": "Email",
"feedback": "Feedback",
"goToDownload": "Go to download page",
"openRepo": "Open GitHub repository",
"view": "View",
"visit": "Visit"
@@ -27,11 +29,16 @@
"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",
"updateError": "Failed to check for updates: {{error}}"
"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",
@@ -68,7 +75,8 @@
"available": "New version available",
"uptodate": "You're up to date",
"error": "Unable to fetch the latest version"
}
},
"downloadingUpdate": "Downloading update"
},
"advancedOptions": {
"closeWhenDone": "Close app when download finishes",
@@ -132,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",
@@ -142,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",
@@ -154,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",
@@ -208,6 +246,8 @@
"about": "About",
"download": "Download",
"playlist": "Download Playlist",
"rss": "RSS",
"subscriptions": "Subscriptions",
"preferences": "Preferences",
"supportedSites": "Supported Sites",
"theme": "Theme:"
@@ -288,6 +328,7 @@
},
"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",
@@ -299,6 +340,8 @@
"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",
@@ -323,6 +366,10 @@
"selectPath": "Select",
"showMoreFormats": "Show more format options",
"showMoreFormatsDescription": "Display additional format options in the interface",
"subscriptionDefaults": {
"filenameDescription": "Pattern used when a subscription does not override its filename.",
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
},
"system": "System",
"theme": "Theme",
"themeDescription": "Choose a light, dark, or system theme for VidBee",
@@ -333,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.",

View File

@@ -7,10 +7,12 @@ import {
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Progress } from '@renderer/components/ui/progress'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
import type { LucideIcon } from 'lucide-react'
import {
Download,
Facebook,
FileText,
Github,
@@ -24,7 +26,7 @@ import {
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { ipcEvents, ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
interface AboutResource {
@@ -47,6 +49,7 @@ export function About() {
const [settings, _setSettings] = useAtom(settingsAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://vidbee.org'
@@ -71,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 () => {
@@ -234,6 +316,19 @@ export function About() {
</div>
) : null}
</div>
{updateDownloadProgress !== null && (
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -247,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')}
@@ -266,49 +367,49 @@ export function About() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.followAuthorTitle')}</CardTitle>
<CardDescription>{t('about.followAuthorDescription')}</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.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>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyShareLink}
className="gap-2"
>
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">
{t('about.followAuthorSupport')}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openShareUrl('https://x.com/nexmoex')}
className="gap-2"
>
<Twitter className="h-4 w-4" />
{t('about.followAuthorActions.follow')}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -19,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -42,7 +42,6 @@ import {
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -51,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -72,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]
@@ -110,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('/')
@@ -123,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)
@@ -140,6 +142,7 @@ 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) => t(`sites.popular.${site.id}.label`))
@@ -534,262 +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>
)}
</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>
)}
{/* 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>
)}
{/* 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>
{/* 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>
</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>
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => {
setPlaylistUrl(e.target.value)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}}
className="flex-1"
disabled={playlistBusy}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePastePlaylistUrl}
onClick={handlePreviewPlaylist}
variant="outline"
disabled={playlistBusy}
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{t('download.paste')}
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}
variant="outline"
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
)}
</div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</CardContent>
</Card>
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/>
)}
</TabsContent>
</Tabs>
{/* Playlist Preview Card (outside main card) */}
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/>
)}
{/* Unified Download History */}
<UnifiedDownloadHistory />

View File

@@ -26,6 +26,14 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
const clampSubscriptionInterval = (value: string) => {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
return 3
}
return Math.min(24, Math.max(1, parsed))
}
export function Settings() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
@@ -235,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>
@@ -296,6 +301,33 @@ export function Settings() {
)}
<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>
@@ -352,6 +384,13 @@ export function Settings() {
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.configPath} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
<Button
variant="secondary"
onClick={() => void handleSettingChange('configPath', '')}
disabled={!settings.configPath}
>
{t('settings.clearConfigFile')}
</Button>
</div>
</ItemActions>
</Item>
@@ -426,6 +465,21 @@ export function Settings() {
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.enableAnalytics}
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
</Tabs>
</div>

View File

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

View File

@@ -6,11 +6,45 @@ import {
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { popularSites } from '@renderer/data/popularSites'
import { ipcServices } from '@renderer/lib/ipc'
import { ExternalLink } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
function SiteIcon({ domain, alt }: { domain: string; alt: string }) {
const [iconUrl, setIconUrl] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const loadIcon = async () => {
try {
const dataUrl = await ipcServices.app.getSiteIcon(domain)
if (!cancelled) {
setIconUrl(dataUrl)
}
} catch (error) {
console.error('Failed to load site icon:', error)
if (!cancelled) {
setIconUrl(null)
}
}
}
void loadIcon()
return () => {
cancelled = true
}
}, [domain])
return <RemoteImage src={iconUrl} alt={alt} className="w-full h-full" />
}
export function SupportedSites() {
const { t } = useTranslation()
@@ -28,7 +62,7 @@ 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">
<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`
@@ -37,11 +71,28 @@ export function SupportedSites() {
const hasDescription = description !== descriptionKey
return (
<li key={site.id} className="rounded-md border border-border px-6 py-5">
<p className="text-sm font-medium">{label}</p>
{hasDescription ? (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
) : null}
<li 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>
)
})}

View File

@@ -5,6 +5,7 @@ export type DownloadRecord = DownloadItem & {
entryType: 'active' | 'history'
downloadedAt?: number
downloadPath?: string
savedFileName?: string
}
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
@@ -27,9 +28,6 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
speed: undefined,
duration: item.duration,
fileSize: item.fileSize,
format: item.format,
quality: item.quality,
codec: item.codec,
createdAt: item.downloadedAt,
startedAt: item.downloadedAt,
completedAt: item.completedAt ?? item.downloadedAt,
@@ -43,6 +41,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
playlistTitle: item.playlistTitle,
playlistIndex: item.playlistIndex,
playlistSize: item.playlistSize,
savedFileName: item.savedFileName,
entryType: 'history',
downloadedAt: item.downloadedAt
})

View File

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

View File

@@ -17,6 +17,7 @@ export interface VideoFormat {
audio_ext?: string
tbr?: number
quality?: number
protocol?: string // http, https, m3u8, m3u8_native, etc.
}
export interface VideoInfo {
@@ -61,9 +62,7 @@ export interface DownloadItem {
// Enhanced video information
duration?: number
fileSize?: number
format?: string
quality?: string
codec?: string
savedFileName?: string
// Timestamps
createdAt: number
startedAt?: number
@@ -74,6 +73,8 @@ export interface DownloadItem {
uploader?: string
viewCount?: number
tags?: string[]
origin?: 'manual' | 'subscription'
subscriptionId?: string
// Download-specific format info
selectedFormat?: VideoFormat
// Playlist context (optional)
@@ -83,6 +84,16 @@ export interface DownloadItem {
playlistSize?: number
}
export interface SubscriptionFeedItem {
id: string
url: string
title: string
publishedAt: number
thumbnail?: string
addedToQueue: boolean
downloadId?: string
}
export interface DownloadHistoryItem {
id: string
url: string
@@ -91,21 +102,20 @@ export interface DownloadHistoryItem {
type: 'video' | 'audio' | 'extract'
status: DownloadStatus
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)
@@ -125,6 +135,11 @@ export interface DownloadOptions {
startTime?: string
endTime?: string
downloadSubs?: boolean
customDownloadPath?: string
customFilenameTemplate?: string
tags?: string[]
origin?: 'manual' | 'subscription'
subscriptionId?: string
}
export interface PlaylistEntry {
@@ -170,8 +185,69 @@ export interface PlaylistDownloadResult {
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
@@ -190,6 +266,10 @@ export interface AppSettings {
closeToTray: boolean
hideDockIcon: boolean
autoUpdate: boolean
subscriptionFilenameTemplate: string
subscriptionOnlyLatestDefault: boolean
subscriptionCheckIntervalHours: number
enableAnalytics: boolean
}
export const defaultSettings: AppSettings = {
@@ -205,8 +285,12 @@ export const defaultSettings: AppSettings = {
theme: 'system',
oneClickDownload: false,
oneClickDownloadType: 'video',
oneClickQuality: 'auto',
oneClickQuality: 'best',
closeToTray: false,
hideDockIcon: false,
autoUpdate: true
autoUpdate: true,
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
subscriptionOnlyLatestDefault: true,
subscriptionCheckIntervalHours: 3,
enableAnalytics: true
}