Compare commits

...

1 Commits

Author SHA1 Message Date
Nexmoe
4e9a0e39f7 feat(download): handle one-click in main 2026-01-18 13:36:17 +08:00
16 changed files with 202 additions and 89 deletions

View File

@@ -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<void> => {
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()

View File

@@ -269,6 +269,8 @@ class DownloadEngine extends EventEmitter {
private queue: DownloadQueue
private sessionPersistTimer: NodeJS.Timeout | null = null
private sessionRestored = false
private prefetchTasks: Map<string, Promise<VideoInfo | null>> = new Map()
private prefetchedInfo: Map<string, VideoInfo> = 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<void> {
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<void> {
@@ -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()
}

View File

@@ -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)

View File

@@ -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<DownloadItem> }
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])
}

View File

@@ -660,4 +660,4 @@
"popularSection": "المنصات الرئيسية",
"viewAll": "عرض جميع المواقع المدعومة"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Hauptplattformen",
"viewAll": "Alle unterstützten Websites anzeigen"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Plataformas principales",
"viewAll": "Ver todos los sitios soportados"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Plateformes principales",
"viewAll": "Voir tous les sites supportés"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Platform utama",
"viewAll": "Lihat semua situs yang didukung"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Piattaforme principali",
"viewAll": "Visualizza tutti i siti supportati"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "主要プラットフォーム",
"viewAll": "サポートされているすべてのサイトを表示"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "주요 플랫폼",
"viewAll": "지원되는 모든 사이트 보기"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Plataformas principais",
"viewAll": "Ver todos os sites suportados"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "Основные платформы",
"viewAll": "Просмотреть все поддерживаемые сайты"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "主流平台",
"viewAll": "檢視全部支援的網站"
}
}
}

View File

@@ -660,4 +660,4 @@
"popularSection": "主流平台",
"viewAll": "查看全部支持的网站"
}
}
}