Compare commits
1 Commits
main
...
nexmoe/bag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9a0e39f7 |
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "المنصات الرئيسية",
|
||||
"viewAll": "عرض جميع المواقع المدعومة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Hauptplattformen",
|
||||
"viewAll": "Alle unterstützten Websites anzeigen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Plataformas principales",
|
||||
"viewAll": "Ver todos los sitios soportados"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Plateformes principales",
|
||||
"viewAll": "Voir tous les sites supportés"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Platform utama",
|
||||
"viewAll": "Lihat semua situs yang didukung"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Piattaforme principali",
|
||||
"viewAll": "Visualizza tutti i siti supportati"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "主要プラットフォーム",
|
||||
"viewAll": "サポートされているすべてのサイトを表示"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "주요 플랫폼",
|
||||
"viewAll": "지원되는 모든 사이트 보기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Plataformas principais",
|
||||
"viewAll": "Ver todos os sites suportados"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "Основные платформы",
|
||||
"viewAll": "Просмотреть все поддерживаемые сайты"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "檢視全部支援的網站"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,4 +660,4 @@
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "查看全部支持的网站"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user