use std::collections::BTreeMap;
use serde::Serialize;
use crate::matcher::span::{MatchSpan, Property};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Confidence {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum MediaType {
Movie,
Episode,
Extra,
}
#[derive(Debug, Clone)]
#[must_use = "a HunchResult is the entire point of calling hunch — dropping it discards parsed properties"]
pub struct HunchResult {
props: BTreeMap<Property, Vec<String>>,
confidence: Confidence,
}
impl HunchResult {
pub(crate) fn from_matches(matches: &[MatchSpan]) -> Self {
let mut props: BTreeMap<Property, Vec<String>> = BTreeMap::new();
for m in matches {
let values = props.entry(m.property).or_default();
let is_lang = matches!(m.property, Property::Language | Property::SubtitleLanguage);
let already_present = if is_lang {
values.iter().any(|v| v.eq_ignore_ascii_case(&m.value))
} else {
values.contains(&m.value)
};
if !already_present {
values.push(m.value.clone());
}
}
if let Some(container) = props.get(&Property::Container).and_then(|v| v.first())
&& let Some(mime) = container_to_mimetype(container)
{
props
.entry(Property::Mimetype)
.or_default()
.push(mime.to_string());
}
Self {
props,
confidence: Confidence::Medium, }
}
pub(crate) fn set(&mut self, property: Property, value: impl Into<String>) {
let values = self.props.entry(property).or_default();
let v = value.into();
if !values.contains(&v) {
values.push(v);
}
}
pub(crate) fn set_confidence(&mut self, confidence: Confidence) {
self.confidence = confidence;
}
#[must_use]
pub fn confidence(&self) -> Confidence {
self.confidence
}
pub fn title(&self) -> Option<&str> {
self.first(Property::Title)
}
pub fn year(&self) -> Option<i32> {
self.first(Property::Year).and_then(|v| v.parse().ok())
}
pub fn season(&self) -> Option<i32> {
self.first(Property::Season).and_then(|v| v.parse().ok())
}
pub fn episode(&self) -> Option<i32> {
self.first(Property::Episode).and_then(|v| v.parse().ok())
}
pub fn episode_title(&self) -> Option<&str> {
self.first(Property::EpisodeTitle)
}
pub fn video_codec(&self) -> Option<&str> {
self.first(Property::VideoCodec)
}
pub fn audio_codec(&self) -> Option<&str> {
self.first(Property::AudioCodec)
}
pub fn audio_bit_rate(&self) -> Option<&str> {
self.first(Property::AudioBitRate)
}
pub fn video_bit_rate(&self) -> Option<&str> {
self.first(Property::VideoBitRate)
}
pub fn mimetype(&self) -> Option<&str> {
self.first(Property::Mimetype)
}
pub fn audio_channels(&self) -> Option<&str> {
self.first(Property::AudioChannels)
}
pub fn source(&self) -> Option<&str> {
self.first(Property::Source)
}
pub fn screen_size(&self) -> Option<&str> {
self.first(Property::ScreenSize)
}
pub fn container(&self) -> Option<&str> {
self.first(Property::Container)
}
pub fn release_group(&self) -> Option<&str> {
self.first(Property::ReleaseGroup)
}
pub fn edition(&self) -> Option<&str> {
self.first(Property::Edition)
}
pub fn streaming_service(&self) -> Option<&str> {
self.first(Property::StreamingService)
}
pub fn color_depth(&self) -> Option<&str> {
self.first(Property::ColorDepth)
}
pub fn video_profile(&self) -> Option<&str> {
self.first(Property::VideoProfile)
}
pub fn part(&self) -> Option<i32> {
self.first(Property::Part).and_then(|s| s.parse().ok())
}
pub fn proper_count(&self) -> Option<u32> {
self.first(Property::ProperCount)
.and_then(|s| s.parse().ok())
}
pub fn media_type(&self) -> Option<MediaType> {
match self.first(Property::MediaType)?.to_lowercase().as_str() {
"movie" => Some(MediaType::Movie),
"episode" => Some(MediaType::Episode),
"extra" => Some(MediaType::Extra),
_ => None,
}
}
#[must_use]
pub fn is_movie(&self) -> bool {
self.media_type() == Some(MediaType::Movie)
}
#[must_use]
pub fn is_episode(&self) -> bool {
self.media_type() == Some(MediaType::Episode)
}
#[must_use]
pub fn is_extra(&self) -> bool {
self.media_type() == Some(MediaType::Extra)
}
pub fn other(&self) -> Vec<&str> {
self.all(Property::Other)
}
pub fn episode_details(&self) -> Option<&str> {
self.first(Property::EpisodeDetails)
}
pub fn language(&self) -> Option<&str> {
self.first(Property::Language)
}
pub fn languages(&self) -> Vec<&str> {
self.all(Property::Language)
}
pub fn subtitle_language(&self) -> Option<&str> {
self.first(Property::SubtitleLanguage)
}
pub fn subtitle_languages(&self) -> Vec<&str> {
self.all(Property::SubtitleLanguage)
}
pub fn bonus(&self) -> Option<i32> {
self.first(Property::Bonus).and_then(|s| s.parse().ok())
}
pub fn date(&self) -> Option<&str> {
self.first(Property::Date)
}
pub fn film(&self) -> Option<i32> {
self.first(Property::Film).and_then(|s| s.parse().ok())
}
pub fn disc(&self) -> Option<i32> {
self.first(Property::Disc).and_then(|s| s.parse().ok())
}
pub fn frame_rate(&self) -> Option<&str> {
self.first(Property::FrameRate)
}
pub fn first(&self, property: Property) -> Option<&str> {
self.props
.get(&property)
.and_then(|v| v.first())
.map(|s| s.as_str())
}
pub fn all(&self, property: Property) -> Vec<&str> {
self.props
.get(&property)
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn properties(&self) -> &BTreeMap<Property, Vec<String>> {
&self.props
}
pub fn to_flat_map(&self) -> BTreeMap<String, serde_json::Value> {
let mut map = BTreeMap::new();
for (k, v) in &self.props {
let key = k.to_string();
let numeric = k.is_numeric();
if v.len() == 1 {
if numeric {
if let Ok(n) = v[0].parse::<i64>() {
map.insert(key, serde_json::Value::Number(n.into()));
continue;
}
}
map.insert(key, serde_json::Value::String(v[0].clone()));
} else {
let arr: Vec<serde_json::Value> = v
.iter()
.map(|s| {
if numeric {
if let Ok(n) = s.parse::<i64>() {
return serde_json::Value::Number(n.into());
}
}
serde_json::Value::String(s.clone())
})
.collect();
map.insert(key, serde_json::Value::Array(arr));
}
}
map
}
}
impl std::fmt::Display for HunchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let map = self.to_flat_map();
match serde_json::to_string_pretty(&map) {
Ok(json) => write!(f, "{json}"),
Err(e) => write!(f, "<serialization error: {e}>"),
}
}
}
fn container_to_mimetype(container: &str) -> Option<&'static str> {
match container.to_ascii_lowercase().as_str() {
"mp4" | "m4v" => Some("video/mp4"),
"mkv" => Some("video/x-matroska"),
"avi" => Some("video/x-msvideo"),
"mov" | "qt" => Some("video/quicktime"),
"webm" => Some("video/webm"),
"flv" => Some("video/x-flv"),
"wmv" => Some("video/x-ms-wmv"),
"mpg" | "mpeg" => Some("video/mpeg"),
"ts" | "m2ts" | "mts" => Some("video/mp2t"),
"vob" => Some("video/dvd"),
"3gp" => Some("video/3gpp"),
"mp3" => Some("audio/mpeg"),
"flac" => Some("audio/flac"),
"m4a" => Some("audio/mp4"),
"ogg" | "oga" => Some("audio/ogg"),
"wav" => Some("audio/wav"),
"wma" => Some("audio/x-ms-wma"),
"aac" => Some("audio/aac"),
"srt" => Some("application/x-subrip"),
"ass" | "ssa" => Some("text/x-ssa"),
"vtt" => Some("text/vtt"),
"sub" => Some("text/plain"),
"idx" => Some("application/x-vobsub"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matcher::span::Property;
fn empty_result() -> HunchResult {
HunchResult {
props: BTreeMap::new(),
confidence: Confidence::Medium,
}
}
#[test]
fn is_movie_true_when_media_type_is_movie() {
let mut r = empty_result();
r.set(Property::MediaType, "movie");
assert!(r.is_movie());
assert!(!r.is_episode());
assert!(!r.is_extra());
}
#[test]
fn is_episode_true_when_media_type_is_episode() {
let mut r = empty_result();
r.set(Property::MediaType, "episode");
assert!(r.is_episode());
assert!(!r.is_movie());
assert!(!r.is_extra());
}
#[test]
fn is_extra_true_when_media_type_is_extra() {
let mut r = empty_result();
r.set(Property::MediaType, "extra");
assert!(r.is_extra());
assert!(!r.is_movie());
assert!(!r.is_episode());
}
#[test]
fn all_three_helpers_false_when_media_type_unknown() {
let r = empty_result();
assert_eq!(r.media_type(), None);
assert!(!r.is_movie());
assert!(!r.is_episode());
assert!(!r.is_extra());
}
#[test]
fn is_movie_case_insensitive_via_media_type() {
let mut r = empty_result();
r.set(Property::MediaType, "MOVIE");
assert!(r.is_movie());
}
}