Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
+497
View File
@@ -0,0 +1,497 @@
//! Series naming + scanning for the Jellyfin TV layout.
//!
//! ```text
//! Seriály/Series (year) [tmdbid-N]/
//! tvshow.nfo (Jellyfin writes it)
//! Season 01/
//! S01E01 - Series.ext
//! Specials/ (season 0)
//! ```
//!
//! Episodes are matched by `SxxEyy`. These helpers parse the episode number out
//! of a raw filename, build the canonical names, and scan a series library into
//! a `Series → Season → Episode` tree.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::{Captures, Regex};
use super::error::{CoreError, CoreResult};
use super::naming::{canonical_stem, is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id};
use super::paths;
// SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
static EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
.expect("valid regex")
});
// Jellyfin's "stacking" tokens for one episode split across several files. The
// token must end the stem and must not be glued to a preceding letter/digit
// (checked in code — the regex crate has no look-behind).
static PART_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:cd|dvd|part|pt|disc|disk)[\s._-]?(\d{1,2})$").expect("valid regex")
});
// The compact code a user types by hand ("S01E05P2") may also use a bare "P";
// only ever matched against the tail *after* the recognised SxxEyy.
static PART_RE_LOOSE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[\s._-]?(?:cd|dvd|part|pt|disc|disk|p)[\s._-]?(\d{1,2})$")
.expect("valid regex")
});
static SEASON_DIR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^Season \d{1,3}$").expect("valid regex"));
static FS_UNSAFE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"[<>:"/\\|?*\x00-\x1f]"#).expect("valid regex"));
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex"));
static YEAR4_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d{4}$").expect("valid regex"));
/// Files next to an episode that are not video (NFO / artwork / subtitles).
pub const SIDECARS: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
pub fn is_sidecar(path: &Path) -> bool {
SIDECARS.contains(&paths::suffix_lower(path).as_str())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Episode {
pub path: PathBuf,
pub season: Option<u32>,
pub episode: Option<u32>,
/// Which physical part of the episode this file is — `None` for a whole episode.
pub part: Option<u32>,
}
impl Episode {
/// `S01E02`, `S01E02.pt2`, or `?` when unrecognised.
pub fn code(&self) -> String {
match (self.season, self.episode) {
(Some(s), Some(e)) => {
let base = format!("S{s:02}E{e:02}");
match self.part {
Some(p) if p > 0 => format!("{base}.pt{p}"),
_ => base,
}
}
_ => "?".into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
pub folder: PathBuf,
pub title: String,
pub year: Option<String>,
pub tmdb_id: Option<i64>,
pub library: String,
pub episodes: Vec<Episode>,
}
impl Series {
/// Episodes grouped by season number (`-1` = unrecognised), each season
/// sorted by episode number.
pub fn seasons(&self) -> BTreeMap<i64, Vec<Episode>> {
let mut grouped: BTreeMap<i64, Vec<Episode>> = BTreeMap::new();
for ep in &self.episodes {
grouped
.entry(ep.season.map_or(-1, i64::from))
.or_default()
.push(ep.clone());
}
for eps in grouped.values_mut() {
eps.sort_by_key(|e| e.episode.unwrap_or(1_000_000));
}
grouped
}
}
fn episode_numbers(caps: &Captures<'_>) -> Option<(u32, u32)> {
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
return Some((s.as_str().parse().ok()?, e.as_str().parse().ok()?));
}
Some((
caps.get(3)?.as_str().parse().ok()?,
caps.get(4)?.as_str().parse().ok()?,
))
}
/// Extract `(season, episode)` from a filename.
pub fn parse_episode(name: &str) -> Option<(u32, u32)> {
episode_numbers(&EPISODE_RE.captures(name)?)
}
/// Part number implied by a stacking suffix right before the extension
/// (`Show S01E02.pt2.mkv`, `Show S01E02-part-2.mkv`, …).
pub fn parse_episode_part(name: &str) -> Option<u32> {
let stem = paths::stem(Path::new(name));
let mut start = 0;
while let Some(caps) = PART_RE.captures_at(&stem, start) {
let whole = caps.get(0)?;
let before = stem[..whole.start()].chars().next_back();
if !before.is_some_and(|c| c.is_ascii_alphanumeric()) {
return caps[1].parse().ok();
}
start = whole.start() + stem[whole.start()..].chars().next()?.len_utf8();
}
None
}
/// Parse a compact user-typed code: `SxxEyy`, optionally followed by a part
/// marker (`S01E02P3`, `S01E02 pt3`, `S01E02.part.3`, `S01E02 cd2`).
pub fn parse_episode_code(text: &str) -> Option<(u32, u32, Option<u32>)> {
let text = text.trim();
let caps = EPISODE_RE.captures(text)?;
let (season, episode) = episode_numbers(&caps)?;
let end = caps.get(0)?.end();
let part = PART_RE_LOOSE
.captures(&text[end..])
.and_then(|c| c[1].parse().ok());
Some((season, episode, part))
}
/// Every video file under `folder` (recursively), sidecars excluded, sorted.
pub fn collect_episode_files(folder: &Path) -> Vec<PathBuf> {
if !folder.is_dir() {
return Vec::new();
}
paths::walk_files(folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.collect()
}
/// Folder name for a season — `Specials` for 0, else `Season NN`.
pub fn season_folder_name(season: u32) -> String {
if season == 0 {
"Specials".into()
} else {
format!("Season {season:02}")
}
}
/// True for `Specials` or `Season <n>` (any padding).
pub fn is_season_folder(name: &str) -> bool {
name == "Specials" || SEASON_DIR_RE.is_match(name)
}
/// True when `folder` holds at least one season subfolder — the shape Curator
/// always gives a series; a movie's version folder never has one.
pub fn looks_like_series_folder(folder: &Path) -> bool {
folder.is_dir()
&& paths::subdirs(folder)
.iter()
.any(|p| is_season_folder(&paths::name(p)))
}
/// A title made safe to sit in a filename (spaces collapsed, edge dots trimmed).
pub fn sanitize_title(title: &str) -> String {
let cleaned = FS_UNSAFE_RE.replace_all(title, "");
WHITESPACE_RE
.replace_all(&cleaned, " ")
.trim_matches(|c| c == ' ' || c == '.')
.to_string()
}
/// Canonical episode filename `SxxEyy - {series title}[.ptN].ext`.
///
/// The release string is deliberately dropped — identity is the code, and
/// Jellyfin fetches the episode title from TMDb.
pub fn episode_filename(
season: u32,
episode: u32,
series_title: &str,
ext: &str,
part: Option<u32>,
) -> String {
let code = format!("S{season:02}E{episode:02}");
let body = sanitize_title(series_title);
let mut name = if body.is_empty() {
code
} else {
format!("{code} - {body}")
};
if let Some(p) = part.filter(|p| *p > 0) {
name.push_str(&format!(".pt{p}"));
}
format!("{name}{ext}")
}
/// Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
pub fn series_folder_stem(
title: &str,
year: Option<&str>,
tmdb_id: Option<i64>,
) -> CoreResult<String> {
let title = title.trim();
if title.is_empty() {
return Err(CoreError::invalid("Název seriálu nesmí být prázdný."));
}
let year_s = year.unwrap_or("").trim();
if YEAR4_RE.is_match(year_s) {
return canonical_stem(title, year_s, tmdb_id, None);
}
Ok(match tmdb_id {
Some(id) if id >= 0 => format!("{title} [tmdbid-{id}]"),
_ => title.to_string(),
})
}
/// True when `path` is already `SxxEyy - {series title}.ext` in its season folder.
pub fn is_canonical_episode(path: &Path, series_title: &str) -> bool {
let name = paths::name(path);
let Some((season, episode)) = parse_episode(&name) else {
return false;
};
let part = parse_episode_part(&name);
let expected = episode_filename(season, episode, series_title, &paths::suffix(path), part);
name == expected
&& path.parent().map(paths::name).as_deref() == Some(season_folder_name(season).as_str())
}
/// Scan one series library into a list of [`Series`].
///
/// Each immediate subfolder is a series; video files anywhere below it are
/// episodes. With `skip_movie_folders` (a `mixed` library) a canonical-shaped
/// folder without any season folder is a movie's version folder and is skipped.
pub fn scan_series(lib_dir: &Path, library: &str, skip_movie_folders: bool) -> Vec<Series> {
if !lib_dir.is_dir() {
return Vec::new();
}
let mut out = Vec::new();
for folder in paths::subdirs(lib_dir) {
let folder_name = paths::name(&folder);
if skip_movie_folders
&& is_canonical_pool_stem(&folder_name)
&& !looks_like_series_folder(&folder)
{
continue;
}
let (title, year) = match parse_pool_stem(&folder_name) {
Some((t, y)) => (t, Some(y.to_string())),
None => (folder_name.clone(), None),
};
let episodes = paths::walk_files(&folder)
.into_iter()
.filter(|f| !is_sidecar(f))
.map(|f| {
let name = paths::name(&f);
let code = parse_episode(&name);
Episode {
season: code.map(|c| c.0),
episode: code.map(|c| c.1),
part: parse_episode_part(&name),
path: f,
}
})
.collect();
out.push(Series {
tmdb_id: parse_tmdb_id(&folder_name),
folder,
title,
year,
library: library.into(),
episodes,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_parse_episode_cases() {
for (name, expected) in [
("Breaking Bad S01E02.mkv", Some((1, 2))),
("bb.s1e2.mkv", Some((1, 2))),
("Show 01x02 x264.mkv", Some((1, 2))),
("Series.S01E02E03.mkv", Some((1, 2))),
("Series S12E145.mkv", Some((12, 145))),
("No code here.mkv", None),
] {
assert_eq!(parse_episode(name), expected, "{name}");
}
}
#[test]
fn test_names() {
assert_eq!(season_folder_name(0), "Specials");
assert_eq!(season_folder_name(12), "Season 12");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", None),
"S01E02 - Most!.mkv"
);
assert_eq!(episode_filename(3, 4, "", ".mkv", None), "S03E04.mkv");
assert_eq!(episode_filename(3, 4, "/", ".mkv", None), "S03E04.mkv");
assert_eq!(
episode_filename(1, 2, "Most!", ".mkv", Some(3)),
"S01E02 - Most!.pt3.mkv"
);
assert_eq!(
episode_filename(1, 2, "", ".mkv", Some(2)),
"S01E02.pt2.mkv"
);
}
#[test]
fn test_sanitize_title_cases() {
assert_eq!(sanitize_title("Most!"), "Most!");
assert_eq!(sanitize_title("A: B / C D"), "A B C D");
assert_eq!(sanitize_title(" trailing. "), "trailing");
}
#[test]
fn test_is_season_folder_cases() {
assert!(is_season_folder("Season 01"));
assert!(is_season_folder("Season 1"));
assert!(is_season_folder("Specials"));
assert!(!is_season_folder("Extras"));
assert!(!is_season_folder("Season"));
}
#[test]
fn test_series_folder_stem_cases() {
assert_eq!(
series_folder_stem("Breaking Bad", Some("2008"), Some(1396)).unwrap(),
"Breaking Bad (2008) [tmdbid-1396]"
);
assert_eq!(
series_folder_stem("Neznámý", None, Some(42)).unwrap(),
"Neznámý [tmdbid-42]"
);
assert_eq!(
series_folder_stem("Jen název", None, None).unwrap(),
"Jen název"
);
assert!(series_folder_stem(" ", None, None).is_err());
}
#[test]
fn test_parse_episode_part_cases() {
for (name, expected) in [
("Show S01E02.pt2.mkv", Some(2)),
("Show S01E01-part-1.mkv", Some(1)),
("Show S01E01 part 1.mkv", Some(1)),
("Show S01E01_cd1.mkv", Some(1)),
("Show S01E01.disc2.mkv", Some(2)),
("Show S01E01.mkv", None),
("Show Abcd2.mkv", None),
("Show Part of Something.mkv", None),
] {
assert_eq!(parse_episode_part(name), expected, "{name}");
}
}
#[test]
fn test_parse_episode_code_cases() {
for (text, expected) in [
("S01E02P3", Some((1, 2, Some(3)))),
("s1e2p3", Some((1, 2, Some(3)))),
("S01E02 pt3", Some((1, 2, Some(3)))),
("S01E02.part.3", Some((1, 2, Some(3)))),
("S01E02 cd2", Some((1, 2, Some(2)))),
("S01E02", Some((1, 2, None))),
("1x02", Some((1, 2, None))),
("no code here", None),
] {
assert_eq!(parse_episode_code(text), expected, "{text}");
}
}
#[test]
fn test_episode_code_property() {
let ep = |s, e, p| Episode {
path: PathBuf::new(),
season: s,
episode: e,
part: p,
};
assert_eq!(ep(Some(1), Some(2), Some(3)).code(), "S01E02.pt3");
assert_eq!(ep(Some(1), Some(2), None).code(), "S01E02");
assert_eq!(ep(None, None, None).code(), "?");
}
#[test]
fn test_is_canonical_episode_cases() {
let dir = tempfile::tempdir().unwrap();
let season = dir.path().join("Season 01");
assert!(is_canonical_episode(
&season.join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&dir.path().join("S01E02 - Breaking Bad.mkv"),
"Breaking Bad"
));
assert!(!is_canonical_episode(
&season.join("S01E02 - Pilot.mkv"),
"Breaking Bad"
));
assert!(is_canonical_episode(
&season.join("S01E02 - Show.pt2.mkv"),
"Show"
));
}
#[test]
fn test_scan_series_reads_tree() {
let dir = tempfile::tempdir().unwrap();
let s1 = dir
.path()
.join("Breaking Bad (2008) [tmdbid-1396]/Season 01");
fs::create_dir_all(&s1).unwrap();
fs::write(s1.join("S01E01 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.mkv"), "").unwrap();
fs::write(s1.join("S01E02 - Breaking Bad.cs.srt"), "").unwrap();
let found = scan_series(dir.path(), "Seriály", false);
assert_eq!(found.len(), 1);
let ser = &found[0];
assert_eq!(
(ser.title.as_str(), ser.year.as_deref(), ser.tmdb_id),
("Breaking Bad", Some("2008"), Some(1396))
);
assert_eq!(ser.episodes.len(), 2);
assert_eq!(ser.seasons().keys().copied().collect::<Vec<_>>(), vec![1]);
}
#[test]
fn test_scan_series_skips_movie_folders_in_mixed() {
let dir = tempfile::tempdir().unwrap();
let movie = dir.path().join("Avatar (2009) [tmdbid-19995]");
fs::create_dir_all(&movie).unwrap();
fs::write(movie.join("Avatar (2009) [tmdbid-19995] - 4K.mkv"), "").unwrap();
assert!(scan_series(dir.path(), "Mix", true).is_empty());
assert_eq!(scan_series(dir.path(), "Mix", false).len(), 1);
}
#[test]
fn test_collect_episode_files_walks_subfolders() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("Breaking.Bad.COMPLETE");
for f in [
"Season 01/BB S01E01.mkv",
"Season 01/BB S01E02.mkv",
"Season 01/BB S01E02.srt",
"Season 02/BB S02E01.avi",
"readme.nfo",
] {
fs::create_dir_all(root.join(f).parent().unwrap()).unwrap();
fs::write(root.join(f), "x").unwrap();
}
let names: Vec<String> = collect_episode_files(&root)
.iter()
.map(|p| paths::name(p))
.collect();
assert_eq!(
names,
vec!["BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
);
assert!(collect_episode_files(&dir.path().join("nope")).is_empty());
}
}