Skip to main content

romm_api/core/
library_scan.rs

1//! Shared `scan_library` task trigger and wait helpers (CLI + TUI).
2
3use std::time::{Duration, Instant};
4
5use anyhow::{anyhow, Context, Result};
6use serde_json::Value;
7
8use crate::client::RommClient;
9use crate::core::cache::{RomCache, RomCacheKey};
10use crate::core::interrupt::{cancelled_error, InterruptContext};
11
12pub const SCAN_LIBRARY_TASK_NAME: &str = "scan_library";
13
14/// After a successful scan wait, optionally drop stale entries from the on-disk ROM list cache.
15#[derive(Clone, Debug, Default)]
16pub enum ScanCacheInvalidate {
17    #[default]
18    None,
19    /// Clear the cached list for this platform (e.g. after `roms upload … --scan --wait`).
20    Platform(u64),
21    /// Clear every platform entry (full `scan_library` scope).
22    AllPlatforms,
23}
24
25/// Options for starting a library scan and optionally blocking until it finishes.
26#[derive(Clone, Debug)]
27pub struct ScanLibraryOptions {
28    pub wait: bool,
29    pub wait_timeout: Duration,
30    pub cache_invalidate: ScanCacheInvalidate,
31    /// Optional `scan_library` task kwargs (e.g. `platform_slugs`).
32    pub task_kwargs: Option<Value>,
33}
34
35/// Drop stale on-disk ROM list cache entries after a successful scan.
36pub fn apply_disk_cache_invalidate(inv: &ScanCacheInvalidate) {
37    match inv {
38        ScanCacheInvalidate::None => {}
39        ScanCacheInvalidate::Platform(pid) => {
40            let mut c = RomCache::load();
41            c.remove(&RomCacheKey::Platform(*pid));
42        }
43        ScanCacheInvalidate::AllPlatforms => {
44            let mut c = RomCache::load();
45            c.remove_all_platform_entries();
46        }
47    }
48}
49
50#[derive(Debug)]
51pub struct ScanLibraryStart {
52    pub task_id: String,
53    pub initial_status: String,
54    pub raw: Value,
55}
56
57/// POST `scan_library` with optional task kwargs (e.g. `{"platform_slugs":["gba"]}`).
58pub async fn start_scan_library(
59    client: &RommClient,
60    kwargs: Option<serde_json::Value>,
61) -> Result<ScanLibraryStart> {
62    let raw = client
63        .run_task(SCAN_LIBRARY_TASK_NAME, kwargs)
64        .await
65        .context("failed to start scan_library task")?;
66    let task_id = raw
67        .get("task_id")
68        .and_then(|v| v.as_str())
69        .filter(|s| !s.is_empty())
70        .ok_or_else(|| {
71            anyhow!(
72                "scan response missing task_id (unexpected server response): {}",
73                raw
74            )
75        })?
76        .to_string();
77    let initial_status = raw
78        .get("status")
79        .and_then(|v| v.as_str())
80        .unwrap_or("unknown")
81        .to_string();
82    Ok(ScanLibraryStart {
83        task_id,
84        initial_status,
85        raw,
86    })
87}
88
89fn status_from_json(v: &Value) -> Option<&str> {
90    v.get("status").and_then(|s| s.as_str())
91}
92
93fn is_terminal_status(status: &str) -> bool {
94    status.eq_ignore_ascii_case("finished")
95        || status.eq_ignore_ascii_case("failed")
96        || status.eq_ignore_ascii_case("stopped")
97        || status.eq_ignore_ascii_case("canceled")
98        || status.eq_ignore_ascii_case("cancelled")
99}
100
101fn is_success_status(status: &str) -> bool {
102    status.eq_ignore_ascii_case("finished")
103}
104
105/// Poll `GET /api/tasks/{task_id}` every 2 seconds until terminal state or timeout.
106/// `on_status` is invoked with each non-terminal status string (may be empty on parse miss).
107/// On success returns the last status JSON (typically `status` == `finished`).
108pub async fn wait_for_task_terminal(
109    client: &RommClient,
110    task_id: &str,
111    timeout: Duration,
112    interrupt: Option<&InterruptContext>,
113    mut on_status: impl FnMut(&str),
114) -> Result<Value> {
115    let deadline = Instant::now() + timeout;
116    loop {
117        if Instant::now() >= deadline {
118            anyhow::bail!(
119                "timed out waiting for library scan task {} after {:?}",
120                task_id,
121                timeout
122            );
123        }
124
125        let body = client
126            .get_task_status(task_id)
127            .await
128            .with_context(|| format!("failed to poll task {task_id}"))?;
129        let st = status_from_json(&body).unwrap_or("");
130
131        if is_terminal_status(st) {
132            if is_success_status(st) {
133                return Ok(body);
134            }
135            anyhow::bail!("library scan task ended with status {st:?}: {body}");
136        }
137
138        on_status(st);
139        if let Some(ctx) = interrupt {
140            tokio::select! {
141                _ = tokio::time::sleep(Duration::from_secs(2)) => {},
142                _ = ctx.cancelled() => return Err(cancelled_error().into()),
143            }
144        } else {
145            tokio::time::sleep(Duration::from_secs(2)).await;
146        }
147    }
148}