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
26fn 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#[derive(Args, Debug, Clone, Default)]
48pub struct RomListArgs {
49 #[arg(long, visible_aliases = ["query", "q"])]
50 pub search_term: Option<String>,
51 #[arg(long, action = clap::ArgAction::Append, visible_alias = "p")]
53 pub platform: Vec<String>,
54 #[arg(long)]
56 pub collection: Option<String>,
57 #[arg(long)]
59 pub smart_collection: Option<String>,
60 #[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#[derive(Args, Debug)]
194pub struct RomsCommand {
195 #[arg(long, global = true)]
197 pub json: bool,
198
199 #[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 #[command(visible_alias = "info")]
211 Get {
212 id: u64,
214 },
215 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 Filters,
244 Delete {
246 #[arg(required = true)]
248 rom_ids: Vec<u64>,
249 #[arg(long, action = clap::ArgAction::Append)]
251 delete_from_fs: Vec<u64>,
252 #[arg(long)]
254 yes: bool,
255 },
256 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 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 NotesAdd {
292 rom_id: u64,
293 #[arg(long)]
295 json: String,
296 },
297 NotesUpdate {
299 rom_id: u64,
300 note_id: u64,
301 #[arg(long)]
302 json: String,
303 },
304 NotesDelete { rom_id: u64, note_id: u64 },
306 ManualsAdd { rom_id: u64, file: PathBuf },
308 CoverSearch {
310 rom_id: u64,
311 #[arg(long)]
312 query: String,
313 #[arg(long, default_value = "name")]
314 search_by: String,
315 },
316 #[command(visible_alias = "up")]
318 Upload {
319 #[arg(long)]
321 platform: String,
322 file: PathBuf,
324 #[arg(short, long)]
326 scan: bool,
327 #[arg(long, requires = "scan")]
329 wait: bool,
330 #[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}