Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)

This commit is contained in:
Renamr Scaffold
2026-08-29 02:42:31 +00:00
parent d0c29048be
commit bab9bfa1ec
9 changed files with 851 additions and 24 deletions
+2
View File
@@ -6,6 +6,8 @@
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
pub mod matching;
pub mod rename;
use rhai::Dynamic;
use std::collections::HashMap;
+156
View File
@@ -0,0 +1,156 @@
//! 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())
}
/// 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(" ")
}
/// 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
}
#[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);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! The on-disk rename operation: given a planned `old path -> new filename` mapping,
//! actually renames the files.
//!
//! Deliberately scoped for v1: same-directory renames only (no moving files into a
//! different folder tree/organizing by naming scheme yet - that's a bigger feature with
//! its own directory-creation and cross-filesystem-move concerns). Conflict policy is
//! fixed at **skip**: if the computed target filename already exists and isn't the same
//! underlying file, that item is left alone and reported as skipped rather than
//! overwritten. There is no undo yet, so the UI is expected to show the caller a
//! confirmation of the full plan before calling [`execute_renames`].
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// One planned rename: `source` is the file's current full path, `new_name` is the
/// filename (including extension) it should have afterward, in the same directory.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenamePlanItem {
pub source: String,
pub new_name: String,
}
/// 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,
}
/// Rejects filenames that could escape the source file's own directory (path
/// separators, `.`/`..`) or are empty. The rename engine only ever renames a file
/// within its existing directory, so a `new_name` that isn't a plain filename is a bug
/// upstream (or a hostile input) rather than something to interpret as a move.
fn is_plain_filename(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains('/')
&& !name.contains('\\')
}
/// 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,
}
}
fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
if !is_plain_filename(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid filename"),
};
}
let current_name = match source.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => {
return RenameOutcome::Failed {
error: "source has no filename".to_string(),
}
}
};
if current_name == new_name {
return RenameOutcome::Unchanged;
}
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.exists() && !same_file(&target, source) {
return RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
};
}
match fs::rename(source, &target) {
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. 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: the first one through wins, the second is
/// reported as a skipped conflict rather than silently overwriting it.
pub fn execute_renames(items: &[RenamePlanItem]) -> Vec<RenameResult> {
items
.iter()
.map(|item| RenameResult {
source: item.source.clone(),
outcome: rename_one(Path::new(&item.source), &item.new_name),
})
.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!("renamr_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);
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);
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);
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);
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);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
}