//! Orchestration for the `-rename` command-line mode: ties together the pure argument //! parsing/file-expansion in `aurora_curator_core::cli` with this crate's own //! Tauri-app-free pieces (`settings::cli_tmdb_token`, `metadata::AnyTvClient`) to //! actually build a rename plan and execute it - all without ever constructing a Tauri //! `App`/`Builder`. `main.rs` only calls [`run`] after confirming (via //! `aurora_curator_core::cli::looks_like_cli_invocation`) that this is a `-rename`/ //! `-h`/`--help` invocation in the first place; a plain GUI launch never reaches this //! module at all. //! //! Async network calls (TMDB/TVmaze lookups) run via `tauri::async_runtime::block_on` //! rather than a directly-owned tokio runtime - confirmed by reading Tauri 2.11's own //! `async_runtime.rs` that this lazily creates its own global `Runtime` via `OnceLock`, //! fully independent of any running `App`, so it's safe to call here with no Tauri app //! ever built. This also avoids adding a `rt`/`macros` tokio feature to this crate just //! for the CLI path. //! //! [`build_plan`] is this module's Rust-side port of the binding vocabulary //! `RenamePanel.tsx` builds client-side in the browser (`{n}`, `{s00e00}`, `{show //! name}`, `{episode name}`, release-tag fields, etc.) - see that file's `runMatching`/ //! Fetch Data handlers, which this deliberately mirrors field-for-field, so a naming //! scheme that works in the GUI renders identically from the command line. use aurora_curator_core::cli::{self, CliInvocation, RenameCliArgs, TvDbChoice}; use aurora_curator_core::format::ExpressionFormat; use aurora_curator_core::matching::{self, ReleaseInfo}; use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem}; use aurora_curator_core::string_bindings; use aurora_curator_core::web::omdb::OmdbClient; use aurora_curator_core::web::tmdb::TmdbClient; use aurora_curator_core::web::tvmaze::TvmazeClient; use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::metadata::{AnyMovieClient, AnyTvClient, MovieLookup}; use crate::settings; const USAGE: &str = "\ Aurora Curator - command-line rename mode Usage: aurora-curator -rename --format [options] ... Options: --format Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required) --db Resolve TV episodes online against this source before rendering names (optional; movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog) --action What to do with each file (default: move; \"test\" prints the plan without touching anything) --conflict What to do when a computed target already exists (default: skip) -non-strict Accepted for compatibility with existing rename scripts; has no effect (local matching has no confidence score to loosen - see the aurora_curator_core::cli module doc) -h, --help Show this help and exit A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data Settings at least once - the CLI never accepts a token or key as a command-line argument. "; /// Entry point called from `main.rs`. Returns the process exit code. pub fn run(args: Vec) -> i32 { match cli::parse_args(&args) { Ok(CliInvocation::Gui) => { // `main.rs` only calls `run()` after `looks_like_cli_invocation` already // confirmed a trigger flag is present, so this arm shouldn't be reachable // in practice - but returning cleanly here is safer than panicking if it // ever is. 0 } Ok(CliInvocation::Help) => { print!("{USAGE}"); 0 } Ok(CliInvocation::Rename(rename_args)) => run_rename(rename_args), Err(err) => { eprintln!("error: {err}"); eprintln!(); eprint!("{USAGE}"); 2 } } } fn run_rename(args: RenameCliArgs) -> i32 { let files = cli::expand_inputs(&args.inputs); if files.is_empty() { eprintln!("error: no files found under the given input(s)"); return 1; } let compiled = match ExpressionFormat::compile(&args.format) { Ok(compiled) => compiled, Err(e) => { eprintln!("error: invalid --format expression: {e}"); return 2; } }; // Resolved once up front (not per-file) so a batch never ends up split across two // different saved-token states, and so a missing token/key fails fast instead of // after however many files were already processed. let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) { match settings::cli_tmdb_token() { Some(token) => Some(token), None => { eprintln!( "error: --db tmdb needs a TMDB token already saved through the app's own Fetch Data Settings" ); return 2; } } } else { None }; let omdb_api_key = if args.db == Some(TvDbChoice::Omdb) { match settings::cli_omdb_api_key() { Some(key) => Some(key), None => { eprintln!( "error: --db omdb needs an OMDb API key already saved through the app's own Fetch Data Settings" ); return 2; } } } else { None }; let tv_client = match args.db { None => None, Some(TvDbChoice::Tvmaze) => Some(AnyTvClient::Tvmaze(TvmazeClient::new())), Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new( tmdb_token.clone().expect("checked above"), ))), Some(TvDbChoice::Omdb) => Some(AnyTvClient::Omdb(OmdbClient::new( omdb_api_key.clone().expect("checked above"), ))), }; // Movies get an online lookup whenever --db tmdb or --db omdb was explicitly // passed - TVmaze has no movie catalog, and no --db at all means "local matching // only" for both. let movie_client = match args.db { Some(TvDbChoice::Tmdb) => Some(AnyMovieClient::Tmdb(TmdbClient::new( tmdb_token.clone().expect("checked above"), ))), Some(TvDbChoice::Omdb) => Some(AnyMovieClient::Omdb(OmdbClient::new( omdb_api_key.clone().expect("checked above"), ))), Some(TvDbChoice::Tvmaze) | None => None, }; let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client)); if items.is_empty() { eprintln!("error: every file's naming scheme failed to render - nothing to rename"); return 1; } if args.dry_run { for item in &items { println!("{} -> {}", item.source, item.new_name); } println!( "{} file(s) would be renamed (--action test: nothing was touched)", items.len() ); return 0; } let results = rename::execute_renames(&items, args.action, args.conflict); print_summary(&results) } fn print_summary(results: &[rename::RenameResult]) -> i32 { let mut renamed = 0; let mut unchanged = 0; let mut skipped = 0; let mut failed = 0; for result in results { match &result.outcome { RenameOutcome::Renamed { new_path } => { println!("{} -> {new_path}", result.source); renamed += 1; } RenameOutcome::Unchanged => unchanged += 1, RenameOutcome::SkippedConflict { existing_path } => { eprintln!("skipped (target already exists): {} -> {existing_path}", result.source); skipped += 1; } RenameOutcome::Failed { error } => { eprintln!("failed: {} ({error})", result.source); failed += 1; } } } println!("{renamed} renamed, {unchanged} unchanged, {skipped} skipped, {failed} failed"); if failed > 0 { 1 } else { 0 } } /// `path`'s file stem and (lowercased, dot-free) extension - the same split /// `src-tauri/src/files.rs`'s `to_file_entry` does for the GUI's own file list, so the /// `{name}`/`{ext}` bindings and the extension appended to every rendered name match /// what the GUI would've produced for the same file. fn split_stem_ext(path: &Path) -> (String, String) { let name = path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string_lossy().to_string()); let ext = path .extension() .map(|s| s.to_string_lossy().to_lowercase()) .unwrap_or_default(); (name, ext) } fn insert_release_bindings(bindings: &mut HashMap, release: &ReleaseInfo) { if let Some(resolution) = &release.resolution { bindings.insert("vf".to_string(), resolution.clone()); bindings.insert("resolution".to_string(), resolution.clone()); } if let Some(source) = &release.source { bindings.insert("source".to_string(), source.clone()); } if let Some(video_codec) = &release.video_codec { bindings.insert("vc".to_string(), video_codec.clone()); bindings.insert("video codec".to_string(), video_codec.clone()); } if let Some(audio_codec) = &release.audio_codec { bindings.insert("ac".to_string(), audio_codec.clone()); bindings.insert("audio codec".to_string(), audio_codec.clone()); } if let Some(group) = &release.group { bindings.insert("group".to_string(), group.clone()); bindings.insert("release group".to_string(), group.clone()); } } /// Builds the on-disk rename plan for `files`: local matching first (mirrors the GUI's /// own Match step), then an optional online lookup per file when a client was passed /// (mirrors Fetch Data) - see the module doc for why the binding names below have to /// match `RenamePanel.tsx`'s exactly. async fn build_plan( files: &[PathBuf], compiled: &ExpressionFormat, tv_client: Option, movie_client: Option, ) -> Vec { // Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole // season's worth of files only needs one online search + episode-list fetch. let mut season_cache: HashMap<(String, i32), crate::metadata::SeasonLookup> = HashMap::new(); let mut items = Vec::with_capacity(files.len()); for path in files { let filename = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); let (name, ext) = split_stem_ext(path); let mut bindings: HashMap = HashMap::new(); bindings.insert("name".to_string(), name); bindings.insert("ext".to_string(), ext.clone()); if let Some(release) = matching::guess_release_info(&filename) { insert_release_bindings(&mut bindings, &release); } if let Some(episode) = matching::guess_episode(&filename) { bindings.insert("n".to_string(), episode.series_name.clone()); bindings.insert("s00e00".to_string(), episode.season_episode_label()); bindings.insert("season".to_string(), episode.season.to_string()); bindings.insert("episode".to_string(), episode.episode.to_string()); bindings.insert("show name".to_string(), episode.series_name.clone()); bindings.insert("season number".to_string(), episode.season.to_string()); bindings.insert("episode number".to_string(), format!("{:02}", episode.episode)); if let Some(client) = &tv_client { let key = (episode.series_name.clone(), episode.season); if !season_cache.contains_key(&key) { let looked_up = client.fetch_season(&episode.series_name, episode.season).await; season_cache.insert(key.clone(), looked_up); } if let Some(Ok((series_name, episodes))) = season_cache.get(&key) { if let Some(found) = episodes.iter().find(|e| e.episode_number == episode.episode) { bindings.insert("n".to_string(), series_name.clone()); bindings.insert("t".to_string(), found.title.clone()); bindings.insert("show name".to_string(), series_name.clone()); bindings.insert("episode name".to_string(), found.title.clone()); if let Some(air_date) = &found.air_date { bindings.insert("airdate".to_string(), air_date.clone()); } } } } } else if let Some(movie) = matching::guess_movie(&filename) { bindings.insert("n".to_string(), movie.title.clone()); bindings.insert("movie name".to_string(), movie.title.clone()); if let Some(year) = movie.year { bindings.insert("y".to_string(), year.to_string()); bindings.insert("year".to_string(), year.to_string()); } if let Some(client) = &movie_client { if let Ok(MovieLookup::Found { title, release_date }) = client.lookup_movie(&movie.title, movie.year).await { bindings.insert("n".to_string(), title.clone()); bindings.insert("movie name".to_string(), title); if let Some(release_date) = &release_date { bindings.insert("airdate".to_string(), release_date.clone()); // Only overwrite the locally-guessed year if Match didn't // already find one in the filename itself - same rule // RenamePanel.tsx's Fetch Data handler uses, since a // filename-carried year is usually more precise than the // online source's release date for disambiguating remakes. if movie.year.is_none() { let year: String = release_date.chars().take(4).collect(); bindings.insert("y".to_string(), year.clone()); bindings.insert("year".to_string(), year); } } } } } let rendered = match compiled.format(&string_bindings(bindings)) { Ok(rendered) => rendered, Err(e) => { eprintln!( "warning: couldn't render a name for {} ({e}) - skipping", path.display() ); continue; } }; let new_name = if ext.is_empty() { rendered } else { format!("{rendered}.{ext}") }; items.push(RenamePlanItem { source: path.to_string_lossy().to_string(), new_name, }); } items } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_dir() -> PathBuf { let n = COUNTER.fetch_add(1, Ordering::SeqCst); let dir = std::env::temp_dir().join(format!("aurora_cli_orchestration_test_{n}_{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn build_plan_renders_tv_and_movie_files_locally_with_no_online_client() { let dir = temp_dir(); let tv_file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv"); let movie_file = dir.join("Arrival.2016.1080p.BluRay.x264-GROUP.mkv"); std::fs::write(&tv_file, b"").unwrap(); std::fs::write(&movie_file, b"").unwrap(); let compiled = ExpressionFormat::compile("{n}").unwrap(); let items = tauri::async_runtime::block_on(build_plan( &[tv_file.clone(), movie_file.clone()], &compiled, None, None, )); assert_eq!(items.len(), 2); let tv_item = items .iter() .find(|i| i.source == tv_file.to_string_lossy()) .expect("tv file in plan"); assert_eq!(tv_item.new_name, "Breaking Bad.mkv"); let movie_item = items .iter() .find(|i| i.source == movie_file.to_string_lossy()) .expect("movie file in plan"); assert_eq!(movie_item.new_name, "Arrival.mkv"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn build_plan_falls_back_to_the_bare_filename_when_nothing_is_recognized() { let dir = temp_dir(); let file = dir.join("random_video_file.mp4"); std::fs::write(&file, b"").unwrap(); let compiled = ExpressionFormat::compile("{name}").unwrap(); let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None)); assert_eq!(items.len(), 1); assert_eq!(items[0].new_name, "random_video_file.mp4"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn build_plan_includes_release_tag_bindings() { let dir = temp_dir(); let file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv"); std::fs::write(&file, b"").unwrap(); let compiled = ExpressionFormat::compile("{resolution} {video codec} {release group}").unwrap(); let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None)); assert_eq!(items[0].new_name, "720p x264 KILLERS.mkv"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn run_prints_usage_and_returns_zero_for_help() { assert_eq!(run(vec!["--help".to_string()]), 0); assert_eq!(run(vec!["-rename".to_string(), "--help".to_string()]), 0); } #[test] fn run_returns_zero_for_a_plain_gui_invocation() { // `main.rs` wouldn't actually call `run()` in this case (it checks // `looks_like_cli_invocation` first), but `run()` itself stays defensive and // returns cleanly rather than panicking if it ever is. assert_eq!(run(vec![]), 0); } #[test] fn run_reports_a_parse_error_with_a_nonzero_exit_code() { assert_eq!(run(vec!["-rename".to_string()]), 2); } #[test] fn run_reports_an_error_when_no_files_match_the_inputs() { let code = run(vec![ "-rename".to_string(), "--format".to_string(), "{n}".to_string(), "/definitely/not/a/real/path/anywhere".to_string(), ]); assert_eq!(code, 1); } #[test] fn run_dry_run_does_not_touch_the_filesystem() { let dir = temp_dir(); let file = dir.join("Breaking.Bad.S05E14.mkv"); std::fs::write(&file, b"hello").unwrap(); let code = run(vec![ "-rename".to_string(), "--format".to_string(), "{n}".to_string(), "--action".to_string(), "test".to_string(), file.to_string_lossy().to_string(), ]); assert_eq!(code, 0); assert!(file.exists(), "dry run must not touch the source file"); assert!(!dir.join("Breaking Bad.mkv").exists()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn run_actually_renames_a_file_with_the_default_move_action() { let dir = temp_dir(); let file = dir.join("Breaking.Bad.S05E14.mkv"); std::fs::write(&file, b"hello").unwrap(); let code = run(vec![ "-rename".to_string(), "--format".to_string(), "{n}".to_string(), file.to_string_lossy().to_string(), ]); assert_eq!(code, 0); assert!(!file.exists()); assert!(dir.join("Breaking Bad.mkv").exists()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token() { // This sandbox has no saved settings.json, so `cli_tmdb_token()` reliably // returns None here; skip rather than false-fail if that's ever not true (e.g. // a real token saved from a prior manual run in this same environment). if settings::cli_tmdb_token().is_some() { eprintln!("skipping: a real TMDB token is saved in this environment's config dir"); return; } let dir = temp_dir(); let file = dir.join("Breaking.Bad.S05E14.mkv"); std::fs::write(&file, b"hello").unwrap(); let code = run(vec![ "-rename".to_string(), "--format".to_string(), "{n}".to_string(), "--db".to_string(), "tmdb".to_string(), file.to_string_lossy().to_string(), ]); assert_eq!(code, 2); assert!(file.exists(), "should fail before touching any file"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn run_fails_cleanly_when_db_omdb_is_requested_without_a_saved_key() { // Mirrors `run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token` // above, for the OMDb credential instead. if settings::cli_omdb_api_key().is_some() { eprintln!("skipping: a real OMDb API key is saved in this environment's config dir"); return; } let dir = temp_dir(); let file = dir.join("Breaking.Bad.S05E14.mkv"); std::fs::write(&file, b"hello").unwrap(); let code = run(vec![ "-rename".to_string(), "--format".to_string(), "{n}".to_string(), "--db".to_string(), "omdb".to_string(), file.to_string_lossy().to_string(), ]); assert_eq!(code, 2); assert!(file.exists(), "should fail before touching any file"); std::fs::remove_dir_all(&dir).ok(); } }