Files
Firelink/src/store/useDownloadStore.ts
T
2026-07-30 22:51:46 +03:30

2275 lines
87 KiB
TypeScript

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<string, Promise<boolean>>();
const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
let queueConfigurationQueue: Promise<void> = Promise.resolve();
type DownloadLifecycleOperation = {
kind: string;
promise: Promise<unknown>;
};
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
const preemptDispatch = ['dispatch'] as const;
const preemptStartSelected = ['dispatch', 'start-selected'] as const;
let pendingStartupResume: Promise<void> | null = null;
type DownloadControlIntent = 'pause' | 'resume';
const downloadControlIntents = new Map<string, DownloadControlIntent>();
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 = <T>(
id: string,
kind: string,
operation: () => Promise<T>,
coalesce = true,
preemptKinds: readonly string[] = []
): Promise<T> => {
const current = downloadLifecycleOperations.get(id);
if (coalesce && current?.kind === kind) {
return current.promise as Promise<T>;
}
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 = <T>(
ids: string[],
kind: string,
operation: () => Promise<T>
): Promise<T> => {
const orderedIds = [...new Set(ids)].sort();
const runNext = (index: number): Promise<T> => {
if (index === orderedIds.length) return operation();
return runDownloadLifecycleOperation(
orderedIds[index],
kind,
() => runNext(index + 1),
false,
preemptDispatch
);
};
return runNext(0);
};
const waitForPendingStartupResume = async (): Promise<void> => {
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<boolean>;
};
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
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<boolean> => {
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<void> => {
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<boolean> {
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<boolean> {
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<typeof useSettingsStore.getState>) => {
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<typeof useSettingsStore.getState>) => {
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<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category);
const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
const nextPosition = new Map<string, number>();
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<string, number> = {
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<DownloadItem, 'isMedia' | 'downloadedBytes' | 'totalBytes' | 'totalIsEstimate' | 'size'>
): 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<Queue, 'maxConcurrent'> & {
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<string, string>();
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<void> => {
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<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'> & {
/** 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<string>;
registerBackendIds: (ids: string[]) => void;
unregisterBackendIds: (ids: string[]) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: (
ids: string | string[],
queueId: string,
targetIndex: number,
beforeId?: string | null,
selectedOrder?: readonly string[]
) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
pendingAddReferer: string;
pendingAddFilename: string;
pendingAddHeaders: string;
pendingAddCookies: string;
pendingAddMediaUrls: string[];
pendingAddBatch: boolean;
pendingAddBatchName: string;
pendingAddRequestContexts: Record<string, PendingAddRequestContext>;
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<void>;
deleteModalState: DeleteModalState;
openDeleteModal: (downloadIds?: string | string[]) => void;
closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
startSelected: (ids: string[]) => Promise<number>;
startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
removeQueue: (id: string) => Promise<void>;
resumePendingDownloads: () => Promise<void>;
initDB: () => Promise<void>;
}
export const useDownloadStore = create<DownloadState>((set, get) => {
const applyPropertiesInternal = async (id: string, updates: Partial<DownloadItem>): Promise<void> => {
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<boolean> => {
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<string, string[]>();
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<string, string[]>();
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();
}
}
});