diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 8abc074..e47244f 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -152,8 +152,8 @@ export const buildDownloadArgs = ( const outputTemplate = path.join(baseDownloadPath, safeTemplate) args.push('-o', outputTemplate) - // Add options for better filename handling - args.push('--no-part') + // Allow resume support across restarts + args.push('--continue') args.push('--no-playlist-reverse') if (process.platform === 'win32') { diff --git a/src/main/index.ts b/src/main/index.ts index 7abb167..3c60d25 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -449,14 +449,20 @@ app.whenReady().then(async () => { } // Initialize yt-dlp + let ytdlpReady = false try { log.info('Initializing yt-dlp...') await ytdlpManager.initialize() + ytdlpReady = true log.info('yt-dlp initialized successfully') } catch (error) { log.error('Failed to initialize yt-dlp:', error) } + if (ytdlpReady) { + downloadEngine.restoreActiveDownloads() + } + await startExtensionApiServer() applyDockVisibility(settingsManager.get('hideDockIcon')) @@ -494,6 +500,7 @@ app.whenReady().then(async () => { app.on('before-quit', () => { isQuitting = true + downloadEngine.flushDownloadSession() }) // Quit when all windows are closed, except on macOS. There, it's common diff --git a/src/main/ipc/services/download-service.ts b/src/main/ipc/services/download-service.ts index 4070823..08cd73c 100644 --- a/src/main/ipc/services/download-service.ts +++ b/src/main/ipc/services/download-service.ts @@ -46,6 +46,11 @@ class DownloadService extends IpcService { return downloadEngine.getQueueStatus() } + @IpcMethod() + getActiveDownloads(_context: IpcContext): DownloadItem[] { + return downloadEngine.getActiveDownloads() + } + @IpcMethod() updateDownloadInfo(_context: IpcContext, id: string, updates: Partial): void { downloadEngine.updateDownloadInfo(id, updates) diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 18f03c4..88c9cd4 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -28,6 +28,11 @@ import { settingsManager } from '../settings' import { scopedLoggers } from '../utils/logger' import { resolvePathWithHome } from '../utils/path-helpers' import { DownloadQueue } from './download-queue' +import { + type DownloadSessionItem, + loadDownloadSession, + saveDownloadSession +} from './download-session-store' import { ffmpegManager } from './ffmpeg-manager' import { historyManager } from './history-manager' import { ytdlpManager } from './ytdlp-manager' @@ -260,6 +265,8 @@ const buildVideoInfoArgs = (url: string, settings: ReturnType = new Map() private queue: DownloadQueue + private sessionPersistTimer: NodeJS.Timeout | null = null + private sessionRestored = false constructor() { super() @@ -269,6 +276,10 @@ class DownloadEngine extends EventEmitter { this.queue.on('start-download', async (item) => { await this.executeDownload(item.id, item.options) }) + + this.queue.on('queue-updated', () => { + this.scheduleSessionPersist() + }) } async getVideoInfo(url: string): Promise { @@ -911,6 +922,8 @@ class DownloadEngine extends EventEmitter { this.activeDownloads.set(id, { controller, process: ytdlpProcess }) + this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() }) + this.scheduleSessionPersist() this.emit('download-started', id) this.upsertHistoryEntry(id, options, { @@ -964,6 +977,11 @@ class DownloadEngine extends EventEmitter { downloaded: progress.downloaded || '', total: progress.total || '' } + this.queue.updateItemInfo(id, { + progress: downloadProgress, + speed: downloadProgress.currentSpeed || '' + }) + this.scheduleSessionPersist() this.emit('download-progress', id, downloadProgress) } ) @@ -1178,6 +1196,72 @@ class DownloadEngine extends EventEmitter { return this.queue.getQueueStatus() } + getActiveDownloads(): DownloadItem[] { + const items = new Map() + for (const item of this.queue.getActiveItems()) { + items.set(item.id, item) + } + for (const item of this.queue.getQueuedItems()) { + items.set(item.id, item) + } + return Array.from(items.values()).sort((a, b) => b.createdAt - a.createdAt) + } + + restoreActiveDownloads(): void { + if (this.sessionRestored) { + return + } + this.sessionRestored = true + + const sessionItems = loadDownloadSession() + if (sessionItems.length === 0) { + return + } + + for (const entry of sessionItems) { + if (!entry?.id || !entry.options?.url || !entry.options.type) { + continue + } + if (this.queue.getItemDetails(entry.id)) { + continue + } + + const historyItem = historyManager.getHistoryById(entry.id) + if (historyItem && ['completed', 'error', 'cancelled'].includes(historyItem.status)) { + continue + } + + const createdAt = entry.item?.createdAt ?? Date.now() + const restoredItem: DownloadItem = { + ...entry.item, + id: entry.id, + url: entry.options.url, + type: entry.options.type, + status: 'pending', + createdAt, + completedAt: undefined + } + + this.queue.add(entry.id, entry.options, restoredItem) + + this.upsertHistoryEntry(entry.id, entry.options, { + title: restoredItem.title || historyItem?.title || `Download ${entry.id}`, + status: 'pending', + downloadedAt: historyItem?.downloadedAt ?? createdAt + }) + } + + this.scheduleSessionPersist() + } + + flushDownloadSession(): void { + if (this.sessionPersistTimer) { + clearTimeout(this.sessionPersistTimer) + this.sessionPersistTimer = null + } + this.persistSession() + } + updateDownloadInfo(id: string, updates: Partial): void { this.queue.updateItemInfo(id, updates) @@ -1249,6 +1333,37 @@ class DownloadEngine extends EventEmitter { if (Object.keys(historyUpdates).length > 0) { this.upsertHistoryEntry(id, snapshot.options, historyUpdates) } + + this.scheduleSessionPersist() + } + + private scheduleSessionPersist(): void { + if (this.sessionPersistTimer) { + return + } + this.sessionPersistTimer = setTimeout(() => { + this.sessionPersistTimer = null + this.persistSession() + }, 1000) + } + + private persistSession(): void { + const entries: DownloadSessionItem[] = [] + const activeEntries = this.queue.getActiveEntries() + const queuedEntries = this.queue.getQueuedEntries() + + for (const entry of [...activeEntries, ...queuedEntries]) { + if (!entry?.item?.id) { + continue + } + entries.push({ + id: entry.item.id, + options: entry.options, + item: entry.item + }) + } + + saveDownloadSession(entries) } private addToHistory( diff --git a/src/main/lib/download-queue.ts b/src/main/lib/download-queue.ts index 70331fd..02148fc 100644 --- a/src/main/lib/download-queue.ts +++ b/src/main/lib/download-queue.ts @@ -82,6 +82,28 @@ export class DownloadQueue extends EventEmitter { } } + getActiveItems(): DownloadItem[] { + return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item })) + } + + getQueuedItems(): DownloadItem[] { + return this.queue.map((item) => ({ ...item.item })) + } + + getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> { + return Array.from(this.activeDownloads.values()).map((entry) => ({ + options: { ...entry.options }, + item: { ...entry.item } + })) + } + + getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> { + return this.queue.map((entry) => ({ + options: { ...entry.options }, + item: { ...entry.item } + })) + } + isDownloading(id: string): boolean { return this.activeDownloads.has(id) } diff --git a/src/main/lib/download-session-store.ts b/src/main/lib/download-session-store.ts new file mode 100644 index 0000000..4a24038 --- /dev/null +++ b/src/main/lib/download-session-store.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs' +import path from 'node:path' +import { app } from 'electron' +import type { DownloadItem, DownloadOptions } from '../../shared/types' +import { scopedLoggers } from '../utils/logger' + +export interface DownloadSessionItem { + id: string + options: DownloadOptions + item: DownloadItem +} + +interface DownloadSessionPayload { + version: 1 + updatedAt: number + items: DownloadSessionItem[] +} + +const SESSION_FILE_NAME = 'download-session.json' + +const getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME) + +const isValidItem = (item: DownloadSessionItem): boolean => + Boolean(item?.id && item.options && item.item) + +export const loadDownloadSession = (): DownloadSessionItem[] => { + const filePath = getSessionFilePath() + if (!fs.existsSync(filePath)) { + return [] + } + + try { + const raw = fs.readFileSync(filePath, 'utf-8') + const payload = JSON.parse(raw) as DownloadSessionPayload + if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) { + return [] + } + return payload.items.filter(isValidItem) + } catch (error) { + scopedLoggers.download.warn('Failed to load download session:', error) + return [] + } +} + +export const saveDownloadSession = (items: DownloadSessionItem[]): void => { + const filePath = getSessionFilePath() + if (items.length === 0) { + try { + fs.rmSync(filePath, { force: true }) + } catch (error) { + scopedLoggers.download.warn('Failed to clear download session:', error) + } + return + } + + const payload: DownloadSessionPayload = { + version: 1, + updatedAt: Date.now(), + items + } + + try { + fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8') + } catch (error) { + scopedLoggers.download.warn('Failed to save download session:', error) + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 77defaf..9cf5f53 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -5,7 +5,7 @@ declare global { interface Window { electron: ElectronAPI api: IpcServices & { - on: (channel: string, callback: (...args: unknown[]) => void) => void + on: (channel: string, callback: (...args: unknown[]) => void) => (...args: unknown[]) => void removeListener: (channel: string, callback: (...args: unknown[]) => void) => void send: (channel: string, ...args: unknown[]) => void } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5ce8824..d0fbeac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' import { toast } from 'sonner' import { ErrorBoundary } from './components/error/ErrorBoundary' +import { useDownloadEvents } from './hooks/use-download-events' import { ipcEvents, ipcServices } from './lib/ipc' import { About } from './pages/About' import { Home } from './pages/Home' @@ -62,6 +63,8 @@ function AppContent() { const currentPage = pathToPage(location.pathname) const supportedSitesUrl = 'https://vidbee.org/supported-sites/' + useDownloadEvents() + const handlePageChange = useCallback( (page: Page) => { const targetPath = pageToPath[page] ?? '/' diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index 3f950b0..6cbfdf1 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -16,12 +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, - addHistoryRecordAtom, - removeDownloadAtom, - updateDownloadAtom -} from '../../store/downloads' +import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads' import { loadSettingsAtom, settingsAtom } from '../../store/settings' import { currentVideoInfoAtom, @@ -116,8 +111,6 @@ export function DownloadDialog({ const loadSettings = useSetAtom(loadSettingsAtom) const updateDownload = useSetAtom(updateDownloadAtom) const addDownload = useSetAtom(addDownloadAtom) - const addHistoryRecord = useSetAtom(addHistoryRecordAtom) - const removeDownload = useSetAtom(removeDownloadAtom) const [url, setUrl] = useState('') const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single') @@ -182,21 +175,6 @@ export function DownloadDialog({ ) }, [playlistInfo, computePlaylistRange, selectedEntryIds]) - const syncHistoryItem = useCallback( - async (id: string) => { - try { - const historyItem = await ipcServices.history.getHistoryById(id) - if (historyItem) { - addHistoryRecord(historyItem) - removeDownload(id) - } - } catch (error) { - console.error('Failed to sync history item:', error) - } - }, - [addHistoryRecord, removeDownload] - ) - // Listen for deep link events useEffect(() => { const handleDeepLink = async (data: unknown) => { @@ -283,69 +261,8 @@ export function DownloadDialog({ useEffect(() => { if (!open) return - - // Load settings when dialog opens loadSettings() - - // Listen for download events from main process - ipcEvents.on('download:started', (...args: unknown[]) => { - const id = args[0] as string - console.log('Download started:', id) - updateDownload({ id, changes: { status: 'downloading' } }) - }) - - ipcEvents.on('download:progress', (...args: unknown[]) => { - const data = args[0] as { id: string; progress: unknown } - console.log('Download progress:', data) - const progress = data.progress as { - percent: number - currentSpeed?: string - eta?: string - downloaded?: string - total?: string - } - updateDownload({ - id: data.id, - changes: { - progress: { - percent: progress.percent || 0, - currentSpeed: progress.currentSpeed || '', - eta: progress.eta || '', - downloaded: progress.downloaded || '', - total: progress.total || '' - }, - speed: progress.currentSpeed || '' - } - }) - }) - - ipcEvents.on('download:completed', (...args: unknown[]) => { - const id = args[0] as string - console.log('Download completed:', id) - updateDownload({ id, changes: { status: 'completed' } }) - toast.success(t('notifications.downloadCompleted')) - void syncHistoryItem(id) - }) - - ipcEvents.on('download:error', (...args: unknown[]) => { - const data = args[0] as { id: string; error: string } - console.error('Download error:', data) - updateDownload({ id: data.id, changes: { status: 'error', error: data.error } }) - toast.error(t('notifications.downloadFailed')) - void syncHistoryItem(data.id) - }) - - ipcEvents.on('download:cancelled', (...args: unknown[]) => { - const id = args[0] as string - console.log('Download cancelled:', id) - updateDownload({ id, changes: { status: 'cancelled' } }) - void syncHistoryItem(id) - }) - - return () => { - // Event listeners are automatically cleaned up when the component unmounts - } - }, [open, loadSettings, syncHistoryItem, t, updateDownload]) + }, [open, loadSettings]) const startOneClickDownload = useCallback( async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => { diff --git a/src/renderer/src/hooks/use-download-events.ts b/src/renderer/src/hooks/use-download-events.ts new file mode 100644 index 0000000..03bbc92 --- /dev/null +++ b/src/renderer/src/hooks/use-download-events.ts @@ -0,0 +1,144 @@ +import { useSetAtom } from 'jotai' +import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { ipcEvents, ipcServices } from '../lib/ipc' +import { + addDownloadAtom, + addHistoryRecordAtom, + removeDownloadAtom, + updateDownloadAtom +} from '../store/downloads' + +const isFinalStatus = (status?: string): boolean => + status === 'completed' || status === 'error' || status === 'cancelled' + +export function useDownloadEvents() { + const updateDownload = useSetAtom(updateDownloadAtom) + const addDownload = useSetAtom(addDownloadAtom) + const addHistoryRecord = useSetAtom(addHistoryRecordAtom) + const removeDownload = useSetAtom(removeDownloadAtom) + const { t } = useTranslation() + + const syncHistoryItem = useCallback( + async (id: string) => { + try { + const historyItem = await ipcServices.history.getHistoryById(id) + if (!historyItem) { + return + } + addHistoryRecord(historyItem) + if (isFinalStatus(historyItem.status)) { + removeDownload(id) + } + } catch (error) { + console.error('Failed to sync history item:', error) + } + }, + [addHistoryRecord, removeDownload] + ) + + useEffect(() => { + const syncActiveDownloads = async () => { + try { + const activeDownloads = await ipcServices.download.getActiveDownloads() + activeDownloads.forEach((item) => { + addDownload(item) + }) + } catch (error) { + console.error('Failed to load active downloads:', error) + } + } + + void syncActiveDownloads() + }, [addDownload]) + + useEffect(() => { + const handleStarted = (rawId: unknown) => { + const id = typeof rawId === 'string' ? rawId : '' + if (!id) { + return + } + updateDownload({ + id, + changes: { + status: 'downloading', + startedAt: Date.now() + } + }) + } + + const handleProgress = (rawData: unknown) => { + const data = rawData as { id?: string; progress?: unknown } + const id = typeof data?.id === 'string' ? data.id : '' + if (!id) { + return + } + const progress = (data.progress ?? {}) as { + percent?: number + currentSpeed?: string + eta?: string + downloaded?: string + total?: string + } + updateDownload({ + id, + changes: { + progress: { + percent: typeof progress.percent === 'number' ? progress.percent : 0, + currentSpeed: progress.currentSpeed || '', + eta: progress.eta || '', + downloaded: progress.downloaded || '', + total: progress.total || '' + }, + speed: progress.currentSpeed || '' + } + }) + } + + const handleCompleted = (rawId: unknown) => { + const id = typeof rawId === 'string' ? rawId : '' + if (!id) { + return + } + updateDownload({ id, changes: { status: 'completed', completedAt: Date.now() } }) + toast.success(t('notifications.downloadCompleted')) + void syncHistoryItem(id) + } + + const handleError = (rawData: unknown) => { + const data = rawData as { id?: string; error?: string } + const id = typeof data?.id === 'string' ? data.id : '' + if (!id) { + return + } + const errorMessage = typeof data?.error === 'string' ? data.error : '' + updateDownload({ id, changes: { status: 'error', error: errorMessage } }) + toast.error(t('notifications.downloadFailed')) + void syncHistoryItem(id) + } + + const handleCancelled = (rawId: unknown) => { + const id = typeof rawId === 'string' ? rawId : '' + if (!id) { + return + } + updateDownload({ id, changes: { status: 'cancelled', completedAt: Date.now() } }) + void syncHistoryItem(id) + } + + const startedSubscription = ipcEvents.on('download:started', handleStarted) + const progressSubscription = ipcEvents.on('download:progress', handleProgress) + const completedSubscription = ipcEvents.on('download:completed', handleCompleted) + const errorSubscription = ipcEvents.on('download:error', handleError) + const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled) + + return () => { + 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]) +} diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index a8fdeca..4eb476f 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -8,6 +8,9 @@ export type DownloadRecord = DownloadItem & { savedFileName?: string } +const isFinalStatus = (status: DownloadHistoryItem['status']): boolean => + status === 'completed' || status === 'error' || status === 'cancelled' + const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}` const toActiveRecord = (item: DownloadItem): DownloadRecord => ({ @@ -51,6 +54,7 @@ export const downloadRecordsAtom = atom>(new Map()) export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => { const downloads = new Map(get(downloadRecordsAtom)) + downloads.delete(recordKey('history', item.id)) downloads.set(recordKey('active', item.id), toActiveRecord(item)) set(downloadRecordsAtom, downloads) }) @@ -87,6 +91,14 @@ export const clearCompletedAtom = atom(null, (get, set) => { export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => { const downloads = new Map(get(downloadRecordsAtom)) + const activeKey = recordKey('active', item.id) + if (downloads.has(activeKey)) { + if (!isFinalStatus(item.status)) { + set(downloadRecordsAtom, downloads) + return + } + downloads.delete(activeKey) + } downloads.set(recordKey('history', item.id), toHistoryRecord(item)) set(downloadRecordsAtom, downloads) })