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