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
|
||||
run: pnpm install
|
||||
|
||||
- name: Download yt-dlp binaries
|
||||
run: |
|
||||
# Download yt-dlp.exe for Windows
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
||||
|
||||
# Download yt-dlp for macOS (for cross-platform builds)
|
||||
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
|
||||
# chmod +x resources/yt-dlp_macos
|
||||
|
||||
# Download yt-dlp for Linux (for cross-platform builds)
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
||||
chmod +x resources/yt-dlp_linux
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
|
||||
@@ -22,11 +24,6 @@
|
||||
- **Real-time Progress** - Detailed download progress tracking and status management
|
||||
- **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
|
||||
|
||||
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:
|
||||
|
||||
### 🎬 Video Platforms
|
||||
|
||||
- **📺 YouTube** - Long-form and livestream video from creators worldwide
|
||||
- **🎵 TikTok** - Short-form mobile videos, effects, and live streams
|
||||
- **📘 Facebook** - Feed, Watch, and Reels videos from public pages
|
||||
- **📷 Instagram** - Feed, Stories, Reels, and Highlights content
|
||||
- **🐦 X (Twitter)** - Timeline posts, Spaces recordings, and broadcasts
|
||||
- **🎥 Vimeo** - High-quality creator and business video hosting
|
||||
- **🌍 Dailymotion** - Global news, sports, and entertainment clips
|
||||
- **🎮 Twitch** - Gaming, music, and IRL live streams and VODs
|
||||
- **💼 LinkedIn** - Professional talks, webinars, and learning videos
|
||||
- **📌 Pinterest** - Idea pins, how-to reels, and lifestyle inspiration videos
|
||||
- **🎨 Tumblr** - Creative short-form media and fan edits
|
||||
- **🇯🇵 Niconico** - Japanese animation, music, and live broadcast archive
|
||||
- **⚡ Kick** - Creator live streams and replays on the Kick platform
|
||||
|
||||
### 🎵 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
|
||||
| Video Platforms | Audio & Other Platforms |
|
||||
| --- | --- |
|
||||
| YouTube | YouTube Music |
|
||||
| TikTok | SoundCloud |
|
||||
| Facebook | Mixcloud |
|
||||
| Instagram | Bandcamp |
|
||||
| X (Twitter) | Reddit |
|
||||
| Vimeo | |
|
||||
| Dailymotion | |
|
||||
| Twitch | |
|
||||
| LinkedIn | |
|
||||
| Pinterest | |
|
||||
| Tumblr | |
|
||||
| Niconico | |
|
||||
| Kick | |
|
||||
|
||||
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
## ?? Contributing
|
||||
|
||||
- **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
|
||||
|
||||
## 🛠️ 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.
|
||||
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.
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -165,9 +73,9 @@ This project is distributed under the MIT License. See `LICENSE` for details.
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [Electron](https://www.electronjs.org/)
|
||||
- [React](https://react.dev/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"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",
|
||||
|
||||
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 type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||
import type {
|
||||
AppSettings,
|
||||
DownloadHistoryItem,
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
DownloadProgress,
|
||||
OneClickQualityPreset,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} 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 { 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
|
||||
}
|
||||
import { scopedLoggers } from './utils/logger'
|
||||
|
||||
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}"`
|
||||
)
|
||||
|
||||
@@ -440,6 +252,7 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -455,12 +268,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 +297,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 +357,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, {
|
||||
@@ -598,6 +413,7 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
||||
// Look for download destination messages
|
||||
scopedLoggers.download.info('ytDlpEvent:', eventType, eventData)
|
||||
if (eventType === 'download' && eventData.includes('Destination:')) {
|
||||
const match = eventData.match(/Destination:\s*(.+)/)
|
||||
if (match?.[1]) {
|
||||
@@ -668,113 +484,47 @@ 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
|
||||
|
||||
// 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'
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Use actual output path from yt-dlp, or fallback to simple generated path
|
||||
let finalOutputPath: string
|
||||
if (actualOutputPath) {
|
||||
finalOutputPath = actualOutputPath
|
||||
scopedLoggers.download.info(
|
||||
'Using actual output path from yt-dlp for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath
|
||||
)
|
||||
} else {
|
||||
// Simple fallback: generate path based on video title and format
|
||||
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}`
|
||||
finalOutputPath = path.join(downloadPath, fileName)
|
||||
scopedLoggers.download.warn(
|
||||
'Using fallback output path for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath
|
||||
)
|
||||
}
|
||||
|
||||
// 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, {
|
||||
@@ -786,9 +536,16 @@ class DownloadEngine extends EventEmitter {
|
||||
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)
|
||||
} 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 +553,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 +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 {
|
||||
scopedLoggers.download.info('Cancelling download for ID:', id)
|
||||
const snapshot = this.queue.getItemDetails(id)
|
||||
|
||||
const download = this.activeDownloads.get(id)
|
||||
@@ -916,6 +570,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, {
|
||||
@@ -1019,14 +674,15 @@ class DownloadEngine extends EventEmitter {
|
||||
): 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()
|
||||
const finalOutputPath = actualOutputPath || options.outputPath
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
title: completedDownload?.item.title || `Download ${id}`,
|
||||
thumbnail: completedDownload?.item.thumbnail,
|
||||
status,
|
||||
outputPath: actualOutputPath || options.outputPath,
|
||||
outputPath: finalOutputPath,
|
||||
completedAt,
|
||||
error,
|
||||
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 { 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 { configureLogger } from './config/logger-config'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { services } from './ipc'
|
||||
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
|
||||
|
||||
@@ -165,15 +172,15 @@ 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)
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { Dirent } from 'node:fs'
|
||||
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'
|
||||
|
||||
@@ -109,10 +114,48 @@ class FileSystemService extends IpcService {
|
||||
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 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> {
|
||||
try {
|
||||
const dirStats = await fs.stat(directory)
|
||||
@@ -171,6 +214,80 @@ 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, ''')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
<Toaster richColors={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSetAtom } from 'jotai'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
@@ -110,6 +111,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
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 () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
@@ -166,11 +186,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 +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 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}
|
||||
@@ -285,13 +298,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>
|
||||
@@ -346,6 +359,21 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<p>{t('history.openFile')}</p>
|
||||
</TooltipContent>
|
||||
</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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -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,6 +181,7 @@
|
||||
"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",
|
||||
@@ -203,6 +210,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": "源代码已开放",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user