fix(download): persist resume sessions (#137)

* fix(download): persist resume sessions

* fix(ipc): cleanup download listeners
This commit is contained in:
Nexmoe
2026-01-17 13:00:36 +08:00
committed by GitHub
parent 73af211bef
commit 894eb9774b
11 changed files with 380 additions and 88 deletions

View File

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

View File

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

View File

@@ -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<DownloadItem>): void {
downloadEngine.updateDownloadInfo(id, updates)

View File

@@ -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<typeof settingsMan
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = 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<VideoInfo> {
@@ -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<string, DownloadItem>()
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<DownloadItem>): 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(

View File

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

View File

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

View File

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

View File

@@ -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] ?? '/'

View File

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

View File

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

View File

@@ -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<Map<string, DownloadRecord>>(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)
})