From 4e9a0e39f711b88c60c2c6db4748e417c4b73e37 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sun, 18 Jan 2026 13:36:17 +0800 Subject: [PATCH] feat(download): handle one-click in main --- src/main/index.ts | 79 ++++++++++++++- src/main/lib/download-engine.ts | 98 +++++++++++++++++-- .../components/download/DownloadDialog.tsx | 66 +------------ src/renderer/src/hooks/use-download-events.ts | 24 ++++- src/renderer/src/locales/ar.json | 2 +- src/renderer/src/locales/de.json | 2 +- src/renderer/src/locales/es.json | 2 +- src/renderer/src/locales/fr.json | 2 +- src/renderer/src/locales/id.json | 2 +- src/renderer/src/locales/it.json | 2 +- src/renderer/src/locales/ja.json | 2 +- src/renderer/src/locales/ko.json | 2 +- src/renderer/src/locales/pt.json | 2 +- src/renderer/src/locales/ru.json | 2 +- src/renderer/src/locales/zh-TW.json | 2 +- src/renderer/src/locales/zh.json | 2 +- 16 files changed, 202 insertions(+), 89 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 3c60d25..69ae658 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,10 @@ import { import log from 'electron-log/main' import { autoUpdater } from 'electron-updater' import appIcon from '../../build/icon.png?asset' +import { + buildAudioFormatPreference, + buildVideoFormatPreference +} from '../shared/utils/format-preferences' import { configureLogger } from './config/logger-config' import { services } from './ipc' import { downloadEngine } from './lib/download-engine' @@ -47,11 +51,13 @@ protocol.registerSchemesAsPrivileged([ let mainWindow: BrowserWindow | null = null let isQuitting = false +let isYtdlpReady = false interface DeepLinkData { url: string type: 'single' | 'playlist' } const pendingDeepLinkUrls: DeepLinkData[] = [] +const pendingOneClickDownloads: DeepLinkData[] = [] let isRendererReady = false const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => { @@ -119,6 +125,10 @@ const handleDeepLinkUrl = (rawUrl: string): void => { log.warn('Ignored unsupported deep link:', rawUrl) return } + if (settingsManager.get('oneClickDownload')) { + queueOneClickDownload(data) + return + } deliverDeepLink(data) } @@ -245,6 +255,14 @@ function setupRendererErrorHandling(): void { } function setupDownloadEvents(): void { + downloadEngine.on('download-queued', (item: unknown) => { + mainWindow?.webContents.send('download:queued', item) + }) + + downloadEngine.on('download-updated', (id: string, updates: unknown) => { + mainWindow?.webContents.send('download:updated', { id, updates }) + }) + downloadEngine.on('download-started', (id: string) => { mainWindow?.webContents.send('download:started', id) }) @@ -266,6 +284,61 @@ function setupDownloadEvents(): void { }) } +const createDownloadId = (): string => + `download_${Date.now()}_${Math.random().toString(36).substring(2, 10)}` + +const queueOneClickDownload = (data: DeepLinkData): void => { + if (!isYtdlpReady) { + pendingOneClickDownloads.push(data) + return + } + void startOneClickDownload(data) +} + +const flushPendingOneClickDownloads = (): void => { + if (!isYtdlpReady || pendingOneClickDownloads.length === 0) { + return + } + const pending = pendingOneClickDownloads.splice(0, pendingOneClickDownloads.length) + for (const data of pending) { + void startOneClickDownload(data) + } +} + +const startOneClickDownload = async (data: DeepLinkData): Promise => { + try { + const settings = settingsManager.getAll() + const downloadType = settings.oneClickDownloadType ?? 'video' + const format = + downloadType === 'video' + ? buildVideoFormatPreference(settings) + : buildAudioFormatPreference(settings) + + if (data.type === 'playlist') { + const result = await downloadEngine.startPlaylistDownload({ + url: data.url, + type: downloadType, + format + }) + log.info('One-click playlist download queued:', { + url: data.url, + count: result.totalCount + }) + return + } + + const downloadId = createDownloadId() + downloadEngine.startDownload(downloadId, { + url: data.url, + type: downloadType, + format + }) + log.info('One-click download queued:', { id: downloadId, url: data.url }) + } catch (error) { + log.error('Failed to start one-click download:', error) + } +} + function sanitizeRequestPath(requestUrl: URL): string { const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}` const trimmedLeading = rawPath.replace(/^\/+/, '') @@ -449,18 +522,18 @@ app.whenReady().then(async () => { } // Initialize yt-dlp - let ytdlpReady = false try { log.info('Initializing yt-dlp...') await ytdlpManager.initialize() - ytdlpReady = true + isYtdlpReady = true log.info('yt-dlp initialized successfully') } catch (error) { log.error('Failed to initialize yt-dlp:', error) } - if (ytdlpReady) { + if (isYtdlpReady) { downloadEngine.restoreActiveDownloads() + flushPendingOneClickDownloads() } await startExtensionApiServer() diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 9eba4ba..126f496 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -269,6 +269,8 @@ class DownloadEngine extends EventEmitter { private queue: DownloadQueue private sessionPersistTimer: NodeJS.Timeout | null = null private sessionRestored = false + private prefetchTasks: Map> = new Map() + private prefetchedInfo: Map = new Map() constructor() { super() @@ -650,7 +652,7 @@ class DownloadEngine extends EventEmitter { }) // Add to queue - this.queue.add(downloadId, downloadOptions, { + const queueItem: DownloadItem = { id: downloadId, url: entry.url, title: entry.title, @@ -662,7 +664,9 @@ class DownloadEngine extends EventEmitter { playlistTitle: playlistInfo.title, playlistIndex: entry.index, playlistSize: selectionSize - }) + } + this.queue.add(downloadId, downloadOptions, queueItem) + this.emit('download-queued', { ...queueItem }) this.upsertHistoryEntry(downloadId, downloadOptions, { title: entry.title, @@ -711,6 +715,7 @@ class DownloadEngine extends EventEmitter { title: 'Downloading...', type: options.type, status: 'pending' as const, + progress: { percent: 0 }, createdAt, tags: options.tags, origin, @@ -728,6 +733,45 @@ class DownloadEngine extends EventEmitter { origin, subscriptionId: options.subscriptionId }) + + this.emit('download-queued', { ...item }) + void this.prefetchVideoInfo(id, options) + } + + private async prefetchVideoInfo(id: string, options: DownloadOptions): Promise { + const url = options.url?.trim() + if (!url) { + return + } + if (this.prefetchTasks.has(id) || this.prefetchedInfo.has(id)) { + return + } + + const task = (async () => { + try { + const info = await this.getVideoInfo(url) + this.prefetchedInfo.set(id, info) + this.updateDownloadInfo(id, { + title: info.title, + thumbnail: info.thumbnail, + duration: info.duration, + description: info.description, + uploader: info.uploader, + viewCount: info.view_count + }) + return info + } catch (error) { + scopedLoggers.download.warn('Failed to prefetch video info for ID:', id, error) + return null + } + })() + + this.prefetchTasks.set(id, task) + try { + await task + } finally { + this.prefetchTasks.delete(id) + } } private async executeDownload(id: string, options: DownloadOptions): Promise { @@ -752,11 +796,7 @@ class DownloadEngine extends EventEmitter { let completedParts = 0 let lastPercent = 0 - // First, get detailed video info to capture basic metadata and formats - try { - const info = await this.getVideoInfo(options.url) - videoInfo = info - + const applyVideoInfo = (info: VideoInfo) => { availableFormats = Array.isArray(info.formats) ? info.formats : [] selectedFormat = resolveSelectedFormat(availableFormats, options, settings) @@ -793,8 +833,33 @@ class DownloadEngine extends EventEmitter { // Store only essential download info selectedFormat }) - } catch (error) { - scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error) + } + + videoInfo = this.prefetchedInfo.get(id) + if (!videoInfo) { + const prefetchTask = this.prefetchTasks.get(id) + if (prefetchTask) { + try { + await prefetchTask + } catch { + // Ignore prefetch failures, download will attempt again below. + } + videoInfo = this.prefetchedInfo.get(id) + } + } + + if (videoInfo) { + this.prefetchedInfo.delete(id) + applyVideoInfo(videoInfo) + } else { + // First, get detailed video info to capture basic metadata and formats + try { + const info = await this.getVideoInfo(options.url) + videoInfo = info + applyVideoInfo(info) + } catch (error) { + scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error) + } } if (!options.customDownloadPath?.trim()) { @@ -991,6 +1056,17 @@ class DownloadEngine extends EventEmitter { // Handle yt-dlp events to capture format info ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => { + if ( + eventType === 'postprocess' || + eventData.toLowerCase().includes('merging formats') || + eventData.toLowerCase().includes('post-process') + ) { + const snapshot = this.queue.getItemDetails(id) + if (snapshot?.item.status !== 'processing') { + this.updateDownloadInfo(id, { status: 'processing' }) + } + } + // Look for format selection messages if (eventType === 'info' && eventData.includes('format')) { // Extract format info from yt-dlp output @@ -1337,6 +1413,10 @@ class DownloadEngine extends EventEmitter { this.upsertHistoryEntry(id, snapshot.options, historyUpdates) } + if (Object.keys(updates).length > 0) { + this.emit('download-updated', id, { ...updates }) + } + this.scheduleSessionPersist() } diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index 1eb6e06..e8f659f 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcEvents, ipcServices } from '../../lib/ipc' -import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads' +import { addDownloadAtom } from '../../store/downloads' import { loadSettingsAtom, settingsAtom } from '../../store/settings' import { currentVideoInfoAtom, @@ -109,7 +109,6 @@ export function DownloadDialog({ const [settings] = useAtom(settingsAtom) const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom) const loadSettings = useSetAtom(loadSettingsAtom) - const updateDownload = useSetAtom(updateDownloadAtom) const addDownload = useSetAtom(addDownloadAtom) const [url, setUrl] = useState('') @@ -302,57 +301,6 @@ export function DownloadDialog({ format }) - try { - const result = await ipcServices.download.getVideoInfoWithCommand(trimmedUrl) - if (!result.info) { - throw new Error(result.error || 'Failed to fetch video info') - } - const videoInfo = result.info - - updateDownload({ - id, - changes: { - title: videoInfo.title, - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now(), - startedAt: Date.now() - } - }) - - await ipcServices.download.updateDownloadInfo(id, { - title: videoInfo.title, - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now(), - startedAt: Date.now() - }) - - toast.success(t('download.videoInfoUpdated')) - } catch (infoError) { - console.warn('Failed to fetch video info for one-click download:', infoError) - updateDownload({ - id, - changes: { - title: t('download.infoUnavailable'), - createdAt: Date.now(), - startedAt: Date.now() - } - }) - - await ipcServices.download.updateDownloadInfo(id, { - title: t('download.infoUnavailable'), - createdAt: Date.now(), - startedAt: Date.now() - }) - } - toast.success(t('download.oneClickDownloadStarted')) if (options?.clearInput) { setUrl('') @@ -362,7 +310,7 @@ export function DownloadDialog({ toast.error(t('notifications.downloadFailed')) } }, - [settings, addDownload, updateDownload, t] + [settings, addDownload, t] ) const handleFetchVideo = useCallback(async () => { @@ -737,16 +685,6 @@ export function DownloadDialog({ try { await ipcServices.download.startDownload(id, options) - await ipcServices.download.updateDownloadInfo(id, { - title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'), - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now() - }) - setOpen(false) // Close dialog after download starts } catch (error) { console.error('Failed to start download:', error) diff --git a/src/renderer/src/hooks/use-download-events.ts b/src/renderer/src/hooks/use-download-events.ts index 03bbc92..3ac4dd2 100644 --- a/src/renderer/src/hooks/use-download-events.ts +++ b/src/renderer/src/hooks/use-download-events.ts @@ -1,3 +1,4 @@ +import type { DownloadItem } from '@shared/types' import { useSetAtom } from 'jotai' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -127,6 +128,25 @@ export function useDownloadEvents() { void syncHistoryItem(id) } + const handleQueued = (rawItem: unknown) => { + const item = rawItem as DownloadItem + if (!item || typeof item.id !== 'string') { + return + } + addDownload(item) + } + + const handleUpdated = (rawData: unknown) => { + const data = rawData as { id?: string; updates?: Partial } + const id = typeof data?.id === 'string' ? data.id : '' + if (!id || !data?.updates) { + return + } + updateDownload({ id, changes: data.updates }) + } + + const queuedSubscription = ipcEvents.on('download:queued', handleQueued) + const updatedSubscription = ipcEvents.on('download:updated', handleUpdated) const startedSubscription = ipcEvents.on('download:started', handleStarted) const progressSubscription = ipcEvents.on('download:progress', handleProgress) const completedSubscription = ipcEvents.on('download:completed', handleCompleted) @@ -134,11 +154,13 @@ export function useDownloadEvents() { const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled) return () => { + ipcEvents.removeListener('download:queued', queuedSubscription) + ipcEvents.removeListener('download:updated', updatedSubscription) ipcEvents.removeListener('download:started', startedSubscription) ipcEvents.removeListener('download:progress', progressSubscription) ipcEvents.removeListener('download:completed', completedSubscription) ipcEvents.removeListener('download:error', errorSubscription) ipcEvents.removeListener('download:cancelled', cancelledSubscription) } - }, [syncHistoryItem, t, updateDownload]) + }, [addDownload, syncHistoryItem, t, updateDownload]) } diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 27aaba8..f1bcf3b 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -660,4 +660,4 @@ "popularSection": "المنصات الرئيسية", "viewAll": "عرض جميع المواقع المدعومة" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index c61d7df..928c6ca 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -660,4 +660,4 @@ "popularSection": "Hauptplattformen", "viewAll": "Alle unterstützten Websites anzeigen" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 1e1d357..ab2e38b 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -660,4 +660,4 @@ "popularSection": "Plataformas principales", "viewAll": "Ver todos los sitios soportados" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index ba41ca2..d1eb955 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -660,4 +660,4 @@ "popularSection": "Plateformes principales", "viewAll": "Voir tous les sites supportés" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/id.json b/src/renderer/src/locales/id.json index 8c12153..94db62b 100644 --- a/src/renderer/src/locales/id.json +++ b/src/renderer/src/locales/id.json @@ -660,4 +660,4 @@ "popularSection": "Platform utama", "viewAll": "Lihat semua situs yang didukung" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/it.json b/src/renderer/src/locales/it.json index 4f972b3..2dec8be 100644 --- a/src/renderer/src/locales/it.json +++ b/src/renderer/src/locales/it.json @@ -660,4 +660,4 @@ "popularSection": "Piattaforme principali", "viewAll": "Visualizza tutti i siti supportati" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index e130082..1679c38 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -660,4 +660,4 @@ "popularSection": "主要プラットフォーム", "viewAll": "サポートされているすべてのサイトを表示" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/ko.json b/src/renderer/src/locales/ko.json index ca80c09..6fb1b02 100644 --- a/src/renderer/src/locales/ko.json +++ b/src/renderer/src/locales/ko.json @@ -660,4 +660,4 @@ "popularSection": "주요 플랫폼", "viewAll": "지원되는 모든 사이트 보기" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index 2b75b60..dd78eab 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -660,4 +660,4 @@ "popularSection": "Plataformas principais", "viewAll": "Ver todos os sites suportados" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 84d27d9..b581b1e 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -660,4 +660,4 @@ "popularSection": "Основные платформы", "viewAll": "Просмотреть все поддерживаемые сайты" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index a3678cd..de90535 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -660,4 +660,4 @@ "popularSection": "主流平台", "viewAll": "檢視全部支援的網站" } -} \ No newline at end of file +} diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index f754381..8a81113 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -660,4 +660,4 @@ "popularSection": "主流平台", "viewAll": "查看全部支持的网站" } -} \ No newline at end of file +}