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 { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; import { updateDockBadge } from '../utils/dockBadge'; import { moveSelectedBlockToIndex, targetIndexForDesiredOrder } from '../utils/queueOrdering'; import i18n from '../i18n'; 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(); let queueConfigurationQueue: Promise = Promise.resolve(); type DownloadLifecycleOperation = { kind: string; promise: Promise; }; const downloadLifecycleOperations = new Map(); const preemptDispatch = ['dispatch'] as const; const preemptStartSelected = ['dispatch', 'start-selected'] as const; let pendingStartupResume: Promise | null = null; type DownloadControlIntent = 'pause' | 'resume'; const downloadControlIntents = new Map(); export interface ResumeDownloadOptions { preserveQueuePosition?: boolean; forceRequeue?: boolean; } // State events do not carry a lifecycle generation. Keep the intent that // initiated a control transition long enough for the listener to discard an // already-emitted event from the previous transition. export const setDownloadControlIntent = (id: string, intent: DownloadControlIntent): void => { downloadControlIntents.set(id, intent); }; export const clearDownloadControlIntent = (id: string, intent?: DownloadControlIntent): void => { if (intent === undefined || downloadControlIntents.get(id) === intent) { downloadControlIntents.delete(id); } }; export const downloadControlIntentFor = (id: string): DownloadControlIntent | undefined => downloadControlIntents.get(id); export const clearDownloadControlIntents = (): void => { downloadControlIntents.clear(); }; const runDownloadLifecycleOperation = ( id: string, kind: string, operation: () => Promise, coalesce = true, preemptKinds: readonly string[] = [] ): Promise => { const current = downloadLifecycleOperations.get(id); if (coalesce && current?.kind === kind) { return current.promise as Promise; } const operationPromise = current && !preemptKinds.includes(current.kind) ? current.promise.catch(() => undefined).then(operation) : operation(); const trackedOperation = operationPromise.finally(() => { if (downloadLifecycleOperations.get(id)?.promise === trackedOperation) { downloadLifecycleOperations.delete(id); } }); downloadLifecycleOperations.set(id, { kind, promise: trackedOperation }); return trackedOperation; }; const runDownloadLifecycleOperations = ( ids: string[], kind: string, operation: () => Promise ): Promise => { const orderedIds = [...new Set(ids)].sort(); const runNext = (index: number): Promise => { if (index === orderedIds.length) return operation(); return runDownloadLifecycleOperation( orderedIds[index], kind, () => runNext(index + 1), false, preemptDispatch ); }; return runNext(0); }; const waitForPendingStartupResume = async (): Promise => { const pending = pendingStartupResume; if (pending) await pending.catch(() => undefined); }; 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 comparableQueuePosition = (download: DownloadItem): number => { const position = download.queuePosition; return typeof position === 'number' && Number.isFinite(position) && position >= 0 ? position : Number.MAX_SAFE_INTEGER; }; const queuePositionComparator = (left: DownloadItem, right: DownloadItem): number => comparableQueuePosition(left) - comparableQueuePosition(right) || 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); }; // Paused downloads remain part of the queue, but they must not be interleaved // with the queue's non-paused rows. Keep the partition in the store so // the table, persistence, and later queue operations all observe the same // order instead of each deriving a different one. const reorderQueueWithPausedAtEnd = (downloads: DownloadItem[], queueId: string): DownloadItem[] => { const queueItems = queueItemsForReordering(downloads, queueId); const nonPausedItems = queueItems.filter(download => download.status !== 'paused'); const pausedItems = queueItems.filter(download => download.status === 'paused'); return applyQueueOrder(downloads, queueId, [...nonPausedItems, ...pausedItems]); }; 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); export class SystemProxyResolutionError extends Error { constructor(reason: string) { super(`System proxy configuration could not be read: ${reason}. Choose No Proxy or try again.`); this.name = 'SystemProxyResolutionError'; } } const isSystemProxyConfigurationError = (error: unknown): boolean => error instanceof SystemProxyResolutionError; const stripSensitiveMediaHeaders = (value: string | null | undefined): string => (value || '') .split(/\r?\n/) .filter(line => { const separator = line.indexOf(':'); if (separator < 0) return true; const name = line.slice(0, separator).trim().toLowerCase(); return ![ 'authorization', 'cookie', 'cookie2', 'proxy-authorization', 'set-cookie', 'set-cookie2' ].includes(name); }) .join('\n') .trim(); const explicitSpeedLimitForDispatch = (itemSpeedLimit: string | undefined): string | null => { const explicitLimit = itemSpeedLimit?.trim(); if (explicitLimit) { return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null); } return null; }; const speedLimitForDispatch = ( itemSpeedLimit: string | undefined, globalSpeedLimit: string, isMedia: boolean | undefined ): string | null => { // Older Add-window rows used "0" as the no-override sentinel. Media // downloads do not have aria2's daemon-wide cap, so preserve the intended // inherit-global behavior when dispatching those persisted rows. if (isMedia && itemSpeedLimit?.trim() === '0') { return normalizeSpeedLimitForBackend(globalSpeedLimit); } const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit); if (explicitLimit !== null || !isMedia) return explicitLimit; return normalizeSpeedLimitForBackend(globalSpeedLimit); }; async function dispatchItemInternal(id: string, proxyOverride?: string | null): Promise { await waitForPendingStartupResume(); 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; if (!['ready', 'staged', 'failed', 'queued'].includes(item.status)) return false; 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); if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) { settings.setShowKeychainModal(true); return false; } let keychainPassword = null; if (login && !item.password && settings.keychainAccessReady) { 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 = proxyOverride === undefined ? await getProxyArgs(settings) : proxyOverride; 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: resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), 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, is_torrent: item.isTorrent || false, torrent_path: item.torrentPath || undefined, torrent_file_indices: item.torrentFileIndices || undefined, torrent_info_hash: item.torrentInfoHash || undefined, 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)) { const proxyBlocked = isSystemProxyConfigurationError(e); useDownloadStore.getState().updateDownload(id, { status: proxyBlocked ? 'queued' : 'failed', lastError: errorMessage(e) }); } return false; } finally { backendDispatchPromises.delete(id); } })(); backendDispatchPromises.set(id, promise); return promise; } export function dispatchItem(id: string, proxyOverride?: string | null): Promise { const current = downloadLifecycleOperations.get(id); if (current && current.kind !== 'dispatch') { return current.promise.then(() => false, () => false); } return runDownloadLifecycleOperation( id, 'dispatch', () => dispatchItemInternal(id, proxyOverride) ); } 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.hostname) return null; if (!parsed.port) parsed.port = String(normalizedPort); return parsed.toString().replace(/\/$/, ''); } catch { return null; } } try { const parsed = new URL(`http://${trimmedHost}:${normalizedPort}`); if ( !parsed.hostname || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash || (parsed.port && Number(parsed.port) !== normalizedPort) || (!parsed.port && normalizedPort !== 80) ) { return null; } return `http://${trimmedHost}:${normalizedPort}`; } catch { return null; } }; 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) { const reason = e instanceof Error ? e.message : String(e); throw new SystemProxyResolutionError(reason); } } 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; updateDockBadge(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(); const normalized: DownloadItem[] = downloads.map(download => { const queueId = download.queueId || MAIN_QUEUE_ID; const position = nextPosition.get(queueId) || 0; nextPosition.set(queueId, position + 1); const persistedPosition = download.queuePosition; const queuePosition = typeof persistedPosition === 'number' && Number.isFinite(persistedPosition) && persistedPosition >= 0 ? Math.trunc(persistedPosition) : position; return { ...download, queueId, queuePosition }; }); let ordered = normalized; for (const queueId of new Set(normalized.map(download => download.queueId || MAIN_QUEUE_ID))) { ordered = reorderQueueWithPausedAtEnd(ordered, queueId); } return ordered; }; const TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES = 1024; const DISPLAYED_SIZE_UNIT_MULTIPLIERS: Record = { B: 1, KB: 1024, KIB: 1024, MB: 1024 ** 2, MIB: 1024 ** 2, GB: 1024 ** 3, GIB: 1024 ** 3, TB: 1024 ** 4, TIB: 1024 ** 4 }; const displayedSizeBytes = (size: string | undefined): number | undefined => { const match = size?.trim().match(/^~\s*([0-9]+(?:\.[0-9]+)?)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i); if (!match) return undefined; const bytes = Number(match[1]) * DISPLAYED_SIZE_UNIT_MULTIPLIERS[match[2].toUpperCase()]; return Number.isFinite(bytes) ? bytes : undefined; }; export const hasStaleTemporaryMediaEstimate = ( download: Pick ): boolean => { if (download.isMedia !== true) return false; const hasImpossibleNumericEstimate = download.totalIsEstimate === true && typeof download.totalBytes === 'number' && Number.isFinite(download.totalBytes) && download.totalBytes > 0 && download.totalBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES && typeof download.downloadedBytes === 'number' && Number.isFinite(download.downloadedBytes) && download.downloadedBytes > download.totalBytes; const visibleEstimateBytes = displayedSizeBytes(download.size); const hasImpossibleVisibleEstimate = visibleEstimateBytes !== undefined && visibleEstimateBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES && typeof download.downloadedBytes === 'number' && Number.isFinite(download.downloadedBytes) && download.downloadedBytes > visibleEstimateBytes && (download.totalBytes == null || download.totalBytes <= TEMPORARY_MEDIA_ESTIMATE_MAX_BYTES); return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate; }; export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => hasStaleTemporaryMediaEstimate(download) ? { ...download, // The old lifecycle could persist yt-dlp's temporary HLS estimate as // both the numeric denominator and the visible size. Neither value is // recoverable after the fact, so remove the false claim on startup. size: undefined, totalBytes: undefined, totalIsEstimate: undefined } : download; export type { DownloadStatus }; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue'; const MAX_QUEUE_CONCURRENT = 12; const queueNameKey = (name: string): string => name.trim().toLowerCase(); const normalizeQueueConcurrency = (value: unknown): number | undefined => { if (typeof value !== 'number' || !Number.isInteger(value)) return undefined; return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined; }; type PersistedQueue = Omit & { maxConcurrent?: number | null; }; const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => { const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent); return maxConcurrent === undefined ? { id: queue.id, name: queue.name, isMain: queue.isMain } : { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent }; }; export const normalizePersistedQueueState = (queues: PersistedQueue[]) => { const validQueues = queues.filter(queue => queue && typeof queue.id === 'string' && typeof queue.name === 'string' ).map(queueWithNormalizedConcurrency); const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID) || validQueues.find(queue => queue.isMain); const persistedMainId = persistedMain?.id.trim(); const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME; const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true }; if (persistedMain?.maxConcurrent !== undefined) { normalizedMain.maxConcurrent = persistedMain.maxConcurrent; } const normalized: Queue[] = [normalizedMain]; const seenIds = new Set([MAIN_QUEUE_ID]); const seenNames = new Set([queueNameKey(mainName)]); const queueIdRemap = new Map(); if (persistedMainId && persistedMainId !== MAIN_QUEUE_ID) { queueIdRemap.set(persistedMainId, MAIN_QUEUE_ID); } for (const queue of validQueues) { const id = queue.id.trim(); if (!id || id === MAIN_QUEUE_ID || id === persistedMainId || seenIds.has(id)) continue; if (queue.isMain) { queueIdRemap.set(id, MAIN_QUEUE_ID); continue; } let name = queue.name.trim() || `Queue ${id.slice(0, 8)}`; const baseName = name; let suffix = 2; while (seenNames.has(queueNameKey(name))) { name = `${baseName} (${suffix})`; suffix += 1; } seenIds.add(id); seenNames.add(queueNameKey(name)); const normalizedQueue = { id, name, isMain: false } as Queue; if (queue.maxConcurrent !== undefined) { const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent); if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent; } normalized.push(normalizedQueue); } return { queues: normalized, queueIdRemap }; }; export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] => normalizePersistedQueueState(queues).queues; const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise => { await invoke('set_queue_concurrency_limits', { limits: queues.map(queue => ({ id: queue.id, maxConcurrent: queue.maxConcurrent ?? null })) }); }; const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean => left.length === right.length && left.every((queue, index) => { const other = right[index]; return other !== undefined && queue.id === other.id && (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null); }); export type { DownloadItem, Queue }; export type ExtensionDownloadRequest = ExtensionDownload; export type AddDownloadAction = | { type: 'start-now' } | { type: 'add-to-queue'; queueId: string }; export type DownloadDraft = Omit & { /** Numeric format estimate supplied by the media Add window. */ sizeBytes?: number; }; export type PendingAddRequestContext = { version: number; referer: string; filename: string; headers: string; cookies: string; cookieScopes?: ExtensionCookieScope[]; 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; moveManyInQueueToPosition: ( ids: string | string[], queueId: string, targetIndex: number, beforeId?: string | null, selectedOrder?: readonly string[] ) => Promise; removeFromQueue: (id: string) => Promise; isAddModalOpen: boolean; pendingAddUrls: string; pendingAddReferer: string; pendingAddFilename: string; pendingAddHeaders: string; pendingAddCookies: string; pendingAddMediaUrls: string[]; pendingAddBatch: boolean; pendingAddBatchName: 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, cookieScopes?: ExtensionCookieScope[] | null, batch?: boolean, batchName?: string | null ) => 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; replaceDownload: (id: string, updates: Partial, 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, options?: ResumeDownloadOptions) => Promise; startSelected: (ids: string[]) => Promise; startQueue: (queueId: string) => Promise; pauseQueue: (queueId: string) => Promise; startAll: () => Promise; pauseAll: () => Promise; assignToQueue: (ids: string[], queueId: string) => Promise; setDownloadSpeedLimit: (id: string, limit: string | null) => Promise; setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise; addQueue: (name: string) => boolean; renameQueue: (id: string, name: string) => boolean; removeQueue: (id: string) => Promise; resumePendingDownloads: () => Promise; initDB: () => Promise; } export const useDownloadStore = create((set, get) => { const applyPropertiesInternal = async (id: string, updates: Partial): Promise => { await waitForPendingStartupResume(); const wasDispatching = await invalidateAndWaitForDispatch(id); const state = get(); const item = state.downloads.find(d => d.id === id); if (!item) return; const normalizedUpdates = updates.fileName === undefined ? updates : { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }; if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { throw new Error(i18n.t($ => $.downloadTable.transferActive)); } if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { state.updateDownload(id, normalizedUpdates); 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, normalizedUpdates); if (isRegistered || wasDispatching) { const dispatched = await dispatchItemInternal(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, normalizedUpdates); } }; const resumeDownloadInternal = async ( id: string, options: ResumeDownloadOptions = {} ): Promise => { await waitForPendingStartupResume(); let targetItem = get().downloads.find(d => d.id === id); if (!targetItem) return false; setDownloadControlIntent(id, 'resume'); let previousStatus = targetItem.status; try { if (options.forceRequeue) { // Fence any older enqueue before replacing a paused backend lifecycle. // Otherwise a late addUri result can win the race and make this // selection start outside the requested order. const { pendingDispatch } = await invalidateDispatch(id); if (pendingDispatch) await pendingDispatch; targetItem = get().downloads.find(download => download.id === id); if (!targetItem || !canStartDownload(targetItem.status)) { clearDownloadControlIntent(id, 'resume'); return false; } previousStatus = targetItem.status; } const currentTargetItem = targetItem; if ( options.forceRequeue && currentTargetItem.status === 'paused' && get().backendRegisteredIds.has(id) ) { await invoke('detach_download_for_reconfigure', { id }); get().unregisterBackendIds([id]); } if (options.forceRequeue) { set(state => ({ pendingOrder: state.pendingOrder.filter(value => value !== id) })); } if (currentTargetItem.status === 'ready' || currentTargetItem.status === 'staged') { get().updateDownload(id, { status: 'queued', hasBeenDispatched: true }); if (await dispatchItemInternal(id)) { return true; } get().updateDownload(id, { status: currentTargetItem.status }); clearDownloadControlIntent(id, 'resume'); return false; } const prevStatus = currentTargetItem.status; const queueItems = get().downloads.filter(d => (d.queueId || MAIN_QUEUE_ID) === (currentTargetItem.queueId || MAIN_QUEUE_ID) ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); const queuePosition = options.preserveQueuePosition ? currentTargetItem.queuePosition : maxPos + 1; get().updateDownload(id, { status: 'queued', speed: '-', eta: '-', ...(queuePosition === undefined ? {} : { queuePosition }), lastTry: new Date().toISOString() }); const resumedExisting = options.forceRequeue ? false : await invoke('resume_download', { id, queueId: currentTargetItem.queueId || MAIN_QUEUE_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 dispatchItemInternal(id); } if (dispatchSucceeded) { get().updateDownload(id, { hasBeenDispatched: true }); return true; } else { const dispatchError = get().downloads.find(download => download.id === id)?.lastError; console.error("Failed to re-enqueue for resume:", dispatchError || "backend rejected the enqueue request"); get().updateDownload(id, { status: prevStatus, ...(dispatchError ? { lastError: dispatchError } : {}) }); clearDownloadControlIntent(id, 'resume'); return false; } } catch (e) { console.error("Failed to resume download:", e); const current = get().downloads.find(download => download.id === id); if (current?.status === 'queued') { get().updateDownload(id, { status: previousStatus }); } clearDownloadControlIntent(id, 'resume'); return false; } }; return { 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) })); throw error; } }); const trackedOperation = operation.finally(() => { if (queueReorderPromises.get(queueId) === trackedOperation) { queueReorderPromises.delete(queueId); } }); queueReorderPromises.set(queueId, trackedOperation); return trackedOperation; }, moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId, selectedOrder) => { const requestedIds = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; const ids = [...new Set(requestedIds)]; const normalizedSelectedOrder = selectedOrder ? [...new Set(selectedOrder)].filter(id => ids.includes(id)) : undefined; if (ids.length === 0) return Promise.resolve(); // Dragging must be one serialized, atomic queue operation. This prevents // a second drag or a keyboard move from calculating against stale order // while the backend is still applying the first drop. 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 = normalizedSelectedOrder ? normalizedSelectedOrder .map(id => queueItems.find(item => item.id === id)) .filter((item): item is DownloadItem => Boolean(item)) : queueItems.filter(item => ids.includes(item.id)); if (selectedItems.length === 0) return; const selectedIds = new Set(selectedItems.map(item => item.id)); const previousPositions = new Map([ ...activeQueueItems(allDownloads, queueId), ...queueItems ].map(item => [item.id, item.queuePosition])); const unselectedItems = queueItems.filter(item => !selectedIds.has(item.id)); const anchoredTargetIndex = beforeId ? unselectedItems.findIndex(item => item.id === beforeId) : -1; const resolvedTargetIndex = anchoredTargetIndex >= 0 ? anchoredTargetIndex : Math.max(0, Math.min(targetIndex, unselectedItems.length)); const reordered = normalizedSelectedOrder ? [ ...unselectedItems.slice(0, resolvedTargetIndex), ...selectedItems, ...unselectedItems.slice(resolvedTargetIndex) ] : moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex); 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; // Staged rows are deliberately not registered with the backend. Convert // the desired local order to a registered-only target before IPC so a // staged row never shifts the backend insertion index. const registeredItems = queueItems.filter(item => get().backendRegisteredIds.has(item.id)); const registeredSelectedIds = new Set(registeredIdsToMove); const registeredDesiredOrder = reordered.filter(item => get().backendRegisteredIds.has(item.id)); const backendTargetIndex = targetIndexForDesiredOrder( registeredItems, registeredSelectedIds, registeredDesiredOrder ); try { const order = await invoke('move_many_in_queue', { ids: registeredIdsToMove, queueId, direction: 'up', targetIndex: backendTargetIndex }) 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 queue block to position:", 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) })); throw error; } }); 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: [], pendingAddBatch: false, pendingAddBatchName: '', 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: [], pendingAddBatch: false, pendingAddBatchName: '', 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, cookieScopes, batch = false, batchName ) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; const cleanReferer = referer?.trim() || ''; const cleanFilename = filename?.trim() || ''; const cleanHeaders = headers?.trim() || ''; const cleanCookies = cookies?.trim() || ''; // Keep the first modal request's grouping decision stable while later // handoffs append URLs. This avoids moving an already-visible destination // when a second request races with the user's Add-window setup. const nextBatch = isAppending ? state.pendingAddBatch : batch; const nextBatchName = nextBatch ? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '') : ''; const cleanCookieScopes = cookieScopes ?.map(scope => ({ url: scope.url.trim(), cookies: scope.cookies.trim() })) .filter(scope => scope.url && scope.cookies); 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, ...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}), media }; } const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts) .filter(([, context]) => context.media) .map(([url]) => url); return { isAddModalOpen: true, pendingAddUrls: mergedUrls, pendingAddReferer: cleanReferer, pendingAddFilename: cleanFilename, pendingAddHeaders: cleanHeaders, pendingAddCookies: cleanCookies, pendingAddMediaUrls, pendingAddBatch: nextBatch, pendingAddBatchName: nextBatchName, 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 ? stripSensitiveMediaHeaders(request.headers) || null : request.headers; get().openAddModalWithUrls( urls.join('\n'), request.referer, urls.length === 1 ? request.filename : null, headers, cookies, request.media === true, request.media === true ? undefined : request.cookie_scopes, request.batch === true && urls.length >= 2, request.batch_name ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: async (item, action) => { const settings = useSettingsStore.getState(); const normalizedItem = { ...item, fileName: canonicalizeDownloadFileName(item.fileName) }; const destPath = await effectiveDestinationForItem(normalizedItem, 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 { sizeBytes, ...downloadDraft } = normalizedItem; const ownedItem: DownloadItem = { ...downloadDraft, totalBytes: normalizedItem.totalBytes ?? sizeBytes, totalIsEstimate: normalizedItem.totalIsEstimate ?? ( normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~') ), connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections), destination: destPath, status: action.type === 'add-to-queue' ? 'staged' : 'ready', queueId, queuePosition, hasBeenDispatched: false }; advanceDownloadLifecycle(item.id); set((state) => ({ downloads: reorderQueueWithPausedAtEnd([...state.downloads, ownedItem], queueId) })); 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; }, replaceDownload: (id, updates, action) => runDownloadLifecycleOperation( id, 'replace', async () => { if (!get().downloads.some(download => download.id === id)) return false; await applyPropertiesInternal(id, updates); if (!get().downloads.some(download => download.id === id)) return false; if (action.type === 'start-now') { const resumed = await resumeDownloadInternal(id); if (resumed) get().updateDownload(id, { hasBeenDispatched: true }); return resumed; } return true; }, false, preemptDispatch ), applyProperties: (id, updates) => runDownloadLifecycleOperation( id, 'properties', () => applyPropertiesInternal(id, updates), false, preemptDispatch ), updateDownload: (id, updates) => { set((state) => { const current = state.downloads.find(download => download.id === id); if (!current) return { downloads: state.downloads }; const downloads = state.downloads.map(d => d.id === id ? { ...d, ...updates, fraction: updates.fraction !== undefined ? updates.fraction : d.fraction } : d ); const shouldNormalizeQueue = updates.status === 'paused' || updates.status === 'queued' || updates.status === 'staged'; const updated = downloads.find(download => download.id === id) ?? current; return { downloads: shouldNormalizeQueue ? reorderQueueWithPausedAtEnd(downloads, updated.queueId || MAIN_QUEUE_ID) : downloads, ...(updates.status === 'paused' ? { pendingOrder: state.pendingOrder.filter(value => value !== id) } : {}) }; }); 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: (id, deleteFile = false, preserveResumable = false) => runDownloadLifecycleOperation( id, `remove:${deleteFile}:${preserveResumable}`, async () => { await waitForPendingStartupResume(); clearDownloadControlIntent(id); 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(); }, true, preemptDispatch ), pauseDownload: (id) => runDownloadLifecycleOperation(id, 'pause', async () => { await waitForPendingStartupResume(); if (!get().downloads.some(download => download.id === id)) return; setDownloadControlIntent(id, 'pause'); const { generation, pendingDispatch } = await invalidateDispatch(id); try { 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: '-' }); } } finally { clearDownloadControlIntent(id, 'pause'); } }, true, preemptStartSelected), redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => { await waitForPendingStartupResume(); const targetItem = get().downloads.find(d => d.id === id); if (!targetItem) { throw new Error(i18n.t($ => $.downloadTable.redownloadNotFound)); } if (!['completed', 'failed', 'paused'].includes(targetItem.status)) { throw new Error(i18n.t($ => $.downloadTable.redownloadActive, { status: i18n.t($ => $.downloads.status[targetItem.status]) })); } const url = targetItem.url?.trim(); if (!url) throw new Error(i18n.t($ => $.downloadTable.originalUrlMissing)); setDownloadControlIntent(id, 'resume'); 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); clearDownloadControlIntent(id, 'resume'); throw e; } get().updateDownload(id, { status: 'queued', fraction: 0, speed: '-', eta: '-', downloadedBytes: undefined, totalBytes: undefined, totalIsEstimate: undefined, hasBeenDispatched: false, dateAdded: new Date().toISOString() }); if (!await dispatchItemInternal(id)) { console.error("Failed to enqueue redownload"); get().updateDownload(id, { status: 'failed' }); clearDownloadControlIntent(id, 'resume'); } else { get().updateDownload(id, { hasBeenDispatched: true }); info(`Download ${id} redownloaded (queued)`); } }, true, preemptDispatch), resumeDownload: (id, options) => runDownloadLifecycleOperation( id, 'resume', () => resumeDownloadInternal(id, options), true, preemptDispatch ), startSelected: (ids) => { const orderedIds = [...new Set(ids)]; if (orderedIds.length === 0) return Promise.resolve(0); return runDownloadLifecycleOperations(orderedIds, 'start-selected', async () => { await waitForPendingStartupResume(); const selectedByQueue = new Map(); for (const id of orderedIds) { const item = get().downloads.find(download => download.id === id); if (!item || !canStartDownload(item.status)) continue; const queueId = item.queueId || MAIN_QUEUE_ID; const queueIds = selectedByQueue.get(queueId) || []; queueIds.push(id); selectedByQueue.set(queueId, queueIds); } const selectedQueueGenerations = new Map( Array.from(selectedByQueue.keys(), queueId => [ queueId, advanceQueueControlGeneration(queueId) ] as const) ); // Make the selection's order explicit before any capacity becomes // available. This keeps the frontend projection and backend pending // queue aligned even when the selected rows were not adjacent. for (const [queueId, queueIds] of selectedByQueue) { const generation = selectedQueueGenerations.get(queueId); if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { continue; } await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds); } let startedCount = 0; const startedByQueue = new Map(); for (const id of orderedIds) { const current = get().downloads.find(download => download.id === id); if (!current || !canStartDownload(current.status)) continue; const queueId = current.queueId || MAIN_QUEUE_ID; const generation = selectedQueueGenerations.get(queueId); if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { continue; } const resumed = await resumeDownloadInternal(id, { preserveQueuePosition: true, forceRequeue: true }); if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) { // A queue pause can win while this item's requeue is in flight. The // pause action is allowed to preempt this bulk-start operation so // this item cannot remain running after the user's pause request. await get().pauseDownload(id); continue; } if (resumed) { startedCount += 1; const startedIds = startedByQueue.get(queueId) || []; startedIds.push(id); startedByQueue.set(queueId, startedIds); // A requeued paused item is appended by the backend. Move the // started prefix immediately so a newly available slot cannot let // older pending work overtake the remaining selection while it is // still being requeued. if (isCurrentQueueControlGeneration(queueId, generation)) { await get().moveManyInQueueToPosition( startedIds, queueId, 0, null, startedIds ); } } } // Newly dispatched rows are now visible to the backend pending list. // Reapply the same ordered block so existing pending work cannot remain // ahead of a user-selected start request. for (const [queueId, queueIds] of selectedByQueue) { const generation = selectedQueueGenerations.get(queueId); if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { continue; } await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds); } return startedCount; }); }, startQueue: (queueId) => { const requestedGeneration = currentQueueControlGeneration(queueId); const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]); const operation = previousOperation.catch(() => []).then(async () => { await waitForPendingStartupResume(); const runnable = get().downloads .filter(item => (item.queueId || MAIN_QUEUE_ID) === queueId && (item.status === 'queued' || canStartDownload(item.status)) ) .sort(queuePositionComparator); if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return []; const needsNewDispatch = runnable.some(item => { const currentItem = get().downloads.find(download => download.id === item.id); if (!currentItem) return false; // Paused rows must go through resumeDownload. This includes rows that // were paused before their first dispatch, for which dispatchItem // would reject the paused status before reaching the backend. if (currentItem.status === 'paused') return false; const backendRegistered = get().backendRegisteredIds.has(item.id); const backendPending = get().pendingOrder.includes(item.id); if (currentItem.status === 'queued' && backendRegistered && !backendPending) { return false; } return currentItem.status === 'ready' || currentItem.status === 'staged' || currentItem.status === 'failed' || !currentItem.hasBeenDispatched || !backendRegistered; }); let queueProxy: string | null | undefined; if (needsNewDispatch) { try { queueProxy = await getProxyArgs(useSettingsStore.getState()); } catch (error) { const message = errorMessage(error); console.error(`Could not safely resolve the proxy for queue ${queueId}:`, error); const runnableIds = new Set(runnable.map(item => item.id)); set(state => ({ downloads: state.downloads.map(item => runnableIds.has(item.id) && item.status !== 'completed' ? { ...item, lastError: message } : item ) })); 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 === 'paused') { const resumed = await get().resumeDownload(item.id, { preserveQueuePosition: true }); if (!resumed) continue; if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) { const afterResume = get().downloads.find(download => download.id === item.id); if ( backendDispatchPromises.has(item.id) || get().backendRegisteredIds.has(item.id) || (afterResume && canPauseDownload(afterResume.status)) ) { await get().pauseDownload(item.id); } continue; } acceptedIds.push(item.id); continue; } if (currentItem.status === 'queued' && backendRegistered && !backendPending) { if (await get().resumeDownload(item.id, { preserveQueuePosition: true })) { acceptedIds.push(item.id); } continue; } if ( currentItem.status === 'ready' || currentItem.status === 'staged' || currentItem.status === 'failed' || !currentItem.hasBeenDispatched || !backendRegistered ) { if (await dispatchItem(item.id, queueProxy)) { 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 === 'queued') { // If it's queued but already dispatched, it might be waiting. 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) => { await waitForPendingStartupResume(); // 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 || MAIN_QUEUE_ID) === 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 () => { await waitForPendingStartupResume(); 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) => { await waitForPendingStartupResume(); const lockedIds = ids.filter(id => get().downloads.some(item => item.id === id)); return runDownloadLifecycleOperations(lockedIds, 'assign-to-queue', async () => { 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(i18n.t($ => $.downloadTable.pauseBeforeMove, { file: locked.fileName })); } 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 => { const 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 ); return { downloads: reorderQueueWithPausedAtEnd(downloads, queueId) }; }); }); }, setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation( id, 'speed-limit', async () => { await waitForPendingStartupResume(); const item = get().downloads.find(download => download.id === id); if (!item) throw new Error('Download no longer exists.'); if (item.isMedia) { throw new Error('Live speed control is unavailable for media downloads.'); } if (!['downloading', 'retrying'].includes(item.status)) { throw new Error('Live speed control requires an active download.'); } const trimmed = limit?.trim() || ''; const normalizedLimit = trimmed ? normalizeSpeedLimitForBackend(trimmed) : null; if (trimmed && normalizedLimit === null) { throw new Error('Enter a valid speed limit.'); } await invoke('set_download_speed_limit', { id, limit: normalizedLimit }); if (get().downloads.some(download => download.id === id)) { get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined }); } }, true, preemptDispatch ), setQueueConcurrency: (id, maxConcurrent) => { const operation = queueConfigurationQueue.then(async () => { if ( maxConcurrent !== null && (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT) ) { throw new Error('Queue concurrency must be between 1 and 12.'); } const currentQueues = get().queues; if (!currentQueues.some(queue => queue.id === id)) { throw new Error('Queue no longer exists.'); } const nextQueues = currentQueues.map(queue => queue.id === id ? maxConcurrent === null ? { id: queue.id, name: queue.name, isMain: queue.isMain } : { ...queue, maxConcurrent } : queue ); await synchronizeQueueConcurrencyLimits(nextQueues); const latestQueues = get().queues; if (!latestQueues.some(queue => queue.id === id)) { await synchronizeQueueConcurrencyLimits(latestQueues); throw new Error('Queue no longer exists.'); } const rebasedQueues = latestQueues.map(queue => queue.id === id ? maxConcurrent === null ? { id: queue.id, name: queue.name, isMain: queue.isMain } : { ...queue, maxConcurrent } : queue ); if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) { await synchronizeQueueConcurrencyLimits(rebasedQueues); } set({ queues: rebasedQueues }); }); queueConfigurationQueue = operation.then(() => undefined, () => undefined); return operation; }, addQueue: (name) => { const normalizedName = name.trim(); if (!normalizedName) return false; const duplicate = get().queues.some(queue => queueNameKey(queue.name) === queueNameKey(normalizedName) ); if (duplicate) return false; const id = crypto.randomUUID(); const q = { id, name: normalizedName, isMain: false }; set((state) => ({ queues: [...state.queues, q] })); return true; }, renameQueue: (id, name) => { const normalizedName = name.trim(); if (!normalizedName) return false; const duplicate = get().queues.some(queue => queue.id !== id && queueNameKey(queue.name) === queueNameKey(normalizedName) ); if (duplicate || !get().queues.some(queue => queue.id === id)) return false; set((state) => ({ queues: state.queues.map(q => { if (q.id === id) { const newQ = { ...q, name: normalizedName }; return newQ; } return q; }) })); return true; }, removeQueue: async (id) => { if (id === MAIN_QUEUE_ID) return; await waitForPendingStartupResume(); 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 }); } }, resumePendingDownloads: () => { if (pendingStartupResume) return pendingStartupResume; const operation = (async () => { const active = get().downloads .filter(d => d.status === 'queued') .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); if (active.length === 0) return; try { const settings = useSettingsStore.getState(); let proxy: string | null; try { proxy = await getProxyArgs(settings); } catch (error) { const message = errorMessage(error); console.error('Could not safely resolve the system proxy during startup resume:', error); const activeIds = new Set(active.map(item => item.id)); set(state => ({ downloads: state.downloads.map(item => activeIds.has(item.id) && item.status === 'queued' ? { ...item, lastError: message } : item ) })); return; } const itemsToEnqueue = []; for (const pendingItem of active) { const item = get().downloads.find(download => download.id === pendingItem.id); if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue; const login = getSiteLogin(item.url, settings); let keychainPassword = null; if (login && !item.password && settings.keychainAccessReady) { 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: resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), 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, is_torrent: item.isTorrent || false, torrent_path: item.torrentPath || undefined, torrent_file_indices: item.torrentFileIndices || undefined, torrent_info_hash: item.torrentInfoHash || undefined, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } const currentItems = new Map(get().downloads.map(item => [item.id, item])); const dispatchableItems = itemsToEnqueue.filter(item => { const current = currentItems.get(item.id); return current && current.status === 'queued' && !get().backendRegisteredIds.has(item.id) && !backendDispatchPromises.has(item.id) && currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation; }); if (dispatchableItems.length === 0) return; const results = await invoke('enqueue_many', { items: dispatchableItems }); 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 acceptedFilenames = new Map( results .filter(result => result.success && Boolean(result.filename)) .map(result => [result.id, result.filename!]) ); const acceptedIdSet = new Set(registeredIds); const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation])); // 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' && currentDownloadLifecycle(download.id).toString() === generationById.get(download.id) ) .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, ...(acceptedFilenames.has(download.id) ? { fileName: acceptedFilenames.get(download.id), category: categoryForFileName(acceptedFilenames.get(download.id)!) } : {}), hasBeenDispatched: true, lastError: undefined } : download ) }; }); // A cancellation can race with the batch RPC. Use the lifecycle-aware // cancellation command for any accepted generation that is no longer // current; never remove by ID because a newer lifecycle could already // own that same download row. await Promise.all(registeredIds .filter(id => !get().backendRegisteredIds.has(id)) .map(id => { const generation = generationById.get(id); if (generation === undefined) return Promise.resolve(); return invoke('cancel_enqueue_generation', { id, generation }).catch(error => { console.warn(`Failed to cancel stale startup enqueue for ${id}:`, error); }); })); 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); throw e; } })(); const trackedOperation = operation.finally(() => { if (pendingStartupResume === trackedOperation) pendingStartupResume = null; }); pendingStartupResume = trackedOperation; return trackedOperation; }, initDB: async () => { try { const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => { try { return [JSON.parse(value) as PersistedQueue]; } catch { console.warn('Skipping malformed persisted queue record during startup'); return []; } }); const normalizedQueueState = normalizePersistedQueueState(persistedQueues); const queues = normalizedQueueState.queues; const knownQueueIds = new Set(queues.map(queue => queue.id)); const downloads = (await invoke('db_get_all_downloads')).map( value => JSON.parse(value) as DownloadItem ).map(download => { const persistedQueueId = download.queueId || MAIN_QUEUE_ID; const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId) || (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID); return normalizePersistedDownloadProgress({ ...download, queueId }); }); set(state => ({ queues, downloads: downloads.length > 0 ? normalizeQueuePositions(downloads) : state.downloads })); // The backend dispatcher is live before the frontend finishes startup. // Synchronize the normalized queue policy before any saved download is // allowed to claim a permit. await synchronizeQueueConcurrencyLimits(queues); // Reset interrupted active downloads to queued. set((state) => ({ downloads: normalizeQueuePositions(state.downloads.map(d => isActiveDownloadStatus(d.status) && d.status !== 'queued' ? { ...d, status: 'queued' as const } : d )) })); } 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(); } } });