handle directories containing multiple seasons of a series
This commit is contained in:
		
							parent
							
								
									06cb5398e3
								
							
						
					
					
						commit
						b5198194cf
					
				
					 4 changed files with 444 additions and 138 deletions
				
			
		
							
								
								
									
										10
									
								
								src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								src/main.rs
									
										
									
									
									
								
							|  | @ -5,6 +5,8 @@ mod config; | |||
| mod shows; | ||||
| mod video; | ||||
| 
 | ||||
| use crate::shows::EpisodeNumber; | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() { | ||||
|     dotenvy::dotenv().ok(); | ||||
|  | @ -31,10 +33,10 @@ async fn main() { | |||
|         let descriptor = format!( | ||||
|             "{}{}", | ||||
|             title, | ||||
|             if let Some(n) = num { | ||||
|                 format!(" episode {}", n) | ||||
|             } else { | ||||
|                 String::new() | ||||
|             match num { | ||||
|                 EpisodeNumber::Standalone => String::new(), | ||||
|                 EpisodeNumber::SingleSeason(n) => format!(" episode {}", n), | ||||
|                 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…
	
	Add table
		Add a link
		
	
		Reference in a new issue