Skip to main content

smugmug_cli/
backup.rs

1//! `smugmug-cli backup`: a dated upload (see `crate::dated`) of the
2//! configured directories, repeated every `interval`, for running unattended
3//! (e.g. in a container).
4//!
5//! Each run after the first only `stat`s files it has seen, so a run over
6//! an unchanged library costs a directory walk and no SmugMug requests. The
7//! next run starts `interval` after the previous one ends, so runs never
8//! overlap; the cache database's lock also keeps a second process out.
9
10use anyhow::{Context, Result, bail};
11use chrono::Local;
12use colored::Colorize;
13use std::sync::Arc;
14use std::sync::atomic::Ordering;
15use std::time::Duration;
16
17use crate::api::SmugMugClient;
18use crate::dated::{self, RunOptions};
19
20/// "90s", "30m", "6h", "1d", "1h30m".
21pub fn parse_interval(text: &str) -> Result<Duration> {
22    let text = text.trim();
23    if text.is_empty() {
24        bail!("Empty interval");
25    }
26    let mut total = 0u64;
27    let mut number = String::new();
28    for c in text.chars() {
29        if c.is_ascii_digit() {
30            number.push(c);
31            continue;
32        }
33        let unit = match c.to_ascii_lowercase() {
34            's' => 1,
35            'm' => 60,
36            'h' => 3600,
37            'd' => 86400,
38            ' ' => continue,
39            _ => bail!("Invalid interval '{}': use e.g. 30m, 6h or 1d", text),
40        };
41        let n: u64 = number
42            .parse()
43            .with_context(|| format!("Invalid interval '{}'", text))?;
44        total += n * unit;
45        number.clear();
46    }
47    if !number.is_empty() {
48        bail!("Invalid interval '{}': missing a unit (s, m, h or d)", text);
49    }
50    if total == 0 {
51        bail!("The interval must be more than zero");
52    }
53    Ok(Duration::from_secs(total))
54}
55
56fn describe(duration: Duration) -> String {
57    let secs = duration.as_secs();
58    match secs {
59        s if s % 86400 == 0 => format!("{}d", s / 86400),
60        s if s % 3600 == 0 => format!("{}h", s / 3600),
61        s if s % 60 == 0 => format!("{}m", s / 60),
62        s => format!("{}s", s),
63    }
64}
65
66pub async fn run(
67    client: Arc<SmugMugClient>,
68    options: RunOptions,
69    interval: Option<Duration>,
70) -> Result<()> {
71    let stop = dated::stop_on_signal();
72    println!("Backing up:");
73    for source in &options.sources {
74        println!("  {}", source.display());
75    }
76    println!(
77        "Into: {}/YYYY/MM/YYYY-MM-DD (private albums by the day each file was taken)",
78        options.root_folder
79    );
80    if !options.excludes.is_empty() {
81        println!("Excluding: {}", options.excludes.join(", "));
82    }
83    match interval {
84        Some(i) if !options.dry_run => println!("Every {} (after each run ends)", describe(i)),
85        _ => {}
86    }
87
88    loop {
89        let started = Local::now();
90        println!(
91            "\n{} {}",
92            "Backup started".bold(),
93            started.format("%Y-%m-%d %H:%M:%S")
94        );
95        match dated::run(client.clone(), &options, &stop).await {
96            Ok(stats) => {
97                dated::print_summary(&stats, &options);
98                println!("  Duration: {}", format_secs(stats.duration_secs));
99                if !options.dry_run {
100                    write_last_run(&options, &stats, started);
101                }
102            }
103            // A run that can't start (SmugMug unreachable, a source gone) is
104            // reported and tried again next interval, not fatal.
105            Err(e) if interval.is_some() && !options.dry_run => {
106                eprintln!("{}", format!("✗ Backup failed: {:#}", e).red());
107            }
108            Err(e) => return Err(e),
109        }
110
111        let Some(interval) = interval.filter(|_| !options.dry_run) else {
112            return Ok(());
113        };
114        if stop.load(Ordering::Relaxed) {
115            return Ok(());
116        }
117        let next = Local::now() + chrono::Duration::from_std(interval)?;
118        println!("Next run at {}", next.format("%Y-%m-%d %H:%M:%S"));
119        // Sleep, waking every few seconds to notice a stop request.
120        let deadline = tokio::time::Instant::now() + interval;
121        while tokio::time::Instant::now() < deadline {
122            if stop.load(Ordering::Relaxed) {
123                return Ok(());
124            }
125            tokio::time::sleep(Duration::from_secs(2).min(deadline - tokio::time::Instant::now()))
126                .await;
127        }
128    }
129}
130
131fn format_secs(secs: u64) -> String {
132    let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
133    if h > 0 {
134        format!("{}h {}m {}s", h, m, s)
135    } else if m > 0 {
136        format!("{}m {}s", m, s)
137    } else {
138        format!("{}s", s)
139    }
140}
141
142/// `last_run.json` in the cache directory: the latest run's numbers, for a
143/// quick look (or a health check) without reading logs.
144fn write_last_run(options: &RunOptions, stats: &dated::RunStats, started: chrono::DateTime<Local>) {
145    #[derive(serde::Serialize)]
146    struct LastRun<'a> {
147        started: String,
148        finished: String,
149        sources: &'a [std::path::PathBuf],
150        folder: &'a str,
151        #[serde(flatten)]
152        stats: &'a dated::RunStats,
153    }
154    let last = LastRun {
155        started: started.to_rfc3339(),
156        finished: Local::now().to_rfc3339(),
157        sources: &options.sources,
158        folder: &options.root_folder,
159        stats,
160    };
161    let path = options.cache_path.join("last_run.json");
162    match serde_json::to_vec_pretty(&last) {
163        Ok(json) => {
164            if let Err(e) = std::fs::write(&path, json) {
165                eprintln!("Couldn't write {}: {}", path.display(), e);
166            }
167        }
168        Err(e) => eprintln!("Couldn't save the run summary: {}", e),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn intervals() {
178        assert_eq!(parse_interval("90s").unwrap(), Duration::from_secs(90));
179        assert_eq!(parse_interval("30m").unwrap(), Duration::from_secs(1800));
180        assert_eq!(parse_interval("6h").unwrap(), Duration::from_secs(6 * 3600));
181        assert_eq!(parse_interval("1d").unwrap(), Duration::from_secs(86400));
182        assert_eq!(parse_interval("1h30m").unwrap(), Duration::from_secs(5400));
183        assert_eq!(parse_interval(" 2H ").unwrap(), Duration::from_secs(7200));
184        assert!(parse_interval("6").is_err());
185        assert!(parse_interval("0h").is_err());
186        assert!(parse_interval("six hours").is_err());
187        assert!(parse_interval("").is_err());
188    }
189
190    #[test]
191    fn descriptions() {
192        assert_eq!(describe(Duration::from_secs(86400)), "1d");
193        assert_eq!(describe(Duration::from_secs(5400)), "90m");
194        assert_eq!(describe(Duration::from_secs(21600)), "6h");
195        assert_eq!(describe(Duration::from_secs(45)), "45s");
196    }
197}