fix(queue): resume paused items through lifecycle

This commit is contained in:
NimBold
2026-07-29 16:06:45 +03:30
parent 0e50bdfe9e
commit ce8a5499c3
2 changed files with 130 additions and 5 deletions
+106
View File
@@ -1078,6 +1078,35 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads.find(item => item.id === 'selected-a')?.queuePosition).toBe(1);
});
it('resumes a selected block whose paused rows were never dispatched', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'selected-undispatched-a', url: 'http://a', fileName: 'a', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-undispatched', queuePosition: 0, hasBeenDispatched: false },
{ id: 'selected-undispatched-b', url: 'http://b', fileName: 'b', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-undispatched', queuePosition: 1, hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (command === 'get_pending_order') return ['selected-undispatched-a', 'selected-undispatched-b'];
if (command === 'move_many_in_queue') return ['selected-undispatched-a', 'selected-undispatched-b'];
return undefined;
});
await expect(useDownloadStore.getState().startSelected([
'selected-undispatched-a',
'selected-undispatched-b'
])).resolves.toBe(2);
const enqueueIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'enqueue_download')
.map(([, args]) => (args as { item: { id: string } }).item.id);
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
});
it('pauses queued items through the global pause action', async () => {
useDownloadStore.setState({
downloads: [
@@ -1807,6 +1836,83 @@ describe('useDownloadStore', () => {
expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true);
});
it('resumes paused items that were never dispatched through the master start action', async () => {
useDownloadStore.setState({
downloads: [
{
id: 'paused-before-dispatch',
url: 'http://paused-before-dispatch',
fileName: 'paused-before-dispatch',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
hasBeenDispatched: false
}
] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false;
if (cmd === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (cmd === 'get_pending_order') return ['paused-before-dispatch'];
return undefined;
});
await expect(useDownloadStore.getState().startAll()).resolves.toBe(1);
expect(ipc.invokeCommand).toHaveBeenCalledWith('resume_download', {
id: 'paused-before-dispatch',
queueId: 'MAIN'
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'paused-before-dispatch' }) })
);
expect(useDownloadStore.getState().downloads[0].hasBeenDispatched).toBe(true);
});
it('resumes an individually paused item that has no backend registration', async () => {
useDownloadStore.setState({
downloads: [{
id: 'individual-paused-before-dispatch',
url: 'http://individual-paused-before-dispatch',
fileName: 'individual-paused-before-dispatch',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
hasBeenDispatched: false
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false;
if (cmd === 'enqueue_download') {
const id = (args as { item: { id: string } }).item.id;
return { id, filename: id };
}
if (cmd === 'get_pending_order') return ['individual-paused-before-dispatch'];
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('individual-paused-before-dispatch'))
.resolves.toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith('resume_download', {
id: 'individual-paused-before-dispatch',
queueId: 'MAIN'
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'individual-paused-before-dispatch' }) })
);
expect(useDownloadStore.getState().downloads[0].hasBeenDispatched).toBe(true);
});
it('migrates legacy downloads without queue ids into the main queue', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
+24 -5
View File
@@ -938,6 +938,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
if (dispatchSucceeded) {
get().updateDownload(id, { hasBeenDispatched: true });
return true;
} else {
console.error("Failed to re-enqueue for resume");
@@ -1616,6 +1617,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
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) {
@@ -1655,6 +1660,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
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);
@@ -1688,12 +1711,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
});
acceptedIds.push(item.id);
}
} else if (currentItem.status === 'paused' || currentItem.status === 'queued') {
} else if (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);
}
}