From 4dfac6bb3493597728fa0accace499d82483ea04 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Fri, 24 Oct 2025 19:22:37 +0800 Subject: [PATCH] chore: add CONTRIBUTING.md for project guidelines and enhance README with author follow section --- .github/workflows/release.yml | 13 - CONTRIBUTING.md | 77 +++ README.md | 132 +---- package.json | 2 +- scripts/set-console-encoding.js | 28 + src/main/config/logger-config.ts | 44 ++ src/main/download-engine.ts | 514 +++--------------- src/main/download-engine/args-builder.ts | 104 ++++ src/main/download-engine/format-utils.ts | 210 +++++++ src/main/index.ts | 17 +- src/main/ipc/services/file-system-service.ts | 119 +++- src/main/utils/logger.ts | 25 + src/renderer/src/App.tsx | 2 +- .../src/components/download/DownloadItem.tsx | 62 ++- .../components/ui/image-with-placeholder.tsx | 2 +- src/renderer/src/locales/en.json | 8 + src/renderer/src/locales/zh.json | 6 + src/renderer/src/pages/About.tsx | 23 + 18 files changed, 808 insertions(+), 580 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 scripts/set-console-encoding.js create mode 100644 src/main/config/logger-config.ts create mode 100644 src/main/download-engine/args-builder.ts create mode 100644 src/main/download-engine/format-utils.ts create mode 100644 src/main/utils/logger.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9c1536..9723918 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5976cec --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index a29d880..b163217 100644 --- a/README.md +++ b/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) diff --git a/package.json b/package.json index a23bd54..6313f85 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/set-console-encoding.js b/scripts/set-console-encoding.js new file mode 100644 index 0000000..2d88eb6 --- /dev/null +++ b/scripts/set-console-encoding.js @@ -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') +} diff --git a/src/main/config/logger-config.ts b/src/main/config/logger-config.ts new file mode 100644 index 0000000..2ccd69d --- /dev/null +++ b/src/main/config/logger-config.ts @@ -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) +} diff --git a/src/main/download-engine.ts b/src/main/download-engine.ts index 554abd4..17a2830 100644 --- a/src/main/download-engine.ts +++ b/src/main/download-engine.ts @@ -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 = { - auto: null, - best: null, - good: 1080, - normal: 720, - bad: 480, - worst: 360 -} - -const qualityPresetToAudioAbr: Record = { - 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 = { - 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 { + 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, diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts new file mode 100644 index 0000000..5078867 --- /dev/null +++ b/src/main/download-engine/args-builder.ts @@ -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 +} diff --git a/src/main/download-engine/format-utils.ts b/src/main/download-engine/format-utils.ts new file mode 100644 index 0000000..6cd083e --- /dev/null +++ b/src/main/download-engine/format-utils.ts @@ -0,0 +1,210 @@ +import type { + AppSettings, + DownloadOptions, + OneClickQualityPreset, + VideoFormat +} from '../../shared/types' + +const qualityPresetToVideoHeight: Record = { + auto: null, + best: null, + good: 1080, + normal: 720, + bad: 480, + worst: 360 +} + +const qualityPresetToAudioAbr: Record = { + 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 = { + 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 +} diff --git a/src/main/index.ts b/src/main/index.ts index 6af580d..f443424 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/services/file-system-service.ts b/src/main/ipc/services/file-system-service.ts index 4b058df..d512b3c 100644 --- a/src/main/ipc/services/file-system-service.ts +++ b/src/main/ipc/services/file-system-service.ts @@ -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 { + 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 { + 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 { try { const dirStats = await fs.stat(directory) @@ -171,6 +214,80 @@ class FileSystemService extends IpcService { return false } } + + private async copyFileToClipboardWindows(resolvedPath: string): Promise { + 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 { + 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 = [ + '', + '', + '', + '', + ` ${this.escapeForPlist(resolvedPath)}`, + '', + '' + ] + 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 { + 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, ''') + } } export { FileSystemService } diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts new file mode 100644 index 0000000..92722a2 --- /dev/null +++ b/src/main/utils/logger.ts @@ -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') +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c96e012..243c65d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -49,7 +49,7 @@ function AppContent() { - + ) } diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 53dc593..c7ec399 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -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) {
{/* Thumbnail */} -
+
} />
@@ -179,18 +199,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
- - -
-

- {download.title} -

-
-
- -

{download.title}

-
-
+
+

+ {download.title} +

+
{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} > - + -

{t('history.openFile')}

+

{t('history.copyToClipboard')}

@@ -346,6 +359,21 @@ export function DownloadItem({ download }: DownloadItemProps) {

{t('history.openFile')}

+ + + + + +

{t('history.copyToClipboard')}

+
+
+
+ + + {t('about.shareTitle')}