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 <dir> (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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QLbx8ov7zMcdDPEqQm4Y98
This commit is contained in:
Renamr Scaffold
2026-08-29 11:30:51 +00:00
co-authored by Claude Sonnet 5
parent 5763c6313b
commit 22a3da9f6f
9 changed files with 1108 additions and 26 deletions
+69 -9
View File
@@ -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 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 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 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 "<naming scheme>" [options] <file-or-folder>...` — 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 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 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, rename engine (`rename/` — Move/Copy/Symlink actions,
Skip/Auto-number/Override conflict policies, can also organize Skip/Auto-number/Override conflict policies, can also organize
into (auto-created) subfolders when 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 (`history/`, a partial `HistoryDialog` port, undo-aware of which
action produced each batch). Not yet ported: full media metadata action produced each batch), and pure `-rename` command-line
bindings, other web datasource clients (TheTVDB/AniDB/OMDb/etc). argument parsing/file expansion (`cli/` — no Tauri or network
No Tauri dependency, so it builds and unit-tests standalone. 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 src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
(naming-scheme rendering, filename matching for both TV and (naming-scheme rendering, filename matching for both TV and
movies, metadata fetch — `metadata.rs` for TV episodes (against 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 plan, native file/folder dialogs, drag-and-drop expansion), plus
`settings.rs` (TMDB token, and which TV metadata source Fetch Data `settings.rs` (TMDB token, and which TV metadata source Fetch Data
uses), `history.rs` (rename history/undo uses), `history.rs` (rename history/undo
log), and `presets.rs` (up to 9 saved naming schemes) — all three log), `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — app 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). metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading, 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 batch containing any movies still needs the TMDB token regardless of which TV source is
selected. 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 "<naming scheme>"` (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 <tmdb|tvmaze>` (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 <move|copy|symlink|test>` (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 <skip|auto-number|override>` (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 <dir>` 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) ## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's 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 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 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 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 fixed skip-on-conflict policy no matter which policy the original rename used). The
yet done: the CLI flag surface itself. 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 <dir>` 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, 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, 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 Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
+458
View File
@@ -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 <dir>` (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<TvDbChoice>,
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<PathBuf>,
}
#[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<CliInvocation, CliError> {
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<String> = None;
let mut db: Option<TvDbChoice> = 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 \"<naming scheme>\"".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<PathBuf>) {
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<PathBuf> = 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<PathBuf> {
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::<PathBuf>::new());
}
}
+1
View File
@@ -5,6 +5,7 @@
//! the workspace README for the porting roadmap (matching engine, media metadata //! the workspace README for the porting roadmap (matching engine, media metadata
//! parsing, web datasource clients, rename engine are not yet ported). //! parsing, web datasource clients, rename engine are not yet ported).
pub mod cli;
pub mod format; pub mod format;
pub mod history; pub mod history;
pub mod matching; pub mod matching;
+1
View File
@@ -19,3 +19,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
walkdir = "2" walkdir = "2"
tokio = { version = "1", features = ["sync"] } tokio = { version = "1", features = ["sync"] }
dirs = "6"
+495
View File
@@ -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 <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> 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 <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 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<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 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<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<TmdbClient>,
) -> 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(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();
}
}
+8
View File
@@ -1,3 +1,4 @@
mod cli;
mod files; mod files;
mod history; mod history;
mod metadata; mod metadata;
@@ -79,6 +80,13 @@ async fn execute_rename(
results 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<String>) -> i32 {
cli::run(args)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
+11
View File
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { 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<String> = 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(); aurora_curator_lib::run();
} }
+15 -12
View File
@@ -53,20 +53,23 @@ pub struct FetchResult {
/// One episode of a resolved season - the shape both TMDB's and TVmaze's own /// 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 /// 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 /// caching/lookup loop in `run_fetch` never needs to know which source produced a
/// result. /// result. `pub(crate)` (not just used by the GUI's own `fetch_metadata` command below)
struct LocalEpisode { /// so `cli.rs`'s `-rename --db` orchestration can reuse the exact same season-caching
episode_number: i32, /// lookup rather than duplicating it - see that module's doc comment.
title: String, pub(crate) struct LocalEpisode {
air_date: Option<String>, pub(crate) episode_number: i32,
pub(crate) title: String,
pub(crate) air_date: Option<String>,
} }
type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>; pub(crate) type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
/// Either TV metadata client, picked once per `fetch_metadata` call based on the /// Either TV metadata client, picked once per `fetch_metadata` call (or, for the CLI,
/// stored `TvMetadataSource` setting, then reused across every item in the batch (so a /// once per `-rename --db` invocation) based on the chosen source, then reused across
/// setting change mid-batch can't produce mixed-source results within one Fetch Data /// every item in the batch (so a setting change mid-batch, or a long CLI run, can't
/// click). /// produce mixed-source results within one Fetch Data click or one `-rename` command).
enum AnyTvClient { /// `pub(crate)`: see `LocalEpisode`'s doc comment above.
pub(crate) enum AnyTvClient {
Tmdb(TmdbClient), Tmdb(TmdbClient),
Tvmaze(TvmazeClient), 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 { match self {
AnyTvClient::Tmdb(client) => { AnyTvClient::Tmdb(client) => {
let best = client let best = client
+50 -5
View File
@@ -1,17 +1,30 @@
//! Small persisted app settings - the TMDB API token, and (since the TVmaze datasource //! 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 //! 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 //! 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. //! as plain JSON in the OS's standard per-app config directory, joined with the app's
//! `~/Library/Application Support/com.auroracurator.app/settings.json` on macOS, //! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
//! `~/.config/aurora-curator/settings.json` on Linux) - not the original FileBot's config //! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora Curator's //! com.auroracurator.app\settings.json` on Windows - not the original FileBot's config
//! own, analogous location for its own settings. //! 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 serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{AppHandle, Manager}; 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 /// 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 /// 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. /// there's nothing to pick between for the movie half of Fetch Data.
@@ -66,6 +79,20 @@ pub fn tmdb_token(app: &AppHandle) -> Option<String> {
load(app).tmdb_token.filter(|t| !t.trim().is_empty()) 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<String> {
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] #[tauri::command]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> { pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
tmdb_token(&app) tmdb_token(&app)
@@ -114,6 +141,24 @@ mod tests {
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\""); 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] #[test]
fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() { fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() {
// Settings written before this field existed (just `{"tmdbToken": "..."}` or // Settings written before this field existed (just `{"tmdbToken": "..."}` or