From 6e6ae51395082529c610304388e35965d1a9f002 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 20:50:46 +0330 Subject: [PATCH] fix(downloads): preserve live progress across lifecycle edges --- src-tauri/src/lib.rs | 42 +++++++++++++++++++++----- src/App.tsx | 2 ++ src/components/AddDownloadsModal.tsx | 2 +- src/store/downloadStore.test.ts | 45 ++++++++++++++++++++++++++++ src/store/downloadStore.ts | 20 +++++++++---- src/store/useDownloadStore.test.ts | 32 ++++++++++++++++++++ src/store/useDownloadStore.ts | 6 ++++ src/utils/downloads.test.ts | 11 +++++++ src/utils/downloads.ts | 13 ++++---- 9 files changed, 150 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd34842..a04527c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -887,9 +887,9 @@ fn parse_media_progress_line(line: &str) -> Option { let progress: serde_json::Value = serde_json::from_str(line[prefix_index + MEDIA_PROGRESS_PREFIX.len()..].trim()).ok()?; let downloaded = progress_json_number(&progress, "downloaded_bytes").unwrap_or(0.0); - let total = progress_json_number(&progress, "total_bytes") - .or_else(|| progress_json_number(&progress, "total_bytes_estimate")) - .unwrap_or(0.0); + let exact_total = progress_json_number(&progress, "total_bytes"); + let estimated_total = progress_json_number(&progress, "total_bytes_estimate"); + let total = exact_total.or(estimated_total).unwrap_or(0.0); let fragment_index = progress_json_number(&progress, "fragment_index"); let fragment_count = progress_json_number(&progress, "fragment_count"); let fraction = if let (Some(fragment_index), Some(fragment_count)) = @@ -930,8 +930,7 @@ fn parse_media_progress_line(line: &str) -> Option { let size = progress_json_string(&progress, "_total_bytes_str") .or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str")) .or_else(|| (total > 0.0).then(|| crate::download::format_size(total))); - let total_is_estimate = progress.get("total_bytes").is_none() - && progress.get("total_bytes_estimate").is_some(); + let total_is_estimate = exact_total.is_none() && estimated_total.is_some(); return Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), @@ -1168,12 +1167,16 @@ fn aggregate_media_byte_progress( ) -> Option<(u64, u64, bool)> { if track_changed { if let Some(total_bytes) = state.current_track_total_bytes { + let completed_downloaded = state + .current_track_downloaded_bytes + .unwrap_or(total_bytes) + .clamp(0.0, total_bytes.max(0.0)); *state .completed_tracks_total_bytes .get_or_insert(0.0) += total_bytes; *state .completed_tracks_downloaded_bytes - .get_or_insert(0.0) += total_bytes; + .get_or_insert(0.0) += completed_downloaded; state.completed_tracks_total_is_estimate |= state.current_track_total_is_estimate; } else { state.completed_tracks_total_bytes = None; @@ -1186,7 +1189,10 @@ fn aggregate_media_byte_progress( state.current_track_total_bytes = progress.total_bytes; state.current_track_downloaded_bytes = progress .downloaded_bytes - .or_else(|| progress.total_bytes.map(|total| total * progress.fraction)); + .or_else(|| progress.total_bytes.map(|total| total * progress.fraction)) + .zip(progress.total_bytes) + .map(|(downloaded, total)| downloaded.clamp(0.0, total.max(0.0))) + .or(progress.downloaded_bytes); state.current_track_total_is_estimate = progress.total_is_estimate; match ( @@ -6422,6 +6428,26 @@ mod tests { ); } + #[test] + fn marks_structured_estimated_total_when_exact_total_is_null() { + let line = format!( + "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"total_bytes\":null,\"total_bytes_estimate\":10485760,\"_total_bytes_estimate_str\":\"~10.00MiB\"}}" + ); + + assert_eq!( + parse_media_progress_line(&line), + Some(MediaProgress { + fraction: 0.5, + speed: "-".to_string(), + eta: "-".to_string(), + size: Some("~10.00MiB".to_string()), + downloaded_bytes: Some(5242880.0), + total_bytes: Some(10485760.0), + total_is_estimate: true, + }) + ); + } + #[test] fn parses_chunked_structured_ytdlp_progress() { let mut buffer = String::new(); @@ -6544,7 +6570,7 @@ mod tests { ); assert_eq!( aggregate_media_byte_progress(&second, true, &mut state), - Some((102, 300, true)) + Some((101, 300, true)) ); } diff --git a/src/App.tsx b/src/App.tsx index 010ab1a..25844fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -762,12 +762,14 @@ function App() { }; window.addEventListener('focus', handleForegroundChange); + window.addEventListener('blur', handleForegroundChange); document.addEventListener('visibilitychange', handleForegroundChange); handleForegroundChange(); return () => { active = false; window.removeEventListener('focus', handleForegroundChange); + window.removeEventListener('blur', handleForegroundChange); document.removeEventListener('visibilitychange', handleForegroundChange); }; }, [autoAddClipboardLinks, coreReady]); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 443ac02..5ac547e 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -801,7 +801,7 @@ export const AddDownloadsModal = () => { category, dateAdded: new Date().toISOString(), connections: Number(connections), - speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0', + speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, headers: headersForRow(item.sourceUrl) || undefined, diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index e8a45ca..1516605 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -11,6 +11,7 @@ vi.mock('../ipc', () => ({ describe('useDownloadProgressStore', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined); useDownloadProgressStore.setState({ progressMap: {} }); }); @@ -156,4 +157,48 @@ describe('useDownloadProgressStore', () => { expect(useDownloadProgressStore.getState().progressMap).toEqual({}); release(); }); + + it('snapshots live progress before clearing it on a terminal transition', 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: 'snapshot', + url: 'https://example.com/file', + fileName: 'file.bin', + status: 'downloading', + category: 'Other', + dateAdded: '' + }] + }); + + const release = await initDownloadListener(); + handlers['download-progress']({ payload: { + id: 'snapshot', + fraction: 0.8, + speed: '1 MB/s', + eta: '2s', + size: '8 MB', + size_is_final: false, + downloaded_bytes: 8192, + total_bytes: 10240, + total_is_estimate: true + } }); + handlers['download-state']({ payload: { + id: 'snapshot', + status: 'paused' + } }); + + const row = useDownloadStore.getState().downloads[0]; + expect(row.status).toBe('paused'); + expect(row.fraction).toBe(0.8); + expect(row.downloadedBytes).toBe(8192); + expect(row.totalBytes).toBe(10240); + expect(row.totalIsEstimate).toBe(true); + expect(useDownloadProgressStore.getState().progressMap).toEqual({}); + release(); + }); }); diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 9ca3eb8..b99b1e8 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -61,7 +61,7 @@ const startDownloadListeners = async () => { if (payload.total_bytes !== null && payload.total_bytes !== undefined) { updates.totalBytes = payload.total_bytes; } - if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) { + if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) { updates.totalIsEstimate = payload.total_is_estimate; } if (Object.keys(updates).length > 0) { @@ -84,13 +84,24 @@ const startDownloadListeners = async () => { return; } + const progress = useDownloadProgressStore.getState().progressMap[payload.id]; if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) { useDownloadProgressStore.getState().clearDownloadProgress(payload.id); } - const progress = useDownloadProgressStore.getState().progressMap[payload.id]; const updates: Partial = { status, - ...(progress ? { fraction: progress.fraction } : {}), + ...(progress ? { + fraction: progress.fraction, + ...(progress.downloaded_bytes != null + ? { downloadedBytes: progress.downloaded_bytes } + : {}), + ...(progress.total_bytes != null + ? { totalBytes: progress.total_bytes } + : {}), + ...(progress.total_is_estimate != null + ? { totalIsEstimate: progress.total_is_estimate } + : {}) + } : {}), ...(payload.error ? { lastError: payload.error } : {}), ...((status === 'downloading' || status === 'retrying') ? { lastTry: new Date().toISOString() } @@ -122,9 +133,6 @@ const startDownloadListeners = async () => { } else if (status === 'completed' || status === 'failed') { mainStore.unregisterBackendIds([payload.id]); } - if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) { - useDownloadProgressStore.getState().clearDownloadProgress(payload.id); - } }), listen('tray-action', (event) => { const mainStore = useDownloadStore.getState(); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 725ab0f..5c71041 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -571,6 +571,38 @@ describe('useDownloadStore', () => { ); }); + it('treats the legacy media zero sentinel as inheriting the global limit', async () => { + const defaultSettings = useSettingsStore.getState(); + vi.mocked(useSettingsStore.getState).mockReturnValue({ + ...defaultSettings, + globalSpeedLimit: '2M' + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'get_pending_order') return ['legacy-media-limit']; + return undefined; + }); + + await useDownloadStore.getState().addDownload({ + id: 'legacy-media-limit', + url: 'https://www.youtube.com/watch?v=legacy', + fileName: 'media.mp4', + category: 'Movies', + dateAdded: '', + isMedia: true, + speedLimit: '0' + }, { type: 'start-now' }); + + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ + item: expect.objectContaining({ + id: 'legacy-media-limit', + speed_limit: '2M' + }) + }) + ); + }); + it('reports a rejected immediate start instead of claiming success', async () => { vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { if (command === 'enqueue_download') { diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index ea1ae3f..12a8bf5 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -147,6 +147,12 @@ const speedLimitForDispatch = ( globalSpeedLimit: string, isMedia: boolean | undefined ): string | null => { + // Older Add-window rows used "0" as the no-override sentinel. Media + // downloads do not have aria2's daemon-wide cap, so preserve the intended + // inherit-global behavior when dispatching those persisted rows. + if (isMedia && itemSpeedLimit?.trim() === '0') { + return normalizeSpeedLimitForBackend(globalSpeedLimit); + } const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit); if (explicitLimit !== null || !isMedia) return explicitLimit; return normalizeSpeedLimitForBackend(globalSpeedLimit); diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 1f883a0..1fbd234 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -30,4 +30,15 @@ describe('download persistence progress snapshots', () => { expect(persisted.totalBytes).toBe(4096); expect(persisted.totalIsEstimate).toBe(false); }); + + it.each(['queued', 'staged', 'retrying', 'processing'] as const)( + 'keeps byte counters for %s snapshots', + (status) => { + const persisted = redactDownloadForPersistence(item(status)); + + expect(persisted.downloadedBytes).toBe(1024); + expect(persisted.totalBytes).toBe(4096); + expect(persisted.totalIsEstimate).toBe(false); + } + ); }); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 214ec8f..ee1fb6e 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -120,12 +120,7 @@ export const isMediaUrl = (rawUrl: string): boolean => { */ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; const VOLATILE_PROGRESS_STATUSES = new Set([ - 'ready', - 'staged', - 'queued', - 'downloading', - 'processing', - 'retrying' + 'downloading' ]); /** @@ -133,8 +128,10 @@ const VOLATILE_PROGRESS_STATUSES = new Set([ * progress fields (`fraction`, `speed`, `eta`) are also dropped as in the * existing persistence path. Numeric byte totals remain for paused, failed, * and completed rows so those snapshots keep their accurate Size-column - * display after restart; active-transfer counters stay in memory to avoid a - * database write for every progress tick. + * display after restart; counters for the actively ticking `downloading` + * state stay in memory to avoid a database write for every progress tick. + * Non-ticking states retain counters so paused, queued, staged, retrying, and + * processing snapshots remain useful across restart and reconfiguration. * * Note: standard persistence intentionally retains `url` because it is the * download source. The backend applies a stricter portable-mode policy: URL