From 5ebf256a5871d46f9be585c52f448f1470290d0d Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Thu, 1 Jan 2026 18:05:13 -0500 Subject: [PATCH] feat: add quiet/verbose modes with command logging --- README.md | 10 + vid-repair-core/src/config/defaults.rs | 2 + vid-repair-core/src/config/mod.rs | 21 ++ vid-repair-core/src/fix/executor.rs | 72 +++++-- vid-repair/src/main.rs | 263 +++++++++++++++++++++++-- 5 files changed, 337 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 3712457..4fd56cb 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ vid-repair fix --policy aggressive /path/to/videos vid-repair scan --json /path/to/videos ``` +### Verbosity 🔈 + +```bash +# More detail (commands + error lines) +vid-repair fix -v /path/to/videos + +# Less detail (summary only) +vid-repair scan -q /path/to/videos +``` + **Note:** `fix` always performs a full scan first, then repairs only if issues are found. Use `scan` when you just want a report without making changes. diff --git a/vid-repair-core/src/config/defaults.rs b/vid-repair-core/src/config/defaults.rs index 637bd4d..351f496 100644 --- a/vid-repair-core/src/config/defaults.rs +++ b/vid-repair-core/src/config/defaults.rs @@ -32,6 +32,8 @@ keep_original = false json = false # Pretty-print JSON output. pretty = true +# quiet|normal|verbose - controls console verbosity. +verbosity = "normal" [performance] # 0 = auto (num CPU threads). Higher values spawn more concurrent scans. diff --git a/vid-repair-core/src/config/mod.rs b/vid-repair-core/src/config/mod.rs index ed01729..0d7758b 100644 --- a/vid-repair-core/src/config/mod.rs +++ b/vid-repair-core/src/config/mod.rs @@ -129,6 +129,8 @@ pub struct ReportConfig { pub json: bool, #[serde(default)] pub pretty: bool, + #[serde(default)] + pub verbosity: Verbosity, } impl Default for ReportConfig { @@ -136,10 +138,25 @@ impl Default for ReportConfig { Self { json: false, pretty: true, + verbosity: Verbosity::Normal, } } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Verbosity { + Quiet, + Normal, + Verbose, +} + +impl Default for Verbosity { + fn default() -> Self { + Verbosity::Normal + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceConfig { #[serde(default)] @@ -185,6 +202,7 @@ pub struct ConfigOverrides { pub output_dir: Option, pub keep_original: Option, pub json: Option, + pub verbosity: Option, pub jobs: Option, pub watch: Option, } @@ -215,6 +233,9 @@ impl Config { if let Some(value) = overrides.json { self.report.json = value; } + if let Some(value) = overrides.verbosity { + self.report.verbosity = value; + } if let Some(value) = overrides.jobs { self.performance.jobs = value; } diff --git a/vid-repair-core/src/fix/executor.rs b/vid-repair-core/src/fix/executor.rs index 9352127..30c6e0b 100644 --- a/vid-repair-core/src/fix/executor.rs +++ b/vid-repair-core/src/fix/executor.rs @@ -15,7 +15,7 @@ use crate::scan::{scan_file, DecodeProgress}; use crate::rules::RuleSet; pub fn apply_fix(path: &Path, plan: &FixPlan, config: &Config, ruleset: &RuleSet) -> Result { - apply_fix_with_progress(path, plan, config, ruleset, None, None) + apply_fix_with_progress(path, plan, config, ruleset, None, None, None) } pub fn apply_fix_with_progress( @@ -25,6 +25,7 @@ pub fn apply_fix_with_progress( ruleset: &RuleSet, duration: Option, progress: Option>, + log_cmd: Option>, ) -> Result { if plan.actions.is_empty() { return Ok(FixOutcome { @@ -43,7 +44,15 @@ pub fn apply_fix_with_progress( let action = &plan.actions[0]; let output = prepare_output_path(path, config)?; - run_ffmpeg_fix(path, &output.temp_path, action.kind, config, duration, progress)?; + run_ffmpeg_fix( + path, + &output.temp_path, + action.kind, + config, + duration, + progress, + log_cmd, + )?; let verification = scan_file(&output.temp_path, config, ruleset) .with_context(|| format!("Failed to verify output {}", output.temp_path.display()))?; @@ -78,26 +87,44 @@ fn run_ffmpeg_fix( config: &Config, duration: Option, progress: Option>, + log_cmd: Option>, ) -> Result<()> { - let mut cmd = Command::new(&config.ffmpeg_path); - cmd.arg("-y").arg("-v").arg("error").arg("-i").arg(path); + let mut args = vec![ + "-y".to_string(), + "-v".to_string(), + "error".to_string(), + "-i".to_string(), + path.display().to_string(), + ]; match kind { FixKind::Remux => { - cmd.arg("-c").arg("copy"); + args.extend(["-c", "copy"].iter().map(|s| s.to_string())); } FixKind::Faststart => { - cmd.arg("-c").arg("copy"); - cmd.arg("-movflags").arg("+faststart"); + args.extend(["-c", "copy", "-movflags", "+faststart"].iter().map(|s| s.to_string())); } FixKind::Reencode => { - cmd.arg("-c:v").arg("libx264"); - cmd.arg("-c:a").arg("aac"); - cmd.arg("-movflags").arg("+faststart"); + args.extend( + ["-c:v", "libx264", "-c:a", "aac", "-movflags", "+faststart"] + .iter() + .map(|s| s.to_string()), + ); } } - cmd.arg(output); + if progress.is_some() { + args.extend(["-nostats", "-progress", "pipe:2"].iter().map(|s| s.to_string())); + } + + args.push(output.display().to_string()); + + if let Some(log_cmd) = log_cmd { + log_cmd(format_command_line(&config.ffmpeg_path, &args)); + } + + let mut cmd = Command::new(&config.ffmpeg_path); + cmd.args(&args); if progress.is_none() { let output = cmd @@ -112,7 +139,6 @@ fn run_ffmpeg_fix( return Ok(()); } - cmd.arg("-nostats").arg("-progress").arg("pipe:2"); cmd.stdout(Stdio::null()).stderr(Stdio::piped()); let mut child = cmd @@ -371,3 +397,25 @@ fn parse_out_time(value: &str) -> Option { None } + +fn format_command_line(program: &str, args: &[String]) -> String { + let mut line = String::new(); + line.push_str("e_arg(program)); + for arg in args { + line.push(' '); + line.push_str("e_arg(arg)); + } + line +} + +fn quote_arg(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if arg.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { + let escaped = arg.replace('"', "\\\""); + format!("\"{}\"", escaped) + } else { + arg.to_string() + } +} diff --git a/vid-repair/src/main.rs b/vid-repair/src/main.rs index cc303ed..1774301 100644 --- a/vid-repair/src/main.rs +++ b/vid-repair/src/main.rs @@ -8,7 +8,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use rayon::prelude::*; use rayon::ThreadPoolBuilder; -use vid_repair_core::config::{Config, ConfigOverrides, FixPolicy, ScanDepth}; +use vid_repair_core::config::{Config, ConfigOverrides, FixPolicy, ScanDepth, Verbosity}; use vid_repair_core::fix::{self, FixOutcome, FixPlan}; use vid_repair_core::report::{ render_fix_line, render_json, render_scan_line, render_summary, FixJsonReport, @@ -30,6 +30,14 @@ struct Cli { #[arg(long)] json: bool, + /// Increase verbosity (show commands and error details) + #[arg(short = 'v', long, action = clap::ArgAction::Count, conflicts_with = "quiet")] + verbose: u8, + + /// Reduce output (hide progress/details) + #[arg(short = 'q', long, conflicts_with = "verbose")] + quiet: bool, + /// Number of parallel jobs (0 = auto) #[arg(long)] jobs: Option, @@ -50,6 +58,8 @@ struct Cli { struct CommonArgs { config: Option, json: bool, + verbose: u8, + quiet: bool, jobs: Option, ffmpeg_path: Option, ffprobe_path: Option, @@ -200,6 +210,8 @@ fn run() -> Result { let Cli { config, json, + verbose, + quiet, jobs, ffmpeg_path, ffprobe_path, @@ -209,6 +221,8 @@ fn run() -> Result { let common = CommonArgs { config, json, + verbose, + quiet, jobs, ffmpeg_path, ffprobe_path, @@ -266,6 +280,11 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result { if common.json { overrides.json = Some(true); } + if common.quiet { + overrides.verbosity = Some(Verbosity::Quiet); + } else if common.verbose > 0 { + overrides.verbosity = Some(Verbosity::Verbose); + } if let Some(jobs) = common.jobs { overrides.jobs = Some(jobs); } @@ -298,7 +317,7 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result { return Ok(0); } - if !config.report.json { + if show_phase(&config) { eprintln!("Scanning {} file(s)...", files.len()); } @@ -314,8 +333,10 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result { let json = render_json(&payload, config.report.pretty)?; println!("{}", json); } else { - for scan in &scans { - println!("{}", render_scan_line(scan)); + if !is_quiet(&config) { + for scan in &scans { + println!("{}", render_scan_line(scan)); + } } println!("{}", render_summary(&scans, None)); } @@ -345,6 +366,11 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result { if common.json { overrides.json = Some(true); } + if common.quiet { + overrides.verbosity = Some(Verbosity::Quiet); + } else if common.verbose > 0 { + overrides.verbosity = Some(Verbosity::Verbose); + } overrides.jobs = common.jobs; overrides.scan_depth = args.scan_depth.map(ScanDepth::from); if args.recursive { @@ -378,7 +404,7 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result { return Ok(0); } - if !config.report.json { + if show_phase(&config) { if args.dry_run { eprintln!("Planning fixes for {} file(s)...", files.len()); } else { @@ -399,8 +425,10 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result { let json = render_json(&payload, config.report.pretty)?; println!("{}", json); } else { - for (scan, fix) in scans.iter().zip(fixes.iter()) { - println!("{}", render_fix_line(scan, fix)); + if !is_quiet(&config) { + for (scan, fix) in scans.iter().zip(fixes.iter()) { + println!("{}", render_fix_line(scan, fix)); + } } println!("{}", render_summary(&scans, Some(&fixes))); } @@ -433,7 +461,7 @@ fn run_scans(files: Vec, config: &Config, ruleset: &RuleSet) -> Result< let errors = AtomicUsize::new(0); let started = AtomicUsize::new(0); let total = files.len(); - let show_progress = !config.report.json && std::io::stderr().is_terminal(); + let show_progress = progress_enabled(config); let in_place = show_progress && jobs.is_none(); let scans = if let Some(jobs) = jobs { @@ -449,13 +477,25 @@ fn run_scans(files: Vec, config: &Config, ruleset: &RuleSet) -> Result< eprint!("[SCAN {}/{}] {}", idx, total, path.display()); let _ = std::io::stderr().flush(); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress) + ); + } let progress = if show_progress { Some(make_progress_callback("SCAN", idx, total, path, in_place)) } else { None }; match scan_file_with_progress(path, config, ruleset, progress) { - Ok(scan) => Some(scan), + Ok(scan) => { + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } + Some(scan) + } Err(err) => { if in_place { eprintln!(); @@ -479,13 +519,25 @@ fn run_scans(files: Vec, config: &Config, ruleset: &RuleSet) -> Result< eprint!("[SCAN {}/{}] {}", idx, total, path.display()); let _ = std::io::stderr().flush(); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress) + ); + } let progress = if show_progress { Some(make_progress_callback("SCAN", idx, total, path, in_place)) } else { None }; match scan_file_with_progress(path, config, ruleset, progress) { - Ok(scan) => Some(scan), + Ok(scan) => { + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } + Some(scan) + } Err(err) => { if in_place { eprintln!(); @@ -535,10 +587,16 @@ fn process_fix_batch( let mut fixes = Vec::new(); let mut errors = 0usize; let total = files.len(); - let show_progress = !config.report.json && std::io::stderr().is_terminal(); + let show_progress = progress_enabled(config); let in_place = show_progress; let mut idx = 0usize; + let log_cmd: Option> = if is_verbose(config) { + Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd))) + } else { + None + }; + for path in files { idx += 1; if show_progress && !in_place { @@ -547,6 +605,13 @@ fn process_fix_batch( eprint!("[SCAN {}/{}] {}", idx, total, path.display()); let _ = std::io::stderr().flush(); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress) + ); + } let scan_progress = if show_progress { Some(make_progress_callback("SCAN", idx, total, &path, in_place)) } else { @@ -563,11 +628,14 @@ fn process_fix_batch( continue; } }; - if show_progress { + if show_phase(config) { eprintln!("{}", render_scan_result_line(&scan)); } + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); - if show_progress { + if show_phase(config) { eprintln!("{}", render_plan_line(&plan)); } let outcome = if dry_run { @@ -587,6 +655,7 @@ fn process_fix_batch( } else { None }; + let log_cmd = log_cmd.clone(); match fix::executor::apply_fix_with_progress( &path, &plan, @@ -594,6 +663,7 @@ fn process_fix_batch( ruleset, scan.probe.duration, fix_progress, + log_cmd, ) { Ok(outcome) => outcome, Err(err) => FixOutcome { @@ -623,14 +693,28 @@ fn process_fix_batch_parallel( let errors = AtomicUsize::new(0); let started = AtomicUsize::new(0); let total = files.len(); - let show_progress = !config.report.json && std::io::stderr().is_terminal(); + let show_progress = progress_enabled(config); + let log_cmd: Option> = if is_verbose(config) { + Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd))) + } else { + None + }; + let results = files .par_iter() .filter_map(|path| { + let log_cmd = log_cmd.clone(); let idx = started.fetch_add(1, Ordering::SeqCst) + 1; if show_progress { eprintln!("[SCAN {}/{}] {}", idx, total, path.display()); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress) + ); + } let scan_progress = if show_progress { Some(make_progress_callback("SCAN", idx, total, path, false)) } else { @@ -644,11 +728,14 @@ fn process_fix_batch_parallel( return None; } }; - if show_progress { + if show_phase(config) { eprintln!("{}", render_scan_result_line(&scan)); } + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); - if show_progress { + if show_phase(config) { eprintln!("{}", render_plan_line(&plan)); } let outcome = if dry_run { @@ -672,6 +759,7 @@ fn process_fix_batch_parallel( ruleset, scan.probe.duration, fix_progress, + log_cmd, ) { Ok(outcome) => outcome, Err(err) => FixOutcome { @@ -696,7 +784,7 @@ fn process_fix_batch_parallel( fn watch_scan(paths: Vec, config: &Config, ruleset: &RuleSet) -> Result<()> { println!("Watch mode enabled. Waiting for files to settle..."); watch::watch_paths(&paths, config, |path| { - let show_progress = !config.report.json && std::io::stderr().is_terminal(); + let show_progress = progress_enabled(config); let in_place = show_progress; if show_progress && !in_place { eprintln!("[SCAN] {}", path.display()); @@ -704,6 +792,13 @@ fn watch_scan(paths: Vec, config: &Config, ruleset: &RuleSet) -> Result eprint!("[SCAN] {}", path.display()); let _ = std::io::stderr().flush(); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress) + ); + } let scan_progress = if show_progress { Some(make_progress_callback("SCAN", 1, 1, &path, in_place)) } else { @@ -711,6 +806,12 @@ fn watch_scan(paths: Vec, config: &Config, ruleset: &RuleSet) -> Result }; match scan_file_with_progress(&path, config, ruleset, scan_progress) { Ok(scan) => { + if show_phase(config) { + eprintln!("{}", render_scan_result_line(&scan)); + } + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } println!("{}", render_scan_line(&scan)); } Err(err) => { @@ -722,8 +823,13 @@ fn watch_scan(paths: Vec, config: &Config, ruleset: &RuleSet) -> Result fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: bool) -> Result<()> { println!("Watch mode enabled. Waiting for files to settle..."); + let log_cmd: Option> = if is_verbose(config) { + Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd))) + } else { + None + }; watch::watch_paths(&paths, config, |path| { - let show_progress = !config.report.json && std::io::stderr().is_terminal(); + let show_progress = progress_enabled(config); let in_place = show_progress; if show_progress && !in_place { eprintln!("[SCAN] {}", path.display()); @@ -731,6 +837,13 @@ fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: b eprint!("[SCAN] {}", path.display()); let _ = std::io::stderr().flush(); } + if is_verbose(config) { + eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path)); + eprintln!( + "{}", + render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress) + ); + } let scan_progress = if show_progress { Some(make_progress_callback("SCAN", 1, 1, &path, in_place)) } else { @@ -739,10 +852,13 @@ fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: b match scan_file_with_progress(&path, config, ruleset, scan_progress) { Ok(scan) => { let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); - if show_progress { + if show_phase(config) { eprintln!("{}", render_scan_result_line(&scan)); eprintln!("{}", render_plan_line(&plan)); } + if is_verbose(config) { + emit_verbose_scan_details(config, &scan); + } let outcome = if dry_run { fix::planner::plan_outcome(plan) } else { @@ -760,6 +876,7 @@ fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: b } else { None }; + let log_cmd = log_cmd.clone(); match fix::executor::apply_fix_with_progress( &path, &plan, @@ -767,6 +884,7 @@ fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: b ruleset, scan.probe.duration, fix_progress, + log_cmd, ) { Ok(outcome) => outcome, Err(err) => { @@ -926,6 +1044,113 @@ fn render_plan_line(plan: &FixPlan) -> String { } } +fn is_quiet(config: &Config) -> bool { + matches!(config.report.verbosity, Verbosity::Quiet) +} + +fn is_verbose(config: &Config) -> bool { + matches!(config.report.verbosity, Verbosity::Verbose) +} + +fn show_phase(config: &Config) -> bool { + !config.report.json && !is_quiet(config) +} + +fn progress_enabled(config: &Config) -> bool { + show_phase(config) && std::io::stderr().is_terminal() +} + +fn emit_verbose_scan_details(config: &Config, scan: &ScanOutcome) { + if !is_verbose(config) || config.report.json { + return; + } + + if !scan.decode_errors.is_empty() { + eprintln!( + "[DECODE] {} error line(s)", + scan.decode_errors.len() + ); + for line in &scan.decode_errors { + eprintln!(" {}", line); + } + } + + if !scan.issues.is_empty() { + eprintln!("[ISSUES] {} total", scan.issues.len()); + for issue in &scan.issues { + eprintln!( + " - {} [{:?}] {}", + issue.code, issue.severity, issue.message + ); + for evidence in &issue.evidence { + eprintln!(" > {}", evidence); + } + } + } +} + +fn render_ffprobe_cmd(ffprobe_path: &str, path: &Path) -> String { + let args = vec![ + "-v".to_string(), + "error".to_string(), + "-print_format".to_string(), + "json".to_string(), + "-show_format".to_string(), + "-show_streams".to_string(), + path.display().to_string(), + ]; + format!("[CMD] {}", format_command_line(ffprobe_path, &args)) +} + +fn render_decode_cmd( + ffmpeg_path: &str, + path: &Path, + depth: ScanDepth, + with_progress: bool, +) -> String { + let mut args = vec![ + "-v".to_string(), + "error".to_string(), + "-i".to_string(), + path.display().to_string(), + ]; + match depth { + ScanDepth::Quick => args.extend(["-t", "5"].iter().map(|s| s.to_string())), + ScanDepth::Standard => args.extend(["-t", "30"].iter().map(|s| s.to_string())), + ScanDepth::Deep => {} + } + + if with_progress { + args.extend(["-nostats", "-progress", "pipe:1"].iter().map(|s| s.to_string())); + } + + args.extend(["-f", "null", "-"].iter().map(|s| s.to_string())); + + format!("[CMD] {}", format_command_line(ffmpeg_path, &args)) +} + +fn format_command_line(program: &str, args: &[String]) -> String { + let mut line = String::new(); + line.push_str("e_arg(program)); + for arg in args { + line.push(' '); + line.push_str("e_arg(arg)); + } + line +} + +fn quote_arg(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if arg.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { + let escaped = arg.replace('"', "\\\""); + format!("\"{}\"", escaped) + } else { + arg.to_string() + } +} + fn emit_progress_line(line: &str, done: bool, in_place: bool, state: Arc>) { if !in_place { eprintln!("{}", line);