import { create } from 'zustand'; import { info } from '../utils/logger'; import { invokeCommand as invoke } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; export type { DownloadCategory } from '../utils/downloads'; const backendDispatchPromises = new Map>(); const downloadLifecycleGenerations = new Map(); const queueReorderPromises = new Map>(); const queueStartPromises = new Map>(); const queueControlGenerations = new Map(); const currentQueueControlGeneration = (queueId: string): number => queueControlGenerations.get(queueId) ?? 0; const advanceQueueControlGeneration = (queueId: string): number => { const nextGeneration = currentQueueControlGeneration(queueId) + 1; queueControlGenerations.set(queueId, nextGeneration); return nextGeneration; }; const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean => currentQueueControlGeneration(queueId) === generation; const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number => (left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) || left.id.localeCompare(right.id); const queueItemsForReordering = (downloads: DownloadItem[], queueId: string): DownloadItem[] => downloads .filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed' && !(isActiveDownloadStatus(download.status) && download.status !== 'queued') ) .sort(queuePositionComparator); const activeQueueItems = (downloads: DownloadItem[], queueId: string): DownloadItem[] => downloads .filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed' && isActiveDownloadStatus(download.status) && download.status !== 'queued' ) .sort(queuePositionComparator); const applyQueueOrder = ( downloads: DownloadItem[], queueId: string, pendingItems: DownloadItem[] ): DownloadItem[] => { const orderedItems = [...activeQueueItems(downloads, queueId), ...pendingItems]; const positions = new Map(orderedItems.map((download, position) => [download.id, position])); return downloads.map(download => positions.has(download.id) ? { ...download, queuePosition: positions.get(download.id) } : download); }; const advanceDownloadLifecycle = (id: string): bigint => { const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n; downloadLifecycleGenerations.set(id, nextGeneration); return nextGeneration; }; const currentDownloadLifecycle = (id: string): bigint => downloadLifecycleGenerations.get(id) ?? 0n; type DispatchInvalidation = { generation: bigint; pendingDispatch?: Promise; }; const invalidateDispatch = async (id: string): Promise => { const generation = currentDownloadLifecycle(id); const nextGeneration = advanceDownloadLifecycle(id); try { await invoke('cancel_enqueue_generation', { id, generation: generation.toString() }); } catch (error) { console.warn(`Failed to cancel stale backend enqueue for ${id}:`, error); } return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) }; }; const invalidateAndWaitForDispatch = async (id: string): Promise => { const { pendingDispatch } = await invalidateDispatch(id); if (!pendingDispatch) return false; await pendingDispatch; return true; }; const isCurrentDownloadLifecycle = (id: string, generation: bigint): boolean => currentDownloadLifecycle(id) === generation && useDownloadStore.getState().downloads.some(download => download.id === id); const removeStaleBackendDispatch = async (id: string): Promise => { try { await invoke('remove_download', { id, deleteAssets: false }); } catch (error) { // The original remove request may already have won this race. Either way, // never allow a stale enqueue to make the deleted row live again. console.warn(`Failed to remove stale backend dispatch for ${id}:`, error); } }; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); const stripCookieHeaders = (value: string | null | undefined): string => (value || '') .split(/\r?\n/) .filter(line => { const separator = line.indexOf(':'); return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie'; }) .join('\n') .trim(); const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => { const explicitLimit = itemSpeedLimit?.trim(); if (explicitLimit) { return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null); } return normalizeSpeedLimitForBackend(globalSpeedLimit); }; export async function dispatchItem(id: string): Promise { if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!; const promise = (async () => { let lifecycleGeneration: bigint | null = null; let backendAccepted = false; try { const state = useDownloadStore.getState(); const item = state.downloads.find(d => d.id === id); if (!item) return false; if (state.backendRegisteredIds.has(id)) return true; lifecycleGeneration = currentDownloadLifecycle(id); const settings = useSettingsStore.getState(); const destination = item.destination || await resolveCategoryDestination(settings, item.category); if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false; const login = getSiteLogin(item.url, settings); let keychainPassword = null; if (login) { try { keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Failed to retrieve keychain password for dispatch:", e); } } if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false; const proxy = await getProxyArgs(settings); if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false; const enqueueItem = { id: item.id, queue_id: item.queueId || MAIN_QUEUE_ID, url: item.url, destination, filename: item.fileName, connections: item.connections || settings.perServerConnections || null, speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, headers: item.headers || null, checksum: item.checksum || null, cookies: item.cookies || null, mirrors: item.mirrors || null, user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, proxy, format_selector: item.mediaFormatSelector || null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, is_media: item.isMedia || false, lifecycle_generation: lifecycleGeneration.toString(), }; useDownloadStore.getState().updateDownload(id, { lastTry: new Date().toISOString() }); const accepted = await invoke('enqueue_download', { item: enqueueItem }); backendAccepted = true; if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { await removeStaleBackendDispatch(id); return false; } const acceptedFilename = accepted?.filename || item.fileName; if (acceptedFilename !== item.fileName) { useDownloadStore.getState().updateDownload(id, { fileName: acceptedFilename, category: categoryForFileName(acceptedFilename) }); } const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID }); if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { await removeStaleBackendDispatch(id); return false; } useDownloadStore.getState().setPendingOrder(order); useDownloadStore.getState().registerBackendIds([id]); useDownloadStore.getState().updateDownload(id, { lastError: undefined }); return true; } catch (e) { console.error(`Failed to dispatch ${id}:`, e); if (backendAccepted && lifecycleGeneration !== null) { await removeStaleBackendDispatch(id); } if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) { useDownloadStore.getState().updateDownload(id, { status: 'failed', lastError: errorMessage(e) }); } return false; } finally { backendDispatchPromises.delete(id); } })(); backendDispatchPromises.set(id, promise); return promise; } export const normalizeCustomProxy = (host: string, port: number): string | null => { const trimmedHost = host.trim(); const normalizedPort = Number.isFinite(port) ? Math.trunc(port) : NaN; if (!trimmedHost || !Number.isFinite(normalizedPort) || normalizedPort < 1 || normalizedPort > 65535) return null; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedHost)) { try { const parsed = new URL(trimmedHost); if (parsed.protocol !== 'http:') return null; if (!parsed.port) parsed.port = String(normalizedPort); return parsed.toString().replace(/\/$/, ''); } catch { return null; } } return `http://${trimmedHost}:${normalizedPort}`; }; export const getProxyArgs = async (settings: ReturnType) => { if (settings.proxyMode === 'system') { try { const sysProxy = await invoke('get_system_proxy'); return typeof sysProxy === 'string' && sysProxy ? sysProxy : "none"; } catch (e) { console.warn("Failed to get system proxy:", e); return "none"; } } if (settings.proxyMode === 'custom') { return normalizeCustomProxy(settings.proxyHost, settings.proxyPort) ?? "none"; } if (settings.proxyMode === 'none') { return "none"; } return null; }; const escapeRegex = (value: string): string => value.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); const wildcardToRegex = (pattern: string): RegExp => new RegExp(`^${escapeRegex(pattern).replace(/\*+/g, '.*')}$`); const patternSpecificity = (pattern: string): number => pattern.replace(/\*/g, '').length; const hostPatternScore = (pattern: string, host: string): number | null => { if (pattern.startsWith('*.')) { const suffix = pattern.substring(2); return host === suffix || host.endsWith(`.${suffix}`) ? 1000 + patternSpecificity(pattern) : null; } if (pattern.includes('*')) { return wildcardToRegex(pattern).test(host) ? 1000 + patternSpecificity(pattern) : null; } return host === pattern ? 2000 + patternSpecificity(pattern) : null; }; const urlPatternScore = (pattern: string, url: URL): number | null => { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(pattern)) { const normalizedPattern = pattern.toLowerCase().replace(/\/+$/, ''); const normalizedUrl = url.toString().toLowerCase().replace(/\/+$/, ''); return wildcardToRegex(normalizedPattern).test(normalizedUrl) ? 4000 + patternSpecificity(normalizedPattern) : null; } if (pattern.includes('/')) { const normalizedPattern = pattern.toLowerCase().replace(/^\/+/, ''); const normalizedTarget = `${url.hostname}${url.pathname}`.toLowerCase().replace(/\/+$/, ''); return wildcardToRegex(normalizedPattern).test(normalizedTarget) ? 3000 + patternSpecificity(normalizedPattern) : null; } return hostPatternScore(pattern, url.hostname.toLowerCase()); }; export const getSiteLogin = (url: string, settings: ReturnType) => { try { const urlObj = new URL(url); let bestMatch: { login: typeof settings.siteLogins[number]; score: number } | null = null; for (const login of settings.siteLogins) { const pattern = login.urlPattern.toLowerCase().trim(); const score = pattern ? urlPatternScore(pattern, urlObj) : null; if (score !== null && (!bestMatch || score > bestMatch.score)) { bestMatch = { login, score }; } } return bestMatch?.login ?? null; } catch (e) {} return null; }; const syncSystemIntegrations = () => { const settings = useSettingsStore.getState(); const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length; invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {}); }; const effectiveDestinationForItem = async ( item: Pick, settings: ReturnType ): Promise => item.destination || resolveCategoryDestination(settings, item.category); const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => { const nextPosition = new Map(); return downloads.map(download => { const queueId = download.queueId || MAIN_QUEUE_ID; const position = nextPosition.get(queueId) || 0; nextPosition.set(queueId, position + 1); return { ...download, queueId, queuePosition: download.queuePosition ?? position }; }); }; export type { DownloadStatus }; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export type { DownloadItem, Queue }; export type ExtensionDownloadRequest = ExtensionDownload; export type AddDownloadAction = | { type: 'start-now' } | { type: 'add-to-queue'; queueId: string }; export type DownloadDraft = Omit; export type PendingAddRequestContext = { version: number; referer: string; filename: string; headers: string; cookies: string; media: boolean; }; export type DeleteModalState = { isOpen: boolean; downloadIds?: string[]; }; interface DownloadState { downloads: DownloadItem[]; queues: Queue[]; pendingOrder: string[]; setPendingOrder: (order: string[]) => void; backendRegisteredIds: Set; registerBackendIds: (ids: string[]) => void; unregisterBackendIds: (ids: string[]) => void; applyProperties: (id: string, updates: Partial) => Promise; moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise; removeFromQueue: (id: string) => Promise; isAddModalOpen: boolean; pendingAddUrls: string; pendingAddReferer: string; pendingAddFilename: string; pendingAddHeaders: string; pendingAddCookies: string; pendingAddMediaUrls: string[]; pendingAddRequestContexts: Record; pendingAddRequestVersion: number; selectedPropertiesDownloadId: string | null; toggleAddModal: (isOpen: boolean) => void; openAddModalWithUrls: ( urls: string, referer?: string | null, filename?: string | null, headers?: string | null, cookies?: string | null, media?: boolean ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; openDeleteModal: (downloadIds?: string | string[]) => void; closeDeleteModal: () => void; setSelectedPropertiesDownloadId: (id: string | null) => void; addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; updateDownload: (id: string, updates: Partial) => void; removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise; pauseDownload: (id: string) => Promise; redownload: (id: string) => Promise; resumeDownload: (id: string) => Promise; startQueue: (queueId: string) => Promise; pauseQueue: (queueId: string) => Promise; startAll: () => Promise; pauseAll: () => Promise; assignToQueue: (ids: string[], queueId: string) => Promise; addQueue: (name: string) => void; renameQueue: (id: string, name: string) => void; removeQueue: (id: string) => Promise; initDB: () => Promise; } export const useDownloadStore = create((set, get) => ({ downloads: [], queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], pendingOrder: [], setPendingOrder: (order) => set({ pendingOrder: order }), moveInQueue: (idOrIds, direction) => { const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; if (ids.length === 0) return Promise.resolve(); // Queue moves must be serialized per queue. Otherwise two optimistic // moves can calculate from the same order and the last RPC silently wins. const firstItem = get().downloads.find(download => ids.includes(download.id)); if (!firstItem) return Promise.resolve(); const queueId = firstItem.queueId || MAIN_QUEUE_ID; const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve(); const operation = previousOperation.catch(() => undefined).then(async () => { const allDownloads = get().downloads; const queueItems = queueItemsForReordering(allDownloads, queueId); const selectedItems = queueItems.filter(item => ids.includes(item.id)); if (selectedItems.length === 0) return; const previousPositions = new Map([ ...activeQueueItems(allDownloads, queueId), ...queueItems ].map(item => [item.id, item.queuePosition])); const unselectedItems = queueItems.filter(item => !ids.includes(item.id)); const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id)); let insertIndex = 0; if (direction === 'up') { const firstSelectedIndex = Math.min(...selectedIndices); insertIndex = Math.max(0, firstSelectedIndex - 1); } else { const lastSelectedIndex = Math.max(...selectedIndices); insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2); } const reordered = [ ...unselectedItems.slice(0, insertIndex), ...selectedItems, ...unselectedItems.slice(insertIndex) ]; set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) })); const registeredIdsToMove = selectedItems .filter(item => get().backendRegisteredIds.has(item.id)) .map(item => item.id); if (registeredIdsToMove.length === 0) return; try { const order = await invoke('move_many_in_queue', { ids: registeredIdsToMove, queueId, direction }) as string[]; if (Array.isArray(order)) { const globalOrder = await invoke('get_pending_order', { queueId: null }) .catch(() => null) as string[] | null; set(state => ({ pendingOrder: Array.isArray(globalOrder) ? globalOrder : [ ...state.pendingOrder.filter(id => !order.includes(id)), ...order ] })); } } catch (error) { console.error("Failed to move in queue backend:", error); // The backend operation is atomic. Restore only queue positions so a // progress/state event received while the RPC was in flight survives. set(state => ({ downloads: state.downloads.map(download => previousPositions.has(download.id) ? { ...download, queuePosition: previousPositions.get(download.id) } : download) })); } }); const trackedOperation = operation.finally(() => { if (queueReorderPromises.get(queueId) === trackedOperation) { queueReorderPromises.delete(queueId); } }); queueReorderPromises.set(queueId, trackedOperation); return trackedOperation; }, removeFromQueue: async (id) => { try { await invoke('remove_from_queue', { id }); set((state) => ({ pendingOrder: state.pendingOrder.filter(x => x !== id) })); } catch (e) { console.error("Failed to remove item from queue:", e); } }, backendRegisteredIds: new Set(), registerBackendIds: (ids) => set((state) => { const nextSet = new Set(state.backendRegisteredIds); for (const id of ids) nextSet.add(id); return { backendRegisteredIds: nextSet }; }), unregisterBackendIds: (ids) => set((state) => { const nextSet = new Set(state.backendRegisteredIds); for (const id of ids) nextSet.delete(id); return { backendRegisteredIds: nextSet }; }), isAddModalOpen: false, pendingAddUrls: '', pendingAddReferer: '', pendingAddFilename: '', pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], pendingAddRequestContexts: {}, pendingAddRequestVersion: 0, selectedPropertiesDownloadId: null, deleteModalState: { isOpen: false }, openDeleteModal: (downloadIds) => set({ deleteModalState: { isOpen: true, downloadIds: Array.isArray(downloadIds) ? downloadIds : (downloadIds ? [downloadIds] : undefined) } }), closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }), toggleAddModal: (isOpen) => set((state) => ({ isAddModalOpen: isOpen, pendingAddUrls: '', pendingAddReferer: '', pendingAddFilename: '', pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], pendingAddRequestContexts: {}, // Invalidate any in-flight Add-modal handoff even when the modal is // opened or closed without URLs. pendingAddRequestVersion: state.pendingAddRequestVersion + 1 })), openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; const existingMediaUrls = isAppending ? state.pendingAddMediaUrls : []; const pendingAddMediaUrls = media ? [...new Set([ ...existingMediaUrls, ...urls.split('\n').map(url => url.trim()).filter(Boolean) ])] : existingMediaUrls; const cleanReferer = referer?.trim() || ''; const cleanFilename = filename?.trim() || ''; const cleanHeaders = headers?.trim() || ''; const cleanCookies = cookies?.trim() || ''; const requestVersion = state.pendingAddRequestVersion + 1; const pendingAddRequestContexts = isAppending ? { ...state.pendingAddRequestContexts } : {}; // Every handoff gets a versioned row context, including an intentionally // empty one. Otherwise a later request for the same URL cannot clear stale // cookies/headers from an earlier capture, and batched React renders can // lose all but the most recent appended URL. for (const rawUrl of urls.split('\n')) { const trimmedUrl = rawUrl.trim(); if (!trimmedUrl) continue; let key = trimmedUrl; try { key = new URL(trimmedUrl).href; } catch { // The Add modal will mark malformed input invalid; retain its original key here. } pendingAddRequestContexts[key] = { version: requestVersion, referer: cleanReferer, filename: cleanFilename, headers: cleanHeaders, cookies: cleanCookies, media }; } return { isAddModalOpen: true, pendingAddUrls: mergedUrls, pendingAddReferer: cleanReferer, pendingAddFilename: cleanFilename, pendingAddHeaders: cleanHeaders, pendingAddCookies: cleanCookies, pendingAddMediaUrls, pendingAddRequestContexts, pendingAddRequestVersion: requestVersion }; }), handleExtensionDownload: async (request) => { const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))]; if (urls.length === 0) return; // Explicit media authentication belongs to yt-dlp's configured browser // cookie source. Keep this frontend guard for events from older desktop or // extension builds; ordinary captured downloads retain their cookies. const cookies = request.media === true ? null : request.cookies; const headers = request.media === true ? stripCookieHeaders(request.headers) || null : request.headers; get().openAddModalWithUrls( urls.join('\n'), request.referer, urls.length === 1 ? request.filename : null, headers, cookies, request.media === true ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: async (item, action) => { const settings = useSettingsStore.getState(); const destPath = await effectiveDestinationForItem(item, settings); const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID; const queueItems = get().downloads.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); const queuePosition = maxPos + 1; const ownedItem: DownloadItem = { ...item, destination: destPath, status: action.type === 'add-to-queue' ? 'staged' : 'ready', queueId, queuePosition, hasBeenDispatched: false }; advanceDownloadLifecycle(item.id); set((state) => ({ downloads: [...state.downloads, ownedItem] })); if (action.type === 'add-to-queue') { info(`Download ${item.id} added to queue ${action.queueId}`); return true; } else if (action.type === 'start-now') { if (await dispatchItem(item.id)) { get().updateDownload(item.id, { hasBeenDispatched: true }); info(`Download ${item.id} started`); return true; } return false; } return false; }, applyProperties: async (id, updates) => { const wasDispatching = await invalidateAndWaitForDispatch(id); const state = get(); const item = state.downloads.find(d => d.id === id); if (!item) return; if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { throw new Error("Cannot change properties while transfer is active. Pause it first."); } if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { state.updateDownload(id, updates); return; } // Queued or Paused const isRegistered = state.backendRegisteredIds.has(id); if (item.status === 'queued') { if (isRegistered) { await invoke('detach_download_for_reconfigure', { id }); state.unregisterBackendIds([id]); set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) })); } state.updateDownload(id, updates); if (isRegistered || wasDispatching) { const dispatched = await dispatchItem(id); if (dispatched) { state.updateDownload(id, { hasBeenDispatched: true }); } else { state.removeFromQueue(id); } } } else if (item.status === 'paused') { if (isRegistered) { try { await invoke('detach_download_for_reconfigure', { id }); } catch (e) { console.error("Failed to detach for reconfigure:", e); throw e; // Preserve old properties if detach fails } state.unregisterBackendIds([id]); } state.updateDownload(id, updates); } }, updateDownload: (id, updates) => { set((state) => ({ downloads: state.downloads.map(d => { if (d.id === id) { const updated = { ...d, ...updates, fraction: updates.fraction !== undefined ? updates.fraction : d.fraction }; return updated; } return d; }) })); if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) { info(`Download ${id} status changed to ${updates.status}`); syncSystemIntegrations(); } else if (updates.status === 'downloading') { info(`Download ${id} status changed to downloading`); syncSystemIntegrations(); } }, removeDownload: async (id, deleteFile = false, preserveResumable = false) => { const { pendingDispatch } = await invalidateDispatch(id); if (pendingDispatch) { await pendingDispatch; } const item = get().downloads.find(d => d.id === id); if (item) { await invoke('remove_download', { id, deleteAssets: deleteFile, preserveResumable }); } set((state) => ({ downloads: state.downloads.filter(d => d.id !== id), pendingOrder: state.pendingOrder.filter(x => x !== id), backendRegisteredIds: new Set( Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id) ) })); useDownloadProgressStore.getState().clearDownloadProgress(id); info(`Download ${id} removed`); syncSystemIntegrations(); }, pauseDownload: async (id) => { const { generation, pendingDispatch } = await invalidateDispatch(id); if (pendingDispatch) { await pendingDispatch; } await invoke('pause_download', { id }); if (!isCurrentDownloadLifecycle(id, generation)) return; const current = get().downloads.find(download => download.id === id); if (current && current.status !== 'completed' && current.status !== 'failed') { get().updateDownload(id, { status: 'paused', speed: '-', eta: '-' }); } }, redownload: async (id) => { const targetItem = get().downloads.find(d => d.id === id); if (!targetItem) { throw new Error('Cannot redownload: download was not found.'); } if (!['completed', 'failed', 'paused'].includes(targetItem.status)) { throw new Error(`Cannot redownload a ${targetItem.status} download. Pause or wait for it to finish first.`); } const url = targetItem.url?.trim(); if (!url) throw new Error('Cannot redownload: original URL is missing.'); await invalidateAndWaitForDispatch(id); // Remove from backend to clear its state and delete the existing file so we can overwrite try { await invoke('remove_download', { id, deleteAssets: true }); get().unregisterBackendIds([id]); } catch (e) { console.warn("Could not remove old download from backend", e); throw e; } get().updateDownload(id, { status: 'queued', fraction: 0, speed: '-', eta: '-', hasBeenDispatched: false, dateAdded: new Date().toISOString() }); if (!await dispatchItem(id)) { console.error("Failed to enqueue redownload"); get().updateDownload(id, { status: 'failed' }); } else { get().updateDownload(id, { hasBeenDispatched: true }); info(`Download ${id} redownloaded (queued)`); } }, resumeDownload: async (id) => { const targetItem = get().downloads.find(d => d.id === id); if (!targetItem) return false; try { if (targetItem.status === 'ready' || targetItem.status === 'staged') { get().updateDownload(id, { status: 'queued', hasBeenDispatched: true }); if (await dispatchItem(id)) { return true; } get().updateDownload(id, { status: targetItem.status }); return false; } const prevStatus = targetItem.status; const queueItems = get().downloads.filter(d => (d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID) ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); get().updateDownload(id, { status: 'queued', speed: '-', eta: '-', queuePosition: maxPos + 1, lastTry: new Date().toISOString() }); const resumedExisting = await invoke('resume_download', { id }); let dispatchSucceeded = resumedExisting; if (!dispatchSucceeded) { get().unregisterBackendIds([id]); // A terminal aria2 gid is intentionally re-enqueued as a new // lifecycle. Advance and cancel the old generation before dispatching // so QueueManager does not reject the legitimate user retry as stale. await invalidateAndWaitForDispatch(id); dispatchSucceeded = await dispatchItem(id); } if (dispatchSucceeded) { return true; } else { console.error("Failed to re-enqueue for resume"); get().updateDownload(id, { status: prevStatus }); return false; } } catch (e) { console.error("Failed to resume download:", e); get().updateDownload(id, { status: targetItem.status }); return false; } }, startQueue: (queueId) => { const requestedGeneration = currentQueueControlGeneration(queueId); const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]); const operation = previousOperation.catch(() => []).then(async () => { const runnable = get().downloads .filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status))) .sort(queuePositionComparator); if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return []; const acceptedIds: string[] = []; for (const item of runnable) { if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break; const currentItem = get().downloads.find(download => download.id === item.id); if (!currentItem || currentItem.status === 'completed') continue; const backendRegistered = get().backendRegisteredIds.has(item.id); const backendPending = get().pendingOrder.includes(item.id); if (currentItem.status === 'queued' && backendRegistered && !backendPending) { if (await get().resumeDownload(item.id)) { acceptedIds.push(item.id); } continue; } if ( currentItem.status === 'ready' || currentItem.status === 'staged' || currentItem.status === 'failed' || !currentItem.hasBeenDispatched || !backendRegistered ) { if (await dispatchItem(item.id)) { if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) { const afterDispatch = get().downloads.find(download => download.id === item.id); if ( backendDispatchPromises.has(item.id) || get().backendRegisteredIds.has(item.id) || (afterDispatch && canPauseDownload(afterDispatch.status)) ) { await get().pauseDownload(item.id); } continue; } const current = get().downloads.find(download => download.id === item.id); get().updateDownload(item.id, { hasBeenDispatched: true, ...(current?.status === item.status ? { status: 'queued' as const } : {}) }); acceptedIds.push(item.id); } } else if (currentItem.status === 'paused' || currentItem.status === 'queued') { // If it's queued but already dispatched, it might be waiting. // If it's paused, we resume it. if (currentItem.status === 'paused') { if (!await get().resumeDownload(item.id)) continue; } acceptedIds.push(item.id); } } info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`); return acceptedIds; }); const trackedOperation = operation.finally(() => { if (queueStartPromises.get(queueId) === trackedOperation) { queueStartPromises.delete(queueId); } }); queueStartPromises.set(queueId, trackedOperation); return trackedOperation; }, pauseQueue: async (queueId) => { // Invalidate queued starts before taking the snapshot. This prevents a // start loop that is waiting on metadata/IPC from dispatching later rows // after the user has already requested Pause Queue. advanceQueueControlGeneration(queueId); const activeIds = get().downloads .filter(item => item.queueId === queueId && (canPauseDownload(item.status) || backendDispatchPromises.has(item.id)) ) .map(item => item.id); if (activeIds.length === 0) return 0; const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id))); const pausedCount = results.filter(result => result.status === 'fulfilled').length; const failedCount = activeIds.length - pausedCount; if (failedCount > 0) { console.error(`Failed to pause ${failedCount} downloads in queue ${queueId}`); } info(`Queue ${queueId} paused, ${pausedCount} items paused`); syncSystemIntegrations(); return pausedCount; }, startAll: async () => { set(state => ({ downloads: state.downloads.map(item => item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID } ) })); const queueIds = new Set( get().downloads .filter(item => item.status === 'queued' || canStartDownload(item.status)) .map(item => item.queueId || MAIN_QUEUE_ID) ); const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId))); return results.reduce((total, ids) => total + ids.length, 0); }, pauseAll: async () => { const queueIds = new Set( get().downloads.map(item => item.queueId || MAIN_QUEUE_ID) ); for (const queueId of queueIds) { advanceQueueControlGeneration(queueId); } const activeIds = get().downloads .filter(item => canPauseDownload(item.status) || backendDispatchPromises.has(item.id)) .map(item => item.id); if (activeIds.length === 0) return 0; const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id))); const pausedCount = results.filter(result => result.status === 'fulfilled').length; syncSystemIntegrations(); return pausedCount; }, assignToQueue: async (ids, queueId) => { const selectedIds = new Set(ids); const selected = get().downloads.filter(item => selectedIds.has(item.id)); const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued'); if (locked) { throw new Error(`Pause ${locked.fileName} before moving it to another queue.`); } const movableSelected = selected.filter(item => item.status !== 'completed'); const movableIds = new Set(movableSelected.map(item => item.id)); await Promise.all(movableSelected.map(item => invalidateAndWaitForDispatch(item.id))); for (const item of get().downloads.filter(item => movableIds.has(item.id))) { if (!get().backendRegisteredIds.has(item.id)) continue; // The UI can still say queued while a dispatch has already reached // Aria2/media. Detach through the backend lifecycle owner for every // registered item; remove_from_queue only handles the pending list. await invoke('detach_download_for_reconfigure', { id: item.id }); get().unregisterBackendIds([item.id]); set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== item.id) })); } const queueItems = get().downloads.filter(item => !movableIds.has(item.id) && (item.queueId || MAIN_QUEUE_ID) === queueId ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); const nextPosition = maxPos + 1; set(state => ({ downloads: state.downloads.map(item => movableIds.has(item.id) && item.status !== 'completed' ? { ...item, queueId, queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id), status: 'staged' as const, hasBeenDispatched: false } : item ) })); }, addQueue: (name) => { const id = crypto.randomUUID(); const q = { id, name, isMain: false }; set((state) => ({ queues: [...state.queues, q] })); }, renameQueue: (id, name) => { set((state) => ({ queues: state.queues.map(q => { if (q.id === id) { const newQ = { ...q, name }; return newQ; } return q; }) })); }, removeQueue: async (id) => { if (id === MAIN_QUEUE_ID) return; const unfinishedIds = get().downloads .filter(download => download.queueId === id && download.status !== 'completed') .map(download => download.id); if (unfinishedIds.length > 0) { await get().assignToQueue(unfinishedIds, MAIN_QUEUE_ID); } set((state) => ({ queues: state.queues.filter(q => q.id !== id), downloads: state.downloads.map(d => d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d ) })); const settings = useSettingsStore.getState(); if (settings.scheduler.selectedQueueIds.includes(id)) { const selectedQueueIds = settings.scheduler.selectedQueueIds.filter(queueId => queueId !== id); settings.setScheduler({ ...settings.scheduler, enabled: selectedQueueIds.length > 0 ? settings.scheduler.enabled : false, selectedQueueIds }); } }, initDB: async () => { try { const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue); const downloads = (await invoke('db_get_all_downloads')).map( value => JSON.parse(value) as DownloadItem ); set(state => ({ queues: queues.length > 0 ? queues : state.queues, downloads: downloads.length > 0 ? normalizeQueuePositions(downloads) : state.downloads })); // Reset interrupted active downloads to queued. set((state) => ({ downloads: state.downloads.map(d => isActiveDownloadStatus(d.status) && d.status !== 'queued' ? { ...d, status: 'queued' as const } : d ) })); // Auto resume downloads that were active or queued const active = get().downloads .filter(d => d.status === 'queued') .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); if (active.length > 0) { try { const settings = useSettingsStore.getState(); const itemsToEnqueue = []; for (const item of active) { const login = getSiteLogin(item.url, settings); let keychainPassword = null; if (login) { try { keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password for login:", e); } } const destPath = item.destination || await resolveCategoryDestination(settings, item.category); itemsToEnqueue.push({ id: item.id, queue_id: item.queueId || MAIN_QUEUE_ID, url: item.url, destination: destPath, filename: item.fileName, connections: item.connections || settings.perServerConnections || null, speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, headers: item.headers || null, checksum: item.checksum || null, cookies: item.cookies || null, mirrors: item.mirrors || null, user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, proxy: await getProxyArgs(settings), format_selector: item.mediaFormatSelector || null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, is_media: item.isMedia || false, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } const results = await invoke('enqueue_many', { items: itemsToEnqueue }); const registeredIds = results.filter(result => result.success).map(result => result.id); const failedErrors = new Map( results .filter(result => !result.success) .map(result => [result.id, result.error || 'Backend rejected the queued download.']) ); const acceptedIdSet = new Set(registeredIds); // Commit backend ownership as soon as enqueue_many accepts an item. // The order query is a separate best-effort view read; if it fails, // forgetting these registrations would let a later queue start // enqueue the same backend lifecycle a second time. set(state => { // A very fast backend transfer can emit a terminal event before // this batch result is merged. Preserve that event's ownership // cleanup instead of re-registering an already-terminal ID. const liveAcceptedIds = new Set( state.downloads .filter(download => acceptedIdSet.has(download.id) && download.status !== 'completed' && download.status !== 'failed' ) .map(download => download.id) ); return { backendRegisteredIds: new Set([ ...state.backendRegisteredIds, ...liveAcceptedIds ]), downloads: state.downloads.map(download => failedErrors.has(download.id) ? { ...download, status: 'failed' as const, lastError: failedErrors.get(download.id) } : liveAcceptedIds.has(download.id) ? { ...download, hasBeenDispatched: true, lastError: undefined } : download ) }; }); try { const order = await invoke('get_pending_order', { queueId: null }); set({ pendingOrder: order }); } catch (e) { console.error("Failed to refresh pending order after auto-resume:", e); } } catch (e) { console.error("Failed to auto-resume active downloads:", e); } } } catch (e) { console.error("Failed to init DB", e); throw e; } } })); let lastSavedDownloads = ''; let isSavingDownloads = false; let nextDownloadsData: string | null = null; async function processDownloadsSave() { if (isSavingDownloads || !nextDownloadsData) return; isSavingDownloads = true; while (nextDownloadsData) { const data = nextDownloadsData; nextDownloadsData = null; try { await invoke('db_replace_downloads', { data }); } catch (error) { console.error('Failed to persist downloads:', error); } } isSavingDownloads = false; } let lastSavedQueues = ''; let isSavingQueues = false; let nextQueuesData: string | null = null; async function processQueuesSave() { if (isSavingQueues || !nextQueuesData) return; isSavingQueues = true; while (nextQueuesData) { const data = nextQueuesData; nextQueuesData = null; try { await invoke('db_replace_queues', { data }); } catch (error) { console.error('Failed to persist queues:', error); } } isSavingQueues = false; } useDownloadStore.subscribe((state, prevState) => { if (state.queues !== prevState.queues) { const data = JSON.stringify(state.queues); if (data !== lastSavedQueues) { lastSavedQueues = data; nextQueuesData = data; processQueuesSave(); } } if (state.downloads !== prevState.downloads) { // Strip secret fields (password/cookies/headers) and volatile progress // before writing to disk. Secrets remain on the in-memory item for the // active session only. const staticDownloads = state.downloads.map(redactDownloadForPersistence); const currentSerialized = JSON.stringify(staticDownloads); if (currentSerialized !== lastSavedDownloads) { lastSavedDownloads = currentSerialized; nextDownloadsData = currentSerialized; processDownloadsSave(); } } });