use std::path::PathBuf;
use std::time::Duration;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use dialoguer::Confirm;
use indicatif::{ProgressBar, ProgressStyle};
use serde_json::json;
use crate::client::RommClient;
use crate::commands::library_scan::{
run_scan_library_flow, ScanCacheInvalidate, ScanLibraryOptions,
};
use crate::commands::print::print_roms_table;
use crate::commands::OutputFormat;
use crate::endpoints::roms::{
DeleteRomNote, DeleteRoms, GetRomByHash, GetRomByMetadataProvider, GetRomFilters, GetRomNotes,
GetRoms, GetSearchCover, GetSearchRoms, PostRomNote, PutRomNote, PutRomUserProps,
};
use crate::services::{self};
use crate::services::{
resolve_manual_collection_id, resolve_platform_ids, resolve_smart_collection_id, RomService,
};
fn parse_opt_bool(label: &str, raw: &Option<String>) -> Result<Option<bool>> {
let Some(s) = raw else {
return Ok(None);
};
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return Ok(None);
}
match t.as_str() {
"true" | "1" | "yes" | "y" => Ok(Some(true)),
"false" | "0" | "no" | "n" => Ok(Some(false)),
_ => Err(anyhow!(
"Invalid boolean for {}: {:?} (use true or false)",
label,
s
)),
}
}
#[derive(Args, Debug, Clone, Default)]
pub struct RomListArgs {
#[arg(long, visible_aliases = ["query", "q"])]
pub search_term: Option<String>,
#[arg(long, action = clap::ArgAction::Append, visible_alias = "p")]
pub platform: Vec<String>,
#[arg(long)]
pub collection: Option<String>,
#[arg(long)]
pub smart_collection: Option<String>,
#[arg(long)]
pub virtual_collection: Option<String>,
#[arg(long)]
pub limit: Option<u32>,
#[arg(long)]
pub offset: Option<u32>,
#[arg(long)]
pub matched: Option<String>,
#[arg(long)]
pub favorite: Option<String>,
#[arg(long)]
pub duplicate: Option<String>,
#[arg(long)]
pub last_played: Option<String>,
#[arg(long)]
pub playable: Option<String>,
#[arg(long)]
pub missing: Option<String>,
#[arg(long)]
pub has_ra: Option<String>,
#[arg(long)]
pub verified: Option<String>,
#[arg(long)]
pub group_by_meta_id: Option<String>,
#[arg(long)]
pub with_char_index: Option<String>,
#[arg(long)]
pub with_filter_values: Option<String>,
#[arg(long = "genre", action = clap::ArgAction::Append)]
pub genres: Vec<String>,
#[arg(long = "franchise", action = clap::ArgAction::Append)]
pub franchises: Vec<String>,
#[arg(long = "collection-tag", action = clap::ArgAction::Append)]
pub collection_tags: Vec<String>,
#[arg(long = "company", action = clap::ArgAction::Append)]
pub companies: Vec<String>,
#[arg(long = "age-rating", action = clap::ArgAction::Append)]
pub age_ratings: Vec<String>,
#[arg(long = "status", action = clap::ArgAction::Append)]
pub statuses: Vec<String>,
#[arg(long = "region", action = clap::ArgAction::Append)]
pub regions: Vec<String>,
#[arg(long = "language", action = clap::ArgAction::Append)]
pub languages: Vec<String>,
#[arg(long = "player-count", action = clap::ArgAction::Append)]
pub player_counts: Vec<String>,
#[arg(long)]
pub genres_logic: Option<String>,
#[arg(long)]
pub franchises_logic: Option<String>,
#[arg(long)]
pub collections_logic: Option<String>,
#[arg(long)]
pub companies_logic: Option<String>,
#[arg(long)]
pub age_ratings_logic: Option<String>,
#[arg(long)]
pub regions_logic: Option<String>,
#[arg(long)]
pub languages_logic: Option<String>,
#[arg(long)]
pub statuses_logic: Option<String>,
#[arg(long)]
pub player_counts_logic: Option<String>,
#[arg(long)]
pub order_by: Option<String>,
#[arg(long)]
pub order_dir: Option<String>,
#[arg(long)]
pub updated_after: Option<String>,
}
async fn build_get_roms(client: &RommClient, a: RomListArgs) -> Result<GetRoms> {
let platform_ids = resolve_platform_ids(client, &a.platform).await?;
let mut platform_id = None;
let mut extra = platform_ids;
if extra.len() == 1 {
platform_id = Some(extra[0]);
extra.clear();
} else if extra.len() > 1 {
platform_id = None;
}
Ok(GetRoms {
search_term: a.search_term,
platform_id,
platform_ids: extra,
collection_id: resolve_manual_collection_id(client, a.collection.as_deref()).await?,
smart_collection_id: resolve_smart_collection_id(client, a.smart_collection.as_deref())
.await?,
virtual_collection_id: a
.virtual_collection
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
matched: parse_opt_bool("matched", &a.matched)?,
favorite: parse_opt_bool("favorite", &a.favorite)?,
duplicate: parse_opt_bool("duplicate", &a.duplicate)?,
last_played: parse_opt_bool("last_played", &a.last_played)?,
playable: parse_opt_bool("playable", &a.playable)?,
missing: parse_opt_bool("missing", &a.missing)?,
has_ra: parse_opt_bool("has_ra", &a.has_ra)?,
verified: parse_opt_bool("verified", &a.verified)?,
group_by_meta_id: parse_opt_bool("group_by_meta_id", &a.group_by_meta_id)?,
with_char_index: parse_opt_bool("with_char_index", &a.with_char_index)?,
with_filter_values: parse_opt_bool("with_filter_values", &a.with_filter_values)?,
genres: a.genres,
franchises: a.franchises,
collections: a.collection_tags,
companies: a.companies,
age_ratings: a.age_ratings,
statuses: a.statuses,
regions: a.regions,
languages: a.languages,
player_counts: a.player_counts,
genres_logic: a.genres_logic,
franchises_logic: a.franchises_logic,
collections_logic: a.collections_logic,
companies_logic: a.companies_logic,
age_ratings_logic: a.age_ratings_logic,
regions_logic: a.regions_logic,
languages_logic: a.languages_logic,
statuses_logic: a.statuses_logic,
player_counts_logic: a.player_counts_logic,
order_by: a.order_by,
order_dir: a.order_dir,
updated_after: a.updated_after,
limit: a.limit,
offset: a.offset,
})
}
#[derive(Args, Debug)]
pub struct RomsCommand {
#[arg(long, global = true)]
pub json: bool,
#[command(flatten)]
pub list: RomListArgs,
#[command(subcommand)]
pub action: Option<RomsAction>,
}
#[derive(Subcommand, Debug)]
pub enum RomsAction {
#[command(visible_alias = "info")]
Get {
id: u64,
},
Find {
#[arg(long)]
crc: Option<String>,
#[arg(long)]
md5: Option<String>,
#[arg(long)]
sha1: Option<String>,
#[arg(long)]
igdb_id: Option<i64>,
#[arg(long)]
moby_id: Option<i64>,
#[arg(long)]
ss_id: Option<i64>,
#[arg(long)]
ra_id: Option<i64>,
#[arg(long)]
launchbox_id: Option<i64>,
#[arg(long)]
hasheous_id: Option<i64>,
#[arg(long)]
tgdb_id: Option<i64>,
#[arg(long)]
flashpoint_id: Option<String>,
#[arg(long)]
hltb_id: Option<i64>,
},
Filters,
Delete {
#[arg(required = true)]
rom_ids: Vec<u64>,
#[arg(long, action = clap::ArgAction::Append)]
delete_from_fs: Vec<u64>,
#[arg(long)]
yes: bool,
},
Props {
id: u64,
#[arg(long)]
is_main_sibling: Option<String>,
#[arg(long)]
backlogged: Option<String>,
#[arg(long)]
now_playing: Option<String>,
#[arg(long)]
hidden: Option<String>,
#[arg(long)]
rating: Option<u8>,
#[arg(long)]
difficulty: Option<u8>,
#[arg(long)]
completion: Option<u8>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
update_last_played: bool,
#[arg(long)]
remove_last_played: bool,
},
NotesList {
rom_id: u64,
#[arg(long)]
public_only: Option<String>,
#[arg(long)]
search: Option<String>,
#[arg(long = "tag", action = clap::ArgAction::Append)]
tags: Vec<String>,
},
NotesAdd {
rom_id: u64,
#[arg(long)]
json: String,
},
NotesUpdate {
rom_id: u64,
note_id: u64,
#[arg(long)]
json: String,
},
NotesDelete { rom_id: u64, note_id: u64 },
ManualsAdd { rom_id: u64, file: PathBuf },
CoverSearch {
rom_id: u64,
#[arg(long)]
query: String,
#[arg(long, default_value = "name")]
search_by: String,
},
#[command(visible_alias = "up")]
Upload {
#[arg(long)]
platform: String,
file: PathBuf,
#[arg(short, long)]
scan: bool,
#[arg(long, requires = "scan")]
wait: bool,
#[arg(long, requires = "wait")]
wait_timeout_secs: Option<u64>,
},
}
fn make_progress_style() -> ProgressStyle {
ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}",
)
.unwrap()
.progress_chars("#>-")
}
async fn upload_one(
client: &RommClient,
platform_id: u64,
file_path: std::path::PathBuf,
pb: ProgressBar,
) -> Result<()> {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
pb.set_message(format!("Uploading {}", filename));
client
.upload_rom(platform_id, &file_path, {
let pb = pb.clone();
move |uploaded, total| {
if pb.length() != Some(total) {
pb.set_length(total);
}
pb.set_position(uploaded);
}
})
.await?;
pb.finish_with_message(format!("✓ Upload complete: {}", filename));
Ok(())
}
pub async fn handle(cmd: RomsCommand, client: &RommClient, format: OutputFormat) -> Result<()> {
match cmd.action {
None => {
let ep = build_get_roms(client, cmd.list.clone()).await?;
let service = RomService::new(client);
let results = service.search_roms(&ep).await?;
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&results)?),
OutputFormat::Text => print_roms_table(&results),
}
}
Some(RomsAction::Get { id }) => {
let service = RomService::new(client);
let rom = service.get_rom(id).await?;
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rom)?),
OutputFormat::Text => println!("{}", serde_json::to_string_pretty(&rom)?),
}
}
Some(RomsAction::Find {
crc,
md5,
sha1,
igdb_id,
moby_id,
ss_id,
ra_id,
launchbox_id,
hasheous_id,
tgdb_id,
flashpoint_id,
hltb_id,
}) => {
let hash_ep = GetRomByHash {
crc_hash: crc.clone(),
md5_hash: md5.clone(),
sha1_hash: sha1.clone(),
};
let has_hash = crc.is_some() || md5.is_some() || sha1.is_some();
let has_meta = igdb_id.is_some()
|| moby_id.is_some()
|| ss_id.is_some()
|| ra_id.is_some()
|| launchbox_id.is_some()
|| hasheous_id.is_some()
|| tgdb_id.is_some()
|| flashpoint_id.is_some()
|| hltb_id.is_some();
if has_hash == has_meta {
anyhow::bail!("Specify either hash flags (--crc/--md5/--sha1) or metadata id flags (--igdb-id, ...), not both.");
}
let v = if has_hash {
client.call(&hash_ep).await?
} else {
client
.call(&GetRomByMetadataProvider {
igdb_id,
moby_id,
ss_id,
ra_id,
launchbox_id,
hasheous_id,
tgdb_id,
flashpoint_id,
hltb_id,
})
.await?
};
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::Filters) => {
let v = client.call(&GetRomFilters).await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::Delete {
rom_ids,
delete_from_fs,
yes,
}) => {
if !yes {
let ok = Confirm::new()
.with_prompt(format!(
"Delete {} ROM(s) from the database (and {} from disk)?",
rom_ids.len(),
delete_from_fs.len()
))
.interact()?;
if !ok {
return Ok(());
}
}
let v = client
.call(&DeleteRoms {
roms: rom_ids,
delete_from_fs,
})
.await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::Props {
id,
is_main_sibling,
backlogged,
now_playing,
hidden,
rating,
difficulty,
completion,
status,
update_last_played,
remove_last_played,
}) => {
if update_last_played && remove_last_played {
anyhow::bail!(
"--update-last-played and --remove-last-played are mutually exclusive."
);
}
let mut body = json!({});
let obj = body.as_object_mut().unwrap();
if let Some(b) = parse_opt_bool("is_main_sibling", &is_main_sibling)? {
obj.insert("is_main_sibling".into(), json!(b));
}
if let Some(b) = parse_opt_bool("backlogged", &backlogged)? {
obj.insert("backlogged".into(), json!(b));
}
if let Some(b) = parse_opt_bool("now_playing", &now_playing)? {
obj.insert("now_playing".into(), json!(b));
}
if let Some(b) = parse_opt_bool("hidden", &hidden)? {
obj.insert("hidden".into(), json!(b));
}
if let Some(r) = rating {
obj.insert("rating".into(), json!(r));
}
if let Some(d) = difficulty {
obj.insert("difficulty".into(), json!(d));
}
if let Some(c) = completion {
obj.insert("completion".into(), json!(c));
}
if let Some(ref s) = status {
if !s.is_empty() {
obj.insert("status".into(), json!(s));
}
}
let v = client
.call(&PutRomUserProps {
rom_id: id,
body,
update_last_played,
remove_last_played,
})
.await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::NotesList {
rom_id,
public_only,
search,
tags,
}) => {
let v = client
.call(&GetRomNotes {
rom_id,
public_only: parse_opt_bool("public_only", &public_only)?,
search,
tags,
})
.await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::NotesAdd { rom_id, json: body }) => {
let parsed: serde_json::Value = serde_json::from_str(&body)?;
let v = client
.call(&PostRomNote {
rom_id,
body: parsed,
})
.await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::NotesUpdate {
rom_id,
note_id,
json: body,
}) => {
let parsed: serde_json::Value = serde_json::from_str(&body)?;
let v = client
.call(&PutRomNote {
rom_id,
note_id,
body: parsed,
})
.await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::NotesDelete { rom_id, note_id }) => {
let v = client.call(&DeleteRomNote { rom_id, note_id }).await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::ManualsAdd { rom_id, file }) => {
let v = client.upload_rom_manual(rom_id, &file).await?;
println!("{}", serde_json::to_string_pretty(&v)?);
}
Some(RomsAction::CoverSearch {
rom_id,
query,
search_by,
}) => {
let cover = client
.call(&GetSearchCover {
search_term: query.clone(),
})
.await?;
let roms = client
.call(&GetSearchRoms {
rom_id,
search_term: Some(query),
search_by: Some(search_by),
})
.await?;
let out = json!({ "cover": cover, "roms": roms });
println!("{}", serde_json::to_string_pretty(&out)?);
}
Some(RomsAction::Upload {
file,
platform,
scan,
wait,
wait_timeout_secs,
}) => {
let resolved_platform_id = match services::resolve_platform_id(
client,
Some(platform.trim()),
)
.await?
{
Some(id) => id,
None => {
return Err(anyhow!(
"`--platform` must not be empty (use a slug or name from `romm-cli platforms list`)"
));
}
};
if !file.exists() {
anyhow::bail!("File or directory does not exist: {:?}", file);
}
let mut files = Vec::new();
if file.is_dir() {
let mut entries = tokio::fs::read_dir(&file).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
files.push(path);
}
}
files.sort();
} else {
files.push(file);
}
if files.is_empty() {
println!("No files found to upload.");
return Ok(());
}
if files.len() > 1 {
println!("Found {} files to upload.", files.len());
}
let mp = indicatif::MultiProgress::new();
let mut successes = 0u32;
for path in files {
let pb = mp.add(ProgressBar::new(0));
pb.set_style(make_progress_style());
match upload_one(client, resolved_platform_id, path.clone(), pb).await {
Ok(()) => successes += 1,
Err(e) => eprintln!("Error uploading {:?}: {}", path, e),
}
}
if scan {
if successes == 0 {
eprintln!("Skipping library scan: no uploads completed successfully.");
} else {
let options = ScanLibraryOptions {
wait,
wait_timeout: Duration::from_secs(wait_timeout_secs.unwrap_or(3600)),
cache_invalidate: if wait {
ScanCacheInvalidate::Platform(resolved_platform_id)
} else {
ScanCacheInvalidate::None
},
task_kwargs: None,
};
run_scan_library_flow(client, options, format, None).await?;
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use crate::commands::{Cli, Commands};
#[test]
fn parse_roms_list_with_platform_filter() {
let cli = Cli::parse_from(["romm-cli", "roms", "--platform", "3ds", "--limit", "10"]);
let Commands::Roms(cmd) = cli.command else {
panic!("expected roms command");
};
assert!(cmd.action.is_none());
assert_eq!(cmd.list.platform, vec!["3ds".to_string()]);
assert_eq!(cmd.list.limit, Some(10));
}
#[test]
fn parse_roms_get_rejects_list_only_filter() {
let parsed = Cli::try_parse_from(["romm-cli", "roms", "get", "1", "--platform", "3ds"]);
assert!(parsed.is_err(), "expected clap parse failure");
}
#[test]
fn parse_roms_list_rejects_platform_id_flag() {
let parsed = Cli::try_parse_from(["romm-cli", "roms", "--platform-id", "3"]);
assert!(parsed.is_err(), "expected clap parse failure");
}
#[test]
fn parse_roms_upload_requires_platform() {
let parsed = Cli::try_parse_from(["romm-cli", "roms", "upload", "foo.bin"]);
assert!(
parsed.is_err(),
"expected clap parse failure without --platform"
);
}
#[test]
fn parse_roms_upload_with_platform_and_file() {
let cli = Cli::parse_from(["romm-cli", "roms", "upload", "--platform", "3ds", "foo.bin"]);
let Commands::Roms(cmd) = cli.command else {
panic!("expected roms command");
};
let Some(RomsAction::Upload { platform, file, .. }) = cmd.action else {
panic!("expected roms upload");
};
assert_eq!(platform, "3ds");
assert_eq!(file, PathBuf::from("foo.bin"));
}
}