From 60dad5703a41a169861ce721972a4912b9d62fce Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 9 Jul 2026 02:59:37 +0330 Subject: [PATCH] fix(media): show live yt-dlp progress (#8) Buffer yt-dlp output chunks before parsing progress events and update the download row from live progress state instead of imperative DOM writes. --- src-tauri/src/lib.rs | 349 ++++++++++++++++++++++---------- src/components/DownloadItem.tsx | 73 +++---- src/store/downloadStore.ts | 11 +- 3 files changed, 274 insertions(+), 159 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71a0929..0de553a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -700,6 +700,40 @@ fn progress_json_string(progress: &serde_json::Value, key: &str) -> Option Vec { + buffer.push_str(chunk); + + let mut lines = Vec::new(); + while let Some(index) = match (buffer.find('\n'), buffer.find('\r')) { + (Some(line_feed), Some(carriage_return)) => Some(line_feed.min(carriage_return)), + (Some(line_feed), None) => Some(line_feed), + (None, Some(carriage_return)) => Some(carriage_return), + (None, None) => None, + } { + let mut line: String = buffer.drain(..=index).collect(); + while line.ends_with('\n') || line.ends_with('\r') { + line.pop(); + } + if !line.trim().is_empty() { + lines.push(line); + } + } + + if lines.is_empty() + && buffer.contains(MEDIA_PROGRESS_PREFIX) + && parse_media_progress_line(buffer).is_some() + { + lines.push(std::mem::take(buffer)); + } + + lines +} + +fn flush_media_output_line(buffer: &mut String) -> Option { + let line = std::mem::take(buffer); + (!line.trim().is_empty()).then_some(line) +} + fn parse_media_progress_line(line: &str) -> Option { if let Some(prefix_index) = line.find(MEDIA_PROGRESS_PREFIX) { let progress: serde_json::Value = @@ -723,8 +757,12 @@ fn parse_media_progress_line(line: &str) -> Option { } else if total > 0.0 { downloaded / total } else { - progress_json_string(&progress, "_percent_str") - .and_then(|percent| percent.trim_end_matches('%').trim().parse::().ok()) + progress_json_number(&progress, "_percent") + .or_else(|| { + progress_json_string(&progress, "_percent_str").and_then(|percent| { + percent.trim_end_matches('%').trim().parse::().ok() + }) + }) .unwrap_or(0.0) / 100.0 }; @@ -895,6 +933,45 @@ fn aggregate_media_fraction( ((*current_track + *last_fraction) / total_tracks).clamp(0.0, 1.0) } +fn emit_media_progress( + app_handle: &tauri::AppHandle, + id: &str, + progress: MediaProgress, + total_tracks: f64, + current_track: &mut f64, + last_fraction: &mut f64, + last_speed_sample: &mut Option<(Instant, f64)>, + last_progress_at: &mut Instant, +) { + let previous_track = *current_track; + let overall_fraction = aggregate_media_fraction( + total_tracks, + current_track, + last_fraction, + progress.fraction, + ); + if *current_track != previous_track { + *last_speed_sample = None; + } + let (speed, eta) = media_progress_speed(&progress, Instant::now(), last_speed_sample); + + let now = Instant::now(); + if now.duration_since(*last_progress_at) >= Duration::from_millis(200) { + let _ = app_handle.emit( + "download-progress", + DownloadProgressEvent { + id: id.to_string(), + fraction: overall_fraction, + speed, + eta, + size: progress.size, + size_is_final: false, + }, + ); + *last_progress_at = now; + } +} + async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { cleanup_media_artifacts(out_path, true).await; } @@ -1230,7 +1307,7 @@ async fn fetch_metadata( } } } - + if size_bytes == 0 { if let Some(len) = res.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(len_str) = len.to_str() { @@ -2834,6 +2911,8 @@ pub(crate) async fn start_media_download_internal( let mut stderr_tail = String::new(); let mut final_output_path: Option = None; + let mut stdout_buffer = String::new(); + let mut stderr_buffer = String::new(); let failure_reason = loop { tokio::select! { _ = cancel_rx.changed() => { @@ -2847,101 +2926,124 @@ pub(crate) async fn start_media_download_internal( event = rx.recv() => { match event { Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => { - let line = String::from_utf8_lossy(&line_bytes); - if let Some(progress) = parse_media_progress_line(&line) { - let previous_track = current_track; - let overall_fraction = aggregate_media_fraction( - total_tracks, - &mut current_track, - &mut last_fraction, - progress.fraction, - ); - if current_track != previous_track { - last_speed_sample = None; - } - let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); - - let now = std::time::Instant::now(); - if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { - let _ = app_handle.emit("download-progress", DownloadProgressEvent { - id: id.to_string(), - fraction: overall_fraction, - speed, - eta, - size: progress.size, - size_is_final: false, - }); - last_progress_at = now; - } - } else { - let candidate = line.trim(); - if !candidate.is_empty() { - let candidate_path = std::path::PathBuf::from(candidate); - if candidate_path.is_absolute() { - final_output_path = Some(candidate_path); + let chunk = String::from_utf8_lossy(&line_bytes); + for line in drain_media_output_lines(&mut stdout_buffer, &chunk) { + if let Some(progress) = parse_media_progress_line(&line) { + emit_media_progress( + &app_handle, + id, + progress, + total_tracks, + &mut current_track, + &mut last_fraction, + &mut last_speed_sample, + &mut last_progress_at, + ); + } else { + let candidate = line.trim(); + if !candidate.is_empty() { + let candidate_path = std::path::PathBuf::from(candidate); + if candidate_path.is_absolute() { + final_output_path = Some(candidate_path); + } } } } } Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => { - let line = String::from_utf8_lossy(&line_bytes); - if let Some(progress) = parse_media_progress_line(&line) { - let previous_track = current_track; - let overall_fraction = aggregate_media_fraction( - total_tracks, - &mut current_track, - &mut last_fraction, - progress.fraction, - ); - if current_track != previous_track { - last_speed_sample = None; - } - let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); - let now = std::time::Instant::now(); - if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { - let _ = app_handle.emit("download-progress", DownloadProgressEvent { - id: id.to_string(), - fraction: overall_fraction, - speed, - eta, - size: progress.size, - size_is_final: false, - }); - last_progress_at = now; - } - } - if !processing_started && is_media_processing_line(&line) { - processing_started = true; - let _ = app_handle.emit( - "download-state", - DownloadStateEvent::new( - id, - crate::ipc::DownloadStatus::Processing, - ), - ); - let _ = app_handle.emit("download-progress", DownloadProgressEvent { - id: id.to_string(), - fraction: 1.0, - speed: "Processing".to_string(), - eta: "-".to_string(), - size: None, - size_is_final: false, - }); - } - let lower = line.to_lowercase(); - if lower.contains("error") || lower.contains("critical") { - log::error!("yt-dlp stderr [{}]: {}", id, line.trim()); - } - stderr_tail.push_str(&line); + let chunk = String::from_utf8_lossy(&line_bytes); + stderr_tail.push_str(&chunk); if stderr_tail.len() > STDERR_TAIL { stderr_tail = stderr_tail.split_off(stderr_tail.len() - STDERR_TAIL); } + for line in drain_media_output_lines(&mut stderr_buffer, &chunk) { + if let Some(progress) = parse_media_progress_line(&line) { + emit_media_progress( + &app_handle, + id, + progress, + total_tracks, + &mut current_track, + &mut last_fraction, + &mut last_speed_sample, + &mut last_progress_at, + ); + } + if !processing_started && is_media_processing_line(&line) { + processing_started = true; + let _ = app_handle.emit( + "download-state", + DownloadStateEvent::new( + id, + crate::ipc::DownloadStatus::Processing, + ), + ); + let _ = app_handle.emit("download-progress", DownloadProgressEvent { + id: id.to_string(), + fraction: 1.0, + speed: "Processing".to_string(), + eta: "-".to_string(), + size: None, + size_is_final: false, + }); + } + let lower = line.to_lowercase(); + if lower.contains("error") || lower.contains("critical") { + log::error!("yt-dlp stderr [{}]: {}", id, line.trim()); + } + } } Some(tauri_plugin_shell::process::CommandEvent::Error(err)) => { log::error!("yt-dlp shell error [{}]: {}", id, err); break err; } Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => { + if let Some(line) = flush_media_output_line(&mut stdout_buffer) { + if let Some(progress) = parse_media_progress_line(&line) { + emit_media_progress( + &app_handle, + id, + progress, + total_tracks, + &mut current_track, + &mut last_fraction, + &mut last_speed_sample, + &mut last_progress_at, + ); + } else { + let candidate = line.trim(); + if !candidate.is_empty() { + let candidate_path = std::path::PathBuf::from(candidate); + if candidate_path.is_absolute() { + final_output_path = Some(candidate_path); + } + } + } + } + if let Some(line) = flush_media_output_line(&mut stderr_buffer) { + if let Some(progress) = parse_media_progress_line(&line) { + emit_media_progress( + &app_handle, + id, + progress, + total_tracks, + &mut current_track, + &mut last_fraction, + &mut last_speed_sample, + &mut last_progress_at, + ); + } + if !processing_started && is_media_processing_line(&line) { + processing_started = true; + let _ = app_handle.emit( + "download-state", + DownloadStateEvent::new( + id, + crate::ipc::DownloadStatus::Processing, + ), + ); + } + } if payload.code == Some(0) { log::info!("yt-dlp completed successfully id: {}", id); let completed_path = final_output_path @@ -3442,7 +3544,7 @@ async fn detach_download_for_reconfigure( serde_json::json!([gid]), ) .await; - + if let Err(e) = pause_res { if !e.contains("cannot be paused now") { return Err(e); @@ -3579,20 +3681,20 @@ fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) { let _ = app_handle.run_on_main_thread(move || { unsafe { - let app_class = class!(NSApplication); - let app: *mut Object = msg_send![app_class, sharedApplication]; - let dock_tile: *mut Object = msg_send![app, dockTile]; - let label = if count > 0 { - count.to_string() - } else { - "".to_string() - }; - let c_label = CString::new(label).unwrap(); - let ns_string_class = class!(NSString); - let ns_label: *mut Object = msg_send![ns_string_class, alloc]; - let ns_label: *mut Object = msg_send![ns_label, initWithUTF8String: c_label.as_ptr()]; - let _: () = msg_send![dock_tile, setBadgeLabel: ns_label]; - let _: () = msg_send![ns_label, release]; + let app_class = class!(NSApplication); + let app: *mut Object = msg_send![app_class, sharedApplication]; + let dock_tile: *mut Object = msg_send![app, dockTile]; + let label = if count > 0 { + count.to_string() + } else { + "".to_string() + }; + let c_label = CString::new(label).unwrap(); + let ns_string_class = class!(NSString); + let ns_label: *mut Object = msg_send![ns_string_class, alloc]; + let ns_label: *mut Object = msg_send![ns_label, initWithUTF8String: c_label.as_ptr()]; + let _: () = msg_send![dock_tile, setBadgeLabel: ns_label]; + let _: () = msg_send![ns_label, release]; } }); } @@ -3663,25 +3765,25 @@ fn create_sleep_preventer() -> Result { let cstr = CString::new(s).unwrap(); macos_sleep::CFStringCreateWithCString(null(), cstr.as_ptr(), 0x08000100) }; - + let type_sys = create_cf_string("PreventSystemSleep"); let type_net = create_cf_string("NetworkClientActive"); let name = create_cf_string("Firelink active download"); - + let mut sys_id: u32 = 0; let mut net_id: u32 = 0; - + let res1 = macos_sleep::IOPMAssertionCreateWithDescription( type_sys, name, null(), null(), null(), 0.0, null(), &mut sys_id ); let res2 = macos_sleep::IOPMAssertionCreateWithDescription( type_net, name, null(), null(), null(), 0.0, null(), &mut net_id ); - + macos_sleep::CFRelease(type_sys); macos_sleep::CFRelease(type_net); macos_sleep::CFRelease(name); - + if res1 == 0 && res2 == 0 { Ok(SleepPreventer::Mac { system_sleep_id: sys_id, network_client_id: net_id }) } else { @@ -3935,16 +4037,16 @@ fn check_automation_permission() -> Result<(), String> { let ns_string_class = class!(NSString); let script_str: *mut Object = msg_send![ns_string_class, alloc]; let script_str: *mut Object = msg_send![script_str, initWithUTF8String: c_script.as_ptr()]; - + let ns_apple_script: *mut Object = msg_send![class!(NSAppleScript), alloc]; let ns_apple_script: *mut Object = msg_send![ns_apple_script, initWithSource: script_str]; - + let mut error_dict: *mut Object = null_mut(); let result: *mut Object = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict]; - + let _: () = msg_send![script_str, release]; let _: () = msg_send![ns_apple_script, release]; - + if result.is_null() { return Err("Automation permission was not granted".to_string()); } @@ -4483,7 +4585,7 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) mod tests { use super::{ aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options, - collect_download_uris, filename_from_content_disposition, + collect_download_uris, drain_media_output_lines, filename_from_content_disposition, filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format, is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key, media_output_template, media_progress_speed, normalize_speed_limit_for_aria2, @@ -4929,6 +5031,35 @@ mod tests { ); } + #[test] + fn parses_chunked_structured_ytdlp_progress() { + let mut buffer = String::new(); + let first = format!("{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,"); + let second = "\"total_bytes\":10485760,\"_percent\":50.0,\"_speed_str\":\"1.00MiB/s\"}\n"; + + assert!(drain_media_output_lines(&mut buffer, &first).is_empty()); + let lines = drain_media_output_lines(&mut buffer, second); + + assert_eq!(lines.len(), 1); + assert_eq!( + parse_media_progress_line(&lines[0]).map(|progress| progress.fraction), + Some(0.5) + ); + assert!(buffer.is_empty()); + } + + #[test] + fn parses_structured_ytdlp_numeric_percent_without_total() { + let line = format!( + "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"_percent\":37.5,\"_speed_str\":\"1.00MiB/s\"}}" + ); + + assert_eq!( + parse_media_progress_line(&line).map(|progress| progress.fraction), + Some(0.375) + ); + } + #[test] fn parses_ffmpeg_snapshot_version_without_collapsing_to_n() { let output = "ffmpeg version N-125385-ge2e889d9da-https://www.martin-riedl.de Copyright (c) 2000-2026 the FFmpeg developers"; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 29f1524..3eae316 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useDownloadStore } from '../store/useDownloadStore'; import { useDownloadProgressStore } from '../store/downloadStore'; @@ -45,47 +45,26 @@ export const DownloadItem = React.memo(({ .map(candidate => candidate.id); })); const moveInQueue = useDownloadStore(state => state.moveInQueue); + const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]); const queueIndex = queueItems.indexOf(downloadId); - const progressBarRef = useRef(null); - const statusTextRef = useRef(null); - const speedTextRef = useRef(null); - const etaTextRef = useRef(null); - - useEffect(() => { - if (!download || download.status !== 'downloading') return; - - const applyProgress = (progress: any) => { - if (!progress) return; - if (progressBarRef.current) { - progressBarRef.current.style.width = `${progress.fraction * 100}%`; - } - if (statusTextRef.current) { - statusTextRef.current.innerText = `${(progress.fraction * 100).toFixed(0)}%`; - statusTextRef.current.title = `${(progress.fraction * 100).toFixed(0)}%`; - } - if (speedTextRef.current) { - speedTextRef.current.innerText = progress.speed; - speedTextRef.current.title = progress.speed; - } - if (etaTextRef.current) { - etaTextRef.current.innerText = progress.eta; - etaTextRef.current.title = progress.eta; - } - }; - - // Apply immediate state on mount - applyProgress(useDownloadProgressStore.getState().progressMap[downloadId]); - - const unsubscribe = useDownloadProgressStore.subscribe((state) => { - applyProgress(state.progressMap[downloadId]); - }); - - return () => unsubscribe(); - }, [downloadId, download?.status]); - if (!download) return null; + const displayFraction = download.status === 'downloading' + ? liveProgress?.fraction ?? download.fraction ?? 0 + : download.fraction ?? 0; + const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; + const displaySpeed = download.status === 'downloading' + ? liveProgress?.speed ?? download.speed + : download.status === 'processing' + ? 'Processing...' + : '-'; + const displayEta = download.status === 'downloading' + ? liveProgress?.eta ?? download.eta + : download.status === 'processing' + ? 'Muxing...' + : '-'; + return (
(({ <>
(({ ) : download.status === 'downloading' ? ( - `${((download.fraction || 0) * 100).toFixed(0)}%` + displayPercent ) : download.status === 'processing' ? ( 'Processing' ) : ( @@ -173,22 +150,20 @@ export const DownloadItem = React.memo(({
- {download.status === 'downloading' ? download.speed : download.status === 'processing' ? 'Processing…' : '-'} + {displaySpeed}
- {download.status === 'downloading' ? download.eta : download.status === 'processing' ? 'Muxing…' : '-'} + {displayEta}
diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 1f324fb..f9ba395 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -46,8 +46,17 @@ export async function initDownloadListener() { const current = mainStore.downloads.find(d => d.id === payload.id); if (current) { const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final)); + const updates: Partial = {}; + if (current.status === 'downloading' || current.status === 'processing') { + updates.fraction = payload.fraction; + updates.speed = payload.speed; + updates.eta = payload.eta; + } if (shouldUpdateSize && current.size !== payload.size) { - mainStore.updateDownload(payload.id, { size: payload.size! }); + updates.size = payload.size!; + } + if (Object.keys(updates).length > 0) { + mainStore.updateDownload(payload.id, updates); } } });