1use std::io::IsTerminal;
4use std::path::PathBuf;
5
6use anyhow::{anyhow, bail, Result};
7use clap::{Args, Subcommand};
8use dialoguer::{Confirm, Select};
9
10use crate::cli_presentation::CliPresentation;
11use crate::commands::OutputFormat;
12use romm_api::client::RommClient;
13use romm_api::core::metadata::{
14 invalidate_platform_rom_cache, search_covers, search_metadata_matches, search_row_apply_fields,
15};
16use romm_api::endpoints::roms::{PutRom, RomUpdateFields};
17use romm_api::types::metadata::{RomMatchFields, SearchRom};
18
19#[derive(Args, Debug)]
20#[command(
21 about = "Search, match, and edit game metadata (not per-user props)",
22 after_help = "Examples:\n \
23 romm-cli roms metadata search 42 --query zelda\n \
24 romm-cli roms metadata match 42 --igdb-id 1234\n \
25 romm-cli roms metadata match 42 --query zelda --pick\n \
26 romm-cli roms metadata edit 42 --summary \"My note\"\n \
27 romm-cli roms metadata unmatch 42 --yes"
28)]
29pub struct MetadataCommand {
30 #[command(subcommand)]
31 pub action: MetadataAction,
32}
33
34#[derive(Subcommand, Debug)]
35pub enum MetadataAction {
36 Search {
38 rom_id: u64,
39 #[arg(long, visible_aliases = ["query", "q"])]
40 query: Option<String>,
41 #[arg(long, default_value = "name")]
42 search_by: String,
43 },
44 Match {
46 rom_id: u64,
47 #[arg(long, visible_aliases = ["query", "q"])]
48 query: Option<String>,
49 #[arg(long, default_value = "name")]
50 search_by: String,
51 #[arg(long)]
53 pick: bool,
54 #[arg(long)]
55 igdb_id: Option<i64>,
56 #[arg(long)]
57 moby_id: Option<i64>,
58 #[arg(long)]
59 ss_id: Option<i64>,
60 #[arg(long)]
61 launchbox_id: Option<i64>,
62 #[arg(long)]
63 flashpoint_id: Option<String>,
64 #[arg(long)]
65 sgdb_id: Option<i64>,
66 #[arg(long)]
67 ra_id: Option<i64>,
68 #[arg(long)]
69 hasheous_id: Option<i64>,
70 #[arg(long)]
71 tgdb_id: Option<i64>,
72 #[arg(long)]
73 hltb_id: Option<i64>,
74 #[arg(long)]
76 cover_query: Option<String>,
77 },
78 Edit {
80 rom_id: u64,
81 #[arg(long)]
82 name: Option<String>,
83 #[arg(long)]
84 summary: Option<String>,
85 #[arg(long)]
86 url_cover: Option<String>,
87 #[arg(long)]
88 artwork: Option<PathBuf>,
89 },
90 Unmatch {
92 rom_id: u64,
93 #[arg(long)]
94 yes: bool,
95 },
96 RemoveCover {
98 rom_id: u64,
99 #[arg(long)]
100 yes: bool,
101 },
102}
103
104pub async fn handle(
105 cmd: MetadataCommand,
106 client: &RommClient,
107 presentation: CliPresentation,
108) -> Result<()> {
109 let format = presentation.format;
110 match cmd.action {
111 MetadataAction::Search {
112 rom_id,
113 query,
114 search_by,
115 } => {
116 let rows = search_metadata_matches(client, rom_id, query, Some(search_by)).await?;
117 print_search_results(&rows, format)?;
118 }
119 MetadataAction::Match {
120 rom_id,
121 query,
122 search_by,
123 pick,
124 igdb_id,
125 moby_id,
126 ss_id,
127 launchbox_id,
128 flashpoint_id,
129 sgdb_id,
130 ra_id,
131 hasheous_id,
132 tgdb_id,
133 hltb_id,
134 cover_query,
135 } => {
136 let match_fields = resolve_match_fields(
137 client,
138 rom_id,
139 query,
140 search_by,
141 pick,
142 RomMatchFields {
143 igdb_id,
144 moby_id,
145 ss_id,
146 launchbox_id,
147 flashpoint_id,
148 sgdb_id,
149 ra_id,
150 hasheous_id,
151 tgdb_id,
152 hltb_id,
153 ..Default::default()
154 },
155 )
156 .await?;
157 let mut fields = match_fields;
158 if let Some(ref cq) = cover_query {
159 if let Some(url) = pick_cover_url(client, cq, presentation).await? {
160 fields.url_cover = Some(url);
161 }
162 }
163 let updated = apply_update(client, rom_id, fields, false, false, None).await?;
164 print_update(&updated, format)?;
165 }
166 MetadataAction::Edit {
167 rom_id,
168 name,
169 summary,
170 url_cover,
171 artwork,
172 } => {
173 if name.is_none() && summary.is_none() && url_cover.is_none() && artwork.is_none() {
174 bail!("Provide at least one of --name, --summary, --url-cover, or --artwork.");
175 }
176 let updated = apply_update(
177 client,
178 rom_id,
179 RomUpdateFields {
180 name,
181 summary,
182 url_cover,
183 ..Default::default()
184 },
185 false,
186 false,
187 artwork,
188 )
189 .await?;
190 print_update(&updated, format)?;
191 }
192 MetadataAction::Unmatch { rom_id, yes } => {
193 if !yes && presentation.is_text() {
194 let ok = Confirm::new()
195 .with_prompt(format!("Unmatch metadata for ROM {rom_id}?"))
196 .interact()?;
197 if !ok {
198 return Ok(());
199 }
200 }
201 let updated = apply_update(
202 client,
203 rom_id,
204 RomUpdateFields::default(),
205 false,
206 true,
207 None,
208 )
209 .await?;
210 print_update(&updated, format)?;
211 }
212 MetadataAction::RemoveCover { rom_id, yes } => {
213 if !yes && presentation.is_text() {
214 let ok = Confirm::new()
215 .with_prompt(format!("Remove cover for ROM {rom_id}?"))
216 .interact()?;
217 if !ok {
218 return Ok(());
219 }
220 }
221 let updated = client.update_rom(&PutRom::remove_cover(rom_id)).await?;
222 invalidate_platform_rom_cache(updated.platform_id);
223 print_update(&updated, format)?;
224 }
225 }
226 Ok(())
227}
228
229async fn resolve_match_fields(
230 client: &RommClient,
231 rom_id: u64,
232 query: Option<String>,
233 search_by: String,
234 pick: bool,
235 explicit: RomMatchFields,
236) -> Result<RomUpdateFields> {
237 if !explicit.is_empty() {
238 return Ok(RomUpdateFields {
239 match_fields: explicit,
240 ..Default::default()
241 });
242 }
243 let use_picker = pick || (std::io::stdout().is_terminal() && query.is_some());
244 if !use_picker {
245 bail!(
246 "Provide a provider id flag (--igdb-id, --ss-id, …) or use --query with --pick for interactive selection."
247 );
248 }
249 let q = query.ok_or_else(|| anyhow!("--query is required for interactive match"))?;
250 let rows = search_metadata_matches(client, rom_id, Some(q), Some(search_by)).await?;
251 if rows.is_empty() {
252 bail!("No metadata matches found.");
253 }
254 let idx = pick_search_index(&rows)?;
255 Ok(search_row_apply_fields(&rows[idx]))
256}
257
258fn pick_search_index(rows: &[SearchRom]) -> Result<usize> {
259 if rows.len() == 1 {
260 return Ok(0);
261 }
262 let labels: Vec<String> = rows
263 .iter()
264 .enumerate()
265 .map(|(i, r)| {
266 format!(
267 "{}. {} [igdb:{:?} ss:{:?} moby:{:?}]",
268 i + 1,
269 r.name,
270 r.igdb_id,
271 r.ss_id,
272 r.moby_id
273 )
274 })
275 .collect();
276 let sel = Select::new()
277 .with_prompt("Select metadata match")
278 .items(&labels)
279 .default(0)
280 .interact()?;
281 Ok(sel)
282}
283
284async fn pick_cover_url(
285 client: &RommClient,
286 query: &str,
287 presentation: CliPresentation,
288) -> Result<Option<String>> {
289 let covers = search_covers(client, query).await?;
290 if covers.is_empty() {
291 if presentation.is_text() {
292 eprintln!("No SteamGridDB covers found for {query:?}; skipping cover.");
293 }
294 return Ok(None);
295 }
296 let mut options: Vec<(String, String)> = Vec::new();
297 for cover in &covers {
298 for res in &cover.resources {
299 if let Some(ref url) = res.url {
300 let label = format!(
301 "{} {}x{}",
302 cover.name,
303 res.width.unwrap_or(0),
304 res.height.unwrap_or(0)
305 );
306 options.push((label, url.clone()));
307 }
308 }
309 }
310 if options.is_empty() {
311 return Ok(None);
312 }
313 if options.len() == 1 {
314 return Ok(Some(options[0].1.clone()));
315 }
316 let labels: Vec<String> = options.iter().map(|(l, _)| l.clone()).collect();
317 let sel = Select::new()
318 .with_prompt("Select cover")
319 .items(&labels)
320 .default(0)
321 .interact()?;
322 Ok(Some(options[sel].1.clone()))
323}
324
325async fn apply_update(
326 client: &RommClient,
327 rom_id: u64,
328 fields: RomUpdateFields,
329 remove_cover: bool,
330 unmatch_metadata: bool,
331 artwork: Option<PathBuf>,
332) -> Result<romm_api::types::metadata::RomUpdateResponse> {
333 let ep = PutRom {
334 rom_id,
335 fields,
336 remove_cover,
337 unmatch_metadata,
338 artwork,
339 };
340 let updated = client.update_rom(&ep).await?;
341 invalidate_platform_rom_cache(updated.platform_id);
342 Ok(updated)
343}
344
345fn print_search_results(rows: &[SearchRom], format: OutputFormat) -> Result<()> {
346 match format {
347 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(rows)?),
348 OutputFormat::Text => {
349 if rows.is_empty() {
350 println!("No matches.");
351 } else {
352 for (i, r) in rows.iter().enumerate() {
353 println!(
354 "{}. {} (igdb:{:?} ss:{:?} moby:{:?})",
355 i + 1,
356 r.name,
357 r.igdb_id,
358 r.ss_id,
359 r.moby_id
360 );
361 }
362 }
363 }
364 }
365 Ok(())
366}
367
368fn print_update(
369 updated: &romm_api::types::metadata::RomUpdateResponse,
370 format: OutputFormat,
371) -> Result<()> {
372 match format {
373 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(updated)?),
374 OutputFormat::Text => {
375 println!(
376 "Updated ROM {} (platform {}): {}",
377 updated.id,
378 updated.platform_id,
379 updated.name.as_deref().unwrap_or("—")
380 );
381 }
382 }
383 Ok(())
384}