chore: add CONTRIBUTING.md for project guidelines and enhance README with author follow section
This commit is contained in:
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -30,19 +30,6 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Download yt-dlp binaries
|
|
||||||
run: |
|
|
||||||
# Download yt-dlp.exe for Windows
|
|
||||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
|
||||||
|
|
||||||
# Download yt-dlp for macOS (for cross-platform builds)
|
|
||||||
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
|
|
||||||
# chmod +x resources/yt-dlp_macos
|
|
||||||
|
|
||||||
# Download yt-dlp for Linux (for cross-platform builds)
|
|
||||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
|
||||||
chmod +x resources/yt-dlp_linux
|
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: pnpm run typecheck
|
run: pnpm run typecheck
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
132
README.md
132
README.md
@@ -14,6 +14,8 @@
|
|||||||
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
|
- **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
|
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
|
||||||
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
|
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
|
||||||
|
- Localized interface support in many languages
|
||||||
|
|
||||||
|
|
||||||
### 🎨 Best-in-class UI Experience
|
### 🎨 Best-in-class UI Experience
|
||||||
|
|
||||||
@@ -22,11 +24,6 @@
|
|||||||
- **Real-time Progress** - Detailed download progress tracking and status management
|
- **Real-time Progress** - Detailed download progress tracking and status management
|
||||||
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
|
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
|
||||||
|
|
||||||
### ⚡ Powerful Features
|
|
||||||
|
|
||||||
- 🎯 Automatically detect platform-friendly formats and store files in custom locations
|
|
||||||
- 🎨 Localized interface support in many languages
|
|
||||||
|
|
||||||
## 📥 Download & Install
|
## 📥 Download & Install
|
||||||
|
|
||||||
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
|
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
|
||||||
@@ -48,116 +45,27 @@
|
|||||||
|
|
||||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||||
|
|
||||||
### 🎬 Video Platforms
|
| Video Platforms | Audio & Other Platforms |
|
||||||
|
| --- | --- |
|
||||||
- **📺 YouTube** - Long-form and livestream video from creators worldwide
|
| YouTube | YouTube Music |
|
||||||
- **🎵 TikTok** - Short-form mobile videos, effects, and live streams
|
| TikTok | SoundCloud |
|
||||||
- **📘 Facebook** - Feed, Watch, and Reels videos from public pages
|
| Facebook | Mixcloud |
|
||||||
- **📷 Instagram** - Feed, Stories, Reels, and Highlights content
|
| Instagram | Bandcamp |
|
||||||
- **🐦 X (Twitter)** - Timeline posts, Spaces recordings, and broadcasts
|
| X (Twitter) | Reddit |
|
||||||
- **🎥 Vimeo** - High-quality creator and business video hosting
|
| Vimeo | |
|
||||||
- **🌍 Dailymotion** - Global news, sports, and entertainment clips
|
| Dailymotion | |
|
||||||
- **🎮 Twitch** - Gaming, music, and IRL live streams and VODs
|
| Twitch | |
|
||||||
- **💼 LinkedIn** - Professional talks, webinars, and learning videos
|
| LinkedIn | |
|
||||||
- **📌 Pinterest** - Idea pins, how-to reels, and lifestyle inspiration videos
|
| Pinterest | |
|
||||||
- **🎨 Tumblr** - Creative short-form media and fan edits
|
| Tumblr | |
|
||||||
- **🇯🇵 Niconico** - Japanese animation, music, and live broadcast archive
|
| Niconico | |
|
||||||
- **⚡ Kick** - Creator live streams and replays on the Kick platform
|
| Kick | |
|
||||||
|
|
||||||
### 🎵 Audio Platforms
|
|
||||||
|
|
||||||
- **🎶 YouTube Music** - Official music videos, albums, and live performances
|
|
||||||
- **🎧 SoundCloud** - Music tracks, playlists, and DJ sets
|
|
||||||
- **🎛️ Mixcloud** - DJ mixes, radio shows, and long-form audio
|
|
||||||
- **🎸 Bandcamp** - Independent artist albums and community releases
|
|
||||||
|
|
||||||
### 🔗 Other Platforms
|
|
||||||
|
|
||||||
- **🤖 Reddit** - Embedded clips and hosted videos from communities
|
|
||||||
|
|
||||||
> **💡 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).
|
> **💡 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).
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## ?? Contributing
|
||||||
|
|
||||||
- **Runtime:** Electron 38, electron-vite, electron-builder
|
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.
|
||||||
- **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
|
|
||||||
|
|
||||||
## 🛠️ Development Setup
|
|
||||||
|
|
||||||
### 📋 Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18 or newer
|
|
||||||
- pnpm 8 or newer
|
|
||||||
|
|
||||||
### 📦 Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🏃♂️ Run the app in development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The Electron app and Vite dev server launch together with hot module replacement.
|
|
||||||
|
|
||||||
## 📜 Useful Scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `pnpm dev` | Run the Electron and Vite development environment |
|
|
||||||
| `pnpm start` | Preview the production build locally |
|
|
||||||
| `pnpm run typecheck` | Type-check the main and renderer projects |
|
|
||||||
| `pnpm build` | Run type checks and produce production bundles |
|
|
||||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables |
|
|
||||||
| `pnpm build:unpack` | Produce unpacked output directories |
|
|
||||||
| `pnpm run check` | Format and lint the codebase with Biome |
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
|
|
||||||
|
|
||||||
## ⚙️ Configuration and Storage
|
|
||||||
|
|
||||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers
|
|
||||||
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services
|
|
||||||
- Logs are recorded with `electron-log` to simplify troubleshooting
|
|
||||||
|
|
||||||
## 📦 Packaging
|
|
||||||
|
|
||||||
Run one of the following commands after a successful build:
|
|
||||||
|
|
||||||
```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
|
## 📄 License
|
||||||
|
|
||||||
@@ -165,9 +73,9 @@ 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/)
|
- [Electron](https://www.electronjs.org/)
|
||||||
- [React](https://react.dev/)
|
- [React](https://react.dev/)
|
||||||
- [Vite](https://vitejs.dev/)
|
- [Vite](https://vitejs.dev/)
|
||||||
- [Tailwind CSS](https://tailwindcss.com/)
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
- [shadcn/ui](https://ui.shadcn.com/)
|
- [shadcn/ui](https://ui.shadcn.com/)
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"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",
|
"build": "pnpm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -2,225 +2,26 @@ import { EventEmitter } from 'node:events'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
|
||||||
DownloadHistoryItem,
|
DownloadHistoryItem,
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
DownloadOptions,
|
DownloadOptions,
|
||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
OneClickQualityPreset,
|
|
||||||
PlaylistDownloadOptions,
|
PlaylistDownloadOptions,
|
||||||
PlaylistInfo,
|
PlaylistInfo,
|
||||||
VideoFormat,
|
VideoFormat,
|
||||||
VideoInfo
|
VideoInfo
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
|
import { buildDownloadArgs } from './download-engine/args-builder'
|
||||||
|
import {
|
||||||
|
findFormatByIdCandidates,
|
||||||
|
parseSizeToBytes,
|
||||||
|
resolveSelectedFormat
|
||||||
|
} from './download-engine/format-utils'
|
||||||
import { DownloadQueue } from './lib/download-queue'
|
import { DownloadQueue } from './lib/download-queue'
|
||||||
import { historyManager } from './lib/history-manager'
|
import { historyManager } from './lib/history-manager'
|
||||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||||
import { settingsManager } from './settings'
|
import { settingsManager } from './settings'
|
||||||
|
import { scopedLoggers } from './utils/logger'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadProcess {
|
interface DownloadProcess {
|
||||||
controller: AbortController
|
controller: AbortController
|
||||||
@@ -284,16 +85,27 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (code === 0 && stdout) {
|
if (code === 0 && stdout) {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(stdout)
|
const info = JSON.parse(stdout)
|
||||||
|
scopedLoggers.download.info('Successfully retrieved video info for:', url)
|
||||||
resolve(info)
|
resolve(info)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
scopedLoggers.download.error('Failed to parse video info for:', url, error)
|
||||||
reject(new Error(`Failed to parse video info: ${error}`))
|
reject(new Error(`Failed to parse video info: ${error}`))
|
||||||
}
|
}
|
||||||
} else {
|
} 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'))
|
reject(new Error(stderr || 'Failed to fetch video info'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
process.on('error', (error) => {
|
process.on('error', (error) => {
|
||||||
|
scopedLoggers.download.error('yt-dlp process error for:', url, error)
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -373,7 +185,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
|
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
|
||||||
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
|
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
|
||||||
|
|
||||||
console.log(
|
scopedLoggers.download.info(
|
||||||
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
|
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -440,6 +252,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
|
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 ytdlp = ytdlpManager.getInstance()
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const downloadPath = options.outputPath || settings.downloadPath
|
const downloadPath = options.outputPath || settings.downloadPath
|
||||||
@@ -455,12 +268,14 @@ class DownloadEngine extends EventEmitter {
|
|||||||
let actualFormat: string | null = null
|
let actualFormat: string | null = null
|
||||||
let actualQuality: string | null = null
|
let actualQuality: string | null = null
|
||||||
let actualCodec: string | null = null
|
let actualCodec: string | null = null
|
||||||
|
let videoInfo: VideoInfo | undefined
|
||||||
|
|
||||||
// First, get detailed video info to capture basic metadata and formats
|
// First, get detailed video info to capture basic metadata and formats
|
||||||
try {
|
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)
|
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
|
||||||
|
|
||||||
if (selectedFormat) {
|
if (selectedFormat) {
|
||||||
@@ -482,28 +297,28 @@ class DownloadEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateDownloadInfo(id, {
|
this.updateDownloadInfo(id, {
|
||||||
title: videoInfo.title,
|
title: info.title,
|
||||||
thumbnail: videoInfo.thumbnail,
|
thumbnail: info.thumbnail,
|
||||||
duration: videoInfo.duration,
|
duration: info.duration,
|
||||||
description: videoInfo.description,
|
description: info.description,
|
||||||
uploader: videoInfo.uploader,
|
uploader: info.uploader,
|
||||||
viewCount: videoInfo.view_count,
|
viewCount: info.view_count,
|
||||||
// Store only essential download info
|
// Store only essential download info
|
||||||
selectedFormat
|
selectedFormat
|
||||||
})
|
})
|
||||||
|
|
||||||
this.upsertHistoryEntry(id, options, {
|
this.upsertHistoryEntry(id, options, {
|
||||||
title: videoInfo.title,
|
title: info.title,
|
||||||
thumbnail: videoInfo.thumbnail,
|
thumbnail: info.thumbnail,
|
||||||
duration: videoInfo.duration,
|
duration: info.duration,
|
||||||
description: videoInfo.description,
|
description: info.description,
|
||||||
uploader: videoInfo.uploader,
|
uploader: info.uploader,
|
||||||
viewCount: videoInfo.view_count,
|
viewCount: info.view_count,
|
||||||
// Store only essential download info
|
// Store only essential download info
|
||||||
selectedFormat
|
selectedFormat
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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 => {
|
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
||||||
@@ -542,7 +357,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = this.buildDownloadArgs(options, downloadPath, settings)
|
const args = buildDownloadArgs(options, downloadPath, settings)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const ytdlpProcess = ytdlp.exec(args, {
|
const ytdlpProcess = ytdlp.exec(args, {
|
||||||
@@ -598,6 +413,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
||||||
// Look for download destination messages
|
// Look for download destination messages
|
||||||
|
scopedLoggers.download.info('ytDlpEvent:', eventType, eventData)
|
||||||
if (eventType === 'download' && eventData.includes('Destination:')) {
|
if (eventType === 'download' && eventData.includes('Destination:')) {
|
||||||
const match = eventData.match(/Destination:\s*(.+)/)
|
const match = eventData.match(/Destination:\s*(.+)/)
|
||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
@@ -668,113 +484,47 @@ class DownloadEngine extends EventEmitter {
|
|||||||
this.queue.downloadCompleted(id)
|
this.queue.downloadCompleted(id)
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
// Try to get the actual output path from yt-dlp events first
|
// Use actual output path from yt-dlp, or fallback to simple generated path
|
||||||
let finalOutputPath = actualOutputPath
|
let finalOutputPath: string
|
||||||
|
if (actualOutputPath) {
|
||||||
// If we don't have the actual path, try to construct it
|
finalOutputPath = actualOutputPath
|
||||||
if (!finalOutputPath) {
|
scopedLoggers.download.info(
|
||||||
try {
|
'Using actual output path from yt-dlp for ID:',
|
||||||
// Get video info to construct the expected output path
|
id,
|
||||||
const videoInfo = await this.getVideoInfo(options.url)
|
'Path:',
|
||||||
const title = videoInfo.title || 'Unknown'
|
finalOutputPath
|
||||||
|
)
|
||||||
// Sanitize title for filename - handle Chinese characters and special chars
|
} else {
|
||||||
const sanitizedTitle = title
|
// Simple fallback: generate path based on video title and format
|
||||||
.replace(/[<>:"/\\|?*]/g, '_')
|
const title = videoInfo?.title || 'Unknown'
|
||||||
.replace(/[\u4e00-\u9fff]/g, '') // Remove Chinese characters
|
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
|
||||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
const extension =
|
||||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4'
|
||||||
.substring(0, 50) // Shorter limit for safety
|
const fileName = `${sanitizedTitle}.${extension}`
|
||||||
|
finalOutputPath = path.join(downloadPath, fileName)
|
||||||
// Determine file extension based on format
|
scopedLoggers.download.warn(
|
||||||
let extension = 'mp4' // default
|
'Using fallback output path for ID:',
|
||||||
if (options.type === 'audio') {
|
id,
|
||||||
extension = options.extractFormat || 'mp3'
|
'Path:',
|
||||||
} else if (actualFormat) {
|
finalOutputPath
|
||||||
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 fileSize: number | undefined
|
||||||
let fileSizeError: unknown
|
try {
|
||||||
if (finalOutputPath) {
|
const fs = await import('node:fs/promises')
|
||||||
try {
|
const stats = await fs.stat(finalOutputPath)
|
||||||
const fs = await import('node:fs/promises')
|
fileSize = stats.size
|
||||||
const stats = await fs.stat(finalOutputPath)
|
} catch (error) {
|
||||||
fileSize = stats.size
|
if (latestKnownSizeBytes !== undefined) {
|
||||||
} catch (error) {
|
fileSize = latestKnownSizeBytes
|
||||||
fileSizeError = error
|
} else {
|
||||||
|
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileSize === undefined && latestKnownSizeBytes !== undefined) {
|
if (fileSize === undefined && latestKnownSizeBytes !== undefined) {
|
||||||
fileSize = latestKnownSizeBytes
|
fileSize = latestKnownSizeBytes
|
||||||
} else if (fileSize === undefined && fileSizeError) {
|
|
||||||
console.warn('Failed to get file size:', fileSizeError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateDownloadInfo(id, {
|
this.updateDownloadInfo(id, {
|
||||||
@@ -786,9 +536,16 @@ class DownloadEngine extends EventEmitter {
|
|||||||
quality: actualQuality || undefined,
|
quality: actualQuality || undefined,
|
||||||
codec: actualCodec || undefined
|
codec: actualCodec || undefined
|
||||||
})
|
})
|
||||||
|
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||||
this.emit('download-completed', id)
|
this.emit('download-completed', id)
|
||||||
this.addToHistory(id, options, 'completed', undefined, finalOutputPath)
|
this.addToHistory(id, options, 'completed', undefined, finalOutputPath)
|
||||||
} else {
|
} 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.emit('download-error', id, new Error(`Download exited with code ${code}`))
|
||||||
this.addToHistory(id, options, 'error', `Download exited with code ${code}`)
|
this.addToHistory(id, options, 'error', `Download exited with code ${code}`)
|
||||||
}
|
}
|
||||||
@@ -796,6 +553,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
ytdlpProcess.on('error', (error: Error) => {
|
ytdlpProcess.on('error', (error: Error) => {
|
||||||
|
scopedLoggers.download.error('Download process error for ID:', id, error)
|
||||||
this.activeDownloads.delete(id)
|
this.activeDownloads.delete(id)
|
||||||
this.queue.downloadCompleted(id)
|
this.queue.downloadCompleted(id)
|
||||||
this.emit('download-error', id, error)
|
this.emit('download-error', id, error)
|
||||||
@@ -803,112 +561,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 {
|
cancelDownload(id: string): boolean {
|
||||||
|
scopedLoggers.download.info('Cancelling download for ID:', id)
|
||||||
const snapshot = this.queue.getItemDetails(id)
|
const snapshot = this.queue.getItemDetails(id)
|
||||||
|
|
||||||
const download = this.activeDownloads.get(id)
|
const download = this.activeDownloads.get(id)
|
||||||
@@ -916,6 +570,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
download.controller.abort()
|
download.controller.abort()
|
||||||
const removedFromQueue = this.queue.remove(id)
|
const removedFromQueue = this.queue.remove(id)
|
||||||
this.activeDownloads.delete(id)
|
this.activeDownloads.delete(id)
|
||||||
|
scopedLoggers.download.info('Download cancelled successfully for ID:', id)
|
||||||
this.emit('download-cancelled', id)
|
this.emit('download-cancelled', id)
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
this.upsertHistoryEntry(id, snapshot.options, {
|
this.upsertHistoryEntry(id, snapshot.options, {
|
||||||
@@ -1019,14 +674,15 @@ class DownloadEngine extends EventEmitter {
|
|||||||
): void {
|
): void {
|
||||||
// Get the download item from the queue to get additional info
|
// Get the download item from the queue to get additional info
|
||||||
const completedDownload = this.queue.getCompletedDownload(id)
|
const completedDownload = this.queue.getCompletedDownload(id)
|
||||||
|
scopedLoggers.download.info('Completed download:', completedDownload)
|
||||||
const completedAt = Date.now()
|
const completedAt = Date.now()
|
||||||
|
const finalOutputPath = actualOutputPath || options.outputPath
|
||||||
|
|
||||||
this.upsertHistoryEntry(id, options, {
|
this.upsertHistoryEntry(id, options, {
|
||||||
title: completedDownload?.item.title || `Download ${id}`,
|
title: completedDownload?.item.title || `Download ${id}`,
|
||||||
thumbnail: completedDownload?.item.thumbnail,
|
thumbnail: completedDownload?.item.thumbnail,
|
||||||
status,
|
status,
|
||||||
outputPath: actualOutputPath || options.outputPath,
|
outputPath: finalOutputPath,
|
||||||
completedAt,
|
completedAt,
|
||||||
error,
|
error,
|
||||||
duration: completedDownload?.item.duration,
|
duration: completedDownload?.item.duration,
|
||||||
|
|||||||
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 { join } from 'node:path'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { app, BrowserWindow, shell } from 'electron'
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
import log from 'electron-log'
|
import log from 'electron-log/main'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import appIcon from '../../build/icon.png?asset'
|
import appIcon from '../../build/icon.png?asset'
|
||||||
|
import { configureLogger } from './config/logger-config'
|
||||||
import { downloadEngine } from './download-engine'
|
import { downloadEngine } from './download-engine'
|
||||||
import { services } from './ipc'
|
import { services } from './ipc'
|
||||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||||
import { settingsManager } from './settings'
|
import { settingsManager } from './settings'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
|
|
||||||
|
// Initialize electron-log for main process
|
||||||
|
log.initialize()
|
||||||
|
|
||||||
|
// Configure logger settings
|
||||||
|
configureLogger()
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
|
|
||||||
@@ -165,15 +172,15 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
// 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
|
// Initialize yt-dlp
|
||||||
try {
|
try {
|
||||||
console.log('Initializing yt-dlp...')
|
log.info('Initializing yt-dlp...')
|
||||||
await ytdlpManager.initialize()
|
await ytdlpManager.initialize()
|
||||||
console.log('yt-dlp initialized successfully')
|
log.info('yt-dlp initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize yt-dlp:', error)
|
log.error('Failed to initialize yt-dlp:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
import type { Dirent } from 'node:fs'
|
import type { Dirent } from 'node:fs'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
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'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
class FileSystemService extends IpcService {
|
class FileSystemService extends IpcService {
|
||||||
static readonly groupName = 'fs'
|
static readonly groupName = 'fs'
|
||||||
|
|
||||||
@@ -109,10 +114,48 @@ class FileSystemService extends IpcService {
|
|||||||
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 {
|
private sanitizePath(target: string): string {
|
||||||
return target.trim().replace(/^['"]|['"]$/g, '')
|
return target.trim().replace(/^['"]|['"]$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
|
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const dirStats = await fs.stat(directory)
|
const dirStats = await fs.stat(directory)
|
||||||
@@ -171,6 +214,80 @@ class FileSystemService extends IpcService {
|
|||||||
return false
|
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, ''')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { FileSystemService }
|
export { FileSystemService }
|
||||||
|
|||||||
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>
|
</ScrollArea>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster richColors={true} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSetAtom } from 'jotai'
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -110,6 +111,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
await handleOpenFileLocation()
|
await handleOpenFileLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopyToClipboard = async () => {
|
||||||
|
if (!download.outputPath) {
|
||||||
|
toast.error(t('notifications.copyFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await ipcServices.fs.copyFileToClipboard(download.outputPath)
|
||||||
|
if (!success) {
|
||||||
|
toast.error(t('notifications.copyFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(t('notifications.videoCopied'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy file to clipboard:', error)
|
||||||
|
toast.error(t('notifications.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRemoveHistory = async () => {
|
const handleRemoveHistory = async () => {
|
||||||
if (!isHistory) return
|
if (!isHistory) return
|
||||||
try {
|
try {
|
||||||
@@ -166,11 +186,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<div className="group relative w-full max-w-full overflow-hidden">
|
<div className="group relative w-full max-w-full overflow-hidden">
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
||||||
{/* Thumbnail */}
|
{/* 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
|
<ImageWithPlaceholder
|
||||||
src={thumbnailSrc}
|
src={thumbnailSrc}
|
||||||
alt={download.title}
|
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" />}
|
fallbackIcon={<Play className="h-6 w-6" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,18 +199,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
<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 w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
||||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||||
<Tooltip>
|
<div className="w-full min-w-0 overflow-hidden">
|
||||||
<TooltipTrigger asChild>
|
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||||
<div className="w-full min-w-0 overflow-hidden">
|
{download.title}
|
||||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
</p>
|
||||||
{download.title}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-xs wrap-break-word">{download.title}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<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">
|
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
|
||||||
{download.type}
|
{download.type}
|
||||||
@@ -285,13 +298,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={handleOpenFile}
|
onClick={handleCopyToClipboard}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t('history.openFile')}</p>
|
<p>{t('history.copyToClipboard')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -346,6 +359,21 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<p>{t('history.openFile')}</p>
|
<p>{t('history.openFile')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={handleCopyToClipboard}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t('history.copyToClipboard')}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function ImageWithPlaceholder({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative w-full h-full', className)}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
|
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
|
||||||
"shareTitle": "Spread the word",
|
"shareTitle": "Spread the word",
|
||||||
"shareDescription": "Share VidBee with your community in one click.",
|
"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": {
|
"shareActions": {
|
||||||
"twitter": "Share on X (Twitter)",
|
"twitter": "Share on X (Twitter)",
|
||||||
"facebook": "Share on Facebook",
|
"facebook": "Share on Facebook",
|
||||||
@@ -175,6 +181,7 @@
|
|||||||
"noHistory": "No download history yet",
|
"noHistory": "No download history yet",
|
||||||
"noHistoryDescription": "Your completed downloads will appear here",
|
"noHistoryDescription": "Your completed downloads will appear here",
|
||||||
"openFile": "Open File",
|
"openFile": "Open File",
|
||||||
|
"copyToClipboard": "Copy to clipboard",
|
||||||
"openFileLocation": "Open File Location",
|
"openFileLocation": "Open File Location",
|
||||||
"openFolder": "Open Folder",
|
"openFolder": "Open Folder",
|
||||||
"openDownloadFolder": "Open Download Folder",
|
"openDownloadFolder": "Open Download Folder",
|
||||||
@@ -203,6 +210,7 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"copyFailed": "Failed to copy to clipboard",
|
"copyFailed": "Failed to copy to clipboard",
|
||||||
|
"videoCopied": "Video copied to clipboard",
|
||||||
"downloadCompleted": "Download completed",
|
"downloadCompleted": "Download completed",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
"downloadStarted": "Download started",
|
"downloadStarted": "Download started",
|
||||||
|
|||||||
@@ -43,6 +43,12 @@
|
|||||||
"website": "官方网站",
|
"website": "官方网站",
|
||||||
"websiteDescription": "产品亮点、路线图与社区动态。"
|
"websiteDescription": "产品亮点、路线图与社区动态。"
|
||||||
},
|
},
|
||||||
|
"followAuthorActions": {
|
||||||
|
"follow": "关注 @nexmoex"
|
||||||
|
},
|
||||||
|
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
|
||||||
|
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
|
||||||
|
"followAuthorTitle": "关注开发者",
|
||||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||||
"resourcesTitle": "资源",
|
"resourcesTitle": "资源",
|
||||||
"sourceCode": "源代码已开放",
|
"sourceCode": "源代码已开放",
|
||||||
|
|||||||
@@ -213,6 +213,29 @@ export function About() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user