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