//! 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 = 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 = 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 = 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 = LazyLock::new(|| Regex::new(r"^Season \d{1,3}$").expect("valid regex")); static FS_UNSAFE_RE: LazyLock = LazyLock::new(|| Regex::new(r#"[<>:"/\\|?*\x00-\x1f]"#).expect("valid regex")); static WHITESPACE_RE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").expect("valid regex")); static YEAR4_RE: LazyLock = 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, pub episode: Option, /// Which physical part of the episode this file is — `None` for a whole episode. pub part: Option, } 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, pub tmdb_id: Option, pub library: String, pub episodes: Vec, } impl Series { /// Episodes grouped by season number (`-1` = unrecognised), each season /// sorted by episode number. pub fn seasons(&self) -> BTreeMap> { let mut grouped: BTreeMap> = 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 { 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)> { 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 { 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 ` (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, ) -> 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, ) -> CoreResult { 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 { 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![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 = 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()); } }