Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)
This commit is contained in:
@@ -15,4 +15,9 @@ 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"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user