Skip to main content

smugmug_cli/dated/
mod.rs

1//! Uploading files into albums by the day they were taken:
2//! `<root folder>/YYYY/MM/YYYY-MM-DD` (with `YYYY-MM-DD (2)`, ... past
3//! SmugMug's per-album limit). Used by `upload` without `--album` and by
4//! `backup`.
5//!
6//! One run: scan (stat only) → skip files the index knows are unchanged →
7//! read the rest (hash, date, render RAWs) on a few blocking threads →
8//! upload, link or collect on several async workers.
9
10pub mod date;
11pub mod index;
12pub mod plan;
13pub mod run;
14pub mod scan;
15
16use colored::Colorize;
17
18pub use run::{RunOptions, RunStats, StopFlag, human_bytes, run};
19
20/// Print what a run did (or, on a dry run, would do).
21pub fn print_summary(stats: &RunStats, options: &RunOptions) {
22    let dry = options.dry_run;
23    println!();
24    println!(
25        "{} {} files found ({} unchanged since last time{})",
26        "•".cyan(),
27        stats.scanned,
28        stats.unchanged,
29        if stats.previously_refused > 0 {
30            format!(
31                ", {} of them refused by SmugMug before",
32                stats.previously_refused
33            )
34        } else {
35            String::new()
36        }
37    );
38    if stats.unsupported > 0 {
39        println!(
40            "{} {} files of types SmugMug doesn't take were ignored",
41            "•".cyan(),
42            stats.unsupported
43        );
44    }
45    if stats.raw_skipped_with_sibling > 0 {
46        println!(
47            "{} {} RAW files skipped: a JPEG or HEIC of the same name is next to them",
48            "•".cyan(),
49            stats.raw_skipped_with_sibling
50        );
51    }
52    if stats.raw_skipped > 0 {
53        println!(
54            "{} {} RAW files skipped (raw_mode)",
55            "•".cyan(),
56            stats.raw_skipped
57        );
58    }
59    if stats.live_photo_videos_skipped > 0 {
60        println!(
61            "{} {} Live Photo videos skipped",
62            "•".cyan(),
63            stats.live_photo_videos_skipped
64        );
65    }
66    if !stats.scan_errors.is_empty() {
67        println!(
68            "{}",
69            format!("⚠ {} paths couldn't be read:", stats.scan_errors.len()).yellow()
70        );
71        for e in stats.scan_errors.iter().take(10) {
72            println!("    {}", e);
73        }
74    }
75
76    if stats.to_process == 0 {
77        println!("{} Nothing new to upload", "✓".green());
78        return;
79    }
80
81    if dry {
82        println!(
83            "\n{} new or changed files ({}) would go into {}/YYYY/MM/YYYY-MM-DD",
84            stats.to_process,
85            human_bytes(stats.bytes_to_process),
86            options.root_folder
87        );
88        print_dates(stats);
89        println!(
90            "\n{}",
91            "Dry run: nothing was read in full or uploaded. Copies of files already on SmugMug will be linked rather than uploaded."
92                .bright_black()
93        );
94        return;
95    }
96
97    println!();
98    println!("  {}: {}", "Uploaded".green(), stats.uploaded);
99    if stats.replaced > 0 {
100        println!("  {}: {}", "Replaced (edited)".cyan(), stats.replaced);
101    }
102    if stats.linked > 0 {
103        println!("  {}: {}", "Already there (copies)".cyan(), stats.linked);
104    }
105    if stats.collected > 0 {
106        println!(
107            "  {}: {}",
108            "Added from other albums".cyan(),
109            stats.collected
110        );
111    }
112    if stats.touched > 0 {
113        println!(
114            "  {}: {}",
115            "Touched, unchanged content".cyan(),
116            stats.touched
117        );
118    }
119    if stats.refused > 0 {
120        println!("  {}: {}", "Refused by SmugMug".red(), stats.refused);
121    }
122    println!("  {}: {}", "Failed (retried next run)".red(), stats.failed);
123    println!("  Data sent: {}", human_bytes(stats.bytes_uploaded));
124    if stats.albums_created > 0 {
125        println!("  Albums created: {}", stats.albums_created);
126    }
127    print_dates(stats);
128    if !stats.errors.is_empty() {
129        println!("\n{}", "Problems:".yellow());
130        for e in &stats.errors {
131            println!("    {}", e);
132        }
133    }
134    if stats.interrupted {
135        println!(
136            "{}",
137            "Stopped early; the rest will be done next run.".yellow()
138        );
139    }
140}
141
142fn print_dates(stats: &RunStats) {
143    if !stats.date_sources.is_empty() {
144        println!("  Dated by:");
145        for (source, count) in &stats.date_sources {
146            println!("    {:>8}  {}", count, source);
147        }
148    }
149    let mut years: std::collections::BTreeMap<i32, usize> = Default::default();
150    for (date, count) in &stats.days {
151        *years.entry(chrono::Datelike::year(date)).or_default() += count;
152    }
153    if years.len() > 1 {
154        println!("  By year:");
155        for (year, count) in &years {
156            println!("    {:>8}  {}", count, year);
157        }
158    }
159    let mut busiest: Vec<_> = stats.days.iter().collect();
160    busiest.sort_by(|a, b| b.1.cmp(a.1));
161    let big: Vec<_> = busiest.iter().take(5).filter(|(_, n)| **n > 500).collect();
162    if !big.is_empty() {
163        println!("  Busiest days:");
164        for (date, count) in big {
165            let note = if **count as u64 > crate::uploader::album_series::MAX_ALBUM_IMAGES {
166                " (more than one album)"
167            } else {
168                ""
169            };
170            println!("    {:>8}  {}{}", count, date, note);
171        }
172    }
173}
174
175/// A stop flag set by Ctrl-C or SIGTERM (`docker stop`): the run finishes
176/// the files it has read and saves its state, so nothing is half done. A
177/// second Ctrl-C exits at once.
178pub fn stop_on_signal() -> StopFlag {
179    let stop: StopFlag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
180    let flag = stop.clone();
181    tokio::spawn(async move {
182        loop {
183            wait_for_signal().await;
184            if flag.swap(true, std::sync::atomic::Ordering::Relaxed) {
185                eprintln!("Stopping now.");
186                std::process::exit(130);
187            }
188            eprintln!(
189                "{}",
190                "Stopping after the uploads in progress (Ctrl-C again to quit now)...".yellow()
191            );
192        }
193    });
194    stop
195}
196
197async fn wait_for_signal() {
198    #[cfg(unix)]
199    {
200        use tokio::signal::unix::{SignalKind, signal};
201        match signal(SignalKind::terminate()) {
202            Ok(mut term) => {
203                tokio::select! {
204                    _ = tokio::signal::ctrl_c() => {}
205                    _ = term.recv() => {}
206                }
207            }
208            Err(_) => {
209                let _ = tokio::signal::ctrl_c().await;
210            }
211        }
212    }
213    #[cfg(not(unix))]
214    {
215        let _ = tokio::signal::ctrl_c().await;
216    }
217}