romm_cli/commands/
library_scan.rs1use 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#[derive(Clone, Debug, Default)]
19pub enum ScanCacheInvalidate {
20 #[default]
21 None,
22 Platform(u64),
24 AllPlatforms,
26}
27
28#[derive(Clone, Debug)]
30pub struct ScanLibraryOptions {
31 pub wait: bool,
32 pub wait_timeout: Duration,
33 pub cache_invalidate: ScanCacheInvalidate,
34 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
59pub 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
107pub 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
152pub 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
172pub 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}