Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d72caee44 | ||
|
|
661165bd65 | ||
|
|
e8cd89d7e4 | ||
|
|
4caf8efa72 | ||
|
|
ed707df837 | ||
|
|
0821aea72b | ||
|
|
4dfac6bb34 | ||
|
|
8ad41eebe2 | ||
|
|
7c64d255b8 | ||
|
|
bad839934d | ||
|
|
be06106f66 | ||
|
|
b6dc09a7e3 | ||
|
|
edbc572bf1 | ||
|
|
370a165572 |
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [windows-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
|
||||
|
||||
77
CONTRIBUTING.md
Normal file
77
CONTRIBUTING.md
Normal 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
128
README.md
@@ -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
|
||||
|
||||

|
||||
*Clean and intuitive interface with download queue management*
|
||||
|
||||

|
||||
*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)
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.8",
|
||||
"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
screenshots/download-queue.png
Normal file
BIN
screenshots/download-queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshots/main-interface.png
Normal file
BIN
screenshots/main-interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
48
scripts/check-ytdlp.js
Normal file
48
scripts/check-ytdlp.js
Normal 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)
|
||||
28
scripts/set-console-encoding.js
Normal file
28
scripts/set-console-encoding.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 设置控制台编码为 UTF-8,解决中文乱码问题
|
||||
* 这个脚本在 Windows 上设置控制台代码页为 UTF-8
|
||||
*/
|
||||
|
||||
const { exec } = require('node:child_process')
|
||||
const os = require('node:os')
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
console.log('Setting console encoding to UTF-8...')
|
||||
|
||||
// 设置控制台代码页为 UTF-8 (65001)
|
||||
exec('chcp 65001', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.warn('Failed to set console code page:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.warn('Console code page setting warning:', stderr)
|
||||
}
|
||||
|
||||
console.log('Console encoding set to UTF-8')
|
||||
console.log('Output:', stdout)
|
||||
})
|
||||
} else {
|
||||
console.log('Not on Windows, no console encoding change needed')
|
||||
}
|
||||
44
src/main/config/logger-config.ts
Normal file
44
src/main/config/logger-config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import log from 'electron-log/main'
|
||||
|
||||
/**
|
||||
* Configure electron-log
|
||||
* Set log format, file path, transport methods, etc.
|
||||
*/
|
||||
export function configureLogger() {
|
||||
// Configure console output format - support colors and scope, time in gray
|
||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
||||
|
||||
// Enable console colors
|
||||
log.transports.console.useStyles = true
|
||||
|
||||
// Configure file output format - include scope information
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
||||
|
||||
// Set log levels
|
||||
// Development: show all logs
|
||||
// Production: show info level and above only
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
log.transports.console.level = isDev ? 'silly' : 'info'
|
||||
log.transports.file.level = isDev ? 'silly' : 'info'
|
||||
|
||||
// Enable IPC transport in development environment to show renderer process logs in main process console
|
||||
if (isDev) {
|
||||
log.transports.ipc.level = 'silly'
|
||||
} else {
|
||||
log.transports.ipc.level = false
|
||||
}
|
||||
|
||||
// Set maximum log file size (10MB)
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024
|
||||
|
||||
// Enable error catching - catch unhandled errors and rejected promises
|
||||
log.errorHandler.startCatching({
|
||||
showDialog: false, // Don't show error dialog, only log to file
|
||||
onError: (options) => {
|
||||
log.error('Unhandled error caught by electron-log:', options.error)
|
||||
log.error('App versions:', options.versions)
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Log file location:', log.transports.file.getFile().path)
|
||||
}
|
||||
104
src/main/download-engine/args-builder.ts
Normal file
104
src/main/download-engine/args-builder.ts
Normal 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
|
||||
}
|
||||
210
src/main/download-engine/format-utils.ts
Normal file
210
src/main/download-engine/format-utils.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
|
||||
const stats = await fs.stat(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stats) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await fs.unlink(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(normalizedPath)
|
||||
if (entries.length === 0) {
|
||||
await fs.rmdir(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FileSystemService }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { DownloadHistoryItem } from '../../../shared/types'
|
||||
import { historyManager } from '../../lib/history-manager'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
class HistoryService extends IpcService {
|
||||
static readonly groupName = 'history'
|
||||
@@ -24,282 +21,10 @@ class HistoryService extends IpcService {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async removeHistoryItem(_context: IpcContext, id: string, outputPath?: string): Promise<boolean> {
|
||||
const record = historyManager.getHistoryById(id)
|
||||
|
||||
await this.deleteOutputResource(record, outputPath)
|
||||
|
||||
removeHistoryItem(_context: IpcContext, id: string): boolean {
|
||||
return historyManager.removeHistoryItem(id)
|
||||
}
|
||||
|
||||
private async deleteOutputResource(
|
||||
record: DownloadHistoryItem | undefined,
|
||||
fallbackOutputPath?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const candidatePaths = new Set<string>()
|
||||
if (record?.outputPath) {
|
||||
candidatePaths.add(record.outputPath)
|
||||
}
|
||||
if (fallbackOutputPath) {
|
||||
candidatePaths.add(fallbackOutputPath)
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (await this.tryDeletePath(candidate)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const directories = this.collectCandidateDirectories(candidatePaths)
|
||||
if (directories.length === 0) {
|
||||
const defaultDownloadPath = settingsManager.get('downloadPath')
|
||||
if (defaultDownloadPath) {
|
||||
directories.push(defaultDownloadPath)
|
||||
}
|
||||
}
|
||||
|
||||
const matchKeys = this.collectMatchKeys(record, candidatePaths)
|
||||
if (matchKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const extensions = this.collectExtensions(candidatePaths, record)
|
||||
for (const directory of directories) {
|
||||
const matchedPath = await this.findMatchingFile(directory, matchKeys, extensions, record)
|
||||
if (matchedPath && (await this.tryDeletePath(matchedPath))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete output resource:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePath(target: string): string {
|
||||
return target.trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
private normalizeMatchString(value?: string): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||
}
|
||||
|
||||
private collectCandidateDirectories(candidatePaths: Set<string>): string[] {
|
||||
const directories = new Set<string>()
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
const normalized = path.normalize(sanitized)
|
||||
if (!path.isAbsolute(normalized)) continue
|
||||
const directory = path.dirname(normalized)
|
||||
if (directory) {
|
||||
directories.add(directory)
|
||||
}
|
||||
}
|
||||
return Array.from(directories)
|
||||
}
|
||||
|
||||
private collectExtensions(
|
||||
candidatePaths: Set<string>,
|
||||
record: DownloadHistoryItem | undefined
|
||||
): Set<string> {
|
||||
const extensions = new Set<string>()
|
||||
|
||||
const addExtension = (ext?: string) => {
|
||||
if (!ext) {
|
||||
return
|
||||
}
|
||||
const trimmed = ext.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
const normalized = trimmed.startsWith('.')
|
||||
? trimmed.toLowerCase()
|
||||
: `.${trimmed.toLowerCase()}`
|
||||
extensions.add(normalized)
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
addExtension(path.extname(sanitized))
|
||||
}
|
||||
|
||||
addExtension(record?.outputPath ? path.extname(record.outputPath) : undefined)
|
||||
addExtension(record?.selectedFormat?.ext)
|
||||
addExtension(record?.selectedFormat?.video_ext)
|
||||
addExtension(record?.selectedFormat?.audio_ext)
|
||||
|
||||
if (extensions.size === 0) {
|
||||
const fallback =
|
||||
record?.type === 'audio'
|
||||
? ['.mp3', '.m4a', '.aac', '.ogg', '.opus', '.flac', '.wav']
|
||||
: ['.mp4', '.mkv', '.webm', '.mov', '.avi']
|
||||
for (const ext of fallback) {
|
||||
extensions.add(ext)
|
||||
}
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
private collectMatchKeys(
|
||||
record: DownloadHistoryItem | undefined,
|
||||
candidatePaths: Set<string>
|
||||
): string[] {
|
||||
const keys = new Set<string>()
|
||||
const fallbackKeys: string[] = []
|
||||
|
||||
const tryAddKey = (value?: string) => {
|
||||
if (!value) return
|
||||
const normalized = this.normalizeMatchString(value)
|
||||
if (!normalized) return
|
||||
if (normalized.length >= 3) {
|
||||
keys.add(normalized)
|
||||
} else {
|
||||
fallbackKeys.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
const sanitized = this.sanitizePath(candidate)
|
||||
if (!sanitized) continue
|
||||
const baseName = path.parse(sanitized).name
|
||||
tryAddKey(baseName)
|
||||
}
|
||||
|
||||
tryAddKey(record?.title)
|
||||
tryAddKey(record?.id)
|
||||
|
||||
if (keys.size === 0) {
|
||||
for (const key of fallbackKeys) {
|
||||
keys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
private async findMatchingFile(
|
||||
directory: string,
|
||||
matchKeys: string[],
|
||||
extensions: Set<string>,
|
||||
record: DownloadHistoryItem | undefined
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const dirStats = await fs.stat(directory).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!dirStats || !dirStats.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const matches: Array<{ path: string; diff: number }> = []
|
||||
const targetTimestamp = record?.completedAt ?? record?.downloadedAt
|
||||
const maxDiff = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
const entryExt = path.extname(entry.name).toLowerCase()
|
||||
if (extensions.size > 0 && entryExt && !extensions.has(entryExt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedName = this.normalizeMatchString(entry.name)
|
||||
if (!normalizedName && matchKeys.length > 0) continue
|
||||
|
||||
const hasMatchKeys = matchKeys.length > 0
|
||||
const isMatch = hasMatchKeys
|
||||
? matchKeys.some((key) => key && normalizedName.includes(key))
|
||||
: true
|
||||
if (!isMatch) continue
|
||||
|
||||
const stats = await fs.stat(entryPath).catch(() => null)
|
||||
if (!stats) continue
|
||||
|
||||
if (targetTimestamp) {
|
||||
const diff = Math.abs(stats.mtimeMs - targetTimestamp)
|
||||
if (diff > maxDiff) {
|
||||
continue
|
||||
}
|
||||
matches.push({ path: entryPath, diff })
|
||||
} else {
|
||||
if (!hasMatchKeys) {
|
||||
continue
|
||||
}
|
||||
matches.push({ path: entryPath, diff: Number.POSITIVE_INFINITY })
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.diff - b.diff)
|
||||
return matches[0]?.path ?? null
|
||||
} catch (error) {
|
||||
console.error('Failed to search for matching file:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async tryDeletePath(rawPath: string): Promise<boolean> {
|
||||
const sanitizedPath = this.sanitizePath(rawPath)
|
||||
if (!sanitizedPath) return false
|
||||
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
const stats = await fs.stat(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stats) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await fs.unlink(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(normalizedPath)
|
||||
if (entries.length === 0) {
|
||||
await fs.rmdir(normalizedPath).catch((error) => {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getHistoryCount(_context: IpcContext): {
|
||||
active: number
|
||||
|
||||
@@ -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(),
|
||||
25
src/main/utils/logger.ts
Normal file
25
src/main/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Main process logger utility
|
||||
* Directly use electron-log/main
|
||||
*/
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
// Export electron-log instance
|
||||
export default log
|
||||
|
||||
// Export commonly used logging methods
|
||||
export const logger = log
|
||||
|
||||
// Predefined scoped loggers
|
||||
export const scopedLoggers = {
|
||||
main: log.scope('main'),
|
||||
ipc: log.scope('ipc'),
|
||||
window: log.scope('window'),
|
||||
download: log.scope('download'),
|
||||
engine: log.scope('engine'),
|
||||
system: log.scope('system'),
|
||||
storage: log.scope('storage'),
|
||||
thumbnail: log.scope('thumbnail'),
|
||||
history: log.scope('history')
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function AppContent() {
|
||||
</ScrollArea>
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
<Toaster richColors={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,14 @@ 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
|
||||
return `${downloadPath}/${fileName}`.replace(/\//g, '\\')
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
@@ -53,6 +52,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 +75,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 +91,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 +149,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 +183,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 +196,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 +287,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<div className={actionsContainerClass}>
|
||||
{isHistory ? (
|
||||
<>
|
||||
{download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -285,13 +295,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 +340,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.status === 'completed' && download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -337,13 +348,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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "已取消",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user