From cf2285fd565959bdb5cab0d00b661cf791721e7a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 22 Aug 2026 09:42:54 +0200 Subject: [PATCH] rsync step: smooth byte-level progress + scan feedback - Parse rsync live per-file byte progress (\r segments without xfr#) so the progress bar advances continuously during large file transfers instead of only per completed file; also stops logging those lines as noise - Update the active step detail during scan/remove/copy phases - smooth_pct helper combines completed files + current file's byte fraction - unit tests for byte-progress parsing and smooth_pct --- src/engine.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++---- src/progress.rs | 36 +++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index eedfe63..e9d68e7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -129,7 +129,7 @@ impl WorkerCtx { self.changed(); } - fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str) { + fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str, pct: Option) { if let Ok(mut s) = self.state.lock() { let p = &mut s.device_prog; p.done = done.to_string(); @@ -139,7 +139,9 @@ impl WorkerCtx { if !current.is_empty() { p.current = current.to_string(); } - if total > 0 { + if let Some(pct) = pct { + p.pct = pct.min(100); + } else if total > 0 { p.pct = ((done as f64 / total as f64) * 100.0) as u16; } } @@ -353,6 +355,14 @@ fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), S } true } + RsyncLine::ByteProgress(p) => { + if let Ok(mut s) = ctx.state.lock() { + s.mirror.pct = p.pct; + s.mirror.speed = p.speed.clone(); + s.mirror.eta = p.eta.clone(); + } + true + } RsyncLine::Total(_) | RsyncLine::File(_) => true, } } else { @@ -475,8 +485,10 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", dest.display()))?; ctx.log(LogLevel::Info, format!("Scanning local {label} library…")); + ctx.set_step(1, StepStatus::Active, format!("scanning local {label} library…")); let src_map = scan_music_dir(&src_dir); ctx.log(LogLevel::Info, format!("Scanning device {}…", dest.display())); + ctx.set_step(1, StepStatus::Active, format!("scanning device {}…", dest.display())); let dst_map = scan_music_dir(&dest); let mut to_copy: Vec = Vec::new(); @@ -510,11 +522,13 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result } if !to_delete.is_empty() { + ctx.set_step(1, StepStatus::Active, format!("removing {} stale file(s)…", to_delete.len())); remove_stale(&dest, &to_delete); ctx.log(LogLevel::Info, format!("Removed {} stale file(s).", to_delete.len())); } if !to_copy.is_empty() { + ctx.set_step(1, StepStatus::Active, format!("copying {} file(s)…", to_copy.len())); let tmp = write_file_list(&to_copy)?; let res = { let args = vec![ @@ -528,16 +542,26 @@ fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result dest.display().to_string(), ]; let total = to_copy.len() as u64; + let mut xfr_done: u64 = 0; + let mut cur_pct: u16 = 0; stream(ctx, "rsync", &args, |line| { match parse_rsync_line(line) { Some(RsyncLine::Progress(p)) => { - let done = if p.done > 0 { p.done } else { total }; - ctx.set_device_progress(done, total, &p.speed, &p.eta, ""); + xfr_done = p.done.max(1); + cur_pct = 100; + let pct = smooth_pct(xfr_done, cur_pct, total); + ctx.set_device_progress(xfr_done, total, &p.speed, &p.eta, "", Some(pct)); + true + } + Some(RsyncLine::ByteProgress(p)) => { + cur_pct = p.pct; + let pct = smooth_pct(xfr_done, cur_pct, total); + ctx.set_device_progress(xfr_done, total, &p.speed, &p.eta, "", Some(pct)); true } Some(RsyncLine::Total(_)) => true, Some(RsyncLine::File(path)) => { - ctx.set_device_progress(0, total, "", "", &path); + ctx.set_device_progress(0, total, "", "", &path, None); ctx.log(LogLevel::Transfer, path); true } @@ -602,6 +626,16 @@ fn write_file_list(items: &[String]) -> Result { // ── mlove finalize ──────────────────────────────────────────────────────────── +/// Combine completed files with the current file's byte fraction for a smooth +/// overall percentage, e.g. 3 files done + file at 50% over 5 total → 70%. +fn smooth_pct(xfr_done: u64, cur_pct: u16, total: u64) -> u16 { + if total == 0 { + return 0; + } + let ratio = xfr_done as f64 + (cur_pct as f64 / 100.0); + ((ratio / total as f64) * 100.0).clamp(0.0, 100.0) as u16 +} + fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> { let out = Command::new(cmd) .args(args) @@ -774,3 +808,21 @@ where } Ok(()) } + +#[cfg(test)] +mod tests { + use super::smooth_pct; + + #[test] + fn smooth_progress_advances_within_a_file() { + // 3 files done, current at 50%, 5 total → (3 + 0.5)/5 = 70% + assert_eq!(smooth_pct(3, 50, 5), 70); + // 3 files done, current at 0% → 60% + assert_eq!(smooth_pct(3, 0, 5), 60); + // 1 file done, current at 100% → 40% (xfr#1 line arrives before next file) + assert_eq!(smooth_pct(1, 100, 5), 40); + // everything done + assert_eq!(smooth_pct(5, 100, 5), 100); + assert_eq!(smooth_pct(0, 100, 0), 0); + } +} diff --git a/src/progress.rs b/src/progress.rs index f16a674..acea0ae 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -6,8 +6,10 @@ const AUDIO_EXTS: &[&str] = &[".m4a", ".mp3", ".aac", ".ogg", ".wav", ".wv", ".a pub enum RsyncLine { /// Total file count announced by rsync ("Transfer starting: N files") Total(u64), - /// A per-file progress update + /// A per-file completion update ("… (xfr#N, to-check=M/T)") Progress(RsyncProgress), + /// Live byte progress of the current file ("bytes pct% speed ETA") + ByteProgress(RsyncProgress), /// A transferred audio file path (current file being handled) File(String), } @@ -113,6 +115,26 @@ pub fn parse_rsync_line(line: &str) -> Option { })); } + // Live byte progress of the current file: " 32.768 0% 999,51MB/s 0:00:00 " + // (no xfr# yet — rsync emits these with \r as the file transfers). + if line.contains('%') { + let starts_with_digit = line + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false); + let speed = find_speed(line); + if starts_with_digit || !speed.is_empty() { + return Some(RsyncLine::ByteProgress(RsyncProgress { + done: 0, + total: 0, + pct: find_pct(line).unwrap_or(0), + speed, + eta: find_eta(line), + })); + } + } + // Plain audio file path line (transferred file being processed). if ends_with_audio_ext(line) { // Skip stats-looking lines that just happen to end in an audio ext. @@ -222,4 +244,16 @@ mod tests { other => panic!("expected file, got {other:?}"), } } + + #[test] + fn parses_live_byte_progress_without_xfr() { + // rsync \r segment during a large-file transfer + match parse_rsync_line(" 32.768 0% 0,00kB/s 0:00:00") { + Some(RsyncLine::ByteProgress(p)) => { + assert_eq!(p.pct, 0); + assert_eq!(p.speed, "0,00kB/s"); + } + other => panic!("expected byte progress, got {other:?}"), + } + } }