From 22a3da9f6f2d4ee29b84f64fea6ea514c9d1aadd Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 11:30:51 +0000 Subject: [PATCH] Add CLI flag surface: -rename/--format/--db/--action/--conflict/-non-strict/-h Ports FileBot's net.filebot.cli flag surface so Aurora Curator can be scripted, not just driven through the GUI. Argument parsing and file-expansion are pure Rust in aurora-curator-core::cli (14 unit tests, no Tauri/network dependency); orchestration lives in src-tauri/src/cli.rs, which reuses the exact same binding vocabulary the GUI's RenamePanel.tsx builds client-side and the exact same TMDB/TVmaze season-caching lookup fetch_metadata already uses, so a naming scheme renders identically whether it's typed into the app or passed via --format. main.rs branches to CLI mode before any Tauri App/window is built at all, using tauri::async_runtime::block_on to drive the async metadata lookups (confirmed safe standalone by reading Tauri's own async_runtime.rs) and a hand-rolled settings::cli_tmdb_token() that replicates Tauri's own app_config_dir() computation (dirs::config_dir().join(identifier), pinned against tauri.conf.json by a new test) so a token saved through the GUI's Fetch Data Settings is found from the CLI path too - the CLI still never accepts a token as an argument. Deliberately not ported: --output (the rename engine's safety invariant against escaping the source directory tree would need to be relaxed) and -non-strict is accepted as a no-op (local matching has no confidence score to loosen). 16 new tests in src-tauri (10 in cli.rs, 1 pinning settings:: APP_CONFIG_IDENTIFIER) plus 14 in aurora-curator-core's cli module - full workspace suite is 113 passing. README updated with a new "Command-line usage" section and roadmap item 6 marked done for the CLI surface. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QLbx8ov7zMcdDPEqQm4Y98 --- README.md | 78 +++- crates/aurora-curator-core/src/cli/mod.rs | 458 ++++++++++++++++++++ crates/aurora-curator-core/src/lib.rs | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/cli.rs | 495 ++++++++++++++++++++++ src-tauri/src/lib.rs | 8 + src-tauri/src/main.rs | 11 + src-tauri/src/metadata.rs | 27 +- src-tauri/src/settings.rs | 55 ++- 9 files changed, 1108 insertions(+), 26 deletions(-) create mode 100644 crates/aurora-curator-core/src/cli/mod.rs create mode 100644 src-tauri/src/cli.rs diff --git a/README.md b/README.md index 9812426..eef454f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ rest of FileBot's full media-metadata bindings (real probed video/audio stream i are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two panes always stay positionally paired — and up to 9 naming schemes can be saved as presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as -Settings/History). See "Porting roadmap" below. +Settings/History). The app can also be driven entirely from the command line — +`aurora-curator -rename --format "" [options] ...` — for +scripting batch renames without opening the window at all; see "Command-line usage" +below. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -141,11 +144,14 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), rename engine (`rename/` — Move/Copy/Symlink actions, Skip/Auto-number/Override conflict policies, can also organize into (auto-created) subfolders when - the naming scheme includes `/`) — and a rename history/undo log + the naming scheme includes `/`), a rename history/undo log (`history/`, a partial `HistoryDialog` port, undo-aware of which - action produced each batch). Not yet ported: full media metadata - bindings, other web datasource clients (TheTVDB/AniDB/OMDb/etc). - No Tauri dependency, so it builds and unit-tests standalone. + action produced each batch), and pure `-rename` command-line + argument parsing/file expansion (`cli/` — no Tauri or network + dependency at all, see "Command-line usage" below). Not yet + ported: full media metadata bindings, other web datasource + clients (TheTVDB/AniDB/OMDb/etc). No Tauri dependency, so it + builds and unit-tests standalone. src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend (naming-scheme rendering, filename matching for both TV and movies, metadata fetch — `metadata.rs` for TV episodes (against @@ -154,8 +160,12 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands plan, native file/folder dialogs, drag-and-drop expansion), plus `settings.rs` (TMDB token, and which TV metadata source Fetch Data uses), `history.rs` (rename history/undo - log), and `presets.rs` (up to 9 saved naming schemes) — all three - stored as JSON in the OS app-config directory — app + log), `presets.rs` (up to 9 saved naming schemes) — all three + stored as JSON in the OS app-config directory — `cli.rs` (the + `-rename` command-line mode's orchestration: builds and executes a + rename plan against `aurora-curator-core::cli`'s parsed arguments, + with no Tauri `App`/window ever created for it — `main.rs` branches + to it before the GUI is touched at all), and app metadata/icons/window config (tauri.conf.json). src/ React + TypeScript UI (Vite). Faithful recreation of the original two-pane rename screen (Aurora theme) wired to real file loading, @@ -221,6 +231,49 @@ A batch of TV-only files can run entirely on TVmaze with no token configured at batch containing any movies still needs the TMDB token regardless of which TV source is selected. +## Command-line usage + +The same binary that opens the GUI can also run a batch rename straight from a script or +terminal — a port of FileBot's `net.filebot.cli` flag surface (`-rename`, `--format`, +`--db`, `--action`, `--conflict`), with a couple of deliberate differences noted below. +Passing `-rename` (or `-h`/`--help`) on the command line skips the window entirely: + +```sh +aurora-curator -rename --format "{show name} - {season number}x{episode number} - {episode name}" \ + --action move --conflict skip ~/Downloads/some-show/ +``` + +- `--format ""` (required) — the same expression syntax as the GUI's + Naming Scheme field: FileBot short names (`{n}`, `{s00e00}`, `{t}`), human-readable + aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video + codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the + file's own current filename/extension when nothing else matched it. +- `--db ` (optional) — resolves TV episodes against that online source + before rendering names, the command-line equivalent of clicking "Fetch Data" after + Match. Movies are only looked up online when this is `tmdb` — TVmaze has no movie + catalog, and with no `--db` at all every file uses local filename matching only, same + as Match without Fetch Data in the GUI. `--db tmdb` needs a TMDB token that's already + been saved through the app's own Fetch Data Settings at least once — **the CLI never + accepts an API token as an argument**, so there's nothing secret to leak into your + shell history or a script. +- `--action ` (default `move`) — what to do at each computed + target; `test` prints the full plan and touches nothing on disk, useful for checking a + naming scheme before committing to it. +- `--conflict ` (default `skip`, `autonumber`/`auto` also + accepted) — what happens when the computed target already exists. +- `-non-strict` — accepted for compatibility with existing FileBot scripts, but is a + genuine no-op here: the local matcher has no confidence score to loosen, and online + disambiguation (`similarity::best_title_match`) always runs regardless of this flag. +- Every remaining argument is treated as an input file or folder; folders are expanded + recursively (hidden files/dotfolders skipped), matching exactly what the GUI's own + "Select Folder…" and drag-and-drop do. + +Deliberately **not** ported: FileBot's `--output ` flag. The rename engine is +scoped, as a safety invariant, to never move a file outside its own source directory +tree (see "Organizing into subfolders" above) — a naming scheme's own `/` already covers +organizing into a subfolder, and relaxing that invariant just for `--output` wasn't +judged worth it. + ## Porting roadmap (not yet done) Roughly in priority order — each references the original FileBot source file it's @@ -291,8 +344,15 @@ ported from, for anyone picking this up: first free slot), or Override (replaces the conflicting file outright — the one irreversible option, called out with red styling in the UI; undoing that batch later restores the *source*, never what got overwritten, since undo always uses its own - fixed skip-on-conflict policy no matter which policy the original rename used). Not - yet done: the CLI flag surface itself. + fixed skip-on-conflict policy no matter which policy the original rename used). The + CLI flag surface is done too — `-rename`/`--format`/`--db`/`--action`/`--conflict`/ + `-non-strict`/`-h` (`crates/aurora-curator-core/src/cli` for pure argument parsing and + directory expansion, `src-tauri/src/cli.rs` for the orchestration — local matching, + optional TMDB/TVmaze lookup, and the actual rename, all without ever constructing a + Tauri `App`); see "Command-line usage" above. `--output ` and `-get-subtitles`/ + `-script`/the rest of FileBot's non-rename CLI modes are not ported — `-get- + subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode + runtime aren't built at all. 7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview, local TV/movie matching, confirmed rename with per-item exclude, rename history/undo, Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the diff --git a/crates/aurora-curator-core/src/cli/mod.rs b/crates/aurora-curator-core/src/cli/mod.rs new file mode 100644 index 0000000..4d33b9a --- /dev/null +++ b/crates/aurora-curator-core/src/cli/mod.rs @@ -0,0 +1,458 @@ +//! Pure argument-parsing and file-expansion logic for Aurora Curator's command-line +//! rename mode (`-rename ...`) - a port of FileBot's `net.filebot.cli.ArgumentBean`/ +//! `CmdlineOperations` flag surface, letting the app be scripted instead of only driven +//! through the GUI. +//! +//! Deliberately split from the actual filesystem/network orchestration, which lives in +//! `src-tauri/src/cli.rs` (it needs the Tauri-side settings store for the saved TMDB +//! token, which this crate has no access to and shouldn't - see that module's doc +//! comment). Everything here is pure logic with no I/O beyond `expand_inputs`'s +//! directory walk, so it's fully unit-testable without a filesystem mock or a Tauri app +//! at all. +//! +//! Deliberately NOT ported, matching the project's existing FileBot-compatibility +//! philosophy of tracking real usage over completeness: FileBot's `--output ` (the +//! rename engine, see `crate::rename`'s own module doc, is deliberately scoped to never +//! move a file outside its own source directory tree - relaxing that safety invariant +//! for `--output` was judged not worth it, especially since a naming scheme's own `/` +//! already covers "organize into a subfolder") and `-non-strict` (accepted for +//! script-compatibility so existing FileBot invocations don't hard-fail on it, but it's +//! a genuine no-op here - the local matcher has no confidence score to loosen, and +//! online disambiguation, see `crate::similarity::best_title_match`, already runs +//! unconditionally regardless of any flag). + +use crate::rename::{ConflictPolicy, RenameAction}; +use std::path::{Path, PathBuf}; + +/// Which online source `--db` should resolve TV episodes against. Deliberately its own +/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this +/// crate can't depend on that crate, and that enum represents a *persisted GUI +/// preference* anyway, not a per-invocation flag) - a CLI run always states its source +/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's +/// Fetch Data Settings last had selected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TvDbChoice { + Tmdb, + Tvmaze, +} + +/// A fully parsed, valid `-rename` invocation. +#[derive(Debug, Clone, PartialEq)] +pub struct RenameCliArgs { + pub format: String, + pub db: Option, + pub action: RenameAction, + /// `--action test` - print the plan, touch nothing on disk. + pub dry_run: bool, + pub conflict: ConflictPolicy, + /// `-non-strict` was passed - accepted, but see the module doc for why it's a no-op. + pub non_strict: bool, + pub inputs: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CliInvocation { + /// No recognized CLI trigger flag was present at all - launch the normal GUI, + /// unchanged from before this feature existed. + Gui, + /// `-h`/`--help` was passed - print usage and exit, touching neither the GUI nor + /// any files (checked before, and independently of, `-rename`). + Help, + Rename(RenameCliArgs), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliError(pub String); + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The trigger flags that mean "this is a CLI invocation, don't launch the window" - +/// exposed separately so `src-tauri/src/main.rs` can decide GUI-vs-CLI with the exact +/// same predicate `parse_args` itself uses for its `Gui`/`Help` split, rather than +/// duplicating (and risking drifting from) the list of trigger flags. +pub fn looks_like_cli_invocation(args: &[String]) -> bool { + args.iter().any(|a| a == "-rename" || a == "-h" || a == "--help") +} + +/// Parses a full `-rename ...` command line (already stripped of `argv[0]`, the +/// executable's own path). Recognizes `-h`/`--help` first and independently of +/// `-rename`, so `aurora-curator -rename --help` still prints usage rather than +/// demanding a `--format`. +pub fn parse_args(args: &[String]) -> Result { + if args.iter().any(|a| a == "-h" || a == "--help") { + return Ok(CliInvocation::Help); + } + if !args.iter().any(|a| a == "-rename") { + return Ok(CliInvocation::Gui); + } + + let mut format: Option = None; + let mut db: Option = None; + let mut action = RenameAction::Move; + let mut dry_run = false; + let mut conflict = ConflictPolicy::Skip; + let mut non_strict = false; + let mut inputs = Vec::new(); + + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + match arg { + "-rename" => {} + "-non-strict" => non_strict = true, + "--format" => { + i += 1; + let value = args + .get(i) + .ok_or_else(|| CliError("--format needs a value".to_string()))?; + format = Some(value.clone()); + } + "--db" => { + i += 1; + let value = args.get(i).ok_or_else(|| CliError("--db needs a value".to_string()))?; + db = Some(match value.to_ascii_lowercase().as_str() { + "tmdb" => TvDbChoice::Tmdb, + "tvmaze" => TvDbChoice::Tvmaze, + other => { + return Err(CliError(format!( + "unknown --db source \"{other}\" (expected tmdb or tvmaze)" + ))) + } + }); + } + "--action" => { + i += 1; + let value = args + .get(i) + .ok_or_else(|| CliError("--action needs a value".to_string()))?; + match value.to_ascii_lowercase().as_str() { + "move" => action = RenameAction::Move, + "copy" => action = RenameAction::Copy, + "symlink" => action = RenameAction::Symlink, + "test" => dry_run = true, + other => { + return Err(CliError(format!( + "unknown --action \"{other}\" (expected move, copy, symlink, or test)" + ))) + } + } + } + "--conflict" => { + i += 1; + let value = args + .get(i) + .ok_or_else(|| CliError("--conflict needs a value".to_string()))?; + conflict = match value.to_ascii_lowercase().as_str() { + "skip" => ConflictPolicy::Skip, + "auto-number" | "autonumber" | "auto" => ConflictPolicy::AutoNumber, + "override" => ConflictPolicy::Override, + other => { + return Err(CliError(format!( + "unknown --conflict \"{other}\" (expected skip, auto-number, or override)" + ))) + } + }; + } + other if other.starts_with('-') && other != "-" => { + return Err(CliError(format!("unrecognized flag \"{other}\""))); + } + other => inputs.push(PathBuf::from(other)), + } + i += 1; + } + + let format = format.ok_or_else(|| CliError("-rename needs --format \"\"".to_string()))?; + if inputs.is_empty() { + return Err(CliError("-rename needs at least one input file or folder".to_string())); + } + + Ok(CliInvocation::Rename(RenameCliArgs { + format, + db, + action, + dry_run, + conflict, + non_strict, + inputs, + })) +} + +fn is_hidden(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) +} + +fn collect(path: &Path, out: &mut Vec) { + if path.is_file() { + out.push(path.to_path_buf()); + } else if path.is_dir() { + if let Ok(entries) = std::fs::read_dir(path) { + let mut children: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| !is_hidden(p)) + .collect(); + children.sort(); + for child in children { + collect(&child, out); + } + } + } +} + +/// Expands `inputs` (files and/or directories) into a flat, deduplicated, sorted list +/// of regular files - directories are walked recursively and hidden entries (dotfiles/ +/// dotdirs) are skipped, mirroring `src-tauri/src/files.rs`'s `expand()` (used by the +/// GUI's own "Select Folder…" picker and drag-and-drop) so a CLI run and a GUI load of +/// the same folder produce the same file set. A path that doesn't exist at all is +/// silently skipped rather than erroring the whole batch - a typo'd path among several +/// good ones shouldn't block processing the rest. +pub fn expand_inputs(inputs: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for input in inputs { + collect(input, &mut files); + } + files.sort(); + files.dedup(); + files +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn s(text: &str) -> String { + text.to_string() + } + + #[test] + fn no_trigger_flags_means_gui() { + // No `-rename`/`-h`/`--help` present at all: treated as a plain GUI launch, + // even with other stray args present (e.g. whatever the OS/launcher passes) - + // only once `-rename` itself is present does an unrecognized flag become an + // error (see `unrecognized_flag_errors_instead_of_being_treated_as_an_input`). + assert_eq!(parse_args(&[]).unwrap(), CliInvocation::Gui); + assert_eq!(parse_args(&[s("--version")]).unwrap(), CliInvocation::Gui); + assert_eq!(parse_args(&[s("some-random-arg")]).unwrap(), CliInvocation::Gui); + } + + #[test] + fn help_wins_even_alongside_rename() { + assert_eq!(parse_args(&[s("--help")]).unwrap(), CliInvocation::Help); + assert_eq!(parse_args(&[s("-h")]).unwrap(), CliInvocation::Help); + assert_eq!( + parse_args(&[s("-rename"), s("--help")]).unwrap(), + CliInvocation::Help + ); + } + + #[test] + fn rename_requires_format() { + let err = parse_args(&[s("-rename"), s("file.mkv")]).unwrap_err(); + assert!(err.0.contains("--format")); + } + + #[test] + fn rename_requires_at_least_one_input() { + let err = parse_args(&[s("-rename"), s("--format"), s("{n}")]).unwrap_err(); + assert!(err.0.contains("input")); + } + + #[test] + fn minimal_valid_invocation_defaults_move_and_skip() { + let parsed = parse_args(&[s("-rename"), s("--format"), s("{n}"), s("file.mkv")]).unwrap(); + match parsed { + CliInvocation::Rename(args) => { + assert_eq!(args.format, "{n}"); + assert_eq!(args.db, None); + assert_eq!(args.action, RenameAction::Move); + assert_eq!(args.dry_run, false); + assert_eq!(args.conflict, ConflictPolicy::Skip); + assert_eq!(args.non_strict, false); + assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]); + } + other => panic!("expected Rename, got {other:?}"), + } + } + + #[test] + fn db_is_case_insensitive_and_rejects_unknown_sources() { + let parsed = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--db"), + s("TVMAZE"), + s("file.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Tvmaze)), + other => panic!("expected Rename, got {other:?}"), + } + + let err = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--db"), + s("thetvdb"), + s("file.mkv"), + ]) + .unwrap_err(); + assert!(err.0.contains("thetvdb")); + } + + #[test] + fn action_test_sets_dry_run_without_changing_the_conflict_policy() { + let parsed = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--action"), + s("test"), + s("file.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => { + assert_eq!(args.dry_run, true); + assert_eq!(args.action, RenameAction::Move); + } + other => panic!("expected Rename, got {other:?}"), + } + } + + #[test] + fn conflict_accepts_filebot_style_aliases() { + for (alias, expected) in [ + ("skip", ConflictPolicy::Skip), + ("auto-number", ConflictPolicy::AutoNumber), + ("autonumber", ConflictPolicy::AutoNumber), + ("auto", ConflictPolicy::AutoNumber), + ("override", ConflictPolicy::Override), + ] { + let parsed = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--conflict"), + s(alias), + s("file.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => assert_eq!(args.conflict, expected, "alias {alias}"), + other => panic!("expected Rename, got {other:?}"), + } + } + } + + #[test] + fn non_strict_flag_is_recorded_but_does_not_consume_a_value() { + let parsed = parse_args(&[ + s("-rename"), + s("-non-strict"), + s("--format"), + s("{n}"), + s("file.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => { + assert_eq!(args.non_strict, true); + assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]); + } + other => panic!("expected Rename, got {other:?}"), + } + } + + #[test] + fn unrecognized_flag_errors_instead_of_being_treated_as_an_input() { + let err = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--output"), + s("/tmp"), + s("file.mkv"), + ]) + .unwrap_err(); + assert!(err.0.contains("--output")); + } + + #[test] + fn multiple_inputs_are_all_collected() { + let parsed = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("a.mkv"), + s("b.mkv"), + s("c.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => { + assert_eq!( + args.inputs, + vec![PathBuf::from("a.mkv"), PathBuf::from("b.mkv"), PathBuf::from("c.mkv")] + ); + } + other => panic!("expected Rename, got {other:?}"), + } + } + + 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_test_{n}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn expand_inputs_walks_directories_recursively_and_skips_hidden_entries() { + let root = temp_dir(); + std::fs::write(root.join("top.mkv"), b"").unwrap(); + std::fs::write(root.join(".hidden.mkv"), b"").unwrap(); + std::fs::create_dir_all(root.join("season 1")).unwrap(); + std::fs::write(root.join("season 1").join("e01.mkv"), b"").unwrap(); + std::fs::create_dir_all(root.join(".hidden_dir")).unwrap(); + std::fs::write(root.join(".hidden_dir").join("e02.mkv"), b"").unwrap(); + + let files = expand_inputs(&[root.clone()]); + + assert_eq!(files.len(), 2, "found: {files:?}"); + assert!(files.contains(&root.join("top.mkv"))); + assert!(files.contains(&root.join("season 1").join("e01.mkv"))); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn expand_inputs_accepts_a_bare_file_and_dedupes_overlapping_inputs() { + let root = temp_dir(); + let file = root.join("only.mkv"); + std::fs::write(&file, b"").unwrap(); + + let files = expand_inputs(&[file.clone(), file.clone(), root.clone()]); + assert_eq!(files, vec![file]); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn expand_inputs_silently_skips_a_nonexistent_path() { + let files = expand_inputs(&[PathBuf::from("/definitely/not/a/real/path/anywhere")]); + assert_eq!(files, Vec::::new()); + } +} diff --git a/crates/aurora-curator-core/src/lib.rs b/crates/aurora-curator-core/src/lib.rs index d6885a5..dac13e8 100644 --- a/crates/aurora-curator-core/src/lib.rs +++ b/crates/aurora-curator-core/src/lib.rs @@ -5,6 +5,7 @@ //! the workspace README for the porting roadmap (matching engine, media metadata //! parsing, web datasource clients, rename engine are not yet ported). +pub mod cli; pub mod format; pub mod history; pub mod matching; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a1fbd9..efd7cb4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,3 +19,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" walkdir = "2" tokio = { version = "1", features = ["sync"] } +dirs = "6" diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 0000000..57fedca --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,495 @@ +//! 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::tmdb::TmdbClient; +use aurora_curator_core::web::tvmaze::TvmazeClient; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::metadata::AnyTvClient; +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 only looked up online when this is tmdb - 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 FileBot script compatibility; 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 source needs a TMDB token already saved through the app's own Fetch Data Settings at least once - +the CLI never accepts a token 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 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 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"), + ))), + }; + // Movies only get an online lookup when --db tmdb was explicitly passed - TVmaze + // has no movie catalog, and no --db at all means "local matching only" for both. + let movie_client = tmdb_token.map(TmdbClient::new); + + 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(info) = client.lookup_movie(&movie.title, movie.year).await { + bindings.insert("n".to_string(), info.title.clone()); + bindings.insert("movie name".to_string(), info.title.clone()); + if let Some(release_date) = &info.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 TMDB'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(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4853828..da05153 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod cli; mod files; mod history; mod metadata; @@ -79,6 +80,13 @@ async fn execute_rename( results } +/// Entry point for the `-rename` command-line mode - see `cli.rs`'s module doc. Never +/// builds a Tauri `App`/`Builder` at all, unlike [`run`] below. Returns the process +/// exit code for `main.rs` to pass to `std::process::exit`. +pub fn run_cli(args: Vec) -> i32 { + cli::run(args) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e89a402..0c4810a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // `-rename`/`-h`/`--help` on the command line means this is a scripted CLI + // invocation, not a normal double-click GUI launch - see + // `aurora_curator_core::cli`'s module doc. Checked with the exact same predicate + // `cli::parse_args` itself uses internally, so this can never drift out of sync + // with what actually triggers CLI mode. No args at all (the normal launch case) + // falls straight through to the unchanged GUI path below. + let args: Vec = std::env::args().skip(1).collect(); + if aurora_curator_core::cli::looks_like_cli_invocation(&args) { + std::process::exit(aurora_curator_lib::run_cli(args)); + } + aurora_curator_lib::run(); } diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index c7d4285..106595e 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -53,20 +53,23 @@ pub struct FetchResult { /// One episode of a resolved season - the shape both TMDB's and TVmaze's own /// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the /// caching/lookup loop in `run_fetch` never needs to know which source produced a -/// result. -struct LocalEpisode { - episode_number: i32, - title: String, - air_date: Option, +/// result. `pub(crate)` (not just used by the GUI's own `fetch_metadata` command below) +/// so `cli.rs`'s `-rename --db` orchestration can reuse the exact same season-caching +/// lookup rather than duplicating it - see that module's doc comment. +pub(crate) struct LocalEpisode { + pub(crate) episode_number: i32, + pub(crate) title: String, + pub(crate) air_date: Option, } -type SeasonLookup = Result<(String, Vec), String>; +pub(crate) type SeasonLookup = Result<(String, Vec), String>; -/// Either TV metadata client, picked once per `fetch_metadata` call based on the -/// stored `TvMetadataSource` setting, then reused across every item in the batch (so a -/// setting change mid-batch can't produce mixed-source results within one Fetch Data -/// click). -enum AnyTvClient { +/// Either TV metadata client, picked once per `fetch_metadata` call (or, for the CLI, +/// once per `-rename --db` invocation) based on the chosen source, then reused across +/// every item in the batch (so a setting change mid-batch, or a long CLI run, can't +/// produce mixed-source results within one Fetch Data click or one `-rename` command). +/// `pub(crate)`: see `LocalEpisode`'s doc comment above. +pub(crate) enum AnyTvClient { Tmdb(TmdbClient), Tvmaze(TvmazeClient), } @@ -81,7 +84,7 @@ impl AnyTvClient { } } - async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup { + pub(crate) async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup { match self { AnyTvClient::Tmdb(client) => { let best = client diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5aecc9b..6289027 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,17 +1,30 @@ //! Small persisted app settings - the TMDB API token, and (since the TVmaze datasource //! was added) which TV metadata source Fetch Data should use ("Fetch Data" is the only //! thing in the app that needs settings at all; everything else works offline). Stored -//! as plain JSON in Tauri's per-OS app config directory (e.g. -//! `~/Library/Application Support/com.auroracurator.app/settings.json` on macOS, -//! `~/.config/aurora-curator/settings.json` on Linux) - not the original FileBot's config -//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora Curator's -//! own, analogous location for its own settings. +//! as plain JSON in the OS's standard per-app config directory, joined with the app's +//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json` +//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\ +//! com.auroracurator.app\settings.json` on Windows - not the original FileBot's config +//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora +//! Curator's own, analogous location for its own settings. +//! +//! The GUI reaches that directory through a running Tauri `AppHandle` +//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a +//! Tauri app at all, so it needs the exact same directory computed a different way - +//! see [`cli_tmdb_token`] below, which replicates Tauri's own `dirs::config_dir() +//! .join(identifier)` computation directly (confirmed against Tauri 2.11's +//! `path/desktop.rs` source) so both code paths are guaranteed to read/write the same +//! file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s +//! `identifier` field by hand - there's no way to share a constant with the JSON config. use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use tauri::{AppHandle, Manager}; +/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc. +const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app"; + /// Which online source Fetch Data resolves TV episodes against. Movies always use /// TMDB regardless of this setting - TVmaze doesn't have a movie catalog at all, so /// there's nothing to pick between for the movie half of Fetch Data. @@ -66,6 +79,20 @@ pub fn tmdb_token(app: &AppHandle) -> Option { load(app).tmdb_token.filter(|t| !t.trim().is_empty()) } +/// Same as [`tmdb_token`], but for the `-rename` CLI path, which has no `AppHandle` to +/// ask (it never builds a Tauri app at all - see `cli.rs`'s module doc). Resolves the +/// config directory itself using the identical `dirs::config_dir().join(identifier)` +/// computation Tauri's own `AppHandle::path().app_config_dir()` uses internally, so a +/// token saved through the GUI's Fetch Data Settings is found here too. Aurora Curator +/// still never accepts an API token as a CLI argument - `--db tmdb` only works once a +/// token has been saved through the app itself at least once. +pub fn cli_tmdb_token() -> Option { + let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER); + let contents = fs::read_to_string(dir.join("settings.json")).ok()?; + let settings: StoredSettings = serde_json::from_str(&contents).ok()?; + settings.tmdb_token.filter(|t| !t.trim().is_empty()) +} + #[tauri::command] pub fn get_tmdb_token(app: AppHandle) -> Option { tmdb_token(&app) @@ -114,6 +141,24 @@ mod tests { assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\""); } + #[test] + fn app_config_identifier_matches_tauri_conf_json() { + // `cli_tmdb_token()` recomputes the GUI's settings-file path by hand (no + // `AppHandle` available in `-rename` mode), so this constant has to be kept in + // sync with `tauri.conf.json`'s `identifier` field manually - see the module + // doc. There's no way to share a single source of truth between a Rust const + // and the JSON config, so this test at least pins the value we're relying on: + // a change to `tauri.conf.json` without a matching change here would silently + // make the CLI look in the wrong directory for a token the GUI already saved. + let conf = include_str!("../tauri.conf.json"); + let parsed: serde_json::Value = serde_json::from_str(conf).unwrap(); + assert_eq!( + parsed["identifier"].as_str(), + Some(APP_CONFIG_IDENTIFIER), + "APP_CONFIG_IDENTIFIER must match tauri.conf.json's identifier field" + ); + } + #[test] fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() { // Settings written before this field existed (just `{"tmdbToken": "..."}` or