use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct M3uPlaylist {
pub entries: Vec<M3uEntry>,
pub header: M3uHeader,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct M3uHeader {
pub epg_url: Option<String>,
pub catchup: Option<String>,
pub catchup_days: Option<String>,
pub catchup_source: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extras: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct M3uEntry {
pub url: Option<String>,
#[serde(default, skip_serializing_if = "SmallVec::is_empty")]
pub urls: SmallVec<[String; 2]>,
pub name: Option<String>,
pub tvg_id: Option<String>,
pub tvg_name: Option<String>,
pub tvg_language: Option<String>,
pub tvg_logo: Option<String>,
pub tvg_url: Option<String>,
pub tvg_rec: Option<String>,
pub tvg_chno: Option<String>,
pub group_title: Option<String>,
pub timeshift: Option<String>,
pub catchup: Option<String>,
pub catchup_days: Option<String>,
pub catchup_source: Option<String>,
pub duration: Option<f64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub stream_properties: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub vlc_options: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
#[serde(default)]
pub is_radio: bool,
pub tvg_shift: Option<f64>,
#[serde(default)]
pub is_media: bool,
pub media_dir: Option<String>,
pub media_size: Option<u64>,
pub provider_name: Option<String>,
pub provider_type: Option<String>,
pub provider_logo: Option<String>,
pub provider_countries: Option<String>,
pub provider_languages: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub web_properties: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extras: HashMap<String, String>,
}
impl M3uEntry {
pub fn has_url(&self) -> bool {
self.url.is_some() || !self.urls.is_empty()
}
pub fn is_identified(&self) -> bool {
self.name.is_some() || self.tvg_name.is_some() || self.tvg_id.is_some()
}
}
impl From<M3uEntry> for crispy_iptv_types::PlaylistEntry {
fn from(e: M3uEntry) -> Self {
use crispy_iptv_types::{CatchupConfig, CatchupType};
let catchup =
if e.catchup.is_some() || e.catchup_days.is_some() || e.catchup_source.is_some() {
Some(CatchupConfig {
catchup_type: e.catchup.as_deref().and_then(|s| match s {
"default" => Some(CatchupType::Default),
"append" => Some(CatchupType::Append),
"shift" => Some(CatchupType::Shift),
"flussonic" | "fs" => Some(CatchupType::Flussonic),
"xc" => Some(CatchupType::Xc),
_ => None,
}),
days: e
.catchup_days
.as_deref()
.and_then(|s| s.parse::<u32>().ok()),
source: e.catchup_source.clone(),
})
} else {
None
};
let group_title = e.group_title.or_else(|| e.groups.first().cloned());
let mut extras = e.extras;
for (k, v) in &e.stream_properties {
extras.insert(format!("kodiprop:{k}"), v.clone());
}
for (k, v) in &e.vlc_options {
extras.insert(format!("vlcopt:{k}"), v.clone());
}
for (k, v) in &e.web_properties {
extras.insert(format!("webprop:{k}"), v.clone());
}
if let Some(ref v) = e.provider_name {
extras.insert("provider-name".to_string(), v.clone());
}
if let Some(ref v) = e.provider_type {
extras.insert("provider-type".to_string(), v.clone());
}
if let Some(ref v) = e.provider_logo {
extras.insert("provider-logo".to_string(), v.clone());
}
if let Some(ref v) = e.provider_countries {
extras.insert("provider-countries".to_string(), v.clone());
}
if let Some(ref v) = e.provider_languages {
extras.insert("provider-languages".to_string(), v.clone());
}
if e.is_media {
extras.insert("media".to_string(), "true".to_string());
}
if let Some(ref v) = e.media_dir {
extras.insert("media-dir".to_string(), v.clone());
}
if let Some(size) = e.media_size {
extras.insert("media-size".to_string(), size.to_string());
}
if let Some(shift) = e.tvg_shift {
extras.insert("tvg-shift".to_string(), shift.to_string());
}
Self {
url: e.url,
urls: e.urls,
name: e.name,
tvg_id: e.tvg_id,
tvg_name: e.tvg_name,
tvg_language: e.tvg_language,
tvg_logo: e.tvg_logo,
tvg_url: e.tvg_url,
tvg_rec: e.tvg_rec,
tvg_chno: e.tvg_chno,
group_title,
timeshift: e.timeshift,
catchup,
duration: e.duration,
is_radio: e.is_radio,
extras,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_entry_has_no_url() {
let entry = M3uEntry::default();
assert!(!entry.has_url());
assert!(!entry.is_identified());
}
#[test]
fn entry_with_name_is_identified() {
let entry = M3uEntry {
name: Some("Test".into()),
..Default::default()
};
assert!(entry.is_identified());
}
#[test]
fn conversion_to_playlist_entry_preserves_fields() {
let entry = M3uEntry {
url: Some("http://example.com/stream".into()),
name: Some("Test Channel".into()),
tvg_id: Some("ch1".into()),
group_title: Some("News".into()),
duration: Some(-1.0),
catchup: Some("default".into()),
catchup_days: Some("3".into()),
..Default::default()
};
let pe: crispy_iptv_types::PlaylistEntry = entry.into();
assert_eq!(pe.url.as_deref(), Some("http://example.com/stream"));
assert_eq!(pe.name.as_deref(), Some("Test Channel"));
assert_eq!(pe.tvg_id.as_deref(), Some("ch1"));
assert_eq!(pe.group_title.as_deref(), Some("News"));
assert_eq!(pe.duration, Some(-1.0));
let c = pe.catchup.unwrap();
assert_eq!(
c.catchup_type,
Some(crispy_iptv_types::CatchupType::Default)
);
assert_eq!(c.days, Some(3));
}
#[test]
fn conversion_preserves_is_radio_flag() {
let entry = M3uEntry {
url: Some("http://example.com/radio".into()),
name: Some("Jazz FM".into()),
is_radio: true,
..Default::default()
};
let pe: crispy_iptv_types::PlaylistEntry = entry.into();
assert!(pe.is_radio);
assert_eq!(pe.name.as_deref(), Some("Jazz FM"));
}
#[test]
fn conversion_maps_provider_and_media_to_extras() {
let entry = M3uEntry {
url: Some("http://example.com/movie".into()),
name: Some("Movie".into()),
is_media: true,
media_dir: Some("/movies".into()),
media_size: Some(1_073_741_824),
provider_name: Some("IPTV-Pro".into()),
tvg_shift: Some(2.5),
..Default::default()
};
let pe: crispy_iptv_types::PlaylistEntry = entry.into();
assert_eq!(pe.extras.get("media").map(String::as_str), Some("true"));
assert_eq!(
pe.extras.get("media-dir").map(String::as_str),
Some("/movies")
);
assert_eq!(
pe.extras.get("media-size").map(String::as_str),
Some("1073741824")
);
assert_eq!(
pe.extras.get("provider-name").map(String::as_str),
Some("IPTV-Pro")
);
assert_eq!(pe.extras.get("tvg-shift").map(String::as_str), Some("2.5"));
}
#[test]
fn conversion_maps_web_properties_to_extras() {
let mut web_properties = HashMap::new();
web_properties.insert("web-regex".to_string(), "<pattern>".to_string());
let entry = M3uEntry {
url: Some("http://example.com/web".into()),
name: Some("Web Ch".into()),
web_properties,
..Default::default()
};
let pe: crispy_iptv_types::PlaylistEntry = entry.into();
assert_eq!(
pe.extras.get("webprop:web-regex").map(String::as_str),
Some("<pattern>")
);
}
}