handle directories containing multiple seasons of a series
This commit is contained in:
parent
06cb5398e3
commit
b5198194cf
10
src/main.rs
10
src/main.rs
|
@ -5,6 +5,8 @@ mod config;
|
||||||
mod shows;
|
mod shows;
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
use crate::shows::EpisodeNumber;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
@ -31,10 +33,10 @@ async fn main() {
|
||||||
let descriptor = format!(
|
let descriptor = format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
title,
|
title,
|
||||||
if let Some(n) = num {
|
match num {
|
||||||
format!(" episode {}", n)
|
EpisodeNumber::Standalone => String::new(),
|
||||||
} else {
|
EpisodeNumber::SingleSeason(n) => format!(" episode {}", n),
|
||||||
String::new()
|
EpisodeNumber::MultiSeason(season, ep) => format!(" season {} episode {}", season, ep),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
134
src/shows.rs
134
src/shows.rs
|
@ -1,134 +0,0 @@
|
||||||
use std::{
|
|
||||||
collections::{BTreeSet, HashMap},
|
|
||||||
fs,
|
|
||||||
io::ErrorKind,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use log::{debug, error, trace, warn};
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
static ref NUMBER_REGEX: Regex = Regex::new("[0-9]+").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ShowSpec {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [episode number, or None for a standalone movie] -> [video file path]
|
|
||||||
type Episodes = HashMap<Option<u32>, PathBuf>;
|
|
||||||
|
|
||||||
pub struct Show {
|
|
||||||
pub episodes: Episodes,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(shows_file: PathBuf) -> HashMap<String, Show> {
|
|
||||||
let show_specs: HashMap<String, ShowSpec> = serde_yaml::from_reader(
|
|
||||||
fs::File::open(shows_file).expect("Failed to open shows file"),
|
|
||||||
)
|
|
||||||
.expect("Failed to parse YAML from shows file");
|
|
||||||
|
|
||||||
show_specs
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, show)| {
|
|
||||||
debug!("Enumerating show {}: {}", name, show.path.display());
|
|
||||||
enumerate_show(show.path)
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Error processing {}: {}", name, e);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.map(|eps| (name, Show { episodes: eps, tags: show.tags }))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enumerate_show(path: PathBuf) -> std::io::Result<Episodes> {
|
|
||||||
let metadata = fs::metadata(&path)?;
|
|
||||||
if metadata.is_file() {
|
|
||||||
debug!("{} is a file, standalone", path.display());
|
|
||||||
Ok(HashMap::from([(None, path)]))
|
|
||||||
} else if metadata.is_dir() {
|
|
||||||
debug!("{} is a directory, enumerating episodes", path.display());
|
|
||||||
enumerate_dir(path)
|
|
||||||
} else {
|
|
||||||
Err(std::io::Error::new(
|
|
||||||
ErrorKind::InvalidInput,
|
|
||||||
format!("Invalid file type for {}", path.display()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enumerate_dir(path: PathBuf) -> std::io::Result<Episodes> {
|
|
||||||
let files: Vec<(PathBuf, String)> = fs::read_dir(&path)?
|
|
||||||
.map(|entry| {
|
|
||||||
let entry = entry?;
|
|
||||||
if !entry.file_type()?.is_file() {
|
|
||||||
debug!("Skipping {}, not a file", entry.path().display());
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some((
|
|
||||||
entry.path(),
|
|
||||||
entry.file_name().into_string().map_err(|o| {
|
|
||||||
std::io::Error::new(ErrorKind::InvalidData, format!("Invalid filename {:?}", o))
|
|
||||||
})?,
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
.filter_map(|r| r.transpose())
|
|
||||||
.collect::<std::io::Result<Vec<(PathBuf, String)>>>()?;
|
|
||||||
|
|
||||||
// For each string prefix which is followed by a number in one or
|
|
||||||
// more filenames, record what the number is for each filename
|
|
||||||
// with that prefix, sorted by number.
|
|
||||||
//
|
|
||||||
// We use a BTreeSet of pairs rather than a BTreeMap so that
|
|
||||||
// duplicates are preserved.
|
|
||||||
let mut prefix_enumerations: HashMap<&str, BTreeSet<(u32, &Path)>> = HashMap::new();
|
|
||||||
for (path, filename) in files.iter() {
|
|
||||||
for m in NUMBER_REGEX.find_iter(filename) {
|
|
||||||
let num = m.as_str().parse();
|
|
||||||
if let Err(e) = num {
|
|
||||||
warn!("Failed to parse candidate number: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let num = num.unwrap();
|
|
||||||
let prefix = filename.get(..m.start()).unwrap();
|
|
||||||
trace!("{}: candidate prefix {}, number {}", filename, prefix, num);
|
|
||||||
prefix_enumerations
|
|
||||||
.entry(prefix)
|
|
||||||
.or_default()
|
|
||||||
.insert((num, path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the prefix enumeration that produces the most consecutive numbers
|
|
||||||
let best_enumeration = prefix_enumerations
|
|
||||||
.into_values()
|
|
||||||
.max_by_key(|set| {
|
|
||||||
set.iter()
|
|
||||||
.zip(set.iter().skip(1))
|
|
||||||
.filter(|((cur, _), (next, _))| next - cur == 1)
|
|
||||||
.count()
|
|
||||||
})
|
|
||||||
.ok_or(std::io::Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
"No valid prefixes found",
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let mut result: Episodes = HashMap::new();
|
|
||||||
for (num, path) in best_enumeration.into_iter() {
|
|
||||||
debug!("Episode {}: {}", num, path.display());
|
|
||||||
if let Some(dup) = result.insert(Some(num), path.to_path_buf()) {
|
|
||||||
warn!(
|
|
||||||
"Duplicate episode number, discarding file {}",
|
|
||||||
dup.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
348
src/shows/enumeration.rs
Normal file
348
src/shows/enumeration.rs
Normal file
|
@ -0,0 +1,348 @@
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeSet, HashMap},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use log::{debug, trace, warn};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::{EpisodeNumber, Episodes};
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref NUMBER_REGEX: Regex = Regex::new("[0-9]+").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An association of numbers to items, sorted by number.
|
||||||
|
//
|
||||||
|
// We use a BTreeSet of pairs rather than a BTreeMap so that
|
||||||
|
// duplicates are preserved.
|
||||||
|
type Enumeration<T> = BTreeSet<(u32, T)>;
|
||||||
|
|
||||||
|
fn consecutiveness<T>(enumeration: &Enumeration<T>) -> usize {
|
||||||
|
enumeration
|
||||||
|
.iter()
|
||||||
|
.zip(enumeration.iter().skip(1))
|
||||||
|
.filter(|((cur, _), (next, _))| next - cur == 1)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fully_consecutive<T>(enumeration: &Enumeration<T>) -> bool {
|
||||||
|
consecutiveness(enumeration) == enumeration.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take a series of items that can be represented as strings. For each
|
||||||
|
// string prefix which is followed by a number in one or more items'
|
||||||
|
// representations, record what the number is for each item that has
|
||||||
|
// that prefix.
|
||||||
|
fn prefix_enumerations<'a, T: Copy + Ord, I: Iterator<Item = T>, F: Fn(T) -> &'a str>(
|
||||||
|
items: I,
|
||||||
|
f: F,
|
||||||
|
) -> HashMap<&'a str, Enumeration<T>> {
|
||||||
|
let mut enumerations: HashMap<&str, Enumeration<T>> = HashMap::new();
|
||||||
|
for item in items {
|
||||||
|
let name = f(item);
|
||||||
|
for m in NUMBER_REGEX.find_iter(name) {
|
||||||
|
let num = m.as_str().parse();
|
||||||
|
if let Err(e) = num {
|
||||||
|
warn!("Failed to parse candidate number: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let num = num.unwrap();
|
||||||
|
let prefix = name.get(..m.start()).unwrap();
|
||||||
|
trace!("{}: candidate prefix {}, number {}", name, prefix, num);
|
||||||
|
enumerations.entry(prefix).or_default().insert((num, item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enumerations
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_episodes(files: Vec<PathBuf>) -> Option<Episodes> {
|
||||||
|
let mut episode_enumerations = prefix_enumerations(files.iter(), |path| {
|
||||||
|
path.file_name().unwrap().to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retain only prefix enumerations that produce consecutive numbers
|
||||||
|
episode_enumerations.retain(|_, en| consecutiveness(en) > 0);
|
||||||
|
|
||||||
|
let mut result: Episodes = HashMap::new();
|
||||||
|
|
||||||
|
if episode_enumerations.len() > 1 {
|
||||||
|
// There are multiple viable episode enumerations, which may
|
||||||
|
// mean that this list of files includes multiple seasons of a
|
||||||
|
// series. Look for a fully consecutive prefix enumeration *of
|
||||||
|
// the candidate episode prefixes* to find season numbers.
|
||||||
|
let season_enumerations = prefix_enumerations(episode_enumerations.keys(), |s| s);
|
||||||
|
let best_season_enumeration = season_enumerations
|
||||||
|
.into_values()
|
||||||
|
.max_by_key(consecutiveness);
|
||||||
|
if let Some(season_enumeration) = best_season_enumeration {
|
||||||
|
if season_enumeration.len() > 1 && fully_consecutive(&season_enumeration) {
|
||||||
|
for (season_num, prefix) in season_enumeration.into_iter() {
|
||||||
|
for (episode_num, path) in episode_enumerations.get(prefix).unwrap().iter() {
|
||||||
|
debug!(
|
||||||
|
"Season {} episode {}: {}",
|
||||||
|
season_num,
|
||||||
|
episode_num,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
if let Some(dup) = result.insert(
|
||||||
|
EpisodeNumber::MultiSeason(season_num, *episode_num),
|
||||||
|
path.to_path_buf(),
|
||||||
|
) {
|
||||||
|
warn!(
|
||||||
|
"Duplicate episode number, discarding file {}",
|
||||||
|
dup.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No evidence found for multiple seasons, so just take the best episode enumeration
|
||||||
|
let best_episode_enumeration = episode_enumerations
|
||||||
|
.into_values()
|
||||||
|
.max_by_key(consecutiveness)?;
|
||||||
|
|
||||||
|
for (num, path) in best_episode_enumeration.into_iter() {
|
||||||
|
debug!("Episode {}: {}", num, path.display());
|
||||||
|
if let Some(dup) = result.insert(EpisodeNumber::SingleSeason(num), path.to_path_buf()) {
|
||||||
|
warn!(
|
||||||
|
"Duplicate episode number, discarding file {}",
|
||||||
|
dup.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enumerate_single_season() {
|
||||||
|
let files = vec![
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 07v2 (1080p) [BBF1FDA4].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 08 (1080p) [95D16D74].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 04v2 (1080p) [D291A3B0].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 11v2 (1080p) [B7B3ECB6].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 05 (1080p) [16CC6267].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 02v2 (1080p) [5E88C757].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 03v2 (1080p) [5F5AD4BD].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 01v2 (1080p) [8C038972].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 12 (1080p) [179132FA].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 06v2 (1080p) [21EA6641].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 09 (1080p) [5A9C6CEC].mkv",
|
||||||
|
),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 10 (1080p) [97953FA7].mkv",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let expected = Some(HashMap::from([
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(1),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 01v2 (1080p) [8C038972].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(2),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 02v2 (1080p) [5E88C757].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(3),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 03v2 (1080p) [5F5AD4BD].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(4),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 04v2 (1080p) [D291A3B0].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(5),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 05 (1080p) [16CC6267].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(6),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 06v2 (1080p) [21EA6641].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(7),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 07v2 (1080p) [BBF1FDA4].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(8),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 08 (1080p) [95D16D74].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(9),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 09 (1080p) [5A9C6CEC].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(10),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 10 (1080p) [97953FA7].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(11),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 11v2 (1080p) [B7B3ECB6].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(12),
|
||||||
|
PathBuf::from(
|
||||||
|
"media/Chainsaw Man/[SubsPlease] Chainsaw Man - 12 (1080p) [179132FA].mkv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert_eq!(enumerate_episodes(files), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enumerate_two_seasons_with_number_in_title() {
|
||||||
|
let files = vec![
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E04 - A Reason to Fight (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E03 - Allelujah Rescue Operation (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E05 - Homeland Burning (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E02 - Gundam Meisters (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E02 - Twin Drive (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E01 - Celestial Being (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E06 - Scars (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E04 - International Negotiation (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E05 - Escape Limit Zone (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E03 - The Changing World (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E01 - The Angels' Second Advent (720p - DUAL Audio).mkv"),
|
||||||
|
PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E06 - Seven Swords (720p - DUAL Audio).mkv"),
|
||||||
|
];
|
||||||
|
let expected = Some(HashMap::from([
|
||||||
|
(EpisodeNumber::MultiSeason(1, 1), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E01 - Celestial Being (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(1, 2), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E02 - Gundam Meisters (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(1, 3), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E03 - The Changing World (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(1, 4), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E04 - International Negotiation (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(1, 5), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E05 - Escape Limit Zone (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(1, 6), PathBuf::from("media/MSG 00/MS Gundam 00 - S01 E06 - Seven Swords (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 1), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E01 - The Angels' Second Advent (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 2), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E02 - Twin Drive (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 3), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E03 - Allelujah Rescue Operation (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 4), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E04 - A Reason to Fight (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 5), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E05 - Homeland Burning (720p - DUAL Audio).mkv")),
|
||||||
|
(EpisodeNumber::MultiSeason(2, 6), PathBuf::from("media/MSG 00/MS Gundam 00 - S02 E06 - Scars (720p - DUAL Audio).mkv")),
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert_eq!(enumerate_episodes(files), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enumerate_non_ascii() {
|
||||||
|
let files = vec![
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 03 / 機動戦士ガンダム Twilight AXIS 第3話 [720p].mkv"),
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 06 / 機動戦士ガンダム Twilight AXIS 第6話 [720p].mkv"),
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 02 / 機動戦士ガンダム Twilight AXIS 第2話 [720p].mkv"),
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 04 / 機動戦士ガンダム Twilight AXIS 第4話 [720p].mkv"),
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 01 / 機動戦士ガンダム Twilight AXIS 第1話 [720p].mkv"),
|
||||||
|
PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 05 / 機動戦士ガンダム Twilight AXIS 第5話 [720p].mkv"),
|
||||||
|
];
|
||||||
|
let expected = Some(HashMap::from([
|
||||||
|
(EpisodeNumber::SingleSeason(1), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 01 / 機動戦士ガンダム Twilight AXIS 第1話 [720p].mkv")),
|
||||||
|
(EpisodeNumber::SingleSeason(2), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 02 / 機動戦士ガンダム Twilight AXIS 第2話 [720p].mkv")),
|
||||||
|
(EpisodeNumber::SingleSeason(3), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 03 / 機動戦士ガンダム Twilight AXIS 第3話 [720p].mkv")),
|
||||||
|
(EpisodeNumber::SingleSeason(4), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 04 / 機動戦士ガンダム Twilight AXIS 第4話 [720p].mkv")),
|
||||||
|
(EpisodeNumber::SingleSeason(5), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 05 / 機動戦士ガンダム Twilight AXIS 第5話 [720p].mkv")),
|
||||||
|
(EpisodeNumber::SingleSeason(6), PathBuf::from("media/Twilight AXIS/[(́◉◞౪◟◉‵)] Mobile Suit Gundam Twilight AXIS - 06 / 機動戦士ガンダム Twilight AXIS 第6話 [720p].mkv")),
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert_eq!(enumerate_episodes(files), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enumerate_extraneous_numbered_items() {
|
||||||
|
let files = vec![
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_03_Resub(720p)[0A99BA5D].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_OP1_Resub(720p)[A5ADDABF].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]Gundam_Frag_(1080p)[546747A1].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_06_Resub(720p)[CAADADF2].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_ED2_Resub(720p)[21717EAF].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_02_Resub(720p)[115ABF72].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_05_Resub(720p)[A0FD098A].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_01_Resub(720p)[7CDE63CD].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_ED1_Resub(720p)[CA9B2E3F].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]Gundam_Frag_II(1080p)[49465CA8].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_04_Resub(720p)[8D219C3C].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_OP2_Resub(720p)[F691D6D5].mkv"),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_OP3_Resub(720p)[BD7DCCE6].mkv"),
|
||||||
|
];
|
||||||
|
let expected = Some(HashMap::from([
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(1),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_01_Resub(720p)[7CDE63CD].mkv"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(2),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_02_Resub(720p)[115ABF72].mkv"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(3),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_03_Resub(720p)[0A99BA5D].mkv"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(4),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_04_Resub(720p)[8D219C3C].mkv"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(5),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_05_Resub(720p)[A0FD098A].mkv"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EpisodeNumber::SingleSeason(6),
|
||||||
|
PathBuf::from("media/ZZ Gundam/[EG]ZZ_Gundam_BD_06_Resub(720p)[CAADADF2].mkv"),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert_eq!(enumerate_episodes(files), expected);
|
||||||
|
}
|
||||||
|
}
|
90
src/shows/mod.rs
Normal file
90
src/shows/mod.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use std::{collections::HashMap, fs, io::ErrorKind, path::PathBuf};
|
||||||
|
|
||||||
|
use log::{debug, error};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
mod enumeration;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ShowSpec {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum EpisodeNumber {
|
||||||
|
Standalone,
|
||||||
|
SingleSeason(u32),
|
||||||
|
MultiSeason(u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Episodes = HashMap<EpisodeNumber, PathBuf>;
|
||||||
|
|
||||||
|
pub struct Show {
|
||||||
|
pub episodes: Episodes,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(shows_file: PathBuf) -> HashMap<String, Show> {
|
||||||
|
let show_specs: HashMap<String, ShowSpec> =
|
||||||
|
serde_yaml::from_reader(fs::File::open(shows_file).expect("Failed to open shows file"))
|
||||||
|
.expect("Failed to parse YAML from shows file");
|
||||||
|
|
||||||
|
show_specs
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, show)| {
|
||||||
|
debug!("Enumerating show {}: {}", name, show.path.display());
|
||||||
|
load_path(show.path)
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Error processing {}: {}", name, e);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.map(|eps| {
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
Show {
|
||||||
|
episodes: eps,
|
||||||
|
tags: show.tags,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_path(path: PathBuf) -> std::io::Result<Episodes> {
|
||||||
|
let metadata = fs::metadata(&path)?;
|
||||||
|
if metadata.is_file() {
|
||||||
|
debug!("{} is a file, standalone", path.display());
|
||||||
|
Ok(HashMap::from([(EpisodeNumber::Standalone, path)]))
|
||||||
|
} else if metadata.is_dir() {
|
||||||
|
debug!("{} is a directory, enumerating episodes", path.display());
|
||||||
|
let files: Vec<PathBuf> = fs::read_dir(&path)?
|
||||||
|
.map(|entry| {
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_file() {
|
||||||
|
debug!("Skipping {}, not a file", entry.path().display());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if entry.file_name().into_string().is_err() {
|
||||||
|
debug!(
|
||||||
|
"Skipping {}, contains invalid unicode",
|
||||||
|
entry.path().display()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(entry.path()))
|
||||||
|
})
|
||||||
|
.filter_map(|r| r.transpose())
|
||||||
|
.collect::<std::io::Result<Vec<PathBuf>>>()?;
|
||||||
|
enumeration::enumerate_episodes(files).ok_or(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"No valid prefixes found",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
format!("Invalid file type for {}", path.display()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue