Files
Firelink/src/store/useDownloadStore.ts
T
2026-07-15 10:23:06 +03:30

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