Skip to main content

romm_cli/commands/
roms.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use anyhow::{anyhow, Result};
5use clap::{Args, Subcommand};
6use dialoguer::Confirm;
7use indicatif::{ProgressBar, ProgressStyle};
8use serde_json::json;
9
10use crate::client::RommClient;
11use crate::commands::library_scan::{
12    run_scan_library_flow, ScanCacheInvalidate, ScanLibraryOptions,
13};
14use crate::commands::print::print_roms_table;
15use crate::commands::OutputFormat;
16use crate::core::resolve::{
17    resolve_manual_collection_id, resolve_platform_id, resolve_platform_ids,
18    resolve_smart_collection_id,
19};
20use crate::endpoints::roms::GetRom;
21use crate::endpoints::roms::{
22    DeleteRomNote, DeleteRoms, GetRomByHash, GetRomByMetadataProvider, GetRomFilters, GetRomNotes,
23    GetRoms, GetSearchCover, GetSearchRoms, PostRomNote, PutRomNote, PutRomUserProps,
24};
25
26/// Optional tri-state: CLI passes `true` / `false` / `yes` / `no` / `1` / `0`.
27fn parse_opt_bool(label: &str, raw: &Option<String>) -> Result<Option<bool>> {
28    let Some(s) = raw else {
29        return Ok(None);
30    };
31    let t = s.trim().to_ascii_lowercase();
32    if t.is_empty() {
33        return Ok(None);
34    }
35    match t.as_str() {
36        "true" | "1" | "yes" | "y" => Ok(Some(true)),
37        "false" | "0" | "no" | "n" => Ok(Some(false)),
38        _ => Err(anyhow!(
39            "Invalid boolean for {}: {:?} (use true or false)",
40            label,
41            s
42        )),
43    }
44}
45
46/// `roms list` flags (also used as default when no subcommand).
47#[derive(Args, Debug, Clone, Default)]
48pub struct RomListArgs {
49    #[arg(long, visible_aliases = ["query", "q"])]
50    pub search_term: Option<String>,
51    /// Platform slug or name; repeat for multiple `platform_ids`
52    #[arg(long, action = clap::ArgAction::Append, visible_alias = "p")]
53    pub platform: Vec<String>,
54    /// Manual collection id or exact name
55    #[arg(long)]
56    pub collection: Option<String>,
57    /// Smart collection id or exact name
58    #[arg(long)]
59    pub smart_collection: Option<String>,
60    /// Virtual collection id (e.g. recent)
61    #[arg(long)]
62    pub virtual_collection: Option<String>,
63    #[arg(long)]
64    pub limit: Option<u32>,
65    #[arg(long)]
66    pub offset: Option<u32>,
67    #[arg(long)]
68    pub matched: Option<String>,
69    #[arg(long)]
70    pub favorite: Option<String>,
71    #[arg(long)]
72    pub duplicate: Option<String>,
73    #[arg(long)]
74    pub last_played: Option<String>,
75    #[arg(long)]
76    pub playable: Option<String>,
77    #[arg(long)]
78    pub missing: Option<String>,
79    #[arg(long)]
80    pub has_ra: Option<String>,
81    #[arg(long)]
82    pub verified: Option<String>,
83    #[arg(long)]
84    pub group_by_meta_id: Option<String>,
85    #[arg(long)]
86    pub with_char_index: Option<String>,
87    #[arg(long)]
88    pub with_filter_values: Option<String>,
89    #[arg(long = "genre", action = clap::ArgAction::Append)]
90    pub genres: Vec<String>,
91    #[arg(long = "franchise", action = clap::ArgAction::Append)]
92    pub franchises: Vec<String>,
93    #[arg(long = "collection-tag", action = clap::ArgAction::Append)]
94    pub collection_tags: Vec<String>,
95    #[arg(long = "company", action = clap::ArgAction::Append)]
96    pub companies: Vec<String>,
97    #[arg(long = "age-rating", action = clap::ArgAction::Append)]
98    pub age_ratings: Vec<String>,
99    #[arg(long = "status", action = clap::ArgAction::Append)]
100    pub statuses: Vec<String>,
101    #[arg(long = "region", action = clap::ArgAction::Append)]
102    pub regions: Vec<String>,
103    #[arg(long = "language", action = clap::ArgAction::Append)]
104    pub languages: Vec<String>,
105    #[arg(long = "player-count", action = clap::ArgAction::Append)]
106    pub player_counts: Vec<String>,
107    #[arg(long)]
108    pub genres_logic: Option<String>,
109    #[arg(long)]
110    pub franchises_logic: Option<String>,
111    #[arg(long)]
112    pub collections_logic: Option<String>,
113    #[arg(long)]
114    pub companies_logic: Option<String>,
115    #[arg(long)]
116    pub age_ratings_logic: Option<String>,
117    #[arg(long)]
118    pub regions_logic: Option<String>,
119    #[arg(long)]
120    pub languages_logic: Option<String>,
121    #[arg(long)]
122    pub statuses_logic: Option<String>,
123    #[arg(long)]
124    pub player_counts_logic: Option<String>,
125    #[arg(long)]
126    pub order_by: Option<String>,
127    #[arg(long)]
128    pub order_dir: Option<String>,
129    #[arg(long)]
130    pub updated_after: Option<String>,
131}
132
133async fn build_get_roms(client: &RommClient, a: RomListArgs) -> Result<GetRoms> {
134    let platform_ids = resolve_platform_ids(client, &a.platform).await?;
135    let mut platform_id = None;
136    let mut extra = platform_ids;
137    if extra.len() == 1 {
138        platform_id = Some(extra[0]);
139        extra.clear();
140    } else if extra.len() > 1 {
141        platform_id = None;
142    }
143
144    Ok(GetRoms {
145        search_term: a.search_term,
146        platform_id,
147        platform_ids: extra,
148        collection_id: resolve_manual_collection_id(client, a.collection.as_deref()).await?,
149        smart_collection_id: resolve_smart_collection_id(client, a.smart_collection.as_deref())
150            .await?,
151        virtual_collection_id: a
152            .virtual_collection
153            .map(|s| s.trim().to_string())
154            .filter(|s| !s.is_empty()),
155        matched: parse_opt_bool("matched", &a.matched)?,
156        favorite: parse_opt_bool("favorite", &a.favorite)?,
157        duplicate: parse_opt_bool("duplicate", &a.duplicate)?,
158        last_played: parse_opt_bool("last_played", &a.last_played)?,
159        playable: parse_opt_bool("playable", &a.playable)?,
160        missing: parse_opt_bool("missing", &a.missing)?,
161        has_ra: parse_opt_bool("has_ra", &a.has_ra)?,
162        verified: parse_opt_bool("verified", &a.verified)?,
163        group_by_meta_id: parse_opt_bool("group_by_meta_id", &a.group_by_meta_id)?,
164        with_char_index: parse_opt_bool("with_char_index", &a.with_char_index)?,
165        with_filter_values: parse_opt_bool("with_filter_values", &a.with_filter_values)?,
166        genres: a.genres,
167        franchises: a.franchises,
168        collections: a.collection_tags,
169        companies: a.companies,
170        age_ratings: a.age_ratings,
171        statuses: a.statuses,
172        regions: a.regions,
173        languages: a.languages,
174        player_counts: a.player_counts,
175        genres_logic: a.genres_logic,
176        franchises_logic: a.franchises_logic,
177        collections_logic: a.collections_logic,
178        companies_logic: a.companies_logic,
179        age_ratings_logic: a.age_ratings_logic,
180        regions_logic: a.regions_logic,
181        languages_logic: a.languages_logic,
182        statuses_logic: a.statuses_logic,
183        player_counts_logic: a.player_counts_logic,
184        order_by: a.order_by,
185        order_dir: a.order_dir,
186        updated_after: a.updated_after,
187        limit: a.limit,
188        offset: a.offset,
189    })
190}
191
192/// CLI entrypoint for listing/searching ROMs via `/api/roms`.
193#[derive(Args, Debug)]
194pub struct RomsCommand {
195    /// Output as JSON (overrides global --json when set).
196    #[arg(long, global = true)]
197    pub json: bool,
198
199    /// Flags for the default `list` action (`romm-cli roms` with no subcommand, or before a subcommand).
200    #[command(flatten)]
201    pub list: RomListArgs,
202
203    #[command(subcommand)]
204    pub action: Option<RomsAction>,
205}
206
207#[derive(Subcommand, Debug)]
208pub enum RomsAction {
209    /// Get detailed information for a single ROM
210    #[command(visible_alias = "info")]
211    Get {
212        /// The ID of the ROM
213        id: u64,
214    },
215    /// Lookup ROM by file hash or metadata provider id
216    Find {
217        #[arg(long)]
218        crc: Option<String>,
219        #[arg(long)]
220        md5: Option<String>,
221        #[arg(long)]
222        sha1: Option<String>,
223        #[arg(long)]
224        igdb_id: Option<i64>,
225        #[arg(long)]
226        moby_id: Option<i64>,
227        #[arg(long)]
228        ss_id: Option<i64>,
229        #[arg(long)]
230        ra_id: Option<i64>,
231        #[arg(long)]
232        launchbox_id: Option<i64>,
233        #[arg(long)]
234        hasheous_id: Option<i64>,
235        #[arg(long)]
236        tgdb_id: Option<i64>,
237        #[arg(long)]
238        flashpoint_id: Option<String>,
239        #[arg(long)]
240        hltb_id: Option<i64>,
241    },
242    /// Print canonical filter values from `GET /api/roms/filters`
243    Filters,
244    /// Delete ROMs from the database (optional filesystem delete)
245    Delete {
246        /// ROM ids to remove from the database
247        #[arg(required = true)]
248        rom_ids: Vec<u64>,
249        /// Also delete these ROM ids from disk (repeat ids as needed)
250        #[arg(long, action = clap::ArgAction::Append)]
251        delete_from_fs: Vec<u64>,
252        /// Skip confirmation
253        #[arg(long)]
254        yes: bool,
255    },
256    /// Update per-user ROM properties (`PUT /api/roms/{id}/props`)
257    Props {
258        id: u64,
259        #[arg(long)]
260        is_main_sibling: Option<String>,
261        #[arg(long)]
262        backlogged: Option<String>,
263        #[arg(long)]
264        now_playing: Option<String>,
265        #[arg(long)]
266        hidden: Option<String>,
267        #[arg(long)]
268        rating: Option<u8>,
269        #[arg(long)]
270        difficulty: Option<u8>,
271        #[arg(long)]
272        completion: Option<u8>,
273        #[arg(long)]
274        status: Option<String>,
275        #[arg(long)]
276        update_last_played: bool,
277        #[arg(long)]
278        remove_last_played: bool,
279    },
280    /// List notes for a ROM
281    NotesList {
282        rom_id: u64,
283        #[arg(long)]
284        public_only: Option<String>,
285        #[arg(long)]
286        search: Option<String>,
287        #[arg(long = "tag", action = clap::ArgAction::Append)]
288        tags: Vec<String>,
289    },
290    /// Add a note (JSON body string, e.g. {\"title\":\"t\",\"content\":\"c\"})
291    NotesAdd {
292        rom_id: u64,
293        /// JSON object
294        #[arg(long)]
295        json: String,
296    },
297    /// Update a note
298    NotesUpdate {
299        rom_id: u64,
300        note_id: u64,
301        #[arg(long)]
302        json: String,
303    },
304    /// Delete a note
305    NotesDelete { rom_id: u64, note_id: u64 },
306    /// Upload a manual file (`POST /api/roms/{id}/manuals`)
307    ManualsAdd { rom_id: u64, file: PathBuf },
308    /// Search covers and metadata matches
309    CoverSearch {
310        rom_id: u64,
311        #[arg(long)]
312        query: String,
313        #[arg(long, default_value = "name")]
314        search_by: String,
315    },
316    /// Upload a ROM file to a platform
317    #[command(visible_alias = "up")]
318    Upload {
319        /// Platform slug or name (e.g. "3ds", "Nintendo 3DS")
320        #[arg(long)]
321        platform: String,
322        /// File or directory to upload
323        file: PathBuf,
324        /// Trigger a library scan after upload completes
325        #[arg(short, long)]
326        scan: bool,
327        /// Wait until the library scan finishes (requires `--scan`; polls every 2 seconds)
328        #[arg(long, requires = "scan")]
329        wait: bool,
330        /// Max seconds to wait when `--wait` is set (default: 3600)
331        #[arg(long, requires = "wait")]
332        wait_timeout_secs: Option<u64>,
333    },
334}
335
336fn make_progress_style() -> ProgressStyle {
337    ProgressStyle::with_template(
338        "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}",
339    )
340    .unwrap()
341    .progress_chars("#>-")
342}
343
344async fn upload_one(
345    client: &RommClient,
346    platform_id: u64,
347    file_path: std::path::PathBuf,
348    pb: ProgressBar,
349) -> Result<()> {
350    let filename = file_path
351        .file_name()
352        .and_then(|n| n.to_str())
353        .unwrap_or("file")
354        .to_string();
355
356    pb.set_message(format!("Uploading {}", filename));
357
358    client
359        .upload_rom(platform_id, &file_path, {
360            let pb = pb.clone();
361            move |uploaded, total| {
362                if pb.length() != Some(total) {
363                    pb.set_length(total);
364                }
365                pb.set_position(uploaded);
366            }
367        })
368        .await?;
369
370    pb.finish_with_message(format!("✓ Upload complete: {}", filename));
371    Ok(())
372}
373
374pub async fn handle(cmd: RomsCommand, client: &RommClient, format: OutputFormat) -> Result<()> {
375    match cmd.action {
376        None => {
377            let ep = build_get_roms(client, cmd.list.clone()).await?;
378            let results = client.call(&ep).await?;
379            match format {
380                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&results)?),
381                OutputFormat::Text => print_roms_table(&results),
382            }
383        }
384        Some(RomsAction::Get { id }) => {
385            let rom = client.call(&GetRom { id }).await?;
386            match format {
387                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rom)?),
388                OutputFormat::Text => println!("{}", serde_json::to_string_pretty(&rom)?),
389            }
390        }
391        Some(RomsAction::Find {
392            crc,
393            md5,
394            sha1,
395            igdb_id,
396            moby_id,
397            ss_id,
398            ra_id,
399            launchbox_id,
400            hasheous_id,
401            tgdb_id,
402            flashpoint_id,
403            hltb_id,
404        }) => {
405            let hash_ep = GetRomByHash {
406                crc_hash: crc.clone(),
407                md5_hash: md5.clone(),
408                sha1_hash: sha1.clone(),
409            };
410            let has_hash = crc.is_some() || md5.is_some() || sha1.is_some();
411            let has_meta = igdb_id.is_some()
412                || moby_id.is_some()
413                || ss_id.is_some()
414                || ra_id.is_some()
415                || launchbox_id.is_some()
416                || hasheous_id.is_some()
417                || tgdb_id.is_some()
418                || flashpoint_id.is_some()
419                || hltb_id.is_some();
420            if has_hash == has_meta {
421                anyhow::bail!("Specify either hash flags (--crc/--md5/--sha1) or metadata id flags (--igdb-id, ...), not both.");
422            }
423            let v = if has_hash {
424                client.call(&hash_ep).await?
425            } else {
426                client
427                    .call(&GetRomByMetadataProvider {
428                        igdb_id,
429                        moby_id,
430                        ss_id,
431                        ra_id,
432                        launchbox_id,
433                        hasheous_id,
434                        tgdb_id,
435                        flashpoint_id,
436                        hltb_id,
437                    })
438                    .await?
439            };
440            println!("{}", serde_json::to_string_pretty(&v)?);
441        }
442        Some(RomsAction::Filters) => {
443            let v = client.call(&GetRomFilters).await?;
444            println!("{}", serde_json::to_string_pretty(&v)?);
445        }
446        Some(RomsAction::Delete {
447            rom_ids,
448            delete_from_fs,
449            yes,
450        }) => {
451            if !yes {
452                let ok = Confirm::new()
453                    .with_prompt(format!(
454                        "Delete {} ROM(s) from the database (and {} from disk)?",
455                        rom_ids.len(),
456                        delete_from_fs.len()
457                    ))
458                    .interact()?;
459                if !ok {
460                    return Ok(());
461                }
462            }
463            let v = client
464                .call(&DeleteRoms {
465                    roms: rom_ids,
466                    delete_from_fs,
467                })
468                .await?;
469            println!("{}", serde_json::to_string_pretty(&v)?);
470        }
471        Some(RomsAction::Props {
472            id,
473            is_main_sibling,
474            backlogged,
475            now_playing,
476            hidden,
477            rating,
478            difficulty,
479            completion,
480            status,
481            update_last_played,
482            remove_last_played,
483        }) => {
484            if update_last_played && remove_last_played {
485                anyhow::bail!(
486                    "--update-last-played and --remove-last-played are mutually exclusive."
487                );
488            }
489            let mut body = json!({});
490            let obj = body.as_object_mut().unwrap();
491            if let Some(b) = parse_opt_bool("is_main_sibling", &is_main_sibling)? {
492                obj.insert("is_main_sibling".into(), json!(b));
493            }
494            if let Some(b) = parse_opt_bool("backlogged", &backlogged)? {
495                obj.insert("backlogged".into(), json!(b));
496            }
497            if let Some(b) = parse_opt_bool("now_playing", &now_playing)? {
498                obj.insert("now_playing".into(), json!(b));
499            }
500            if let Some(b) = parse_opt_bool("hidden", &hidden)? {
501                obj.insert("hidden".into(), json!(b));
502            }
503            if let Some(r) = rating {
504                obj.insert("rating".into(), json!(r));
505            }
506            if let Some(d) = difficulty {
507                obj.insert("difficulty".into(), json!(d));
508            }
509            if let Some(c) = completion {
510                obj.insert("completion".into(), json!(c));
511            }
512            if let Some(ref s) = status {
513                if !s.is_empty() {
514                    obj.insert("status".into(), json!(s));
515                }
516            }
517            let v = client
518                .call(&PutRomUserProps {
519                    rom_id: id,
520                    body,
521                    update_last_played,
522                    remove_last_played,
523                })
524                .await?;
525            println!("{}", serde_json::to_string_pretty(&v)?);
526        }
527        Some(RomsAction::NotesList {
528            rom_id,
529            public_only,
530            search,
531            tags,
532        }) => {
533            let v = client
534                .call(&GetRomNotes {
535                    rom_id,
536                    public_only: parse_opt_bool("public_only", &public_only)?,
537                    search,
538                    tags,
539                })
540                .await?;
541            println!("{}", serde_json::to_string_pretty(&v)?);
542        }
543        Some(RomsAction::NotesAdd { rom_id, json: body }) => {
544            let parsed: serde_json::Value = serde_json::from_str(&body)?;
545            let v = client
546                .call(&PostRomNote {
547                    rom_id,
548                    body: parsed,
549                })
550                .await?;
551            println!("{}", serde_json::to_string_pretty(&v)?);
552        }
553        Some(RomsAction::NotesUpdate {
554            rom_id,
555            note_id,
556            json: body,
557        }) => {
558            let parsed: serde_json::Value = serde_json::from_str(&body)?;
559            let v = client
560                .call(&PutRomNote {
561                    rom_id,
562                    note_id,
563                    body: parsed,
564                })
565                .await?;
566            println!("{}", serde_json::to_string_pretty(&v)?);
567        }
568        Some(RomsAction::NotesDelete { rom_id, note_id }) => {
569            let v = client.call(&DeleteRomNote { rom_id, note_id }).await?;
570            println!("{}", serde_json::to_string_pretty(&v)?);
571        }
572        Some(RomsAction::ManualsAdd { rom_id, file }) => {
573            let v = client.upload_rom_manual(rom_id, &file).await?;
574            println!("{}", serde_json::to_string_pretty(&v)?);
575        }
576        Some(RomsAction::CoverSearch {
577            rom_id,
578            query,
579            search_by,
580        }) => {
581            let cover = client
582                .call(&GetSearchCover {
583                    search_term: query.clone(),
584                })
585                .await?;
586            let roms = client
587                .call(&GetSearchRoms {
588                    rom_id,
589                    search_term: Some(query),
590                    search_by: Some(search_by),
591                })
592                .await?;
593            let out = json!({ "cover": cover, "roms": roms });
594            println!("{}", serde_json::to_string_pretty(&out)?);
595        }
596        Some(RomsAction::Upload {
597            file,
598            platform,
599            scan,
600            wait,
601            wait_timeout_secs,
602        }) => {
603            let resolved_platform_id = match resolve_platform_id(client, Some(platform.trim()))
604                .await?
605            {
606                Some(id) => id,
607                None => {
608                    return Err(anyhow!(
609                        "`--platform` must not be empty (use a slug or name from `romm-cli platforms list`)"
610                    ));
611                }
612            };
613
614            if !file.exists() {
615                anyhow::bail!("File or directory does not exist: {:?}", file);
616            }
617
618            let mut files = Vec::new();
619            if file.is_dir() {
620                let mut entries = tokio::fs::read_dir(&file).await?;
621                while let Some(entry) = entries.next_entry().await? {
622                    let path = entry.path();
623                    if path.is_file() {
624                        files.push(path);
625                    }
626                }
627                files.sort();
628            } else {
629                files.push(file);
630            }
631
632            if files.is_empty() {
633                println!("No files found to upload.");
634                return Ok(());
635            }
636
637            if files.len() > 1 {
638                println!("Found {} files to upload.", files.len());
639            }
640
641            let mp = indicatif::MultiProgress::new();
642            let mut successes = 0u32;
643            for path in files {
644                let pb = mp.add(ProgressBar::new(0));
645                pb.set_style(make_progress_style());
646                match upload_one(client, resolved_platform_id, path.clone(), pb).await {
647                    Ok(()) => successes += 1,
648                    Err(e) => eprintln!("Error uploading {:?}: {}", path, e),
649                }
650            }
651
652            if scan {
653                if successes == 0 {
654                    eprintln!("Skipping library scan: no uploads completed successfully.");
655                } else {
656                    let options = ScanLibraryOptions {
657                        wait,
658                        wait_timeout: Duration::from_secs(wait_timeout_secs.unwrap_or(3600)),
659                        cache_invalidate: if wait {
660                            ScanCacheInvalidate::Platform(resolved_platform_id)
661                        } else {
662                            ScanCacheInvalidate::None
663                        },
664                        task_kwargs: None,
665                    };
666                    run_scan_library_flow(client, options, format, None).await?;
667                }
668            }
669        }
670    }
671
672    Ok(())
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use clap::Parser;
679
680    use crate::commands::{Cli, Commands};
681
682    #[test]
683    fn parse_roms_list_with_platform_filter() {
684        let cli = Cli::parse_from(["romm-cli", "roms", "--platform", "3ds", "--limit", "10"]);
685        let Commands::Roms(cmd) = cli.command else {
686            panic!("expected roms command");
687        };
688        assert!(cmd.action.is_none());
689        assert_eq!(cmd.list.platform, vec!["3ds".to_string()]);
690        assert_eq!(cmd.list.limit, Some(10));
691    }
692
693    #[test]
694    fn parse_roms_get_rejects_list_only_filter() {
695        let parsed = Cli::try_parse_from(["romm-cli", "roms", "get", "1", "--platform", "3ds"]);
696        assert!(parsed.is_err(), "expected clap parse failure");
697    }
698
699    #[test]
700    fn parse_roms_list_rejects_platform_id_flag() {
701        let parsed = Cli::try_parse_from(["romm-cli", "roms", "--platform-id", "3"]);
702        assert!(parsed.is_err(), "expected clap parse failure");
703    }
704
705    #[test]
706    fn parse_roms_upload_requires_platform() {
707        let parsed = Cli::try_parse_from(["romm-cli", "roms", "upload", "foo.bin"]);
708        assert!(
709            parsed.is_err(),
710            "expected clap parse failure without --platform"
711        );
712    }
713
714    #[test]
715    fn parse_roms_upload_with_platform_and_file() {
716        let cli = Cli::parse_from(["romm-cli", "roms", "upload", "--platform", "3ds", "foo.bin"]);
717        let Commands::Roms(cmd) = cli.command else {
718            panic!("expected roms command");
719        };
720        let Some(RomsAction::Upload { platform, file, .. }) = cmd.action else {
721            panic!("expected roms upload");
722        };
723        assert_eq!(platform, "3ds");
724        assert_eq!(file, PathBuf::from("foo.bin"));
725    }
726}