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
25fn 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#[derive(Args, Debug, Clone, Default)]
47pub struct RomListArgs {
48 #[arg(long, visible_aliases = ["query", "q"])]
49 pub search_term: Option<String>,
50 #[arg(long, action = clap::ArgAction::Append, visible_alias = "p")]
52 pub platform: Vec<String>,
53 #[arg(long)]
55 pub collection: Option<String>,
56 #[arg(long)]
58 pub smart_collection: Option<String>,
59 #[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#[derive(Args, Debug)]
193pub struct RomsCommand {
194 #[arg(long, global = true)]
196 pub json: bool,
197
198 #[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 #[command(visible_alias = "info")]
210 Get {
211 id: u64,
213 },
214 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 Filters,
243 Delete {
245 #[arg(required = true)]
247 rom_ids: Vec<u64>,
248 #[arg(long, action = clap::ArgAction::Append)]
250 delete_from_fs: Vec<u64>,
251 #[arg(long)]
253 yes: bool,
254 },
255 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 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 NotesAdd {
291 rom_id: u64,
292 #[arg(long)]
294 json: String,
295 },
296 NotesUpdate {
298 rom_id: u64,
299 note_id: u64,
300 #[arg(long)]
301 json: String,
302 },
303 NotesDelete { rom_id: u64, note_id: u64 },
305 ManualsAdd { rom_id: u64, file: PathBuf },
307 CoverSearch {
309 rom_id: u64,
310 #[arg(long)]
311 query: String,
312 #[arg(long, default_value = "name")]
313 search_by: String,
314 },
315 #[command(visible_alias = "up")]
317 Upload {
318 #[arg(long)]
320 platform: String,
321 file: PathBuf,
323 #[arg(short, long)]
325 scan: bool,
326 #[arg(long, requires = "scan")]
328 wait: bool,
329 #[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}