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