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:
co-authored by
Claude Sonnet 5
parent
5763c6313b
commit
22a3da9f6f
@@ -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 "<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
|
||||
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 "<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)
|
||||
|
||||
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 <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,
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -19,3 +19,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
dirs = "6"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> i32 {
|
||||
cli::run(args)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
|
||||
+15
-12
@@ -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<String>,
|
||||
/// 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<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
|
||||
/// 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
|
||||
|
||||
@@ -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<String> {
|
||||
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]
|
||||
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user