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