diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 8de852b..625a5a7 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -420,6 +420,15 @@ impl DownloadStateEvent { } } + pub fn paused_with_error(id: impl Into, error: impl Into) -> Self { + Self { + id: id.into(), + status: DownloadStatus::Paused.as_str().to_string(), + error: Some(error.into()), + file_name: None, + } + } + pub fn completed_with_file(id: impl Into, file_name: impl Into) -> Self { Self { id: id.into(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62456e7..d2bcc45 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4701,9 +4701,9 @@ async fn resume_download( ); let _ = app_handle_clone.emit( "download-state", - crate::ipc::DownloadStateEvent::new( + crate::ipc::DownloadStateEvent::paused_with_error( &id_clone, - crate::ipc::DownloadStatus::Paused, + unpause_error, ), ); return; diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index 66192c5..7121ce0 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { initDownloadListener, useDownloadProgressStore } from './downloadStore'; import { clearDownloadControlIntents, + downloadControlIntentFor, setDownloadControlIntent, useDownloadStore } from './useDownloadStore'; @@ -427,4 +428,37 @@ describe('useDownloadProgressStore', () => { expect(useDownloadStore.getState().downloads[0].status).toBe('paused'); release(); }); + + it('accepts an authoritative resume failure while ignoring stale paused events', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'resume-failure', + url: 'https://example.com/file', + fileName: 'file.bin', + status: 'queued', + category: 'Other', + dateAdded: '' + }] + }); + setDownloadControlIntent('resume-failure', 'resume'); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'resume-failure', + status: 'paused', + error: 'aria2 resume failed' + } }); + + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + status: 'paused', + lastError: 'aria2 resume failed' + }); + expect(downloadControlIntentFor('resume-failure')).toBeUndefined(); + release(); + }); }); diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 677c205..3c9c97b 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -110,7 +110,8 @@ const startDownloadListeners = async () => { // row visibly paused and make dispatch reject it before IPC. if (status === 'paused' && current.status === 'queued' && - downloadControlIntentFor(payload.id) === 'resume') { + downloadControlIntentFor(payload.id) === 'resume' && + !payload.error) { // Keep the resume intent until an active or terminal event proves // that the new lifecycle has taken over. An explicit pause replaces // this intent in pauseDownload, so a genuine user pause is still @@ -123,6 +124,7 @@ const startDownloadListeners = async () => { } if (status === 'paused') { clearDownloadControlIntent(payload.id, 'pause'); + if (payload.error) clearDownloadControlIntent(payload.id, 'resume'); } // Prevent stale lifecycle events from moving a paused row back into an diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index fd1e9b3..0ce1956 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1836,6 +1836,28 @@ describe('useDownloadStore', () => { expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true); }); + it('direct queue controls treat missing queue ids as the main queue', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'legacy-ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' }, + { id: 'legacy-active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' }, + ] as any[], + }); + + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'get_pending_order') return ['legacy-ready']; + return undefined; + }); + + await expect(useDownloadStore.getState().startQueue('00000000-0000-0000-0000-000000000001')) + .resolves.toEqual(['legacy-ready']); + await expect(useDownloadStore.getState().pauseQueue('00000000-0000-0000-0000-000000000001')) + .resolves.toBe(2); + + expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')) + .toHaveLength(2); + }); + it('resumes paused items that were never dispatched through the master start action', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index a589a5d..932f82b 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1609,7 +1609,10 @@ export const useDownloadStore = create((set, get) => { const operation = previousOperation.catch(() => []).then(async () => { await waitForPendingStartupResume(); const runnable = get().downloads - .filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status))) + .filter(item => + (item.queueId || MAIN_QUEUE_ID) === queueId && + (item.status === 'queued' || canStartDownload(item.status)) + ) .sort(queuePositionComparator); if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return []; @@ -1736,7 +1739,7 @@ export const useDownloadStore = create((set, get) => { advanceQueueControlGeneration(queueId); const activeIds = get().downloads .filter(item => - item.queueId === queueId && + (item.queueId || MAIN_QUEUE_ID) === queueId && (canPauseDownload(item.status) || backendDispatchPromises.has(item.id)) ) .map(item => item.id);