From ce8a5499c367ba078002b11833b0d15fc5a72535 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 29 Jul 2026 16:06:45 +0330 Subject: [PATCH] fix(queue): resume paused items through lifecycle --- src/store/useDownloadStore.test.ts | 106 +++++++++++++++++++++++++++++ src/store/useDownloadStore.ts | 29 ++++++-- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 96176c0..fd1e9b3 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -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 []; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 49b74f6..a589a5d 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -938,6 +938,7 @@ export const useDownloadStore = create((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((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((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((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); } }