pub mod discord;
pub use discord::DiscordExtension;
use once_cell::sync::Lazy;
use regex::Regex;
static SXEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap());
static X_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap());
static FULL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)season[.\s]*(\d{1,2}).*episode[.\s]*(\d{1,3})").unwrap());
static COMPACT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[.\s](\d{1,2})(\d{2})[.\s]").unwrap());
#[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>) {
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));
}
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));
}
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));
}
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>())
{
let full_number = season * 100 + episode;
if (1..=99).contains(&season)
&& (1..=99).contains(&episode)
&& !(1900..=2099).contains(&full_number)
{
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)));
}
#[test]
fn test_compact_format_season_10_episode_02() {
let result = parse_episode_info("Show.Name.1002.mkv");
assert_eq!(
result,
(Some(10), Some(2)),
"According to the comment, .1002. should parse as season 10, episode 2"
);
}
#[test]
fn test_compact_format_single_digit_season() {
assert_eq!(parse_episode_info("Show.Name.102.mkv"), (Some(1), Some(2)));
assert_eq!(parse_episode_info("Show.Name.315.mkv"), (Some(3), Some(15)));
}
#[test]
fn test_compact_format_rejects_years() {
assert_eq!(
parse_episode_info("Movie.1999.BluRay.mkv"),
(None, None),
"1999 should be treated as a year, not season 19 episode 99"
);
assert_eq!(
parse_episode_info("Movie.2020.1080p.mkv"),
(None, None),
"2020 should be treated as a year, not season 20 episode 20"
);
}
}