mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 10:23:29 +00:00
fix(downloads): harden queue and lifecycle synchronization
Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -45,4 +46,41 @@ describe('useDownloadProgressStore', () => {
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'terminal',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'completed',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal',
|
||||
fraction: 0.1,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s',
|
||||
size: '1 MB',
|
||||
size_is_final: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal',
|
||||
status: 'failed',
|
||||
error: 'stale failure'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,10 +52,15 @@ const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||
// or completion event. Do not let that stale chunk repopulate the live
|
||||
// progress map or overwrite a later lifecycle's first frame.
|
||||
if (current && ['completed', 'failed', 'paused'].includes(current.status)) {
|
||||
return;
|
||||
}
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
@@ -81,7 +86,7 @@ const startDownloadListeners = async () => {
|
||||
|
||||
// Prevent race condition: don't transition backwards from terminal state
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
(status !== 'completed' && status !== 'failed')) {
|
||||
status !== current.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -248,8 +248,15 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().removeDownload('late');
|
||||
const remove = useDownloadStore.getState().removeDownload('late');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'late' })
|
||||
);
|
||||
});
|
||||
resolveEnqueue({ id: 'late', filename: 'late.bin' });
|
||||
await remove;
|
||||
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||
@@ -688,6 +695,134 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
|
||||
});
|
||||
|
||||
it('does not reassign an item that completes while queue assignment is awaiting cancellation', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'race-ready', status: 'ready', queueId: 'old' },
|
||||
{ id: 'race-done', status: 'completed', queueId: 'old' }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
let resolveCancellation!: () => void;
|
||||
const cancellation = new Promise<void>(resolve => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'cancel_enqueue_generation') return cancellation as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const assignment = useDownloadStore.getState().assignToQueue(['race-ready', 'race-done'], 'new');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'cancel_enqueue_generation',
|
||||
expect.objectContaining({ id: 'race-ready' })
|
||||
);
|
||||
});
|
||||
useDownloadStore.getState().updateDownload('race-ready', { status: 'completed' });
|
||||
resolveCancellation();
|
||||
await assignment;
|
||||
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'race-ready')).toMatchObject({
|
||||
status: 'completed',
|
||||
queueId: 'old'
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels the rest of a queue start when pause is requested during dispatch', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'queue-first', url: 'http://test/first', fileName: 'first', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 0 },
|
||||
{ id: 'queue-second', url: 'http://test/second', fileName: 'second', destination: '/tmp', status: 'ready', category: 'Other', dateAdded: '', queueId: 'race-queue', queuePosition: 1 }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
||||
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
||||
resolveEnqueue = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
||||
if (command === 'enqueue_download') return enqueue as never;
|
||||
return Promise.resolve(undefined) as never;
|
||||
});
|
||||
|
||||
const start = useDownloadStore.getState().startQueue('race-queue');
|
||||
await vi.waitFor(() => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'queue-first' }) })
|
||||
);
|
||||
});
|
||||
const pause = useDownloadStore.getState().pauseQueue('race-queue');
|
||||
resolveEnqueue({ id: 'queue-first', filename: 'first' });
|
||||
|
||||
await expect(pause).resolves.toBe(1);
|
||||
await expect(start).resolves.toEqual([]);
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command, args]) =>
|
||||
command === 'enqueue_download' && (args as any)?.item?.id === 'queue-second'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses one atomic backend move and keeps queue positions unique around active transfers', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'active', status: 'downloading', queueId: 'move-queue', queuePosition: 0 },
|
||||
{ id: 'one', status: 'queued', queueId: 'move-queue', queuePosition: 1 },
|
||||
{ id: 'two', status: 'queued', queueId: 'move-queue', queuePosition: 2 },
|
||||
{ id: 'three', status: 'queued', queueId: 'move-queue', queuePosition: 3 }
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['three']),
|
||||
pendingOrder: ['one', 'two', 'three']
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'move_many_in_queue') return ['one', 'three', 'two'];
|
||||
if (command === 'get_pending_order') return ['one', 'three', 'two'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().moveInQueue('three', 'up');
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
|
||||
ids: ['three'],
|
||||
queueId: 'move-queue',
|
||||
direction: 'up'
|
||||
});
|
||||
expect(vi.mocked(ipc.invokeCommand)).not.toHaveBeenCalledWith('move_in_queue', expect.anything());
|
||||
const positions = useDownloadStore.getState().downloads
|
||||
.filter(item => item.queueId === 'move-queue')
|
||||
.map(item => item.queuePosition);
|
||||
expect(new Set(positions).size).toBe(4);
|
||||
});
|
||||
|
||||
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'registered-queued',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: 'old',
|
||||
queuePosition: 0
|
||||
}],
|
||||
backendRegisteredIds: new Set(['registered-queued']),
|
||||
pendingOrder: ['registered-queued']
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().assignToQueue(['registered-queued'], 'new');
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||
'detach_download_for_reconfigure',
|
||||
{ id: 'registered-queued' }
|
||||
);
|
||||
expect(useDownloadStore.getState().pendingOrder).not.toContain('registered-queued');
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('staged');
|
||||
});
|
||||
|
||||
it('disables scheduler when its last selected queue is deleted', async () => {
|
||||
const originalSettings = useSettingsStore.getState();
|
||||
const setScheduler = vi.fn();
|
||||
|
||||
+224
-119
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -17,6 +17,56 @@ 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;
|
||||
@@ -290,7 +340,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
|
||||
|
||||
const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -395,73 +445,87 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
pendingOrder: [],
|
||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||
moveInQueue: async (idOrIds, direction) => {
|
||||
moveInQueue: (idOrIds, direction) => {
|
||||
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// Assume all items belong to the same queue as the first item
|
||||
const firstItem = get().downloads.find(d => d.id === ids[0]);
|
||||
if (!firstItem) return;
|
||||
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 queueItems = get().downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
download.status !== 'completed' &&
|
||||
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||
)
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
||||
|
||||
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
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)
|
||||
];
|
||||
|
||||
const positions = new Map(reordered.map((download, position) => [download.id, position]));
|
||||
const previousDownloads = get().downloads;
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(download => positions.has(download.id)
|
||||
? { ...download, queuePosition: positions.get(download.id) }
|
||||
: download)
|
||||
}));
|
||||
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
|
||||
const operation = previousOperation.catch(() => undefined).then(async () => {
|
||||
const allDownloads = get().downloads;
|
||||
const queueItems = queueItemsForReordering(allDownloads, queueId);
|
||||
|
||||
const registeredIdsToMove = selectedItems
|
||||
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||
.map(item => item.id);
|
||||
|
||||
if (registeredIdsToMove.length === 0) return;
|
||||
|
||||
// For backend sync, we must call move_in_queue in the correct order to maintain the block
|
||||
const idsToMove = direction === 'up' ? registeredIdsToMove : [...registeredIdsToMove].reverse();
|
||||
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
try {
|
||||
let order: string[] = [];
|
||||
for (const id of idsToMove) {
|
||||
order = (await invoke('move_in_queue', { id, queueId, direction })) as string[];
|
||||
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);
|
||||
}
|
||||
if (order.length > 0) {
|
||||
set({ pendingOrder: order });
|
||||
|
||||
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)
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to move in queue backend:", e);
|
||||
set({ downloads: previousDownloads });
|
||||
}
|
||||
});
|
||||
const trackedOperation = operation.finally(() => {
|
||||
if (queueReorderPromises.get(queueId) === trackedOperation) {
|
||||
queueReorderPromises.delete(queueId);
|
||||
}
|
||||
});
|
||||
queueReorderPromises.set(queueId, trackedOperation);
|
||||
return trackedOperation;
|
||||
},
|
||||
removeFromQueue: async (id) => {
|
||||
try {
|
||||
@@ -643,8 +707,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (item.status === 'queued') {
|
||||
if (isRegistered) {
|
||||
await invoke('remove_from_queue', { id });
|
||||
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) {
|
||||
@@ -692,7 +757,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
|
||||
await invalidateDispatch(id);
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
}
|
||||
const item = get().downloads.find(d => d.id === id);
|
||||
|
||||
if (item) {
|
||||
@@ -820,56 +888,89 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
const runnable = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
|
||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
||||
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) return [];
|
||||
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
|
||||
|
||||
const acceptedIds: string[] = [];
|
||||
for (const item of runnable) {
|
||||
const backendRegistered = get().backendRegisteredIds.has(item.id);
|
||||
const backendPending = get().pendingOrder.includes(item.id);
|
||||
const acceptedIds: string[] = [];
|
||||
for (const item of runnable) {
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
|
||||
|
||||
if (item.status === 'queued' && backendRegistered && !backendPending) {
|
||||
if (await get().resumeDownload(item.id)) {
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
item.status === 'ready' ||
|
||||
item.status === 'staged' ||
|
||||
item.status === 'failed' ||
|
||||
!item.hasBeenDispatched ||
|
||||
!backendRegistered
|
||||
) {
|
||||
if (await dispatchItem(item.id)) {
|
||||
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 (item.status === 'paused' || item.status === 'queued') {
|
||||
// If it's queued but already dispatched, it might be waiting.
|
||||
// If it's paused, we resume it.
|
||||
if (item.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);
|
||||
}
|
||||
}
|
||||
|
||||
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
|
||||
return acceptedIds;
|
||||
});
|
||||
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))
|
||||
.filter(item =>
|
||||
item.queueId === queueId &&
|
||||
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
|
||||
)
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
@@ -899,8 +1000,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
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))
|
||||
.filter(item => canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
|
||||
.map(item => item.id);
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
@@ -917,35 +1024,33 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
selected
|
||||
.filter(item => item.status !== 'completed')
|
||||
.map(item => invalidateAndWaitForDispatch(item.id))
|
||||
);
|
||||
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 => selectedIds.has(item.id))) {
|
||||
for (const item of get().downloads.filter(item => movableIds.has(item.id))) {
|
||||
if (!get().backendRegisteredIds.has(item.id)) continue;
|
||||
if (item.status === 'queued') {
|
||||
await invoke('remove_from_queue', { id: item.id });
|
||||
} else if (item.status === 'paused') {
|
||||
await invoke('detach_download_for_reconfigure', { id: item.id });
|
||||
}
|
||||
// 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 =>
|
||||
!selectedIds.has(item.id) &&
|
||||
!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 =>
|
||||
selectedIds.has(item.id) && item.status !== 'completed'
|
||||
movableIds.has(item.id) && item.status !== 'completed'
|
||||
? {
|
||||
...item,
|
||||
queueId,
|
||||
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
|
||||
queuePosition: nextPosition + movableSelected.findIndex(selectedItem => selectedItem.id === item.id),
|
||||
status: 'staged' as const,
|
||||
hasBeenDispatched: false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user