pub mod discord;
pub mod trakt;
pub use discord::DiscordExtension;
pub use trakt::TraktExtension;
#[derive(Debug, Clone)]
pub struct MediaInfo {
pub title: String,
pub file_name: String,
pub total_bytes: u64,
pub tmdb_id: Option<u64>,
pub year: Option<u32>,
pub media_type: Option<String>,
pub poster_url: Option<String>,
pub season: Option<u32>,
pub episode: Option<u32>,
}
pub fn parse_episode_info(filename: &str) -> (Option<u32>, Option<u32>) {
use regex::Regex;
let sxex_re = Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap();
if let Some(caps) = sxex_re.captures(filename)
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
return (Some(season), Some(episode));
}
let x_re = Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap();
if let Some(caps) = x_re.captures(filename)
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
return (Some(season), Some(episode));
}
let full_re = Regex::new(r"(?i)season[.\s]*(\d{1,2}).*episode[.\s]*(\d{1,3})").unwrap();
if let Some(caps) = full_re.captures(filename)
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
return (Some(season), Some(episode));
}
let compact_re = Regex::new(r"[.\s](\d)(\d{2})[.\s]").unwrap();
if let Some(caps) = compact_re.captures(filename)
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
&& let (Ok(season), Ok(episode)) =
(s.as_str().parse::<u32>(), e.as_str().parse::<u32>())
{
if (1..=99).contains(&season) && (1..=99).contains(&episode) {
return (Some(season), Some(episode));
}
}
(None, None)
}
#[derive(Debug, Clone)]
pub enum PlaybackEvent {
Started(MediaInfo),
Progress {
media: MediaInfo,
downloaded_bytes: u64,
position_percent: f64,
},
Stopped {
media: MediaInfo,
watched_percent: f64,
},
}
pub trait Extension: Send + Sync {
fn name(&self) -> &str;
fn on_init(&mut self) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn on_event(&self, event: &PlaybackEvent);
fn on_shutdown(&self) {}
}
pub struct ExtensionManager {
extensions: Vec<Box<dyn Extension>>,
}
impl ExtensionManager {
pub fn new() -> Self {
Self {
extensions: Vec::new(),
}
}
pub fn register(&mut self, mut ext: Box<dyn Extension>) {
match ext.on_init() {
Ok(()) => {
tracing::info!(name = ext.name(), "extension loaded");
self.extensions.push(ext);
}
Err(e) => {
tracing::error!(name = ext.name(), error = %e, "failed to load extension");
}
}
}
pub fn broadcast(&self, event: PlaybackEvent) {
for ext in &self.extensions {
ext.on_event(&event);
}
}
pub fn shutdown(&self) {
for ext in &self.extensions {
ext.on_shutdown();
}
}
}
impl Default for ExtensionManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_episode_sxex_format() {
assert_eq!(
parse_episode_info("Show.Name.S01E02.720p.HDTV.mkv"),
(Some(1), Some(2))
);
assert_eq!(
parse_episode_info("Show.Name.S10E23.720p.mkv"),
(Some(10), Some(23))
);
assert_eq!(parse_episode_info("show.s1e5.web.mp4"), (Some(1), Some(5)));
}
#[test]
fn test_parse_episode_x_format() {
assert_eq!(
parse_episode_info("Show.Name.1x02.HDTV.mkv"),
(Some(1), Some(2))
);
assert_eq!(
parse_episode_info("Show.Name.10x23.mkv"),
(Some(10), Some(23))
);
}
#[test]
fn test_parse_episode_full_format() {
assert_eq!(
parse_episode_info("Show Name Season 1 Episode 2.mkv"),
(Some(1), Some(2))
);
assert_eq!(
parse_episode_info("Show.Name.Season.3.Episode.15.mkv"),
(Some(3), Some(15))
);
}
#[test]
fn test_parse_episode_no_match() {
assert_eq!(
parse_episode_info("Movie.2019.1080p.BluRay.mkv"),
(None, None)
);
assert_eq!(parse_episode_info("Random.File.Name.mkv"), (None, None));
}
#[test]
fn test_parse_episode_case_insensitive() {
assert_eq!(parse_episode_info("show.S01e02.mkv"), (Some(1), Some(2)));
assert_eq!(parse_episode_info("show.s01E02.mkv"), (Some(1), Some(2)));
}
}