Compare commits

..

15 Commits

Author SHA1 Message Date
Nexmoe
840a432b7f chore: release v0.2.0 2025-10-25 11:30:03 +08:00
Nexmoe
cc17e29fbe chore: macos icon 2025-10-25 11:25:22 +08:00
Nexmoe
dd5f0a729b feat: test for mac 2025-10-25 11:06:40 +08:00
Nexmoe
9d72caee44 chore: release v0.1.8 2025-10-24 22:07:15 +08:00
Nexmoe
661165bd65 chore: add script to check yt-dlp binary existence for cross-platform builds 2025-10-24 22:06:59 +08:00
Nexmoe
e8cd89d7e4 chore: update release workflow to download yt-dlp binaries for Windows and Linux 2025-10-24 22:03:48 +08:00
Nexmoe
4caf8efa72 chore: release v0.1.7 2025-10-24 20:47:35 +08:00
Nexmoe
ed707df837 refactor: move download engine to lib directory for better organization 2025-10-24 20:47:23 +08:00
Nexmoe
0821aea72b refactor: streamline download path handling and remove unused output path logic 2025-10-24 19:51:25 +08:00
Nexmoe
4dfac6bb34 chore: add CONTRIBUTING.md for project guidelines and enhance README with author follow section 2025-10-24 19:22:37 +08:00
Nexmoe
8ad41eebe2 chore: update README with enhanced features, add screenshots, and implement auto-updater functionality 2025-10-23 23:22:32 +08:00
Nexmoe
7c64d255b8 chore: release v0.1.6 2025-10-23 22:28:24 +08:00
Nexmoe
bad839934d chore: remove unnecessary directory creation step in release workflow 2025-10-23 22:28:15 +08:00
Nexmoe
be06106f66 chore: release v0.1.5 2025-10-23 22:25:47 +08:00
Nexmoe
b6dc09a7e3 chore: enhance release workflow to download yt-dlp binaries for cross-platform support 2025-10-23 22:25:37 +08:00
29 changed files with 1091 additions and 1065 deletions

View File

@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
os: [windows-latest]
os: [windows-latest, macos-latest]
steps:
- name: Check out Git repository
@@ -30,8 +30,18 @@ jobs:
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- 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
@@ -40,9 +50,9 @@ jobs:
# 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-mac
if: matrix.os == 'macos-latest'
run: pnpm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'

77
CONTRIBUTING.md Normal file
View File

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

128
README.md
View File

@@ -1,97 +1,81 @@
# VidBee
# 🐝 VidBee
<div align="center">
<img src="build/icon.png" alt="VidBee icon" width="120" />
<h3>A minimal Electron downloader for video and audio</h3>
<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>
</div>
## Features
- Download single videos, audio-only tracks, or entire playlists through a unified flow.
- Queue multiple jobs with progress tracking, pause, resume, retry, and download history.
- Detect platform-friendly formats automatically and store files in custom locations.
- Quick actions for popular sites plus manual URL entry for anything yt-dlp supports.
- Theme toggle (system, light, dark) and localized interface in English and Simplified Chinese.
- Desktop-native touches such as tray integration, update checks, and persistent settings.
## ✨ Core Features
## Tech Stack
- **Runtime:** Electron 38, electron-vite, electron-builder.
- **Frontend:** React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
- **Tooling:** TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
### 🌍 Global Video Download Support
## Getting Started
### Prerequisites
- Node.js 18 or newer
- pnpm 8 or newer
- **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
### Install dependencies
```bash
pnpm install
```
### Run the app in development
```bash
pnpm dev
```
### 🎨 Best-in-class UI Experience
The Electron app and Vite dev server launch together with hot module replacement.
- **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
## Useful Scripts
| Command | Description |
## 📥 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
## 📸 Screenshots
![VidBee Main Interface](screenshots/main-interface.png)
*Clean and intuitive interface with 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 |
| --- | --- |
| `pnpm dev` | Run the Electron and Vite development environment. |
| `pnpm start` | Preview the production build locally. |
| `pnpm run typecheck` | Type-check the main and renderer projects. |
| `pnpm build` | Run type checks and produce production bundles. |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
| `pnpm build:unpack` | Produce unpacked output directories. |
| `pnpm run check` | Format and lint the codebase with Biome. |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
## Project Structure
```
src/
├─ main/ # Electron main process, IPC services, configuration
├─ preload/ # Context bridge and preload helpers
└─ renderer/
├─ src/
│ ├─ pages/ # Application routes (Home, Settings, Playlist, etc.)
│ ├─ components/ # UI components, download views, shared controls
│ ├─ data/ # Static datasets such as popularSites.ts
│ ├─ hooks/ # Custom hooks and global atoms
│ ├─ lib/ # Utilities shared across the renderer
│ └─ assets/ # Global styles and icons
└─ index.html
```
> **💡 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).
## Internationalization
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
## ?? Contributing
## Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
- Logs are recorded with `electron-log` to simplify troubleshooting.
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.
## Packaging
Run one of the following commands after a successful build:
## 📄 License
```bash
pnpm build:win
pnpm build:mac
pnpm build:linux
```
Artifacts are generated under `dist/`. Use `pnpm build:unpack` to create unpacked directories for manual inspection.
## Contributing
Issues and pull requests are welcome. Keep changes focused, document user facing updates, and run `pnpm run check` before opening a PR.
## License
This project is distributed under the MIT License. See `LICENSE` for details.
## Thanks
## 🙏 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)

Binary file not shown.

BIN
icon.icns

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "0.1.4",
"version": "0.2.0",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -11,13 +11,13 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux",
"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:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp"
},
"dependencies": {

BIN
resources/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

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

@@ -0,0 +1,48 @@
#!/usr/bin/env node
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]
if (!platform) {
console.error('❌ Error: Platform argument is required!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
// Run the check
checkYtDlpExists(platform)

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
import path from 'node:path'
import type { AppSettings, DownloadOptions } from '../../shared/types'
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
const format = options.format
const audioFormat = options.audioFormat
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
}
return `bestvideo+${audioFormat}`
}
if (audioFormat === 'none') {
return `${format}+none`
}
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
return `${format}+${audio}`
}
export const resolveAudioFormatSelector = (options: DownloadOptions): string => {
const format = options.format
if (!format) {
return 'bestaudio'
}
if (format.includes('/') || format.includes('+') || format.includes('[')) {
return format
}
return format
}
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
args.push('-f', resolveVideoFormatSelector(options))
} else if (options.type === 'audio') {
args.push('-f', resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
if (options.startTime || options.endTime) {
const start = options.startTime || '0'
const end = options.endTime || ''
args.push('--download-sections', `*${start}-${end || ''}`)
}
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
}
// Output path with proper encoding handling
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
args.push('-o', outputTemplate)
// Add options for better filename handling
args.push('--no-part')
args.push('--no-playlist-reverse')
if (process.platform === 'win32') {
args.push('--windows-filenames')
}
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
if (settings.configPath) {
args.push('--config-location', settings.configPath)
}
args.push(options.url)
return args
}

View File

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

View File

@@ -1,15 +1,22 @@
import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import log from 'electron-log'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { downloadEngine } from './download-engine'
import { configureLogger } from './config/logger-config'
import { services } from './ipc'
import { downloadEngine } from './lib/download-engine'
import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
// Initialize electron-log for main process
log.initialize()
// Configure logger settings
configureLogger()
let mainWindow: BrowserWindow | null = null
let isQuitting = false
@@ -85,6 +92,72 @@ function setupDownloadEvents(): void {
})
}
function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') {
console.log('Skipping auto-updater initialization in development mode')
return
}
try {
console.log('Initializing auto-updater...')
log.transports.file.level = 'info'
autoUpdater.logger = log
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
mainWindow?.webContents.send('update:available', info)
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -99,95 +172,21 @@ app.whenReady().then(async () => {
})
// IPC services are automatically registered by electron-ipc-decorator when imported
console.log('IPC services available:', Object.keys(services))
log.info('IPC services available:', Object.keys(services))
// Initialize yt-dlp
try {
console.log('Initializing yt-dlp...')
log.info('Initializing yt-dlp...')
await ytdlpManager.initialize()
console.log('yt-dlp initialized successfully')
log.info('yt-dlp initialized successfully')
} catch (error) {
console.error('Failed to initialize yt-dlp:', error)
}
// Initialize auto-updater (only in production)
if (process.env.NODE_ENV === 'production') {
try {
console.log('Initializing auto-updater...')
// Configure logging
log.transports.file.level = 'info'
autoUpdater.logger = log
// Configure auto-updater for production
autoUpdater.autoDownload = true // Auto download updates
autoUpdater.autoInstallOnAppQuit = true // Auto install on quit
// Set up event listeners
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:available', info)
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
// Send error to renderer
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
// Send progress to renderer
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:downloaded', info)
// Show system notification
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
// Check for updates on startup if auto-update is enabled
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
// Use checkForUpdatesAndNotify for automatic notifications
await autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
} else {
console.log('Skipping auto-updater initialization in development mode')
log.error('Failed to initialize yt-dlp:', error)
}
createWindow()
initAutoUpdater()
// Create system tray
createTray()

View File

@@ -6,7 +6,7 @@ import type {
PlaylistInfo,
VideoInfo
} from '../../../shared/types'
import { downloadEngine } from '../../download-engine'
import { downloadEngine } from '../../lib/download-engine'
class DownloadService extends IpcService {
static readonly groupName = 'download'

View File

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

View File

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

View File

@@ -2,225 +2,26 @@ import { EventEmitter } from 'node:events'
import path from 'node:path'
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
import type {
AppSettings,
DownloadHistoryItem,
DownloadItem,
DownloadOptions,
DownloadProgress,
OneClickQualityPreset,
PlaylistDownloadOptions,
PlaylistInfo,
VideoFormat,
VideoInfo
} from '../shared/types'
import { DownloadQueue } from './lib/download-queue'
import { historyManager } from './lib/history-manager'
import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const selectVideoFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
if (heightDiff !== 0) return heightDiff
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
if (fpsDiff !== 0) return fpsDiff
return (b.tbr ?? 0) - (a.tbr ?? 0)
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const heightLimit = qualityPresetToVideoHeight[preset]
if (!heightLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const height = format.height ?? 0
return height > 0 && height <= heightLimit
})
return withinLimit ?? sorted[0]
}
const selectAudioFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
if (bitrateDiff !== 0) return bitrateDiff
const sizeA = a.filesize ?? a.filesize_approx ?? 0
const sizeB = b.filesize ?? b.filesize_approx ?? 0
if (sizeB !== sizeA) return sizeB - sizeA
return 0
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const abrLimit = qualityPresetToAudioAbr[preset]
if (!abrLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const bitrate = format.tbr ?? 0
return bitrate > 0 && bitrate <= abrLimit
})
return withinLimit ?? sorted[0]
}
const findFormatBySelector = (
formats: VideoFormat[],
selector?: string
): VideoFormat | undefined => {
if (!selector) {
return undefined
}
const candidateIds = selector
.split('/')
.map((option) => option.split('+')[0].trim())
.filter((option) => option.length > 0)
for (const candidateId of candidateIds) {
const match = formats.find((format) => format.format_id === candidateId)
if (match) {
return match
}
}
return undefined
}
const findFormatByIdCandidates = (
formats: VideoFormat[],
rawFormatId: string | undefined
): VideoFormat | undefined => {
if (!rawFormatId) {
return undefined
}
const parts = rawFormatId
.split('+')
.map((part) => part.trim())
.filter((part) => part.length > 0)
for (const part of parts) {
const match = formats.find((format) => format.format_id === part)
if (match) {
return match
}
}
return undefined
}
const parseSizeToBytes = (value?: string): number | undefined => {
if (!value) {
return undefined
}
const cleaned = value.trim().replace(/^~\s*/, '')
if (!cleaned) {
return undefined
}
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
if (!match) {
return undefined
}
const amount = Number(match[1].replace(/,/g, ''))
if (Number.isNaN(amount)) {
return undefined
}
const unit = match[2].toUpperCase()
const multipliers: Record<string, number> = {
B: 1,
KB: 1_000,
KIB: 1_024,
MB: 1_000_000,
MIB: 1_048_576,
GB: 1_000_000_000,
GIB: 1_073_741_824,
TB: 1_000_000_000_000,
TIB: 1_099_511_627_776
}
const multiplier = multipliers[unit]
if (!multiplier) {
return undefined
}
return Math.round(amount * multiplier)
}
const resolveSelectedFormat = (
formats: VideoFormat[],
options: DownloadOptions,
settings: AppSettings
): VideoFormat | undefined => {
const directMatch = findFormatBySelector(formats, options.format)
if (directMatch) {
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
if (options.type === 'video') {
const videoFormats = formats.filter(
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
)
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&
format.acodec !== 'none' &&
(!format.video_ext || format.video_ext === 'none')
)
return selectAudioFormatForPreset(audioFormats, preset)
}
return undefined
}
} from '../../shared/types'
import { buildDownloadArgs } from '../download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
resolveSelectedFormat
} from '../download-engine/format-utils'
import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger'
import { DownloadQueue } from './download-queue'
import { historyManager } from './history-manager'
import { ytdlpManager } from './ytdlp-manager'
interface DownloadProcess {
controller: AbortController
@@ -284,16 +85,27 @@ class DownloadEngine extends EventEmitter {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolve(info)
} catch (error) {
scopedLoggers.download.error('Failed to parse video info for:', url, error)
reject(new Error(`Failed to parse video info: ${error}`))
}
} else {
scopedLoggers.download.error(
'Failed to fetch video info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
reject(new Error(stderr || 'Failed to fetch video info'))
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error for:', url, error)
reject(error)
})
})
@@ -373,7 +185,7 @@ class DownloadEngine extends EventEmitter {
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
console.log(
scopedLoggers.download.info(
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
)
@@ -419,6 +231,7 @@ class DownloadEngine extends EventEmitter {
}
const createdAt = Date.now()
const settings = settingsManager.getAll()
const item: DownloadItem = {
id,
@@ -435,14 +248,15 @@ class DownloadEngine extends EventEmitter {
title: item.title,
status: 'pending',
downloadedAt: createdAt,
outputPath: options.outputPath
downloadPath: settings.downloadPath
})
}
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const downloadPath = options.outputPath || settings.downloadPath
const downloadPath = settings.downloadPath
// Set environment variables for proper encoding on Windows
if (process.platform === 'win32') {
@@ -455,12 +269,14 @@ class DownloadEngine extends EventEmitter {
let actualFormat: string | null = null
let actualQuality: string | null = null
let actualCodec: string | null = null
let videoInfo: VideoInfo | undefined
// First, get detailed video info to capture basic metadata and formats
try {
const videoInfo = await this.getVideoInfo(options.url)
const info = await this.getVideoInfo(options.url)
videoInfo = info
availableFormats = Array.isArray(videoInfo.formats) ? videoInfo.formats : []
availableFormats = Array.isArray(info.formats) ? info.formats : []
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
if (selectedFormat) {
@@ -482,28 +298,28 @@ class DownloadEngine extends EventEmitter {
}
this.updateDownloadInfo(id, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
uploader: videoInfo.uploader,
viewCount: videoInfo.view_count,
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
description: info.description,
uploader: info.uploader,
viewCount: info.view_count,
// Store only essential download info
selectedFormat
})
this.upsertHistoryEntry(id, options, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
uploader: videoInfo.uploader,
viewCount: videoInfo.view_count,
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
description: info.description,
uploader: info.uploader,
viewCount: info.view_count,
// Store only essential download info
selectedFormat
})
} catch (error) {
console.warn('Failed to get detailed video info:', error)
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
}
const applySelectedFormat = (formatId: string | undefined): boolean => {
@@ -542,7 +358,7 @@ class DownloadEngine extends EventEmitter {
return true
}
const args = this.buildDownloadArgs(options, downloadPath, settings)
const args = buildDownloadArgs(options, downloadPath, settings)
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
@@ -593,38 +409,8 @@ class DownloadEngine extends EventEmitter {
}
)
// Handle yt-dlp events to capture output file path and format info
let actualOutputPath: string | null = null
// Handle yt-dlp events to capture format info
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
// Look for download destination messages
if (eventType === 'download' && eventData.includes('Destination:')) {
const match = eventData.match(/Destination:\s*(.+)/)
if (match?.[1]) {
actualOutputPath = match[1].trim()
}
}
// Also look for other output path patterns
if (
eventType === 'download' &&
(eventData.includes('has already been downloaded') ||
eventData.includes('has already been downloaded'))
) {
const match = eventData.match(/\[download\]\s*(.+?)\s+has already been downloaded/)
if (match?.[1]) {
actualOutputPath = match[1].trim()
}
}
// Look for final output path in download messages
if (eventType === 'download' && eventData.includes('[download] 100%')) {
const match = eventData.match(/\[download\]\s*100%.*?of\s*(.+?)\s+at/)
if (match?.[1]) {
actualOutputPath = match[1].trim()
}
}
// Look for format selection messages
if (eventType === 'info' && eventData.includes('format')) {
// Extract format info from yt-dlp output
@@ -668,127 +454,51 @@ class DownloadEngine extends EventEmitter {
this.queue.downloadCompleted(id)
if (code === 0) {
// Try to get the actual output path from yt-dlp events first
let finalOutputPath = actualOutputPath
// 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)
// If we don't have the actual path, try to construct it
if (!finalOutputPath) {
try {
// Get video info to construct the expected output path
const videoInfo = await this.getVideoInfo(options.url)
const title = videoInfo.title || 'Unknown'
scopedLoggers.download.info('Generated file path for ID:', id, 'Path:', finalOutputPath)
// Sanitize title for filename - handle Chinese characters and special chars
const sanitizedTitle = title
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/[\u4e00-\u9fff]/g, '') // Remove Chinese characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.substring(0, 50) // Shorter limit for safety
// Determine file extension based on format
let extension = 'mp4' // default
if (options.type === 'audio') {
extension = options.extractFormat || 'mp3'
} else if (actualFormat) {
extension = actualFormat
}
// Construct the expected output path
const expectedFileName = `${sanitizedTitle}.${extension}`
const expectedPath = path.join(downloadPath, expectedFileName)
// Check if the file exists
const fs = await import('node:fs/promises')
try {
await fs.access(expectedPath)
finalOutputPath = expectedPath
} catch {
// Try to find any file with similar name in the download directory
try {
const files = await fs.readdir(downloadPath)
const matchingFile = files.find((file) => {
// Look for files that might match our download
const isVideoFile =
file.endsWith('.mp4') ||
file.endsWith('.webm') ||
file.endsWith('.mkv') ||
file.endsWith('.avi') ||
file.endsWith('.mov')
const isAudioFile =
file.endsWith('.mp3') ||
file.endsWith('.m4a') ||
file.endsWith('.aac') ||
file.endsWith('.ogg')
const isCorrectType =
(options.type === 'video' && isVideoFile) ||
(options.type === 'audio' && isAudioFile)
// Check if file was created recently (within last 5 minutes)
const filePath = path.join(downloadPath, file)
try {
const fsSync = require('node:fs')
const stats = fsSync.statSync(filePath)
const isRecent = Date.now() - stats.mtime.getTime() < 5 * 60 * 1000
return isCorrectType && isRecent
} catch {
return false
}
})
if (matchingFile) {
finalOutputPath = path.join(downloadPath, matchingFile)
}
} catch (error) {
console.warn('Failed to search for matching files:', error)
}
}
} catch (error) {
console.warn('Failed to construct output path:', error)
}
}
// Fallback to default path if still no output path
if (!finalOutputPath) {
// Create a generic filename with timestamp
const timestamp = Date.now()
const extension = options.type === 'audio' ? options.extractFormat || 'mp3' : 'mp4'
const genericFileName = `download_${timestamp}.${extension}`
finalOutputPath = path.join(downloadPath, genericFileName)
}
// Get file size if we have the output path
let fileSize: number | undefined
let fileSizeError: unknown
if (finalOutputPath) {
try {
const fs = await import('node:fs/promises')
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
} catch (error) {
fileSizeError = error
try {
const fs = await import('node:fs/promises')
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
} catch (error) {
if (latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else {
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
}
}
if (fileSize === undefined && latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else if (fileSize === undefined && fileSizeError) {
console.warn('Failed to get file size:', fileSizeError)
}
this.updateDownloadInfo(id, {
status: 'completed',
outputPath: finalOutputPath,
completedAt: Date.now(),
fileSize,
format: actualFormat || undefined,
quality: actualQuality || undefined,
codec: actualCodec || undefined
})
scopedLoggers.download.info('Download completed successfully for ID:', id)
this.emit('download-completed', id)
this.addToHistory(id, options, 'completed', undefined, finalOutputPath)
this.addToHistory(id, options, 'completed', undefined)
} else {
scopedLoggers.download.error(
'Download failed with exit code for ID:',
id,
'Exit code:',
code
)
this.emit('download-error', id, new Error(`Download exited with code ${code}`))
this.addToHistory(id, options, 'error', `Download exited with code ${code}`)
}
@@ -796,6 +506,7 @@ class DownloadEngine extends EventEmitter {
// Handle errors
ytdlpProcess.on('error', (error: Error) => {
scopedLoggers.download.error('Download process error for ID:', id, error)
this.activeDownloads.delete(id)
this.queue.downloadCompleted(id)
this.emit('download-error', id, error)
@@ -803,112 +514,8 @@ class DownloadEngine extends EventEmitter {
})
}
private buildDownloadArgs(
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
): string[] {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
args.push('-f', this.resolveVideoFormatSelector(options))
} else if (options.type === 'audio') {
args.push('-f', this.resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
if (options.startTime || options.endTime) {
const start = options.startTime || '0'
const end = options.endTime || ''
args.push('--download-sections', `*${start}-${end || ''}`)
}
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
}
// Output path with proper encoding handling
// Use sanitized filename to avoid encoding issues
const outputTemplate = path.join(downloadPath, '%(title).100s.%(ext)s')
args.push('-o', outputTemplate)
// Add options for better filename handling on Windows
if (process.platform === 'win32') {
// On Windows, use a more conservative approach to avoid encoding issues
args.push('--windows-filenames') // Use Windows-compatible filenames
}
// Browser cookies
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
// Proxy
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Config file
if (settings.configPath) {
args.push('--config-location', settings.configPath)
}
// URL (must be last)
args.push(options.url)
return args
}
private resolveVideoFormatSelector(options: DownloadOptions): string {
const format = options.format
const audioFormat = options.audioFormat
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
}
return `bestvideo+${audioFormat}`
}
if (audioFormat === 'none') {
return `${format}+none`
}
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
return `${format}+${audio}`
}
private resolveAudioFormatSelector(options: DownloadOptions): string {
const format = options.format
if (!format) {
return 'bestaudio'
}
if (format.includes('/') || format.includes('+') || format.includes('[')) {
return format
}
return format
}
cancelDownload(id: string): boolean {
scopedLoggers.download.info('Cancelling download for ID:', id)
const snapshot = this.queue.getItemDetails(id)
const download = this.activeDownloads.get(id)
@@ -916,6 +523,7 @@ class DownloadEngine extends EventEmitter {
download.controller.abort()
const removedFromQueue = this.queue.remove(id)
this.activeDownloads.delete(id)
scopedLoggers.download.info('Download cancelled successfully for ID:', id)
this.emit('download-cancelled', id)
if (snapshot) {
this.upsertHistoryEntry(id, snapshot.options, {
@@ -962,9 +570,6 @@ class DownloadEngine extends EventEmitter {
if (updates.duration !== undefined) {
historyUpdates.duration = updates.duration
}
if (updates.outputPath !== undefined) {
historyUpdates.outputPath = updates.outputPath
}
if (updates.fileSize !== undefined) {
historyUpdates.fileSize = updates.fileSize
}
@@ -1014,19 +619,17 @@ class DownloadEngine extends EventEmitter {
id: string,
options: DownloadOptions,
status: DownloadHistoryItem['status'],
error?: string,
actualOutputPath?: string
error?: string
): void {
// Get the download item from the queue to get additional info
const completedDownload = this.queue.getCompletedDownload(id)
scopedLoggers.download.info('Completed download:', completedDownload)
const completedAt = Date.now()
this.upsertHistoryEntry(id, options, {
title: completedDownload?.item.title || `Download ${id}`,
thumbnail: completedDownload?.item.thumbnail,
status,
outputPath: actualOutputPath || options.outputPath,
completedAt,
error,
duration: completedDownload?.item.duration,
@@ -1055,7 +658,7 @@ class DownloadEngine extends EventEmitter {
thumbnail: updates.thumbnail,
type: options.type,
status: updates.status || 'pending',
outputPath: updates.outputPath,
downloadPath: updates.downloadPath,
fileSize: updates.fileSize,
duration: updates.duration,
downloadedAt: updates.downloadedAt ?? Date.now(),

View File

@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
import appIcon from '../../resources/icon.png?asset'
import trayIcon from '../../resources/tray-icon.png?asset'
import { settingsManager } from './settings'
let tray: Tray | null = null
@@ -85,9 +86,17 @@ export function createTray(): void {
return
}
const trayIcon = nativeImage.createFromPath(appIcon)
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
const trayIconImage = nativeImage.createFromPath(iconPath)
tray = new Tray(trayIcon)
// For macOS, ensure the icon is properly sized
if (process.platform === 'darwin') {
// Resize to 16x16 for macOS tray
trayIconImage.setTemplateImage(true)
}
tray = new Tray(trayIconImage)
// Set tooltip
tray.setToolTip('VidBee')

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

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

View File

@@ -3,7 +3,8 @@ import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { ipcServices } from './lib/ipc'
import { About } from './pages/About'
import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
@@ -13,6 +14,22 @@ type Page = 'home' | 'settings' | 'about' | 'sites'
function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const [platform, setPlatform] = useState<string>('')
useEffect(() => {
// Get platform info to determine if we should show title bar
const getPlatform = async () => {
try {
const platformInfo = await ipcServices.app.getPlatform()
setPlatform(platformInfo)
} catch (error) {
console.error('Failed to get platform info:', error)
// Default to showing title bar if platform detection fails
setPlatform('unknown')
}
}
getPlatform()
}, [])
const renderPage = () => {
switch (currentPage) {
@@ -36,8 +53,8 @@ function AppContent() {
{/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
{/* Custom Title Bar */}
<TitleBar />
{/* Custom Title Bar - Hide on macOS */}
{platform !== 'darwin' && <TitleBar />}
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
@@ -49,7 +66,7 @@ function AppContent() {
</ScrollArea>
</main>
<Toaster />
<Toaster richColors={true} />
</div>
)
}

View File

@@ -3,17 +3,8 @@ import { Button } from '@renderer/components/ui/button'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Progress } from '@renderer/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
ExternalLink,
FolderOpen,
Loader2,
Play,
Trash2,
X
} from 'lucide-react'
import { useAtomValue, useSetAtom } from 'jotai'
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
@@ -23,6 +14,16 @@ import {
removeDownloadAtom,
removeHistoryRecordAtom
} 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 normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
return `${normalizedDownloadPath}/${fileName}`
}
interface DownloadItemProps {
download: DownloadRecord
@@ -53,6 +54,7 @@ const formatDate = (timestamp?: number) => {
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'
@@ -75,14 +77,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
}
}
const handleOpenFileLocation = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFolderFailed'))
return
}
const handleOpenFolder = async () => {
try {
const success = await ipcServices.fs.openFileLocation(download.outputPath)
// 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 success = await ipcServices.fs.openFileLocation(filePath)
if (!success) {
toast.error(t('notifications.openFolderFailed'))
}
@@ -91,29 +93,46 @@ export function DownloadItem({ download }: DownloadItemProps) {
toast.error(t('notifications.openFolderFailed'))
}
}
const handleOpenFile = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFileFailed'))
// need title, downloadPath, format
const handleCopyToClipboard = async () => {
if (!download.title || !download.downloadPath || !download.format) {
toast.error(t('notifications.copyFailed'))
return
}
try {
await ipcServices.fs.openFileLocation(download.outputPath)
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath
const format = download.format
const filePath = generateFilePath(downloadPath, download.title, format)
const success = await ipcServices.fs.copyFileToClipboard(filePath)
if (!success) {
toast.error(t('notifications.copyFailed'))
return
}
toast.success(t('notifications.videoCopied'))
} catch (error) {
console.error('Failed to open file:', error)
toast.error(t('notifications.openFileFailed'))
console.error('Failed to copy file to clipboard:', error)
toast.error(t('notifications.copyFailed'))
}
}
const handleOpenFolder = async () => {
await handleOpenFileLocation()
}
// need id
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
// 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)
// Remove from history first
await ipcServices.history.removeHistoryItem(download.id)
// Then try to delete the file
await ipcServices.fs.deleteFile(filePath)
removeHistory(download.id)
toast.success(t('notifications.itemRemoved'))
} catch (error) {
@@ -132,7 +151,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
case 'processing':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'pending':
return <Loader2 className="h-4 w-4 text-muted-foreground" />
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
case 'cancelled':
return <X className="h-4 w-4 text-muted-foreground" />
default:
@@ -166,11 +185,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
<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">
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
<ImageWithPlaceholder
src={thumbnailSrc}
alt={download.title}
className="w-32 h-18 object-cover aspect-video"
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
@@ -179,18 +198,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs wrap-break-word">{download.title}</p>
</TooltipContent>
</Tooltip>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
{download.type}
@@ -277,7 +289,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
<div className={actionsContainerClass}>
{isHistory ? (
<>
{download.outputPath && (
{download.status === 'completed' && (
<>
<Tooltip>
<TooltipTrigger asChild>
@@ -285,13 +297,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
onClick={handleCopyToClipboard}
disabled={!download.title || !download.downloadPath || !download.format}
>
<ExternalLink className="h-4 w-4" />
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -329,7 +342,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
</>
) : (
<>
{download.status === 'completed' && download.outputPath && (
{download.status === 'completed' && (
<>
<Tooltip>
<TooltipTrigger asChild>
@@ -337,13 +350,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
onClick={handleCopyToClipboard}
>
<ExternalLink className="h-4 w-4" />
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>

View File

@@ -44,7 +44,7 @@ export function ImageWithPlaceholder({
}
return (
<div className={cn('relative', className)}>
<div className={cn('relative w-full h-full', className)}>
{isLoading && (
<div
className={cn(

View File

@@ -48,6 +48,12 @@
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word",
"shareDescription": "Share VidBee with your community in one click.",
"followAuthorActions": {
"follow": "Follow @nexmoex"
},
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
"followAuthorTitle": "Follow the Developer",
"shareActions": {
"twitter": "Share on X (Twitter)",
"facebook": "Share on Facebook",
@@ -175,10 +181,10 @@
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openFile": "Open File",
"copyToClipboard": "Copy to clipboard",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
"outputPath": "Output Path",
"removeItem": "Remove Item",
"stats": {
"cancelled": "Cancelled",
@@ -203,6 +209,7 @@
},
"notifications": {
"copyFailed": "Failed to copy to clipboard",
"videoCopied": "Video copied to clipboard",
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",

View File

@@ -43,6 +43,12 @@
"website": "官方网站",
"websiteDescription": "产品亮点、路线图与社区动态。"
},
"followAuthorActions": {
"follow": "关注 @nexmoex"
},
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
"followAuthorTitle": "关注开发者",
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"sourceCode": "源代码已开放",
@@ -170,7 +176,6 @@
"openFileLocation": "打开文件位置",
"openFolder": "打开文件夹",
"openDownloadFolder": "打开下载文件夹",
"outputPath": "输出路径",
"removeItem": "移除项目",
"stats": {
"cancelled": "已取消",

View File

@@ -213,6 +213,29 @@ 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>

View File

@@ -4,6 +4,7 @@ import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
export type DownloadRecord = DownloadItem & {
entryType: 'active' | 'history'
downloadedAt?: number
downloadPath?: string
}
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
@@ -22,7 +23,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
status: item.status,
progress: undefined,
error: item.error,
outputPath: item.outputPath,
downloadPath: item.downloadPath,
speed: undefined,
duration: item.duration,
fileSize: item.fileSize,

View File

@@ -54,7 +54,6 @@ export interface DownloadItem {
status: DownloadStatus
progress?: DownloadProgress
error?: string
outputPath?: string
speed?: string
// Enhanced video information
duration?: number
@@ -83,7 +82,7 @@ export interface DownloadHistoryItem {
thumbnail?: string
type: 'video' | 'audio' | 'extract'
status: DownloadStatus
outputPath?: string
downloadPath?: string
fileSize?: number
duration?: number
downloadedAt: number
@@ -113,7 +112,6 @@ export interface DownloadOptions {
startTime?: string
endTime?: string
downloadSubs?: boolean
outputPath?: string
}
export interface PlaylistInfo {