Skip to main content

romm_cli/commands/
metadata.rs

1//! `roms metadata` — search, match, edit, unmatch ROM game metadata.
2
3use 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 metadata providers for match candidates
37    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    /// Apply a metadata provider match (`PUT /api/roms/{id}`)
45    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        /// Interactive picker (default when no ID flags and stdout is a TTY)
52        #[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        /// After match, optionally set cover from SteamGridDB search using this term
75        #[arg(long)]
76        cover_query: Option<String>,
77    },
78    /// Edit name, summary, or cover URL / artwork file
79    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    /// Clear provider metadata links (`?unmatch_metadata=true`)
91    Unmatch {
92        rom_id: u64,
93        #[arg(long)]
94        yes: bool,
95    },
96    /// Remove stored cover image (`?remove_cover=true`)
97    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}