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 #[command(visible_alias = "meta")]
342 Metadata(super::metadata::MetadataCommand),
343}
344
345const UPLOAD_PROGRESS_PLAIN: &str =
346 "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({eta}) {msg}";
347const UPLOAD_PROGRESS_COLOR: &str =
348 "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}";
349
350async fn upload_one(
351 client: &RommClient,
352 platform_id: u64,
353 file_path: std::path::PathBuf,
354 pb: ProgressBar,
355) -> Result<()> {
356 let filename = file_path
357 .file_name()
358 .and_then(|n| n.to_str())
359 .unwrap_or("file")
360 .to_string();
361
362 pb.set_message(format!("Uploading {}", filename));
363
364 client
365 .upload_rom(platform_id, &file_path, {
366 let pb = pb.clone();
367 move |uploaded, total| {
368 if pb.length() != Some(total) {
369 pb.set_length(total);
370 }
371 pb.set_position(uploaded);
372 }
373 })
374 .await?;
375
376 pb.finish_with_message(format!("✓ Upload complete: {}", filename));
377 Ok(())
378}
379
380pub async fn handle(
381 cmd: RomsCommand,
382 client: &RommClient,
383 presentation: CliPresentation,
384) -> Result<()> {
385 let format = presentation.format;
386 match cmd.action {
387 None => {
388 let ep = build_get_roms(client, cmd.list.clone()).await?;
389 let results = client.call(&ep).await?;
390 match format {
391 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&results)?),
392 OutputFormat::Text => print_roms_table(&results),
393 }
394 }
395 Some(RomsAction::Get { id }) => {
396 let rom = client.call(&GetRom { id }).await?;
397 match format {
398 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rom)?),
399 OutputFormat::Text => println!("{}", serde_json::to_string_pretty(&rom)?),
400 }
401 }
402 Some(RomsAction::Find {
403 crc,
404 md5,
405 sha1,
406 igdb_id,
407 moby_id,
408 ss_id,
409 ra_id,
410 launchbox_id,
411 hasheous_id,
412 tgdb_id,
413 flashpoint_id,
414 hltb_id,
415 }) => {
416 let hash_ep = GetRomByHash {
417 crc_hash: crc.clone(),
418 md5_hash: md5.clone(),
419 sha1_hash: sha1.clone(),
420 };
421 let has_hash = crc.is_some() || md5.is_some() || sha1.is_some();
422 let has_meta = igdb_id.is_some()
423 || moby_id.is_some()
424 || ss_id.is_some()
425 || ra_id.is_some()
426 || launchbox_id.is_some()
427 || hasheous_id.is_some()
428 || tgdb_id.is_some()
429 || flashpoint_id.is_some()
430 || hltb_id.is_some();
431 if has_hash == has_meta {
432 anyhow::bail!("Specify either hash flags (--crc/--md5/--sha1) or metadata id flags (--igdb-id, ...), not both.");
433 }
434 let v = if has_hash {
435 client.call(&hash_ep).await?
436 } else {
437 client
438 .call(&GetRomByMetadataProvider {
439 igdb_id,
440 moby_id,
441 ss_id,
442 ra_id,
443 launchbox_id,
444 hasheous_id,
445 tgdb_id,
446 flashpoint_id,
447 hltb_id,
448 })
449 .await?
450 };
451 println!("{}", serde_json::to_string_pretty(&v)?);
452 }
453 Some(RomsAction::Filters) => {
454 let v = client.call(&GetRomFilters).await?;
455 println!("{}", serde_json::to_string_pretty(&v)?);
456 }
457 Some(RomsAction::Delete {
458 rom_ids,
459 delete_from_fs,
460 yes,
461 }) => {
462 if !yes {
463 let ok = Confirm::new()
464 .with_prompt(format!(
465 "Delete {} ROM(s) from the database (and {} from disk)?",
466 rom_ids.len(),
467 delete_from_fs.len()
468 ))
469 .interact()?;
470 if !ok {
471 return Ok(());
472 }
473 }
474 let v = client
475 .call(&DeleteRoms {
476 roms: rom_ids,
477 delete_from_fs,
478 })
479 .await?;
480 println!("{}", serde_json::to_string_pretty(&v)?);
481 }
482 Some(RomsAction::Props {
483 id,
484 is_main_sibling,
485 backlogged,
486 now_playing,
487 hidden,
488 rating,
489 difficulty,
490 completion,
491 status,
492 update_last_played,
493 remove_last_played,
494 }) => {
495 if update_last_played && remove_last_played {
496 anyhow::bail!(
497 "--update-last-played and --remove-last-played are mutually exclusive."
498 );
499 }
500 let mut body = json!({});
501 let obj = body.as_object_mut().unwrap();
502 if let Some(b) = parse_opt_bool("is_main_sibling", &is_main_sibling)? {
503 obj.insert("is_main_sibling".into(), json!(b));
504 }
505 if let Some(b) = parse_opt_bool("backlogged", &backlogged)? {
506 obj.insert("backlogged".into(), json!(b));
507 }
508 if let Some(b) = parse_opt_bool("now_playing", &now_playing)? {
509 obj.insert("now_playing".into(), json!(b));
510 }
511 if let Some(b) = parse_opt_bool("hidden", &hidden)? {
512 obj.insert("hidden".into(), json!(b));
513 }
514 if let Some(r) = rating {
515 obj.insert("rating".into(), json!(r));
516 }
517 if let Some(d) = difficulty {
518 obj.insert("difficulty".into(), json!(d));
519 }
520 if let Some(c) = completion {
521 obj.insert("completion".into(), json!(c));
522 }
523 if let Some(ref s) = status {
524 if !s.is_empty() {
525 obj.insert("status".into(), json!(s));
526 }
527 }
528 let v = client
529 .call(&PutRomUserProps {
530 rom_id: id,
531 body,
532 update_last_played,
533 remove_last_played,
534 })
535 .await?;
536 println!("{}", serde_json::to_string_pretty(&v)?);
537 }
538 Some(RomsAction::NotesList {
539 rom_id,
540 public_only,
541 search,
542 tags,
543 }) => {
544 let v = client
545 .call(&GetRomNotes {
546 rom_id,
547 public_only: parse_opt_bool("public_only", &public_only)?,
548 search,
549 tags,
550 })
551 .await?;
552 println!("{}", serde_json::to_string_pretty(&v)?);
553 }
554 Some(RomsAction::NotesAdd { rom_id, json: body }) => {
555 let parsed: serde_json::Value = serde_json::from_str(&body)?;
556 let v = client
557 .call(&PostRomNote {
558 rom_id,
559 body: parsed,
560 })
561 .await?;
562 println!("{}", serde_json::to_string_pretty(&v)?);
563 }
564 Some(RomsAction::NotesUpdate {
565 rom_id,
566 note_id,
567 json: body,
568 }) => {
569 let parsed: serde_json::Value = serde_json::from_str(&body)?;
570 let v = client
571 .call(&PutRomNote {
572 rom_id,
573 note_id,
574 body: parsed,
575 })
576 .await?;
577 println!("{}", serde_json::to_string_pretty(&v)?);
578 }
579 Some(RomsAction::NotesDelete { rom_id, note_id }) => {
580 let v = client.call(&DeleteRomNote { rom_id, note_id }).await?;
581 println!("{}", serde_json::to_string_pretty(&v)?);
582 }
583 Some(RomsAction::ManualsAdd { rom_id, file }) => {
584 let v = client.upload_rom_manual(rom_id, &file).await?;
585 println!("{}", serde_json::to_string_pretty(&v)?);
586 }
587 Some(RomsAction::CoverSearch {
588 rom_id,
589 query,
590 search_by,
591 }) => {
592 let cover = client
593 .call(&GetSearchCover {
594 search_term: query.clone(),
595 })
596 .await?;
597 let roms = client
598 .call(&GetSearchRoms {
599 rom_id,
600 search_term: Some(query),
601 search_by: Some(search_by),
602 })
603 .await?;
604 let out = json!({ "cover": cover, "roms": roms });
605 println!("{}", serde_json::to_string_pretty(&out)?);
606 }
607 Some(RomsAction::Metadata(cmd)) => {
608 super::metadata::handle(cmd, client, presentation).await?;
609 }
610 Some(RomsAction::Upload {
611 file,
612 platform,
613 scan,
614 wait,
615 wait_timeout_secs,
616 }) => {
617 let resolved_platform_id = match resolve_platform_id(client, Some(platform.trim()))
618 .await?
619 {
620 Some(id) => id,
621 None => {
622 return Err(anyhow!(
623 "`--platform` must not be empty (use a slug or name from `romm-cli platforms list`)"
624 ));
625 }
626 };
627
628 if !file.exists() {
629 anyhow::bail!("File or directory does not exist: {:?}", file);
630 }
631
632 let mut files = Vec::new();
633 if file.is_dir() {
634 let mut entries = tokio::fs::read_dir(&file).await?;
635 while let Some(entry) = entries.next_entry().await? {
636 let path = entry.path();
637 if path.is_file() {
638 files.push(path);
639 }
640 }
641 files.sort();
642 } else {
643 files.push(file);
644 }
645
646 if files.is_empty() {
647 if presentation.is_json() {
648 println!(
649 "{}",
650 serde_json::to_string_pretty(&serde_json::json!({
651 "uploaded": 0,
652 "failed": 0,
653 "total": 0
654 }))?
655 );
656 } else {
657 println!("No files found to upload.");
658 }
659 return Ok(());
660 }
661
662 if presentation.is_text() && files.len() > 1 {
663 println!("Found {} files to upload.", files.len());
664 }
665
666 let mp = presentation.multi_progress();
667 let style = presentation.progress_style(UPLOAD_PROGRESS_PLAIN, UPLOAD_PROGRESS_COLOR);
668 let total = files.len() as u32;
669 let mut successes = 0u32;
670 let mut failures = 0u32;
671 for path in files {
672 let pb = if let Some(ref mp) = mp {
673 let pb = mp.add(ProgressBar::new(0));
674 pb.set_style(style.clone());
675 pb
676 } else {
677 ProgressBar::hidden()
678 };
679 match upload_one(client, resolved_platform_id, path.clone(), pb).await {
680 Ok(()) => successes += 1,
681 Err(e) => {
682 failures += 1;
683 eprintln!("Error uploading {:?}: {}", path, e);
684 }
685 }
686 }
687
688 if presentation.is_json() {
689 println!(
690 "{}",
691 serde_json::to_string_pretty(&serde_json::json!({
692 "uploaded": successes,
693 "failed": failures,
694 "total": total
695 }))?
696 );
697 }
698
699 if scan {
700 if successes == 0 {
701 eprintln!("Skipping library scan: no uploads completed successfully.");
702 } else {
703 let options = ScanLibraryOptions {
704 wait,
705 wait_timeout: Duration::from_secs(wait_timeout_secs.unwrap_or(3600)),
706 cache_invalidate: if wait {
707 ScanCacheInvalidate::Platform(resolved_platform_id)
708 } else {
709 ScanCacheInvalidate::None
710 },
711 task_kwargs: None,
712 };
713 run_scan_library_flow(client, options, presentation, None).await?;
714 }
715 }
716 }
717 }
718
719 Ok(())
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use clap::Parser;
726
727 use crate::commands::{Cli, Commands};
728
729 #[test]
730 fn parse_roms_list_with_platform_filter() {
731 let cli = Cli::parse_from(["romm-cli", "roms", "--platform", "3ds", "--limit", "10"]);
732 let Commands::Roms(cmd) = cli.command else {
733 panic!("expected roms command");
734 };
735 assert!(cmd.action.is_none());
736 assert_eq!(cmd.list.platform, vec!["3ds".to_string()]);
737 assert_eq!(cmd.list.limit, Some(10));
738 }
739
740 #[test]
741 fn parse_roms_get_rejects_list_only_filter() {
742 let parsed = Cli::try_parse_from(["romm-cli", "roms", "get", "1", "--platform", "3ds"]);
743 assert!(parsed.is_err(), "expected clap parse failure");
744 }
745
746 #[test]
747 fn parse_roms_list_rejects_platform_id_flag() {
748 let parsed = Cli::try_parse_from(["romm-cli", "roms", "--platform-id", "3"]);
749 assert!(parsed.is_err(), "expected clap parse failure");
750 }
751
752 #[test]
753 fn parse_roms_upload_requires_platform() {
754 let parsed = Cli::try_parse_from(["romm-cli", "roms", "upload", "foo.bin"]);
755 assert!(
756 parsed.is_err(),
757 "expected clap parse failure without --platform"
758 );
759 }
760
761 #[test]
762 fn parse_roms_upload_with_platform_and_file() {
763 let cli = Cli::parse_from(["romm-cli", "roms", "upload", "--platform", "3ds", "foo.bin"]);
764 let Commands::Roms(cmd) = cli.command else {
765 panic!("expected roms command");
766 };
767 let Some(RomsAction::Upload { platform, file, .. }) = cmd.action else {
768 panic!("expected roms upload");
769 };
770 assert_eq!(platform, "3ds");
771 assert_eq!(file, PathBuf::from("foo.bin"));
772 }
773}