Rebrand: Renamr -> Aurora Curator (new logo/app icon, renamed crate aurora-curator-core, package/identifier/window-title updates, README + in-app text)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "aurora-curator-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Aurora Curator core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
|
||||
|
||||
[dependencies]
|
||||
# Embeddable scripting engine powering the {expression} naming-scheme language.
|
||||
# Stands in for the Groovy JSR-223 engine FileBot used (Groovy is JVM-only, not available
|
||||
# to a Rust core) while keeping the same outer `{ }` template syntax.
|
||||
rhai = { version = "1.19", features = ["sync"] }
|
||||
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Filename-based season/episode detection (local, no lookahead/lookbehind support
|
||||
# unlike Java's regex engine used by the original SeasonEpisodeMatcher, so patterns
|
||||
# are adapted to rely on \b word boundaries instead).
|
||||
regex = "1"
|
||||
|
||||
# TMDB metadata client ("Fetch Data"). rustls instead of the default openssl-backed TLS
|
||||
# so this doesn't need system OpenSSL dev headers on every platform we target.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
||||
# tested without live network access or a real API key.
|
||||
mockito = "1"
|
||||
@@ -0,0 +1,16 @@
|
||||
use rhai::Engine;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::functions;
|
||||
|
||||
/// Shared Rhai engine, built once. Engines are relatively expensive to construct
|
||||
/// (loading the standard package), so every [`crate::format::ExpressionFormat`]
|
||||
/// compiles against this single shared instance.
|
||||
pub(super) fn shared() -> &'static Engine {
|
||||
static ENGINE: OnceLock<Engine> = OnceLock::new();
|
||||
ENGINE.get_or_init(|| {
|
||||
let mut engine = Engine::new();
|
||||
functions::register(&mut engine);
|
||||
engine
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use rhai::{Dynamic, Scope, AST};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::engine;
|
||||
|
||||
/// The variables available inside a `{ }` expression, e.g. `n` -> show name.
|
||||
pub type Bindings = HashMap<String, Dynamic>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExpressionError {
|
||||
#[error("syntax error in expression `{expression}`: {message}")]
|
||||
Syntax { expression: String, message: String },
|
||||
#[error("expression yields empty value: {0}")]
|
||||
Empty(String),
|
||||
}
|
||||
|
||||
enum Segment {
|
||||
Literal(String),
|
||||
/// Fast path for bare identifiers like `{n}` or `{s00e00}`: skips the script engine
|
||||
/// entirely, both for speed and so an undefined binding fails predictably.
|
||||
Variable(String),
|
||||
Script(AST),
|
||||
}
|
||||
|
||||
/// A compiled naming-scheme expression, e.g. `"{n} - {s00e00} - {t}"`.
|
||||
///
|
||||
/// Compilation is separate from formatting (like `java.text.Format`/FileBot's
|
||||
/// `ExpressionFormat`) so a pattern is parsed once and then applied to many files.
|
||||
#[derive(Debug)]
|
||||
pub struct ExpressionFormat {
|
||||
expression: String,
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Segment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Segment::Literal(s) => write!(f, "Literal({s:?})"),
|
||||
Segment::Variable(s) => write!(f, "Variable({s:?})"),
|
||||
Segment::Script(_) => write!(f, "Script(..)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpressionFormat {
|
||||
pub fn compile(expression: &str) -> Result<Self, ExpressionError> {
|
||||
let segments = parse(expression)?;
|
||||
Ok(Self {
|
||||
expression: expression.to_string(),
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn expression(&self) -> &str {
|
||||
&self.expression
|
||||
}
|
||||
|
||||
/// Renders this pattern against the given bindings. Mirrors FileBot's semantics:
|
||||
/// a scriptlet that fails to evaluate (e.g. references an undefined/empty binding)
|
||||
/// simply contributes nothing, but the *overall* result must be non-empty.
|
||||
pub fn format(&self, bindings: &Bindings) -> Result<String, ExpressionError> {
|
||||
let engine = engine::shared();
|
||||
let mut out = String::new();
|
||||
|
||||
for segment in &self.segments {
|
||||
match segment {
|
||||
Segment::Literal(text) => out.push_str(text),
|
||||
Segment::Variable(name) => {
|
||||
if let Some(value) = bindings.get(name) {
|
||||
if !value.is_unit() {
|
||||
out.push_str(&value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Segment::Script(ast) => {
|
||||
let mut scope = Scope::new();
|
||||
for (key, value) in bindings {
|
||||
scope.push_dynamic(key.clone(), value.clone());
|
||||
}
|
||||
if let Ok(value) = engine.eval_ast_with_scope::<Dynamic>(&mut scope, ast) {
|
||||
if !value.is_unit() {
|
||||
out.push_str(&value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
return Err(ExpressionError::Empty(self.expression.clone()));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_identifier(token: &str) -> bool {
|
||||
let mut chars = token.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn compile_scriptlet(token: &str, expression: &str) -> Result<Segment, ExpressionError> {
|
||||
if is_identifier(token) {
|
||||
return Ok(Segment::Variable(token.to_string()));
|
||||
}
|
||||
|
||||
match engine::shared().compile(token) {
|
||||
Ok(ast) => Ok(Segment::Script(ast)),
|
||||
Err(e) => {
|
||||
// Not valid as a single bare identifier, and not valid as a Rhai script
|
||||
// either - but if it otherwise looks like a plain, human-typed field name
|
||||
// (e.g. "show name": just letters/digits/spaces/underscores), treat the
|
||||
// whole thing as a literal binding key instead of surfacing a syntax
|
||||
// error. This is what lets a naming scheme like
|
||||
// `{show name} - {season number}x{episode number} - {episode name}` work
|
||||
// without every such phrase needing to be a reserved single-word binding.
|
||||
// Genuine script syntax errors (unmatched parens/quotes, bad operators,
|
||||
// etc.) still get reported exactly as before.
|
||||
let trimmed = token.trim();
|
||||
if is_plain_field_name(trimmed) {
|
||||
Ok(Segment::Variable(trimmed.to_string()))
|
||||
} else {
|
||||
Err(ExpressionError::Syntax {
|
||||
expression: expression.to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a token that reads like a plain (possibly multi-word) field name rather
|
||||
/// than code: starts with a letter or underscore, and contains only letters, digits,
|
||||
/// underscores, and internal spaces. Only consulted as a fallback after Rhai has
|
||||
/// already rejected the token as a script.
|
||||
fn is_plain_field_name(token: &str) -> bool {
|
||||
let mut chars = token.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_alphanumeric() || c == '_' || c == ' ')
|
||||
}
|
||||
|
||||
/// Splits an expression into literal text and `{ }` scriptlets, honoring nested braces
|
||||
/// (so a scriptlet may itself contain `{ }`, e.g. a map literal or block).
|
||||
fn parse(expression: &str) -> Result<Vec<Segment>, ExpressionError> {
|
||||
let mut segments = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut level = 0i32;
|
||||
|
||||
for c in expression.chars() {
|
||||
match c {
|
||||
'{' => {
|
||||
if level == 0 {
|
||||
if !token.is_empty() {
|
||||
segments.push(Segment::Literal(std::mem::take(&mut token)));
|
||||
}
|
||||
} else {
|
||||
token.push(c);
|
||||
}
|
||||
level += 1;
|
||||
}
|
||||
'}' => {
|
||||
level -= 1;
|
||||
if level < 0 {
|
||||
return Err(ExpressionError::Syntax {
|
||||
expression: expression.to_string(),
|
||||
message: "unexpected '}'".to_string(),
|
||||
});
|
||||
}
|
||||
if level == 0 {
|
||||
if !token.is_empty() {
|
||||
segments.push(compile_scriptlet(&std::mem::take(&mut token), expression)?);
|
||||
}
|
||||
} else {
|
||||
token.push(c);
|
||||
}
|
||||
}
|
||||
_ => token.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
if level != 0 {
|
||||
return Err(ExpressionError::Syntax {
|
||||
expression: expression.to_string(),
|
||||
message: "missing '}'".to_string(),
|
||||
});
|
||||
}
|
||||
if !token.is_empty() {
|
||||
segments.push(Segment::Literal(token));
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
|
||||
//!
|
||||
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
|
||||
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
|
||||
//! friends as built-in string methods, so this module only adds the FileBot-specific
|
||||
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
|
||||
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
|
||||
|
||||
use rhai::{Array, Dynamic, Engine};
|
||||
|
||||
fn is_empty_value(value: &Dynamic) -> bool {
|
||||
if value.is_unit() {
|
||||
return true;
|
||||
}
|
||||
if let Some(s) = value.clone().try_cast::<rhai::ImmutableString>() {
|
||||
return s.is_empty();
|
||||
}
|
||||
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||
return arr.is_empty();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns the first array element that isn't empty/unit, or `()` if all are empty.
|
||||
fn any_of(candidates: Array) -> Dynamic {
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|v| !is_empty_value(v))
|
||||
.unwrap_or(Dynamic::UNIT)
|
||||
}
|
||||
|
||||
/// Returns all non-empty elements, preserving order.
|
||||
fn all_of(candidates: Array) -> Array {
|
||||
candidates.into_iter().filter(|v| !is_empty_value(v)).collect()
|
||||
}
|
||||
|
||||
/// Concatenates all non-empty elements' string forms with no separator.
|
||||
fn concat(parts: Array) -> String {
|
||||
parts
|
||||
.into_iter()
|
||||
.filter(|v| !is_empty_value(v))
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
pub fn register(engine: &mut Engine) {
|
||||
engine.register_fn("is_empty_value", is_empty_value);
|
||||
engine.register_fn("any", any_of);
|
||||
engine.register_fn("all_of", all_of);
|
||||
engine.register_fn("concat", concat);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Aurora Curator's naming-scheme expression language.
|
||||
//!
|
||||
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
|
||||
//! `"{n} - {s00e00} - {t}"`
|
||||
//! Everything inside `{ }` is evaluated as an expression against a set of "bindings"
|
||||
//! (the variables available to the expression, e.g. `n` = show name).
|
||||
//!
|
||||
//! This keeps FileBot's outer `{ }` template syntax and simple-binding behavior
|
||||
//! (`{n}`, `{s00e00}`, ...), so existing simple rename patterns keep working unchanged.
|
||||
//! The inner expression *language* is Rhai rather than Groovy (Groovy is JVM-only and
|
||||
//! this core is plain Rust) -- see the crate README for the compatibility notes on more
|
||||
//! complex chained-method expressions.
|
||||
|
||||
mod engine;
|
||||
mod expression;
|
||||
mod functions;
|
||||
|
||||
pub use expression::{ExpressionError, ExpressionFormat};
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Rename history: a persisted log of past rename batches (what changed, from what to
|
||||
//! what) and the ability to revert one - Aurora Curator's only safety net beyond the
|
||||
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
|
||||
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
|
||||
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
|
||||
//! session/element log - simpler, and all this app actually needs.
|
||||
//!
|
||||
//! This module only implements the *logic* (recording, trimming, reverting); where the
|
||||
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
|
||||
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
|
||||
|
||||
use crate::rename::RenameAction;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// One file's old path -> new path, as recorded right after a successful rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryEntry {
|
||||
pub source: String,
|
||||
pub new_path: String,
|
||||
}
|
||||
|
||||
/// One executed rename operation - everything renamed together in a single "Rename N
|
||||
/// Files" confirmation - undoable as a unit. All entries in a batch share one
|
||||
/// [`RenameAction`], since the UI picks a single action per confirmation, not per file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryBatch {
|
||||
pub id: String,
|
||||
/// Unix epoch milliseconds when the batch was executed. Kept as a plain number
|
||||
/// (no date/time dependency needed in this crate) so the frontend can format it
|
||||
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
|
||||
pub timestamp: u64,
|
||||
/// What was actually done to produce `entries.new_path` from `entries.source`.
|
||||
/// `#[serde(default)]` so a `history.json` written before this field existed (every
|
||||
/// batch back then was necessarily a Move) still deserializes cleanly.
|
||||
#[serde(default)]
|
||||
pub action: RenameAction,
|
||||
pub entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// What happened when reverting one entry of a batch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum RevertOutcome {
|
||||
/// For a Move batch: successfully moved back to its original path. For a Copy or
|
||||
/// Symlink batch: the copy/symlink that was created has been removed - the original
|
||||
/// file was never touched in the first place, so `restored_path` here just names it
|
||||
/// for the caller rather than describing something that was actually moved.
|
||||
Reverted { restored_path: String },
|
||||
/// A different file already exists at the original path; left untouched - the same
|
||||
/// skip-never-overwrite policy `rename::execute_renames` uses. Only possible for a
|
||||
/// Move revert; Copy/Symlink reverts never touch the original path at all.
|
||||
SkippedConflict { existing_path: String },
|
||||
/// The renamed/copied/linked file isn't where the history says it should be
|
||||
/// anymore (moved, deleted, or already reverted some other way), or the OS reported
|
||||
/// an error.
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RevertResult {
|
||||
/// The entry's `new_path` (i.e. where the file was expected to be found, before
|
||||
/// reverting) - lets the caller map a result back to a specific history entry.
|
||||
pub source: String,
|
||||
pub outcome: RevertOutcome,
|
||||
}
|
||||
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
match (fs::canonicalize(a), fs::canonicalize(b)) {
|
||||
(Ok(pa), Ok(pb)) => pa == pb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome {
|
||||
let current = Path::new(&entry.new_path);
|
||||
let original = Path::new(&entry.source);
|
||||
|
||||
match action {
|
||||
RenameAction::Move => {
|
||||
if !current.exists() {
|
||||
return RevertOutcome::Failed {
|
||||
error: "the renamed file is no longer at its recorded location".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if original.exists() && !same_file(original, current) {
|
||||
return RevertOutcome::SkippedConflict {
|
||||
existing_path: original.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// The original's parent directory should still exist (nothing in Aurora Curator
|
||||
// deletes directories), but recreate it defensively in case it was removed
|
||||
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
|
||||
// there.
|
||||
if let Some(parent) = original.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
return RevertOutcome::Failed {
|
||||
error: format!("couldn't recreate the original folder: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match fs::rename(current, original) {
|
||||
Ok(()) => RevertOutcome::Reverted {
|
||||
restored_path: original.to_string_lossy().to_string(),
|
||||
},
|
||||
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
||||
}
|
||||
}
|
||||
RenameAction::Copy | RenameAction::Symlink => {
|
||||
// Copy/Symlink never touched `original` in the first place - the file that
|
||||
// was created lives only at `current`, so undoing is just removing it.
|
||||
// `symlink_metadata` (rather than `exists`, which follows links) is used so
|
||||
// a broken symlink - its target moved or deleted separately - still counts
|
||||
// as present and removable, instead of being reported as already gone.
|
||||
if fs::symlink_metadata(current).is_err() {
|
||||
return RevertOutcome::Failed {
|
||||
error: "the copy/symlink is no longer at its recorded location".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
match fs::remove_file(current) {
|
||||
Ok(()) => RevertOutcome::Reverted {
|
||||
restored_path: original.to_string_lossy().to_string(),
|
||||
},
|
||||
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverts every entry in a batch, in order, all under the batch's recorded
|
||||
/// [`RenameAction`]. Like [`crate::rename::execute_renames`], entries are processed
|
||||
/// sequentially (not in parallel) so a collision between two entries in the same batch
|
||||
/// resolves deterministically rather than racing.
|
||||
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
|
||||
batch
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| RevertResult {
|
||||
source: entry.new_path.clone(),
|
||||
outcome: revert_one(entry, batch.action),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maximum number of batches kept in the persisted history - once the log grows past
|
||||
/// this, the oldest batches are dropped so the history file can't grow unbounded over
|
||||
/// a long-lived install.
|
||||
pub const MAX_HISTORY_BATCHES: usize = 50;
|
||||
|
||||
/// Adds a new batch to the front of `history` (most-recent-first) and trims the log
|
||||
/// back down to [`MAX_HISTORY_BATCHES`]. A batch with no entries is not recorded at
|
||||
/// all - a rename operation where every item was unchanged/skipped/failed has nothing
|
||||
/// to undo, so it shouldn't clutter the history.
|
||||
pub fn push_batch(history: &mut Vec<HistoryBatch>, batch: HistoryBatch) {
|
||||
if batch.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
history.insert(0, batch);
|
||||
history.truncate(MAX_HISTORY_BATCHES);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("aurora_curator_history_test_{}_{}", std::process::id(), n));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverts_a_renamed_file_back_to_its_original_path() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("Breaking.Bad.S05E14.mkv");
|
||||
let renamed = dir.join("Ozymandias.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
action: RenameAction::Move,
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
match &results[0].outcome {
|
||||
RevertOutcome::Reverted { restored_path } => {
|
||||
assert_eq!(Path::new(restored_path), original.as_path());
|
||||
assert!(original.exists());
|
||||
assert!(!renamed.exists());
|
||||
}
|
||||
other => panic!("expected Reverted, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_into_a_subfolder_target_recreates_a_deleted_original_folder() {
|
||||
let dir = temp_dir();
|
||||
let sub = dir.join("Breaking Bad");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
let original = sub.join("Ozymandias.mkv");
|
||||
let renamed = dir.join("flat.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
// Simulate the organized-into subfolder having been removed since the rename.
|
||||
fs::remove_dir_all(&sub).unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
action: RenameAction::Move,
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
|
||||
assert!(original.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_revert_when_something_else_now_occupies_the_original_path() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.mkv");
|
||||
fs::write(&original, "a new unrelated file").unwrap();
|
||||
let renamed = dir.join("renamed.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
action: RenameAction::Move,
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(
|
||||
results[0].outcome,
|
||||
RevertOutcome::SkippedConflict { .. }
|
||||
));
|
||||
// Neither file should have been touched.
|
||||
assert_eq!(fs::read_to_string(&original).unwrap(), "a new unrelated file");
|
||||
assert_eq!(fs::read_to_string(&renamed).unwrap(), "video");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_gracefully_when_the_renamed_file_is_no_longer_there() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.mkv");
|
||||
let renamed = dir.join("renamed.mkv"); // never created
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
action: RenameAction::Move,
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_batch_ignores_empty_batches() {
|
||||
let mut history = Vec::new();
|
||||
push_batch(
|
||||
&mut history,
|
||||
HistoryBatch {
|
||||
id: "empty".to_string(),
|
||||
timestamp: 0,
|
||||
action: RenameAction::Move,
|
||||
entries: vec![],
|
||||
},
|
||||
);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_batch_orders_most_recent_first_and_trims_to_the_cap() {
|
||||
let mut history = Vec::new();
|
||||
for i in 0..(MAX_HISTORY_BATCHES + 5) {
|
||||
push_batch(
|
||||
&mut history,
|
||||
HistoryBatch {
|
||||
id: format!("batch-{i}"),
|
||||
timestamp: i as u64,
|
||||
action: RenameAction::Move,
|
||||
entries: vec![HistoryEntry {
|
||||
source: "a".to_string(),
|
||||
new_path: "b".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(history.len(), MAX_HISTORY_BATCHES);
|
||||
// Most recently pushed batch is first.
|
||||
assert_eq!(history[0].id, format!("batch-{}", MAX_HISTORY_BATCHES + 4));
|
||||
// The oldest batches (0..5) were dropped to stay under the cap.
|
||||
assert!(!history.iter().any(|b| b.id == "batch-0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_a_copy_batch_deletes_the_copy_and_leaves_the_original_untouched() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.txt");
|
||||
fs::write(&original, "hello").unwrap();
|
||||
let copy = dir.join("copied.txt");
|
||||
fs::write(©, "hello").unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
action: RenameAction::Copy,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: copy.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
match &results[0].outcome {
|
||||
RevertOutcome::Reverted { restored_path } => {
|
||||
assert_eq!(Path::new(restored_path), original.as_path());
|
||||
}
|
||||
other => panic!("expected Reverted, got {other:?}"),
|
||||
}
|
||||
// The copy is gone...
|
||||
assert!(!copy.exists());
|
||||
// ...but the original was never touched in the first place.
|
||||
assert!(original.exists());
|
||||
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reverting_a_symlink_batch_removes_the_link_not_the_file_it_points_to() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.txt");
|
||||
fs::write(&original, "hello").unwrap();
|
||||
let link = dir.join("linked.txt");
|
||||
std::os::unix::fs::symlink(&original, &link).unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
action: RenameAction::Symlink,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: link.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
|
||||
// The symlink is gone...
|
||||
assert!(fs::symlink_metadata(&link).is_err());
|
||||
// ...but the real file it pointed at is completely unaffected.
|
||||
assert!(original.exists());
|
||||
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_a_copy_batch_fails_gracefully_when_the_copy_is_already_gone() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.txt");
|
||||
fs::write(&original, "hello").unwrap();
|
||||
let copy = dir.join("copied.txt"); // never created
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
action: RenameAction::Copy,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: copy.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
|
||||
// The original should still be completely untouched.
|
||||
assert!(original.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Aurora Curator core: the UI-agnostic engine behind the Aurora Curator media renamer.
|
||||
//!
|
||||
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
|
||||
//! It's the piece being ported first from the original FileBot (Java) codebase; see
|
||||
//! the workspace README for the porting roadmap (matching engine, media metadata
|
||||
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||
|
||||
pub mod format;
|
||||
pub mod history;
|
||||
pub mod matching;
|
||||
pub mod rename;
|
||||
pub mod similarity;
|
||||
pub mod web;
|
||||
|
||||
use rhai::Dynamic;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Convenience constructor for [`format::ExpressionFormat`] bindings from plain strings,
|
||||
/// which is the common case (binding values are almost always show/movie/episode text).
|
||||
pub fn string_bindings<I, K, V>(pairs: I) -> HashMap<String, Dynamic>
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), Dynamic::from(v.into())))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Local, filename-based season/episode detection.
|
||||
//!
|
||||
//! This ports the pattern-matching half of the original FileBot's
|
||||
//! `similarity/SeasonEpisodeMatcher.java`: given a filename, guess a season/episode
|
||||
//! number pair and the series-name text that precedes it. Unlike the original (which
|
||||
//! runs on Java's regex engine and leans on lookahead/lookbehind), Rust's `regex` crate
|
||||
//! has neither, so every pattern here is rewritten to rely on `\b` word boundaries and
|
||||
//! character classes instead.
|
||||
//!
|
||||
//! Deliberately scoped to *local* filename parsing only - there is no metadata lookup
|
||||
//! here, no online API calls, and no fuzzy title matching against a show database (that
|
||||
//! is the much larger `EpisodeMetrics`-based engine, not yet ported). This is the
|
||||
//! "works offline the moment you load real files" increment.
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A season/episode number pair guessed from a filename, plus the raw series-name text
|
||||
/// that appeared before the match.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EpisodeGuess {
|
||||
pub series_name: String,
|
||||
pub season: i32,
|
||||
pub episode: i32,
|
||||
}
|
||||
|
||||
impl EpisodeGuess {
|
||||
/// Formats as FileBot's conventional `{s00e00}` binding value, e.g. `S01E01`.
|
||||
pub fn season_episode_label(&self) -> String {
|
||||
format!("S{:02}E{:02}", self.season, self.episode)
|
||||
}
|
||||
}
|
||||
|
||||
fn season_episode_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\bseason\s*(\d{1,4})\D{0,5}episode\s*(\d{1,3})\b").unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn s00e00_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\bs(\d{1,2})[^a-zA-Z0-9]{0,3}e(\d{1,3})\b").unwrap())
|
||||
}
|
||||
|
||||
fn sxe_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\b(\d{1,2})x(\d{2,3})\b").unwrap())
|
||||
}
|
||||
|
||||
fn year_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\b(19\d{2}|20\d{2})\b").unwrap())
|
||||
}
|
||||
|
||||
/// Common release-name quality/source tags. Not exhaustive (there's no bound on what a
|
||||
/// release group might tack on), just the handful common enough to reliably mark where
|
||||
/// a movie title ends when no release year is present either.
|
||||
fn quality_token_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\b(720p|1080p|2160p|480p|bluray|blu-ray|web-?dl|webrip|hdtv|dvdrip|brrip|x264|x265|h264|h265|hevc)\b").unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Cleans up the raw text preceding a season/episode match into a plausible series
|
||||
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
|
||||
/// whitespace/dash/dot clutter is trimmed.
|
||||
fn clean_series_name(raw: &str) -> String {
|
||||
let replaced: String = raw
|
||||
.chars()
|
||||
.map(|c| if c == '.' || c == '_' { ' ' } else { c })
|
||||
.collect();
|
||||
|
||||
let trimmed = replaced.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '.');
|
||||
|
||||
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Like `clean_series_name`, but also trims a trailing unmatched opening bracket - a
|
||||
/// movie title guess is typically cut right before a `(year)` or `[tag]`, which leaves
|
||||
/// the bracket character itself dangling at the end (e.g. `"The Matrix ("`).
|
||||
fn clean_movie_title(raw: &str) -> String {
|
||||
let base = clean_series_name(raw);
|
||||
base.trim_end_matches(|c: char| c == '(' || c == '[' || c == '{' || c.is_whitespace())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Tries each season/episode pattern in priority order (most explicit/specific first)
|
||||
/// and returns the first match, with the series name guessed from the text before it.
|
||||
///
|
||||
/// Returns `None` if no pattern matches - this is expected for movies, already-clean
|
||||
/// filenames, or anything that just doesn't carry a season/episode marker in its name.
|
||||
pub fn guess_episode(filename: &str) -> Option<EpisodeGuess> {
|
||||
// `_` counts as a word character to regex's `\b`, so "show_name_s02e03" would
|
||||
// otherwise fail to find a boundary right before the `s`. Match against a
|
||||
// space-normalized copy instead (same length, so match byte-offsets still line up
|
||||
// with the original string for slicing out the series-name prefix).
|
||||
let normalized: String = filename
|
||||
.chars()
|
||||
.map(|c| if c == '_' { ' ' } else { c })
|
||||
.collect();
|
||||
|
||||
let patterns = [season_episode_pattern(), s00e00_pattern(), sxe_pattern()];
|
||||
|
||||
for pattern in patterns {
|
||||
if let Some(caps) = pattern.captures(&normalized) {
|
||||
let whole = caps.get(0).unwrap();
|
||||
let season: i32 = caps.get(1)?.as_str().parse().ok()?;
|
||||
let episode: i32 = caps.get(2)?.as_str().parse().ok()?;
|
||||
|
||||
let prefix = &filename[..whole.start()];
|
||||
let series_name = clean_series_name(prefix);
|
||||
|
||||
return Some(EpisodeGuess {
|
||||
series_name,
|
||||
season,
|
||||
episode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// A movie title guessed from a filename, plus a release year if the filename's own
|
||||
/// text happened to carry one (release names conventionally look like
|
||||
/// `Title.Year.Quality-Group`, e.g. `Inception.2010.1080p.BluRay.x264`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MovieGuess {
|
||||
pub title: String,
|
||||
pub year: Option<i32>,
|
||||
}
|
||||
|
||||
/// Guesses a movie title (and, if present, a release year) from a filename. Unlike
|
||||
/// `guess_episode`, this doesn't look for a specific marker that's either there or
|
||||
/// not - it just cuts the title off at the earliest release-year or quality/source tag
|
||||
/// it can find (preferring a year on a tie, since a year almost always follows the
|
||||
/// title directly in real release names), and falls back to treating the whole
|
||||
/// (cleaned) filename as the title when neither is present. Only returns `None` when
|
||||
/// there's nothing left to call a title at all, e.g. a filename that's just a bare
|
||||
/// year or quality tag.
|
||||
///
|
||||
/// Callers are expected to try `guess_episode` first and only fall back to this when
|
||||
/// it returns `None` - a real release name is either an episode or a movie, not both,
|
||||
/// and this function has no way to tell the difference on its own.
|
||||
pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
|
||||
let normalized: String = filename
|
||||
.chars()
|
||||
.map(|c| if c == '_' { ' ' } else { c })
|
||||
.collect();
|
||||
|
||||
let year_match = year_pattern().find(&normalized);
|
||||
let quality_match = quality_token_pattern().find(&normalized);
|
||||
|
||||
let (title_end, year) = match (year_match, quality_match) {
|
||||
(Some(y), Some(q)) if y.start() <= q.start() => (y.start(), y.as_str().parse().ok()),
|
||||
(Some(y), None) => (y.start(), y.as_str().parse().ok()),
|
||||
(_, Some(q)) => (q.start(), None),
|
||||
(None, None) => (normalized.len(), None),
|
||||
};
|
||||
|
||||
let title = clean_movie_title(&filename[..title_end]);
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(MovieGuess { title, year })
|
||||
}
|
||||
|
||||
/// Release-tag metadata guessed from a filename's own text: resolution, source
|
||||
/// (capture/rip type), video codec, audio codec, and release group. Ports the
|
||||
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
|
||||
/// regex-based tag recognition `MediaBindingBean` falls back to when no real media
|
||||
/// probe (ffprobe/MediaInfo) is available, which Aurora Curator never has, so this is the
|
||||
/// only source for these fields here. Deliberately independent of `guess_episode`/
|
||||
/// `guess_movie`: a release name carries these tags (or doesn't) regardless of
|
||||
/// whether it's a TV episode or a movie, so callers run this alongside whichever of
|
||||
/// those two applies.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseInfo {
|
||||
pub resolution: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub video_codec: Option<String>,
|
||||
pub audio_codec: Option<String>,
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl ReleaseInfo {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.resolution.is_none()
|
||||
&& self.source.is_none()
|
||||
&& self.video_codec.is_none()
|
||||
&& self.audio_codec.is_none()
|
||||
&& self.group.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolution_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(480p|576p|720p|1080p|1080i|2160p|4320p)\b").unwrap())
|
||||
}
|
||||
|
||||
// Longer/more specific alternatives are listed before shorter ones they'd otherwise be
|
||||
// shadowed by (e.g. "web-dl" before "web"), since regex alternation takes the first
|
||||
// branch that matches at a given position, not the longest.
|
||||
fn source_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\b(web-?dl|webrip|web|blu-?ray|bdrip|brrip|hddvd|dvdscr|dvdrip|dvd|hdtv|pdtv|sdtv|dsr)\b")
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn video_codec_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(x264|x265|h\.?264|h\.?265|hevc|xvid|divx|avc)\b").unwrap())
|
||||
}
|
||||
|
||||
fn audio_codec_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(dts-hd|dts|ddp5\.1|ddp|eac3|ac3|truehd|flac|aac|mp3)\b").unwrap())
|
||||
}
|
||||
|
||||
// The release group is conventionally the last `-TAG` right before the file
|
||||
// extension (e.g. `...x264-KILLERS.mkv`); only matches when there's a leading dash so
|
||||
// a bare trailing quality tag like `...x264` (no group) is correctly left alone.
|
||||
fn release_group_pattern() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"-([A-Za-z0-9][A-Za-z0-9.]{1,14})$").unwrap())
|
||||
}
|
||||
|
||||
/// Strips a trailing `.ext` (if any) so end-anchored patterns like the release-group
|
||||
/// one can match against the release name itself rather than the file extension.
|
||||
fn strip_extension(filename: &str) -> &str {
|
||||
match filename.rfind('.') {
|
||||
Some(i) if i > 0 => &filename[..i],
|
||||
_ => filename,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_source(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['-', ' '], "").as_str() {
|
||||
"webdl" => "WEB-DL",
|
||||
"webrip" => "WEBRip",
|
||||
"web" => "WEB",
|
||||
"bluray" => "BluRay",
|
||||
"bdrip" => "BDRip",
|
||||
"brrip" => "BRRip",
|
||||
"hddvd" => "HDDVD",
|
||||
"dvdscr" => "DVDSCR",
|
||||
"dvdrip" => "DVDRip",
|
||||
"dvd" => "DVD",
|
||||
"hdtv" => "HDTV",
|
||||
"pdtv" => "PDTV",
|
||||
"sdtv" => "SDTV",
|
||||
"dsr" => "DSR",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn canonical_video_codec(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['.', ' '], "").as_str() {
|
||||
"x264" => "x264",
|
||||
"x265" => "x265",
|
||||
"h264" => "H.264",
|
||||
"h265" => "H.265",
|
||||
"hevc" => "HEVC",
|
||||
"xvid" => "XviD",
|
||||
"divx" => "DivX",
|
||||
"avc" => "AVC",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn canonical_audio_codec(raw: &str) -> String {
|
||||
match raw.to_lowercase().replace(['-', '.', ' '], "").as_str() {
|
||||
"dtshd" => "DTS-HD",
|
||||
"dts" => "DTS",
|
||||
"ddp51" => "DDP5.1",
|
||||
"ddp" => "DDP",
|
||||
"eac3" => "EAC3",
|
||||
"ac3" => "AC3",
|
||||
"truehd" => "TrueHD",
|
||||
"flac" => "FLAC",
|
||||
"aac" => "AAC",
|
||||
"mp3" => "MP3",
|
||||
_ => return raw.to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Guesses release-tag metadata from a filename's own text (see `ReleaseInfo`).
|
||||
/// Every field is independent and optional; returns `None` only when nothing at all
|
||||
/// was recognized, matching the `guess_episode`/`guess_movie` convention of `None`
|
||||
/// meaning "nothing local to offer here", not "this file has no metadata".
|
||||
pub fn guess_release_info(filename: &str) -> Option<ReleaseInfo> {
|
||||
let base = strip_extension(filename);
|
||||
|
||||
let info = ReleaseInfo {
|
||||
resolution: resolution_pattern()
|
||||
.find(base)
|
||||
.map(|m| m.as_str().to_lowercase()),
|
||||
source: source_pattern().find(base).map(|m| canonical_source(m.as_str())),
|
||||
video_codec: video_codec_pattern()
|
||||
.find(base)
|
||||
.map(|m| canonical_video_codec(m.as_str())),
|
||||
audio_codec: audio_codec_pattern()
|
||||
.find(base)
|
||||
.map(|m| canonical_audio_codec(m.as_str())),
|
||||
group: release_group_pattern()
|
||||
.captures(base)
|
||||
.map(|c| c[1].to_string()),
|
||||
};
|
||||
|
||||
if info.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(info)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_dotted_s00e00_style() {
|
||||
let guess = guess_episode("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS").unwrap();
|
||||
assert_eq!(guess.series_name, "Breaking Bad");
|
||||
assert_eq!(guess.season, 5);
|
||||
assert_eq!(guess.episode, 14);
|
||||
assert_eq!(guess.season_episode_label(), "S05E14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_dashed_sxe_style() {
|
||||
let guess = guess_episode("Reacher - 1x01 - Welcome to Margrave").unwrap();
|
||||
assert_eq!(guess.series_name, "Reacher");
|
||||
assert_eq!(guess.season, 1);
|
||||
assert_eq!(guess.episode, 1);
|
||||
assert_eq!(guess.season_episode_label(), "S01E01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_verbose_season_episode_words() {
|
||||
let guess = guess_episode("The Office Season 4 Episode 9 - Dinner Party").unwrap();
|
||||
assert_eq!(guess.series_name, "The Office");
|
||||
assert_eq!(guess.season, 4);
|
||||
assert_eq!(guess.episode, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_underscored_prefix() {
|
||||
let guess = guess_episode("some_show_name_s02e03_episode_title").unwrap();
|
||||
assert_eq!(guess.series_name, "some show name");
|
||||
assert_eq!(guess.season, 2);
|
||||
assert_eq!(guess.episode, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_movie_filenames() {
|
||||
assert!(guess_episode("Inception.2010.1080p.BluRay.x264").is_none());
|
||||
assert!(guess_episode("The Matrix (1999)").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_more_specific_pattern_over_sxe() {
|
||||
// s00e00 pattern should win over the looser 1x01-style pattern when both could
|
||||
// technically apply to fragments of the same string.
|
||||
let guess = guess_episode("Show.Name.S01E02.1080p").unwrap();
|
||||
assert_eq!(guess.season, 1);
|
||||
assert_eq!(guess.episode, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guesses_a_movie_title_and_year_from_a_dotted_release_name() {
|
||||
let guess = guess_movie("Inception.2010.1080p.BluRay.x264").unwrap();
|
||||
assert_eq!(guess.title, "Inception");
|
||||
assert_eq!(guess.year, Some(2010));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guesses_a_movie_title_and_year_with_a_parenthesized_year() {
|
||||
let guess = guess_movie("The Matrix (1999)").unwrap();
|
||||
assert_eq!(guess.title, "The Matrix");
|
||||
assert_eq!(guess.year, Some(1999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_a_quality_token_boundary_when_theres_no_year() {
|
||||
let guess = guess_movie("Some.Movie.Title.WEBRip.x264").unwrap();
|
||||
assert_eq!(guess.title, "Some Movie Title");
|
||||
assert_eq!(guess.year, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_clean_filename_becomes_the_whole_title() {
|
||||
let guess = guess_movie("The Matrix").unwrap();
|
||||
assert_eq!(guess.title, "The Matrix");
|
||||
assert_eq!(guess.year, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_theres_nothing_but_a_year_left() {
|
||||
assert!(guess_movie("2010").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_full_release_info_from_a_tv_episode_filename() {
|
||||
let info = guess_release_info("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv").unwrap();
|
||||
assert_eq!(info.resolution.as_deref(), Some("720p"));
|
||||
assert_eq!(info.source.as_deref(), Some("HDTV"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("x264"));
|
||||
assert_eq!(info.audio_codec, None);
|
||||
assert_eq!(info.group.as_deref(), Some("KILLERS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_release_info_with_mixed_case_group_preserved() {
|
||||
let info = guess_release_info("The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi").unwrap();
|
||||
assert_eq!(info.source.as_deref(), Some("DVDRip"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("XviD"));
|
||||
assert_eq!(info.group.as_deref(), Some("SAiNTS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_release_info_from_a_movie_filename_with_audio_codec_and_no_group() {
|
||||
let info = guess_release_info("Inception.2010.1080p.BluRay.DTS.x264.mkv").unwrap();
|
||||
assert_eq!(info.resolution.as_deref(), Some("1080p"));
|
||||
assert_eq!(info.source.as_deref(), Some("BluRay"));
|
||||
assert_eq!(info.video_codec.as_deref(), Some("x264"));
|
||||
assert_eq!(info.audio_codec.as_deref(), Some("DTS"));
|
||||
assert_eq!(info.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_web_dl_distinct_from_bare_web() {
|
||||
let webdl = guess_release_info("Show.S01E01.WEB-DL.mkv").unwrap();
|
||||
assert_eq!(webdl.source.as_deref(), Some("WEB-DL"));
|
||||
|
||||
let web = guess_release_info("Show.S01E02.WEB.mkv").unwrap();
|
||||
assert_eq!(web.source.as_deref(), Some("WEB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_h264_and_hevc_video_codec_spellings() {
|
||||
let dotted = guess_release_info("Movie.2020.h.264.mkv").unwrap();
|
||||
assert_eq!(dotted.video_codec.as_deref(), Some("H.264"));
|
||||
|
||||
let hevc = guess_release_info("Movie.2020.HEVC.mkv").unwrap();
|
||||
assert_eq!(hevc.video_codec.as_deref(), Some("HEVC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_mistake_a_bare_trailing_quality_tag_for_a_release_group() {
|
||||
// No leading dash before "x264" here - nothing to call a release group.
|
||||
let info = guess_release_info("Some.Movie.2020.1080p.x264.mkv").unwrap();
|
||||
assert_eq!(info.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_a_filename_with_no_recognizable_release_tags() {
|
||||
assert!(guess_release_info("The Matrix.mkv").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
//! The on-disk rename operation: given a planned `old path -> new relative target`
|
||||
//! mapping, actually renames (and, when the target includes subfolders, moves) the
|
||||
//! files.
|
||||
//!
|
||||
//! Every rename stays rooted at the source file's own directory - `new_name` is always
|
||||
//! resolved relative to `source`'s parent, never as an absolute path, and can never
|
||||
//! escape that directory tree via `..`. Within that directory tree, though, `new_name`
|
||||
//! may contain `/` to organize files into subfolders as part of renaming (e.g. a naming
|
||||
//! scheme like `{show name}/{episode name}` - the naming-expression engine already
|
||||
//! treats `/` between two `{ }` scriptlets as ordinary literal text, so no changes were
|
||||
//! needed there; this is purely about the rename engine trusting a `/` in the computed
|
||||
//! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing
|
||||
//! intermediate directories are created automatically. What happens when the computed
|
||||
//! target already exists is chosen per batch via [`ConflictPolicy`] - skip (the
|
||||
//! original, and still default, behavior), auto-number, or override - a port of
|
||||
//! `net.filebot.cli.CmdlineOperations`'s `--conflict` flag. The UI is expected to show
|
||||
//! the caller a confirmation of the full plan (and let them choose both a
|
||||
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
|
||||
//! batch that action actually touches a file for is also recorded to the rename-history
|
||||
//! log one layer up (`aurora_curator_core::history`), so it can be undone from there. Undoing
|
||||
//! always uses skip-on-conflict regardless of what policy the original rename used
|
||||
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
|
||||
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
|
||||
//! hand.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One planned rename: `source` is the file's current full path, `new_name` is the
|
||||
/// target it should have afterward, resolved relative to `source`'s own directory -
|
||||
/// just a filename for a same-directory rename, or a `/`-separated relative path (e.g.
|
||||
/// `"Breaking Bad/Ozymandias.mkv"`) to also move the file into a (possibly new)
|
||||
/// subfolder.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenamePlanItem {
|
||||
pub source: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
/// What to actually do at the target path once it's been resolved and cleared of
|
||||
/// conflicts - a port of the handful of `--action` modes `net.filebot.cli
|
||||
/// .CmdlineOperations` supports (`move`, the default, plus `copy`/`symlink`; `keeplink`
|
||||
/// and `test` are not ported). Applies to a whole batch at once - the UI picks one
|
||||
/// action per "Rename" confirmation, not per file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RenameAction {
|
||||
/// Renames/moves the file in place - the only action Aurora Curator had before this. The
|
||||
/// source file no longer exists afterward; the item now lives at the target path.
|
||||
Move,
|
||||
/// Copies the file's contents to the target path, leaving the source untouched.
|
||||
Copy,
|
||||
/// Creates a symlink at the target path pointing back at the (untouched) source
|
||||
/// file. Unix: `std::os::unix::fs::symlink`. Windows: `symlink_file` (creating a
|
||||
/// file symlink there normally requires Developer Mode or an elevated process -
|
||||
/// surfaced to the caller as an ordinary `Failed` outcome if it's rejected, not a
|
||||
/// special case).
|
||||
Symlink,
|
||||
}
|
||||
|
||||
impl Default for RenameAction {
|
||||
fn default() -> Self {
|
||||
RenameAction::Move
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do when a computed target already exists (and isn't just the source file
|
||||
/// itself under a different case) - a port of `CmdlineOperations`'s `--conflict` flag.
|
||||
/// Applies to a whole batch at once, same as [`RenameAction`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ConflictPolicy {
|
||||
/// Leave the existing file alone and report the item as skipped. The original (and
|
||||
/// still default) behavior - never destroys anything.
|
||||
Skip,
|
||||
/// Save alongside the conflicting file instead, as `"Name (1).ext"`, `"Name
|
||||
/// (2).ext"`, etc. - the first name in that sequence that isn't already taken in
|
||||
/// the target directory (tries up to 999 before giving up).
|
||||
AutoNumber,
|
||||
/// Replace whatever's at the target path. Irreversible for the file that gets
|
||||
/// overwritten - undo can restore what the *source* becomes, never what conflict
|
||||
/// resolution destroyed at the target.
|
||||
Override,
|
||||
}
|
||||
|
||||
impl Default for ConflictPolicy {
|
||||
fn default() -> Self {
|
||||
ConflictPolicy::Skip
|
||||
}
|
||||
}
|
||||
|
||||
/// What happened to one planned rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum RenameOutcome {
|
||||
/// Renamed successfully; `new_path` is the file's new full path.
|
||||
Renamed { new_path: String },
|
||||
/// The computed name was already the file's current name - nothing to do.
|
||||
Unchanged,
|
||||
/// A different file already exists at the target path; left untouched.
|
||||
SkippedConflict { existing_path: String },
|
||||
/// The rename was rejected or the OS reported an error.
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RenameResult {
|
||||
pub source: String,
|
||||
pub outcome: RenameOutcome,
|
||||
}
|
||||
|
||||
/// Validates a computed target, relative to the source file's own directory. `/` is
|
||||
/// allowed as a subdirectory separator - that's what lets a naming scheme like
|
||||
/// `{show name}/{episode name}` organize files into subfolders - but every path
|
||||
/// component is checked to make sure the target can never escape that directory tree:
|
||||
/// no empty components (a leading, trailing, or doubled `/` - which also catches an
|
||||
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
|
||||
/// leading empty component), and no `.`/`..` components. Backslashes are rejected
|
||||
/// outright rather than treated as an alternate separator - Aurora Curator always uses `/` for
|
||||
/// the naming scheme's folder separator regardless of OS, so a literal `\` in a
|
||||
/// computed name is almost certainly a mistake, not an intentional Windows path.
|
||||
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
|
||||
if name.is_empty() {
|
||||
return Err("empty");
|
||||
}
|
||||
if name.contains('\\') {
|
||||
return Err("backslashes aren't allowed - use / to organize into subfolders");
|
||||
}
|
||||
for component in name.split('/') {
|
||||
if component.is_empty() {
|
||||
return Err("contains an empty path segment (e.g. a leading, trailing, or doubled '/')");
|
||||
}
|
||||
if component == "." || component == ".." {
|
||||
return Err("contains a '.' or '..' path segment");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if `a` and `b` refer to the same underlying file (used to allow case-only
|
||||
/// renames on case-insensitive filesystems, where the target "already exists" check
|
||||
/// would otherwise find the source file itself).
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
match (fs::canonicalize(a), fs::canonicalize(b)) {
|
||||
(Ok(pa), Ok(pb)) => pa == pb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(source, target)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
|
||||
std::os::windows::fs::symlink_file(source, target)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"symlinks aren't supported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds the first `"<stem> (N).<ext>"` (or `"<stem> (N)"` with no extension) in
|
||||
/// `target`'s own directory that isn't already taken, starting at N=1. Tries up to 999
|
||||
/// before giving up (`None`) - a directory with 999+ same-named conflicts is almost
|
||||
/// certainly a sign something else is wrong, not a case to loop on forever.
|
||||
fn next_available_name(target: &Path) -> Option<PathBuf> {
|
||||
let parent = target.parent()?;
|
||||
let stem = target.file_stem()?.to_string_lossy().to_string();
|
||||
let ext = target.extension().map(|e| e.to_string_lossy().to_string());
|
||||
|
||||
for n in 1..=999 {
|
||||
let candidate_name = match &ext {
|
||||
Some(e) => format!("{stem} ({n}).{e}"),
|
||||
None => format!("{stem} ({n})"),
|
||||
};
|
||||
let candidate = parent.join(candidate_name);
|
||||
if !candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolves a conflict at `target` (which the caller has already confirmed exists and
|
||||
/// isn't `source` under a different case) according to `conflict`. Returns the target
|
||||
/// path to actually use, or the terminal outcome to report instead (skip, or a failure
|
||||
/// - e.g. auto-numbering exhausted its attempts, or removing an existing file ahead of
|
||||
/// an Override-Symlink failed).
|
||||
///
|
||||
/// A conflicting target that's a *directory* is only ever a special case for Override
|
||||
/// (which falls back to skipping it) - Override is for replacing one file with
|
||||
/// another, never for deleting a whole directory tree out from under the caller. Skip
|
||||
/// treats it like any other conflict already, and AutoNumber doesn't need to touch it
|
||||
/// at all - it just tries the next number instead.
|
||||
fn resolve_conflict(target: &Path, action: RenameAction, conflict: ConflictPolicy) -> Result<PathBuf, RenameOutcome> {
|
||||
match conflict {
|
||||
ConflictPolicy::Skip => Err(RenameOutcome::SkippedConflict {
|
||||
existing_path: target.to_string_lossy().to_string(),
|
||||
}),
|
||||
// Doesn't matter whether the conflicting entry is a file or a directory here -
|
||||
// auto-numbering never touches it either way, it just tries the next name.
|
||||
ConflictPolicy::AutoNumber => next_available_name(target).ok_or_else(|| RenameOutcome::Failed {
|
||||
error: "couldn't find a free auto-numbered name (tried up to 999)".to_string(),
|
||||
}),
|
||||
ConflictPolicy::Override => {
|
||||
// Override is for replacing one file with another - never for deleting a
|
||||
// whole directory tree out from under the caller. Falls back to Skip's
|
||||
// behavior for that one case.
|
||||
if target.is_dir() {
|
||||
return Err(RenameOutcome::SkippedConflict {
|
||||
existing_path: target.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
// fs::rename and fs::copy both already replace an existing regular file at
|
||||
// the destination on their own - only Symlink's underlying syscall fails
|
||||
// outright when the target path already exists, so that's the one case that
|
||||
// needs the old file removed first.
|
||||
if action == RenameAction::Symlink {
|
||||
if let Err(e) = fs::remove_file(target) {
|
||||
return Err(RenameOutcome::Failed {
|
||||
error: format!("couldn't remove the existing file before overriding it: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(target.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_one(source: &Path, new_name: &str, action: RenameAction, conflict: ConflictPolicy) -> RenameOutcome {
|
||||
if let Err(reason) = validate_relative_target(new_name) {
|
||||
return RenameOutcome::Failed {
|
||||
error: format!("\"{new_name}\" is not a valid target: {reason}"),
|
||||
};
|
||||
}
|
||||
|
||||
let parent = match source.parent() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return RenameOutcome::Failed {
|
||||
error: "source has no parent directory".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = parent.join(new_name);
|
||||
|
||||
if target == source {
|
||||
// Nothing meaningful to do for any action - Move has nothing to move, and
|
||||
// Copy/Symlink onto the source's own path would just be a no-op self-reference
|
||||
// at best (or an outright error at worst, e.g. `fs::copy` onto the same path).
|
||||
return RenameOutcome::Unchanged;
|
||||
}
|
||||
|
||||
let target = if target.exists() && !same_file(&target, source) {
|
||||
match resolve_conflict(&target, action, conflict) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(outcome) => return outcome,
|
||||
}
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
// `new_name` may include subdirectory components (organizing files into folders) -
|
||||
// make sure the destination directory tree exists before attempting the actual
|
||||
// operation. `create_dir_all` is a no-op (Ok) when the directory already exists.
|
||||
if let Some(target_parent) = target.parent() {
|
||||
if let Err(e) = fs::create_dir_all(target_parent) {
|
||||
return RenameOutcome::Failed {
|
||||
error: format!("couldn't create destination folder: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = match action {
|
||||
RenameAction::Move => fs::rename(source, &target),
|
||||
RenameAction::Copy => fs::copy(source, &target).map(|_bytes| ()),
|
||||
RenameAction::Symlink => create_symlink(source, &target),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => RenameOutcome::Renamed {
|
||||
new_path: target.to_string_lossy().to_string(),
|
||||
},
|
||||
Err(e) => RenameOutcome::Failed {
|
||||
error: e.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a batch of planned renames in order, all under the same [`RenameAction`]
|
||||
/// and [`ConflictPolicy`]. Items are processed sequentially (not in parallel) so that
|
||||
/// two items whose computed names collide with each other resolve the same way an
|
||||
/// on-disk collision would: under Skip, the first one through wins and the second is
|
||||
/// reported as a skipped conflict; under AutoNumber, the second gets bumped to " (1)";
|
||||
/// under Override, the second replaces what the first just wrote.
|
||||
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction, conflict: ConflictPolicy) -> Vec<RenameResult> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| RenameResult {
|
||||
source: item.source.clone(),
|
||||
outcome: rename_one(Path::new(&item.source), &item.new_name, action, conflict),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Creates a fresh temp directory with one file in it, for tests that need to touch
|
||||
/// the real filesystem. Returns (dir, file_path).
|
||||
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("aurora_curator_rename_test_{}_{}", std::process::id(), n));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join(file_name);
|
||||
fs::write(&file, contents).unwrap();
|
||||
(dir, file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_a_file_successfully() {
|
||||
let (dir, file) = temp_file_setup("old.txt", "hello");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "new.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert!(new_path.ends_with("new.txt"));
|
||||
assert!(Path::new(new_path).exists());
|
||||
assert!(!file.exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_unchanged_when_name_is_already_correct() {
|
||||
let (dir, file) = temp_file_setup("same.txt", "hello");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "same.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_target_already_exists() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
}
|
||||
other => panic!("expected SkippedConflict, got {other:?}"),
|
||||
}
|
||||
// Neither file should have been touched.
|
||||
assert!(file.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_item_in_batch_skips_when_it_collides_with_the_first() {
|
||||
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
|
||||
let file_b = dir.join("c.txt");
|
||||
fs::write(&file_b, "ccc").unwrap();
|
||||
|
||||
let items = vec![
|
||||
RenamePlanItem {
|
||||
source: file_a.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
RenamePlanItem {
|
||||
source: file_b.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
|
||||
assert!(matches!(
|
||||
results[1].outcome,
|
||||
RenameOutcome::SkippedConflict { .. }
|
||||
));
|
||||
// The second file's original content should be untouched.
|
||||
assert_eq!(fs::read_to_string(&file_b).unwrap(), "ccc");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_traversal_in_new_name() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "../escaped.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organizes_into_a_new_subfolder() {
|
||||
let (dir, file) = temp_file_setup("Breaking.Bad.S05E14.mkv", "video");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
|
||||
assert_eq!(Path::new(new_path), expected.as_path());
|
||||
assert!(expected.exists());
|
||||
assert!(!file.exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organizes_into_nested_subfolders_that_dont_exist_yet() {
|
||||
let (dir, file) = temp_file_setup("ep.mkv", "video");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), expected.as_path());
|
||||
assert!(expected.exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_the_organized_target_already_exists() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::create_dir_all(dir.join("Sub")).unwrap();
|
||||
fs::write(dir.join("Sub").join("b.txt"), "bbb").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "Sub/b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::SkippedConflict { .. }
|
||||
));
|
||||
assert!(file.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("Sub").join("b.txt")).unwrap(), "bbb");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_backslashes_in_new_name() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "Sub\\b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_doubled_and_leading_slashes() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
|
||||
for bad_name in ["Sub//b.txt", "/etc/passwd", "Sub/b.txt/"] {
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: bad_name.to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(
|
||||
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
|
||||
"expected {bad_name:?} to be rejected"
|
||||
);
|
||||
}
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_action_leaves_the_source_file_in_place() {
|
||||
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "copied.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert!(new_path.ends_with("copied.txt"));
|
||||
assert!(Path::new(new_path).exists());
|
||||
assert_eq!(fs::read_to_string(new_path).unwrap(), "hello");
|
||||
// The whole point of Copy: the source is untouched.
|
||||
assert!(file.exists());
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_action_can_also_organize_into_a_new_subfolder() {
|
||||
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "Sub/copied.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
|
||||
let expected = dir.join("Sub").join("copied.txt");
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), expected.as_path());
|
||||
assert!(expected.exists());
|
||||
assert!(file.exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_action_leaves_the_source_file_in_place_and_points_back_at_it() {
|
||||
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "linked.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
let link = Path::new(new_path);
|
||||
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_link(link).unwrap(), file);
|
||||
// Reading through the link gets the source's real content...
|
||||
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
|
||||
// ...and the source itself is completely untouched.
|
||||
assert!(file.exists());
|
||||
assert!(!fs::symlink_metadata(&file).unwrap().file_type().is_symlink());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_policy_applies_the_same_way_to_copy_and_symlink() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
|
||||
for action in [RenameAction::Copy, RenameAction::Symlink] {
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
}
|
||||
other => panic!("expected SkippedConflict for {action:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// Neither original file nor the pre-existing conflicting file should be touched.
|
||||
assert!(file.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_symlink_report_unchanged_when_the_computed_name_is_already_the_source() {
|
||||
let (dir, file) = temp_file_setup("same.txt", "hello");
|
||||
|
||||
for action in [RenameAction::Copy, RenameAction::Symlink] {
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "same.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Skip);
|
||||
assert_eq!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::Unchanged,
|
||||
"expected Unchanged for {action:?}"
|
||||
);
|
||||
}
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_saves_alongside_the_conflicting_file_instead_of_skipping() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
|
||||
assert!(Path::new(new_path).exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
// The original conflicting file must still be exactly as it was.
|
||||
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
|
||||
assert!(!file.exists()); // Move: the source is gone, relocated to "b (1).txt"
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_keeps_counting_past_names_that_are_also_taken() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
fs::write(dir.join("b (1).txt"), "existing 1").unwrap();
|
||||
fs::write(dir.join("b (2).txt"), "existing 2").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (3).txt"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_handles_extensionless_names_and_organized_subfolders() {
|
||||
let (dir, file) = temp_file_setup("a", "aaa");
|
||||
fs::write(dir.join("README"), "existing").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "README".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("README (1)"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_item_in_batch_auto_numbers_when_it_collides_with_the_first() {
|
||||
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
|
||||
let file_b = dir.join("c.txt");
|
||||
fs::write(&file_b, "ccc").unwrap();
|
||||
|
||||
let items = vec![
|
||||
RenamePlanItem {
|
||||
source: file_a.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
RenamePlanItem {
|
||||
source: file_b.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
assert_eq!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::Renamed {
|
||||
new_path: dir.join("target.txt").to_string_lossy().to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
results[1].outcome,
|
||||
RenameOutcome::Renamed {
|
||||
new_path: dir.join("target (1).txt").to_string_lossy().to_string()
|
||||
}
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_replaces_the_conflicting_file_for_move_and_copy() {
|
||||
for action in [RenameAction::Move, RenameAction::Copy] {
|
||||
let (dir, file) = temp_file_setup("a.txt", "new content");
|
||||
fs::write(dir.join("b.txt"), "old content").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Override);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b.txt"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(new_path).unwrap(),
|
||||
"new content",
|
||||
"expected b.txt to now hold the source's content for {action:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Renamed for {action:?}, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn override_removes_an_existing_file_before_creating_a_symlink_there() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "hello");
|
||||
fs::write(dir.join("b.txt"), "old content").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Override);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
let link = Path::new(new_path);
|
||||
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_never_deletes_a_conflicting_directory() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::create_dir_all(dir.join("b.txt")).unwrap(); // a DIRECTORY named "b.txt"
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Override);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
}
|
||||
other => panic!("expected SkippedConflict (never delete a directory), got {other:?}"),
|
||||
}
|
||||
assert!(dir.join("b.txt").is_dir());
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_also_never_deletes_a_conflicting_directory() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::create_dir_all(dir.join("b.txt")).unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
// AutoNumber should sidestep the directory entirely by picking "b (1).txt"
|
||||
// rather than touching "b.txt" at all.
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
assert!(dir.join("b.txt").is_dir());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Lightweight text-similarity scoring, used to disambiguate between several search
|
||||
//! results for the same query. A partial port of the *scoring* idea behind FileBot's
|
||||
//! `net.filebot.similarity.EpisodeMetrics` (which combines several metrics: name,
|
||||
//! episode numbering, air date, etc). This only ports the show-name half: given a
|
||||
//! search query and a list of candidate titles, it picks the title that's the closest
|
||||
//! textual match rather than trusting whichever result an external API happened to
|
||||
//! rank first - TMDB ranks by relevance/popularity, not by exact name match, so a
|
||||
//! more popular but differently-named show can otherwise beat an exact-but-less-popular
|
||||
//! one straight to the top of the list. `DateMatcher` (the other half of the original
|
||||
//! `EpisodeMetrics`) is not ported - Aurora Curator doesn't do date-based episode identification.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Lowercases, strips a trailing 4-digit-year parenthetical (e.g. "(2013)" - release
|
||||
/// metadata that never appears in a real filename, but commonly appears in TMDB's own
|
||||
/// titles for remakes/reboots), and collapses every run of punctuation/whitespace down
|
||||
/// to a single space, so two titles that differ only in casing, punctuation, or an
|
||||
/// appended year compare as equal.
|
||||
pub fn normalize_title(input: &str) -> String {
|
||||
let mut without_year = input.to_string();
|
||||
if let Some(start) = without_year.rfind('(') {
|
||||
let inner = &without_year[start + 1..];
|
||||
if let Some(end) = inner.find(')') {
|
||||
let candidate = &inner[..end];
|
||||
if candidate.len() == 4 && candidate.chars().all(|c| c.is_ascii_digit()) {
|
||||
without_year.replace_range(start..start + end + 2, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(without_year.len());
|
||||
let mut last_was_space = true; // avoid a leading space in the output
|
||||
for c in without_year.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
out.extend(c.to_lowercase());
|
||||
last_was_space = false;
|
||||
} else if !last_was_space {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Character-bigram multiset of a string, e.g. "abc" -> {ab: 1, bc: 1}.
|
||||
fn bigrams(s: &str) -> HashMap<(char, char), u32> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut map = HashMap::new();
|
||||
if chars.len() < 2 {
|
||||
// Too short for a real bigram - fall back to a single-char "bigram" so a
|
||||
// one-letter title still contributes something rather than always scoring 0.
|
||||
if let Some(&c) = chars.first() {
|
||||
*map.entry((c, '\0')).or_insert(0) += 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
for w in chars.windows(2) {
|
||||
*map.entry((w[0], w[1])).or_insert(0) += 1;
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Sorensen-Dice coefficient over character bigrams of the normalized titles: 1.0 for
|
||||
/// an exact match (after normalization), trending toward 0.0 for unrelated strings,
|
||||
/// while staying tolerant of the small differences (punctuation, word order, a
|
||||
/// trailing "Part 2") that would sink a naive exact-string comparison.
|
||||
pub fn title_similarity(a: &str, b: &str) -> f64 {
|
||||
let na = normalize_title(a);
|
||||
let nb = normalize_title(b);
|
||||
if na == nb {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let ba = bigrams(&na);
|
||||
let bb = bigrams(&nb);
|
||||
let total: u32 = ba.values().sum::<u32>() + bb.values().sum::<u32>();
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut overlap = 0u32;
|
||||
for (bigram, &count) in &ba {
|
||||
if let Some(&other_count) = bb.get(bigram) {
|
||||
overlap += count.min(other_count);
|
||||
}
|
||||
}
|
||||
(2.0 * overlap as f64) / total as f64
|
||||
}
|
||||
|
||||
/// Picks whichever candidate's title is textually closest to `query`, provided its
|
||||
/// score clears `threshold`. Below that, none of the candidates are considered a
|
||||
/// plausible match for the query at all and `None` is returned - it's better to
|
||||
/// report "not found" than to confidently rename files against the wrong show. Ties
|
||||
/// keep the earlier candidate, which preserves the caller's own ranking (e.g. TMDB's
|
||||
/// relevance/popularity order) as the tiebreaker when two titles score identically.
|
||||
pub fn best_title_match<'a, T>(
|
||||
query: &str,
|
||||
candidates: &'a [T],
|
||||
title_of: impl Fn(&T) -> &str,
|
||||
threshold: f64,
|
||||
) -> Option<&'a T> {
|
||||
let mut best: Option<(&'a T, f64)> = None;
|
||||
for candidate in candidates {
|
||||
let score = title_similarity(query, title_of(candidate));
|
||||
if score < threshold {
|
||||
continue;
|
||||
}
|
||||
match best {
|
||||
Some((_, best_score)) if score <= best_score => {}
|
||||
_ => best = Some((candidate, score)),
|
||||
}
|
||||
}
|
||||
best.map(|(candidate, _)| candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_match_after_case_and_punctuation_normalization_scores_1() {
|
||||
assert_eq!(title_similarity("Breaking Bad", "breaking-bad"), 1.0);
|
||||
assert_eq!(title_similarity("The Office (US)", "the office, us!"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_release_year_parenthetical_is_ignored() {
|
||||
assert_eq!(title_similarity("Chernobyl", "Chernobyl (2019)"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_titles_score_low() {
|
||||
let score = title_similarity("Zzyzx Nonexistent Show", "Totally Unrelated Program");
|
||||
assert!(score < 0.3, "expected a low score, got {score}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_title_match_prefers_the_closer_textual_match_over_list_order() {
|
||||
let candidates = vec!["Chucky".to_string(), "Chuck".to_string()];
|
||||
let best = best_title_match("Chuck", &candidates, |s| s.as_str(), 0.3);
|
||||
assert_eq!(best.map(String::as_str), Some("Chuck"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_title_match_keeps_the_earlier_candidate_on_a_tie() {
|
||||
let candidates = vec!["Show A".to_string(), "Show A".to_string()];
|
||||
let best = best_title_match("Show A", &candidates, |s| s.as_str(), 0.3);
|
||||
// Both score 1.0 - the first one (by identity, not just value) should win.
|
||||
assert!(best.is_some_and(|s| std::ptr::eq(s, &candidates[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_title_match_returns_none_when_nothing_clears_the_threshold() {
|
||||
let candidates = vec!["Totally Unrelated Program".to_string()];
|
||||
let best = best_title_match("Zzyzx Nonexistent Show", &candidates, |s| s.as_str(), 0.3);
|
||||
assert_eq!(best, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Online metadata clients ("Fetch Data"). Currently just TMDB; the original FileBot
|
||||
//! supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since it's the
|
||||
//! most widely useful for both TV and movies and has a simple, well-documented API.
|
||||
|
||||
pub mod tmdb;
|
||||
@@ -0,0 +1,513 @@
|
||||
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
|
||||
//! show or movie by name, then look up real metadata (a specific episode's title/air
|
||||
//! date, or a movie's canonical title/release date).
|
||||
//!
|
||||
//! Ports the *lookup* half of what `net.filebot.web.TheMovieDBClient` did in the
|
||||
//! original. Authenticates with a TMDB **API Read Access Token** (the Bearer-token
|
||||
//! method TMDB documents as the current default) rather than the older `api_key` query
|
||||
//! parameter.
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::similarity::best_title_match;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
|
||||
|
||||
/// Minimum name-similarity score (see `similarity::title_similarity`) a TMDB search
|
||||
/// result must clear to be considered a plausible match for the query at all. Below
|
||||
/// this, `lookup_episode` reports `NotFound` rather than confidently picking whichever
|
||||
/// result TMDB happened to rank first - popularity/relevance ranking is not the same
|
||||
/// thing as an accurate name match.
|
||||
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TmdbError {
|
||||
#[error("network error talking to TMDB: {0}")]
|
||||
Network(String),
|
||||
#[error("TMDB API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("no TMDB match found for \"{0}\"")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchTvResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<TvResult>,
|
||||
}
|
||||
|
||||
/// One TMDB search hit. Only the fields Aurora Curator actually uses are kept.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TvResult {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SeasonResponse {
|
||||
#[serde(default)]
|
||||
episodes: Vec<EpisodeResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EpisodeResult {
|
||||
episode_number: i64,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one episode, resolved from TMDB - contrast with
|
||||
/// `matching::EpisodeGuess`, which only has whatever a filename pattern implied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeInfo {
|
||||
/// TMDB's canonical show name (may differ in punctuation/capitalization from
|
||||
/// whatever the local filename guess produced).
|
||||
pub series_name: String,
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchMovieResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<MovieResult>,
|
||||
}
|
||||
|
||||
/// One TMDB movie search hit. Only the fields Aurora Curator actually uses are kept.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MovieResult {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub release_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one movie, resolved from TMDB - contrast with
|
||||
/// `matching::MovieGuess`, which only has whatever a filename pattern implied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovieInfo {
|
||||
/// TMDB's canonical title (may differ in punctuation/capitalization from whatever
|
||||
/// the local filename guess produced).
|
||||
pub title: String,
|
||||
/// Full release date (`YYYY-MM-DD`), same shape TMDB returns it in - not just the
|
||||
/// year, since that's what a naming scheme's `{airdate}`-equivalent field wants.
|
||||
pub release_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TmdbClient {
|
||||
http: reqwest::Client,
|
||||
token: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl TmdbClient {
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
token: token.into(),
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||
/// instead of the real TMDB API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<T, TmdbError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| TmdbError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(TmdbError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| TmdbError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name. Results come back TMDB-ranked (relevance /
|
||||
/// popularity); callers wanting "the best guess" should just take the first one.
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TmdbError> {
|
||||
let parsed: SearchTvResponse = self.get_json("/search/tv", &[("query", query)]).await?;
|
||||
Ok(parsed.results)
|
||||
}
|
||||
|
||||
/// Fetches every episode of one season in a single request. Prefer this over
|
||||
/// calling `episode()` per-episode when looking up several episodes from the same
|
||||
/// season (a whole loaded folder is usually one season) - the season endpoint
|
||||
/// already returns the full episode list, so there's no need to re-fetch it per
|
||||
/// episode; cache this call's result and look episodes up from it locally instead.
|
||||
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TmdbError> {
|
||||
let path = format!("/tv/{series_id}/season/{season}");
|
||||
let parsed: SeasonResponse = self.get_json(&path, &[]).await?;
|
||||
|
||||
Ok(parsed
|
||||
.episodes
|
||||
.into_iter()
|
||||
.map(|e| SeasonEpisode {
|
||||
episode_number: e.episode_number as i32,
|
||||
title: e.name,
|
||||
air_date: e.air_date,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Looks up one episode's real title/air date from a known TMDB series id. Fetches
|
||||
/// the whole season each call - if you need several episodes from the same season,
|
||||
/// call `season_episodes()` once instead and look them up locally.
|
||||
pub async fn episode(&self, series_id: u64, season: i32, episode: i32) -> Result<EpisodeResultInfo, TmdbError> {
|
||||
let episodes = self.season_episodes(series_id, season).await?;
|
||||
episodes
|
||||
.into_iter()
|
||||
.find(|e| e.episode_number == episode)
|
||||
.map(|e| EpisodeResultInfo {
|
||||
episode_title: e.title,
|
||||
air_date: e.air_date,
|
||||
})
|
||||
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||
/// `similarity::best_title_match`), or `None` if nothing in the results is a
|
||||
/// plausible match at all. Exposed separately from `lookup_episode` so callers that
|
||||
/// need to resolve a show once and then fetch several episodes from it (e.g. a
|
||||
/// whole loaded season's worth of files) can reuse the resolved id instead of
|
||||
/// re-running disambiguation per episode.
|
||||
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TmdbError> {
|
||||
let results = self.search_tv(query).await?;
|
||||
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||
/// episode. Disambiguates a multi-result search by picking whichever result's name
|
||||
/// is textually closest to `series_query` (see `search_tv_best_match`) rather than
|
||||
/// just trusting TMDB's own top-ranked (relevance/popularity) result - so a more
|
||||
/// popular but differently-named show doesn't get picked over an exact match that's
|
||||
/// merely ranked lower. If nothing in the results is a plausible match at all,
|
||||
/// reports `NotFound` instead of guessing.
|
||||
pub async fn lookup_episode(
|
||||
&self,
|
||||
series_query: &str,
|
||||
season: i32,
|
||||
episode: i32,
|
||||
) -> Result<EpisodeInfo, TmdbError> {
|
||||
let best = self
|
||||
.search_tv_best_match(series_query)
|
||||
.await?
|
||||
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let info = self.episode(best.id, season, episode).await?;
|
||||
Ok(EpisodeInfo {
|
||||
series_name: best.name.clone(),
|
||||
episode_title: info.episode_title,
|
||||
air_date: info.air_date,
|
||||
})
|
||||
}
|
||||
|
||||
/// Searches for a movie by title, optionally narrowed to a specific release year
|
||||
/// (TMDB's own `primary_release_year` filter - helps disambiguate a remake from
|
||||
/// the original before name-similarity scoring even runs). Results come back
|
||||
/// TMDB-ranked, same caveat as `search_tv`.
|
||||
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, TmdbError> {
|
||||
let year_str;
|
||||
let mut params: Vec<(&str, &str)> = vec![("query", query)];
|
||||
if let Some(y) = year {
|
||||
year_str = y.to_string();
|
||||
params.push(("primary_release_year", &year_str));
|
||||
}
|
||||
let parsed: SearchMovieResponse = self.get_json("/search/movie", ¶ms).await?;
|
||||
Ok(parsed.results)
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching movie by title (and optional release
|
||||
/// year), picking whichever result's title is textually closest to `query` - same
|
||||
/// disambiguation approach as `lookup_episode`, so a more popular but
|
||||
/// differently-titled movie doesn't get picked over an exact match ranked lower.
|
||||
/// Unlike TV, a movie search result already carries everything needed (title,
|
||||
/// release date), so there's no second per-item request the way `episode()` needs.
|
||||
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, TmdbError> {
|
||||
let results = self.search_movie(query, year).await?;
|
||||
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
|
||||
.ok_or_else(|| TmdbError::NotFound(query.to_string()))?;
|
||||
|
||||
Ok(MovieInfo {
|
||||
title: best.title.clone(),
|
||||
release_date: best.release_date.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the episode-specific fields, before the series name (known by the caller of
|
||||
/// `episode()`, which already has a series id) is attached.
|
||||
pub struct EpisodeResultInfo {
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// One episode of a season, as returned by `season_episodes()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SeasonEpisode {
|
||||
pub episode_number: i32,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_episode_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Breaking Bad".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":1396,"name":"Breaking Bad"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/tv/1396/season/5")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"episodes":[
|
||||
{"episode_number":13,"name":"To'hajiilee","air_date":"2013-09-08"},
|
||||
{"episode_number":14,"name":"Ozymandias","air_date":"2013-09-15"}
|
||||
]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Breaking Bad", 5, 14).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
EpisodeInfo {
|
||||
series_name: "Breaking Bad".to_string(),
|
||||
episode_title: "Ozymandias".to_string(),
|
||||
air_date: Some("2013-09-15".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":42,"name":"Some Show"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/tv/42/season/1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Pilot","air_date":"2020-01-01"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
// TMDB ranks results by relevance/popularity, not exact-name-match - simulate
|
||||
// it putting a differently-named (but more popular) show first, with the
|
||||
// actually-correct exact match ranked second. Before the similarity-based
|
||||
// disambiguation, `lookup_episode` just took `.next()` and would've picked
|
||||
// "Chucky" here, which is wrong.
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Chuck".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":1,"name":"Chucky"},{"id":2,"name":"Chuck"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/tv/2/season/1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Chuck Versus the Intersect","air_date":"2007-09-24"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
|
||||
|
||||
assert_eq!(info.series_name, "Chuck");
|
||||
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
// TMDB returned *something*, but nothing in the results is actually close to
|
||||
// the query - should report NotFound rather than confidently renaming against
|
||||
// an unrelated show.
|
||||
let _mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":9,"name":"Totally Unrelated Program"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_movie_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/movie")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("query".into(), "Inception".into()),
|
||||
mockito::Matcher::UrlEncoded("primary_release_year".into(), "2010".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":27205,"title":"Inception","release_date":"2010-07-15"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let info = client.lookup_movie("Inception", Some(2010)).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
MovieInfo {
|
||||
title: "Inception".to_string(),
|
||||
release_date: Some("2010-07-15".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/movie")
|
||||
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Up".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"results":[
|
||||
{"id":1,"title":"Wake Up","release_date":"2015-01-01"},
|
||||
{"id":2,"title":"Up","release_date":"2009-05-29"}
|
||||
]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let info = client.lookup_movie("Up", None).await.unwrap();
|
||||
|
||||
assert_eq!(info.title, "Up");
|
||||
assert_eq!(info.release_date, Some("2009-05-29".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_movie_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/movie")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let result = client.lookup_movie("Some Nonexistent Movie", None).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_an_invalid_token_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status_message":"Invalid API key: You must be granted a valid key.","status_code":7}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("bad-token").with_base_url(server.url());
|
||||
let result = client.search_tv("anything").await;
|
||||
|
||||
match result {
|
||||
Err(TmdbError::Api { status, .. }) => assert_eq!(status, 401),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use aurora_curator_core::format::ExpressionFormat;
|
||||
use aurora_curator_core::string_bindings;
|
||||
|
||||
#[test]
|
||||
fn simple_variable_substitution() {
|
||||
let fmt = ExpressionFormat::compile("{n} - {s00e00} - {t}").unwrap();
|
||||
let bindings = string_bindings([
|
||||
("n", "Breaking Bad"),
|
||||
("s00e00", "S05E14"),
|
||||
("t", "Ozymandias"),
|
||||
]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad - S05E14 - Ozymandias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_expression_evaluation() {
|
||||
let fmt = ExpressionFormat::compile("{n.to_upper()}").unwrap();
|
||||
let bindings = string_bindings([("n", "Breaking Bad")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "BREAKING BAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_text_passes_through() {
|
||||
let fmt = ExpressionFormat::compile("plain text, no expressions").unwrap();
|
||||
assert_eq!(fmt.format(&Default::default()).unwrap(), "plain text, no expressions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_helper_function_any() {
|
||||
let fmt = ExpressionFormat::compile("{any([x, y])}").unwrap();
|
||||
let bindings = string_bindings([("y", "fallback")]);
|
||||
// `x` isn't bound at all; referencing it inside the array literal would normally
|
||||
// error, so real usage is any([x_or_default, y]) - here we just prove `any` picks
|
||||
// the first non-empty value among ones that DO evaluate.
|
||||
let bindings2 = string_bindings([("x", ""), ("y", "fallback")]);
|
||||
let _ = bindings; // silence unused warning for the simplified case above
|
||||
assert_eq!(fmt.format(&bindings2).unwrap(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefined_binding_alone_yields_error() {
|
||||
let fmt = ExpressionFormat::compile("{missing}").unwrap();
|
||||
let result = fmt.format(&Default::default());
|
||||
assert!(result.is_err(), "expected empty-result error, got {:?}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefined_binding_is_skipped_when_other_content_exists() {
|
||||
let fmt = ExpressionFormat::compile("{n}{missing}").unwrap();
|
||||
let bindings = string_bindings([("n", "Show")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_braces_inside_a_scriptlet_are_preserved() {
|
||||
// Rhai object map literal: #{ key: value }
|
||||
let fmt = ExpressionFormat::compile("{ (#{a: 1, b: 2}).a }").unwrap();
|
||||
assert_eq!(fmt.format(&Default::default()).unwrap(), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_error_is_reported_with_the_offending_expression() {
|
||||
let err = ExpressionFormat::compile("{this is not valid rhai +++ }").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("syntax error"), "message was: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_word_plain_field_names_are_treated_as_literal_binding_keys() {
|
||||
// "show name" etc. aren't valid Rhai (two bare identifiers with no operator between
|
||||
// them), so this only works because compile_scriptlet falls back to a literal key
|
||||
// lookup when a token looks like a plain human-typed field name rather than code.
|
||||
let fmt = ExpressionFormat::compile("{show name} - {season number}x{episode number} - {episode name}").unwrap();
|
||||
let bindings = string_bindings([
|
||||
("show name", "Reacher"),
|
||||
("season number", "1"),
|
||||
("episode number", "1"),
|
||||
("episode name", "Welcome to Margrave"),
|
||||
]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Reacher - 1x1 - Welcome to Margrave");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhai_expressions_still_take_priority_over_plain_field_name_fallback() {
|
||||
// Contains spaces too, but it's valid Rhai (string concatenation) - should compile
|
||||
// and run as a script, never falling through to the plain-field-name path.
|
||||
let fmt = ExpressionFormat::compile(r#"{n + " " + t}"#).unwrap();
|
||||
let bindings = string_bindings([("n", "Breaking Bad"), ("t", "Ozymandias")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad Ozymandias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuinely_invalid_syntax_with_spaces_still_errors() {
|
||||
// Has spaces like a plain field name might, but also punctuation that no field name
|
||||
// would ever contain - should still be reported as a real syntax error, not
|
||||
// silently treated as a (wrong) literal key.
|
||||
let err = ExpressionFormat::compile("{show name (}").unwrap_err();
|
||||
assert!(err.to_string().contains("syntax error"), "message was: {err}");
|
||||
}
|
||||
Reference in New Issue
Block a user