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::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#[derive(Clone, Debug, Default)]
20pub enum ScanCacheInvalidate {
21 #[default]
22 None,
23 Platform(u64),
25 AllPlatforms,
27}
28
29#[derive(Clone, Debug)]
31pub struct ScanLibraryOptions {
32 pub wait: bool,
33 pub wait_timeout: Duration,
34 pub cache_invalidate: ScanCacheInvalidate,
35 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
60pub 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
108pub 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
153pub 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
179pub 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}