Skip to main content

romm_cli/commands/
library_scan.rs

1//! Shared `scan_library` task trigger and optional wait (used by `roms upload` and `scan`).
2
3use std::time::{Duration, Instant};
4
5use anyhow::{anyhow, Context, Result};
6use indicatif::ProgressBar;
7use serde_json::Value;
8
9use crate::client::RommClient;
10use crate::core::cache::{RomCache, RomCacheKey};
11
12use super::OutputFormat;
13
14pub const SCAN_LIBRARY_TASK_NAME: &str = "scan_library";
15
16/// After a successful `--wait`, optionally drop stale entries from the on-disk ROM list cache.
17#[derive(Clone, Debug, Default)]
18pub enum ScanCacheInvalidate {
19    #[default]
20    None,
21    /// Clear the cached list for this platform (e.g. after `roms upload … --scan --wait`).
22    Platform(u64),
23    /// Clear every platform entry (full `scan_library` scope).
24    AllPlatforms,
25}
26
27/// Options for starting a library scan and optionally blocking until it finishes.
28#[derive(Clone, Debug)]
29pub struct ScanLibraryOptions {
30    pub wait: bool,
31    pub wait_timeout: Duration,
32    pub cache_invalidate: ScanCacheInvalidate,
33}
34
35fn apply_cache_invalidate(inv: &ScanCacheInvalidate) {
36    match inv {
37        ScanCacheInvalidate::None => {}
38        ScanCacheInvalidate::Platform(pid) => {
39            let mut c = RomCache::load();
40            c.remove(&RomCacheKey::Platform(*pid));
41        }
42        ScanCacheInvalidate::AllPlatforms => {
43            let mut c = RomCache::load();
44            c.remove_all_platform_entries();
45        }
46    }
47}
48
49#[derive(Debug)]
50pub struct ScanLibraryStart {
51    pub task_id: String,
52    pub initial_status: String,
53    pub raw: Value,
54}
55
56/// POST `scan_library` with no kwargs (RomM task accepts no `platform_id`; see docs).
57pub async fn start_scan_library(client: &RommClient) -> Result<ScanLibraryStart> {
58    let raw = client
59        .run_task(SCAN_LIBRARY_TASK_NAME, None)
60        .await
61        .context("failed to start scan_library task")?;
62    let task_id = raw
63        .get("task_id")
64        .and_then(|v| v.as_str())
65        .filter(|s| !s.is_empty())
66        .ok_or_else(|| {
67            anyhow!(
68                "scan response missing task_id (unexpected server response): {}",
69                raw
70            )
71        })?
72        .to_string();
73    let initial_status = raw
74        .get("status")
75        .and_then(|v| v.as_str())
76        .unwrap_or("unknown")
77        .to_string();
78    Ok(ScanLibraryStart {
79        task_id,
80        initial_status,
81        raw,
82    })
83}
84
85fn status_from_json(v: &Value) -> Option<&str> {
86    v.get("status").and_then(|s| s.as_str())
87}
88
89fn is_terminal_status(status: &str) -> bool {
90    status.eq_ignore_ascii_case("finished")
91        || status.eq_ignore_ascii_case("failed")
92        || status.eq_ignore_ascii_case("stopped")
93        || status.eq_ignore_ascii_case("canceled")
94        || status.eq_ignore_ascii_case("cancelled")
95}
96
97fn is_success_status(status: &str) -> bool {
98    status.eq_ignore_ascii_case("finished")
99}
100
101/// Poll `GET /api/tasks/{task_id}` every 2 seconds until terminal state or timeout.
102/// `on_status` is invoked with each non-terminal status string (may be empty on parse miss).
103/// On success returns the last status JSON (typically `status` == `finished`).
104pub async fn wait_for_task_terminal(
105    client: &RommClient,
106    task_id: &str,
107    timeout: Duration,
108    mut on_status: impl FnMut(&str),
109) -> Result<Value> {
110    let deadline = Instant::now() + timeout;
111    loop {
112        if Instant::now() >= deadline {
113            anyhow::bail!(
114                "timed out waiting for library scan task {} after {:?}",
115                task_id,
116                timeout
117            );
118        }
119
120        let body = client
121            .get_task_status(task_id)
122            .await
123            .with_context(|| format!("failed to poll task {task_id}"))?;
124        let st = status_from_json(&body).unwrap_or("");
125
126        if is_terminal_status(st) {
127            if is_success_status(st) {
128                return Ok(body);
129            }
130            anyhow::bail!("library scan task ended with status {st:?}: {body}");
131        }
132
133        on_status(st);
134        tokio::time::sleep(Duration::from_secs(2)).await;
135    }
136}
137
138/// CLI: poll task status with an `indicatif` spinner (do not use under the TUI alternate screen).
139pub async fn wait_for_scan_task(
140    client: &RommClient,
141    task_id: &str,
142    timeout: Duration,
143) -> Result<Value> {
144    let pb = ProgressBar::new_spinner();
145    pb.enable_steady_tick(Duration::from_millis(120));
146    pb.set_message(format!("Waiting for library scan (task {task_id})…"));
147
148    let result = wait_for_task_terminal(client, task_id, timeout, |st| {
149        pb.set_message(format!("Library scan: {st}…"));
150    })
151    .await;
152
153    pb.finish_and_clear();
154    result
155}
156
157/// Start a library scan; optionally wait. Prints human text or JSON per `format`.
158pub async fn run_scan_library_flow(
159    client: &RommClient,
160    options: ScanLibraryOptions,
161    format: OutputFormat,
162) -> Result<()> {
163    match format {
164        OutputFormat::Text => println!("Triggering library scan..."),
165        OutputFormat::Json => {}
166    }
167
168    let start = start_scan_library(client).await?;
169
170    match format {
171        OutputFormat::Text => println!(
172            "Scan started: task_id={}, status={}",
173            start.task_id, start.initial_status
174        ),
175        OutputFormat::Json if !options.wait => {
176            println!("{}", serde_json::to_string_pretty(&start.raw)?);
177        }
178        OutputFormat::Json => {}
179    }
180
181    if options.wait {
182        let final_body = wait_for_scan_task(client, &start.task_id, options.wait_timeout).await?;
183        apply_cache_invalidate(&options.cache_invalidate);
184        match format {
185            OutputFormat::Text => println!("Library scan finished successfully."),
186            OutputFormat::Json => {
187                let mut out = start.raw;
188                if let Value::Object(ref mut m) = out {
189                    m.insert("final_status".into(), final_body);
190                }
191                println!("{}", serde_json::to_string_pretty(&out)?);
192            }
193        }
194    }
195
196    Ok(())
197}