498 lines
17 KiB
Rust
498 lines
17 KiB
Rust
//! 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());
|
|
}
|
|
}
|