Files
aurora-curator/src-tauri/src/cli.rs
T

553 lines
22 KiB
Rust
Raw Normal View History

//! 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 <naming scheme> [options] <file-or-folder>...
Options:
--format <expr> Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required)
--db <tmdb|tvmaze|omdb> 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 <move|copy|symlink|test> What to do with each file (default: move; \"test\" prints the plan without touching anything)
--conflict <skip|auto-number|override> 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<String>) -> 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<String, String>, 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<AnyTvClient>,
movie_client: Option<AnyMovieClient>,
) -> Vec<RenamePlanItem> {
// 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<String, String> = 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();
}
}