mirror: true overall progress via --check-first + visible scan phase

With SFTP remotes rclone lists the tree while transferring, so the
`Transferred:` total grows during the run: the first ticks showed a
single file's percentage (e.g. 2.9 MiB / 8.6 MiB, 35%) which then
collapsed to 0% once the real 113 GiB total appeared. That is what
looked like "progress per album".

- pass --check-first: rclone finishes the remote listing before any
  transfer starts, so the byte total is final and the gauge is a true
  overall percentage for the whole mirror
- while scanning, no bytes move yet, so the panel now shows
  `Scanning remote · 11,810 items listed` and `0 B / 113 GiB to transfer`
  (parsed from rclone's `Checks: … Listed N`) instead of a bogus 0% bar
- once transfers start the display switches back to percentage +
  bytes + current file

Verified against the real storagebox remote (capped with
RCLONE_MAX_TRANSFER): scan ticks show listed counts climbing to 11,810
with the pending total, then transfers show `9.984 MiB / 113.477 GiB`
with the final denominator and no per-file percentage jumps.

Bump to 0.3.2.
This commit is contained in:
Sebastian
2026-09-10 20:13:23 +02:00
parent 0ffe32ae08
commit 9366186245
7 changed files with 147 additions and 11 deletions
Generated
+1 -1
View File
@@ -253,7 +253,7 @@ dependencies = [
[[package]] [[package]]
name = "dap-tui" name = "dap-tui"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dap-tui" name = "dap-tui"
version = "0.3.1" version = "0.3.2"
edition = "2021" edition = "2021"
description = "Device sync for portable players — TUI edition (ratatui)" description = "Device sync for portable players — TUI edition (ratatui)"
license = "MIT" license = "MIT"
+4 -1
View File
@@ -87,7 +87,10 @@ Firmware types: `apple` (podkit command), `rockbox`, `android`, `sony`
## How the sync pipeline works ## How the sync pipeline works
1. **Mirror** — `rclone sync` (or local `rsync`) pulls the StorageBox source 1. **Mirror** — `rclone sync` (or local `rsync`) pulls the StorageBox source
into the local library. into the local library. rclone runs with `--check-first`, so the remote
listing completes before transfers start: the gauge then shows a true
overall percentage instead of the growing-total estimate, and the listing
phase is displayed as `Scanning remote · N items listed`.
2. **Diff** — the device is scanned and compared to the source with 2. **Diff** — the device is scanned and compared to the source with
NFC-normalised paths, size, and 2-second-rounded mtimes (FAT32 friendly). NFC-normalised paths, size, and 2-second-rounded mtimes (FAT32 friendly).
3. **Sync** — stale files are removed first, then `rsync --files-from` copies 3. **Sync** — stale files are removed first, then `rsync --files-from` copies
+33 -4
View File
@@ -1,5 +1,5 @@
use crate::config::{self, Config, Device, Firmware}; use crate::config::{self, Config, Device, Firmware};
use crate::progress::{parse_rclone_current, parse_rclone_stats, parse_rsync_line, RsyncLine}; use crate::progress::{parse_rclone_current, parse_rclone_listed, parse_rclone_stats, parse_rsync_line, RsyncLine};
use chrono::Local; use chrono::Local;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
@@ -49,6 +49,9 @@ pub struct ProgressInfo {
pub speed: String, pub speed: String,
pub eta: String, pub eta: String,
pub current: String, pub current: String,
/// rclone only: number of remote items listed so far (during the scan
/// phase before `--check-first` lets transfers start).
pub listed: u64,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -137,6 +140,13 @@ impl WorkerCtx {
self.changed(); self.changed();
} }
fn set_mirror_listed(&self, listed: u64) {
if let Ok(mut s) = self.state.lock() {
s.mirror.listed = listed;
}
self.changed();
}
fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str, pct: Option<u16>) { fn set_device_progress(&self, done: u64, total: u64, speed: &str, eta: &str, current: &str, pct: Option<u16>) {
if let Ok(mut s) = self.state.lock() { if let Ok(mut s) = self.state.lock() {
let p = &mut s.device_prog; let p = &mut s.device_prog;
@@ -420,10 +430,23 @@ fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), S
// multi-line `Transferred: … / …, N%, speed, ETA …` block newline-terminated, // multi-line `Transferred: … / …, N%, speed, ETA …` block newline-terminated,
// so it streams live through the pipe and we can update the gauge in real // so it streams live through the pipe and we can update the gauge in real
// time while a cloud→local mirror is running. // time while a cloud→local mirror is running.
//
// `--check-first` completes the remote listing before any transfer starts.
// Without it (e.g. over SFTP) rclone lists while transferring, so the
// `Transferred:` total keeps growing and the percentage stuck near 0% —
// early ticks even showed a single file's percentage, which looked like
// per-album progress. With the full total known up front the gauge is a
// true overall percentage; the initial listing shows as a scan phase.
args.extend( args.extend(
["--create-empty-src-dirs", "--progress", "--stats", "1s"] [
.iter() "--create-empty-src-dirs",
.map(|s| s.to_string()), "--check-first",
"--progress",
"--stats",
"1s",
]
.iter()
.map(|s| s.to_string()),
); );
stream(ctx, "rclone", &args, |line| { stream(ctx, "rclone", &args, |line| {
@@ -431,6 +454,11 @@ fn run_mirror(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), S
ctx.set_mirror(p); ctx.set_mirror(p);
return true; return true;
} }
// `Checks: … Listed N` — the remote scan phase before transfers start.
if let Some(listed) = parse_rclone_listed(line) {
ctx.set_mirror_listed(listed);
return true;
}
if let Some(file) = parse_rclone_current(line) { if let Some(file) = parse_rclone_current(line) {
// Per-file `Transferring:` entry — show it as the current file // Per-file `Transferring:` entry — show it as the current file
// instead of logging it once per second. // instead of logging it once per second.
@@ -947,6 +975,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
fn process_is_alive(pid: i32) -> bool { fn process_is_alive(pid: i32) -> bool {
match std::fs::read_to_string(format!("/proc/{pid}/stat")) { match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
+24
View File
@@ -234,6 +234,16 @@ pub fn parse_rclone_stats(line: &str) -> Option<RcloneStats> {
}) })
} }
/// Parse the "Listed N" counter from an rclone `Checks:` progress line:
/// `Checks: 0 / 0, -, Listed 11810`.
/// rclone prints this while scanning the remote tree (before `--check-first`
/// lets any transfer start), so it is the only progress signal in that phase.
pub fn parse_rclone_listed(line: &str) -> Option<u64> {
let (_, rest) = line.trim().split_once("Listed")?;
let n = rest.split_whitespace().next()?;
n.replace(',', "").parse().ok()
}
/// A size like `80 MiB` / `0 B` — but not a plain file count like `6`. /// A size like `80 MiB` / `0 B` — but not a plain file count like `6`.
fn has_size_unit(s: &str) -> bool { fn has_size_unit(s: &str) -> bool {
s.chars().any(|c| c.is_ascii_alphabetic()) s.chars().any(|c| c.is_ascii_alphabetic())
@@ -339,6 +349,20 @@ mod tests {
assert_eq!(s.eta, "2s"); assert_eq!(s.eta, "2s");
} }
#[test]
fn parses_rclone_listed_count() {
assert_eq!(
parse_rclone_listed("Checks: 0 / 0, -, Listed 11810"),
Some(11810)
);
assert_eq!(
parse_rclone_listed("Checks: 5 / 10, 50%, Listed 1,234"),
Some(1234)
);
assert!(parse_rclone_listed("Transferred: 1 MiB / 2 MiB, 50%").is_none());
assert!(parse_rclone_listed("Elapsed time: 5.0s").is_none());
}
#[test] #[test]
fn parses_rclone_current_file() { fn parses_rclone_current_file() {
assert_eq!( assert_eq!(
+39
View File
@@ -356,4 +356,43 @@ mod tests {
assert!(out.contains("32.082 MiB"), "transferred bytes missing"); assert!(out.contains("32.082 MiB"), "transferred bytes missing");
assert!(out.contains("sub/file3.bin"), "current file missing"); assert!(out.contains("sub/file3.bin"), "current file missing");
} }
#[test]
fn renders_mirror_scanning_phase() {
use crate::engine::{StepState, StepStatus};
let mut app = test_app();
{
let mut s = app.engine.state.lock().unwrap();
s.running = true;
s.device = Some("Test Player".into());
s.steps = vec![
StepState {
title: "Mirror StorageBox".into(),
detail: "refreshing local library…".into(),
status: StepStatus::Active,
},
StepState {
title: "Sync Test Player".into(),
detail: "rockbox · music".into(),
status: StepStatus::Pending,
},
];
// --check-first listing phase: no bytes moved, total still growing.
s.mirror.pct = 0;
s.mirror.done = "0 B".into();
s.mirror.total = "113.477 GiB".into();
s.mirror.listed = 11810;
}
app.tab = crate::app::Tab::Sync;
let out = render(&app, 100, 30);
assert!(out.contains("Scanning remote"), "scan label missing");
assert!(out.contains("11,810"), "listed count missing");
assert!(out.contains("113.477 GiB"), "pending total missing");
assert!(
!out.contains("Mirroring local library"),
"scan phase must replace the 0% mirror label"
);
}
} }
+45 -4
View File
@@ -258,7 +258,7 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
.position(|s| s.status == StepStatus::Active) .position(|s| s.status == StepStatus::Active)
.unwrap_or(usize::MAX); .unwrap_or(usize::MAX);
let (pct, done, total, speed, eta, current, label) = if active == 0 { let (pct, done, total, speed, eta, current, listed, label) = if active == 0 {
( (
state.mirror.pct, state.mirror.pct,
state.mirror.done.clone(), state.mirror.done.clone(),
@@ -266,6 +266,7 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
state.mirror.speed.clone(), state.mirror.speed.clone(),
state.mirror.eta.clone(), state.mirror.eta.clone(),
state.mirror.current.clone(), state.mirror.current.clone(),
state.mirror.listed,
"Mirroring local library", "Mirroring local library",
) )
} else if active == 1 { } else if active == 1 {
@@ -276,20 +277,39 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
state.device_prog.speed.clone(), state.device_prog.speed.clone(),
state.device_prog.eta.clone(), state.device_prog.eta.clone(),
state.device_prog.current.clone(), state.device_prog.current.clone(),
0,
"Syncing device", "Syncing device",
) )
} else { } else {
(0, String::new(), String::new(), String::new(), String::new(), String::new(), "Finalizing") (
0,
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
0,
"Finalizing",
)
}; };
// With --check-first, rclone lists the whole remote before transferring:
// no bytes move yet, so show the scan instead of a meaningless 0% gauge.
let scanning = listed > 0 && (done.is_empty() || done == "0 B");
let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner); let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
let gauge_label = if scanning {
format!(" Scanning remote · {} items listed ", fmt_count(listed))
} else {
format!(" {label} · {pct}% ")
};
let g = Gauge::default() let g = Gauge::default()
.block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::BORDER))) .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::BORDER)))
.gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT))
.ratio((pct as f64) / 100.0) .ratio((pct as f64) / 100.0)
.label(Span::styled( .label(Span::styled(
format!(" {label} · {pct}% "), gauge_label,
// No explicit colour: Gauge swaps fg/bg under the label so text // No explicit colour: Gauge swaps fg/bg under the label so text
// stays readable over both the filled and empty bar. // stays readable over both the filled and empty bar.
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
@@ -309,6 +329,9 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
if total.is_empty() { "…".to_string() } else { total }, if total.is_empty() { "…".to_string() } else { total },
Style::default().fg(theme::CYAN), Style::default().fg(theme::CYAN),
)); ));
if scanning {
left.push(Span::styled(" to transfer", Style::default().fg(theme::DIM)));
}
let mut right: Vec<Span> = Vec::new(); let mut right: Vec<Span> = Vec::new();
if !speed.is_empty() { if !speed.is_empty() {
@@ -326,10 +349,15 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
left.extend(right); left.extend(right);
let mut lines = vec![Line::from(left)]; let mut lines = vec![Line::from(left)];
let current_line = if scanning && current.is_empty() {
"building the file list…".to_string()
} else {
current.clone()
};
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
truncate(&current, inner.width.saturating_sub(2) as usize), truncate(&current_line, inner.width.saturating_sub(2) as usize),
Style::default().fg(theme::DIM), Style::default().fg(theme::DIM),
), ),
])); ]));
@@ -338,3 +366,16 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
stats, stats,
); );
} }
/// `11810` → `11,810`
fn fmt_count(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
out
}