use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utils::*;
pub mod utils;
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
pub struct NameAndLang {
#[serde(rename = "$text", skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@lang")]
pub lang: Option<String>,
}
#[derive(Debug, PartialEq, Clone, Serialize, Default)]
pub struct ValueAndLang {
#[serde(rename = "$text", skip_serializing_if = "String::is_empty")]
pub value: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@lang")]
pub lang: Option<String>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct EmptyTag {}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
pub struct Tv {
#[serde(skip_serializing_if = "Option::is_none", rename = "@source-info-url")]
pub source_info_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@source-info-name")]
pub source_info_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@source-data-url")]
pub source_data_url: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "@generator-info-name"
)]
pub generator_info_name: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "@generator-info-url"
)]
pub generator_info_url: Option<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "channel"
)]
pub channels: Vec<Channel>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "programme"
)]
pub programmes: Vec<Programme>,
}
impl Tv {
pub fn find_by_channel(&self, channel_id: &str) -> Vec<&Programme> {
self.programmes
.iter()
.filter(|p| p.channel == channel_id)
.collect()
}
pub fn get_channel(&self, channel_id: &str) -> Option<&Channel> {
self.channels.iter().find(|c| c.id == channel_id)
}
pub fn sort_programmes_by_date(&mut self) {
self.programmes.sort_by(|a, b| a.start.cmp(&b.start));
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
pub struct Channel {
#[serde(rename = "@id")]
pub id: String,
#[serde(
rename = "display-name",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub display_names: Vec<NameAndLang>,
#[serde(
rename = "icon",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub icons: Vec<Icon>,
#[serde(
rename = "url",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub urls: Vec<Url>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
pub struct Url {
#[serde(rename = "$text")]
pub value: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@system")]
pub system: Option<String>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)]
pub struct Programme {
#[serde(rename = "@channel")]
pub channel: String,
#[serde(rename = "@start")]
pub start: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@stop")]
pub stop: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@pdc-start")]
pub pdc_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@vps-start")]
pub vps_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub showview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub videoplus: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clumpidx: Option<String>,
#[serde(
rename = "title",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub titles: Vec<ValueAndLang>,
#[serde(
rename = "sub-title",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub sub_titles: Vec<ValueAndLang>,
#[serde(
rename = "desc",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub descriptions: Vec<ValueAndLang>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credits: Option<Credits>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(
rename = "category",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub categories: Vec<NameAndLang>,
#[serde(
rename = "keyword",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub keywords: Vec<ValueAndLang>,
#[serde(rename = "language", skip_serializing_if = "Option::is_none")]
pub language: Option<ValueAndLang>,
#[serde(rename = "orig-language", skip_serializing_if = "Option::is_none")]
pub orig_language: Option<ValueAndLang>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub length: Option<Length>,
#[serde(
rename = "icon",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub icons: Vec<Icon>,
#[serde(
rename = "url",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub urls: Vec<Url>,
#[serde(
rename = "country",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub countries: Vec<NameAndLang>,
#[serde(
rename = "episode-num",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub episode_num: Vec<EpisodeNum>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<Video>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<Audio>,
#[serde(skip_serializing_if = "Option::is_none", rename = "previously-shown")]
pub previously_shown: Option<PreviouslyShown>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premiere: Option<ValueAndLang>,
#[serde(rename = "last-chance", skip_serializing_if = "Option::is_none")]
pub last_chance: Option<ValueAndLang>,
#[serde(
skip_serializing_if = "std::ops::Not::not",
serialize_with = "bool_to_new_tag",
deserialize_with = "new_tag_to_boolean",
default
)]
pub new: bool, #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::new")]
pub subtitles: Vec<Subtitles>,
#[serde(
rename = "rating",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub ratings: Vec<Rating>,
#[serde(
rename = "star-rating",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub star_ratings: Vec<StarRating>,
#[serde(
rename = "review",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub reviews: Vec<Review>,
#[serde(
rename = "image",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub images: Vec<Icon>,
}
impl Programme {
pub fn start_datetime(&self) -> Result<DateTime<Utc>, chrono::ParseError> {
DateTime::parse_from_str(&self.start, "%Y%m%d%H%M%S %z").map(|dt| dt.with_timezone(&Utc))
}
pub fn stop_datetime(&self) -> Option<DateTime<Utc>> {
self.stop
.as_ref()
.and_then(|s| DateTime::parse_from_str(s, "%Y%m%d%H%M%S %z").ok())
.map(|dt| dt.with_timezone(&Utc))
}
pub fn is_live(&self) -> bool {
let now = Utc::now();
if let (Ok(start), Some(stop)) = (self.start_datetime(), self.stop_datetime()) {
return now >= start && now <= stop;
}
false
}
pub fn get_title(&self, lang: Option<&str>) -> Option<&str> {
if let Some(l) = lang {
self.titles
.iter()
.find(|t| t.lang.as_deref() == Some(l))
.map(|t| t.value.as_str())
} else {
self.titles.first().map(|t| t.value.as_str())
}
}
pub fn cleanse(&mut self, items: &[String]) {
for item in items {
match item.as_str() {
"credits" => self.credits = None,
"directors" => {
if let Some(c) = self.credits.as_mut() {
c.directors.clear();
}
}
"actors" => {
if let Some(c) = self.credits.as_mut() {
c.actors.clear();
}
}
"writers" => {
if let Some(c) = self.credits.as_mut() {
c.writers.clear();
}
}
"adapters" => {
if let Some(c) = self.credits.as_mut() {
c.adapters.clear();
}
}
"producers" => {
if let Some(c) = self.credits.as_mut() {
c.producers.clear();
}
}
"composers" => {
if let Some(c) = self.credits.as_mut() {
c.composers.clear();
}
}
"editors" => {
if let Some(c) = self.credits.as_mut() {
c.editors.clear();
}
}
"presenters" => {
if let Some(c) = self.credits.as_mut() {
c.presenters.clear();
}
}
"commentators" => {
if let Some(c) = self.credits.as_mut() {
c.commentators.clear();
}
}
"guests" => {
if let Some(c) = self.credits.as_mut() {
c.guests.clear();
}
}
"images" => self.images.clear(),
"icons" => self.icons.clear(),
"descriptions" | "desc" => self.descriptions.clear(),
"categories" => self.categories.clear(),
"keywords" => self.keywords.clear(),
"sub-titles" => self.sub_titles.clear(),
"languages" => self.language = None,
"origin-languages" => self.orig_language = None,
"length" => self.length = None,
"countries" => self.countries.clear(),
"episode-nums" => self.episode_num.clear(),
"video" | "videos" => self.video = None,
"audio" | "audios" => self.audio = None,
"previously-shown" | "previously-showns" => self.previously_shown = None,
"premiere" | "premieres" => self.premiere = None,
"last-chance" | "last-chances" => self.last_chance = None,
"new" => self.new = false,
"subtitles" => self.subtitles.clear(),
"ratings" => self.ratings.clear(),
"star-ratings" => self.star_ratings.clear(),
"reviews" => self.reviews.clear(),
"urls" => self.urls.clear(),
"date" | "dates" => self.date = None,
_ => {}
}
}
}
pub fn generate_hash(&self) -> u64 {
fn fnv1a(state: u64, bytes: &[u8]) -> u64 {
const PRIME: u64 = 0x00000100_000001B3;
bytes
.iter()
.fold(state, |h, &b| (h ^ b as u64).wrapping_mul(PRIME))
}
const OFFSET: u64 = 0xcbf29ce484222325;
let title = self.titles.first().map(|t| t.value.as_str()).unwrap_or("");
let h = fnv1a(OFFSET, self.channel.as_bytes());
let h = fnv1a(h, b"\0");
let h = fnv1a(h, self.start.as_bytes());
let h = fnv1a(h, b"\0");
fnv1a(h, title.as_bytes())
}
pub fn fingerprint(&self) -> String {
format!(
"{}-{}-{}",
self.channel,
self.start,
self.titles
.first()
.map(|t| t.value.as_str())
.unwrap_or("no-title")
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Actor {
#[serde(rename = "$text")]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@role")]
pub role: Option<String>,
#[serde(
rename = "@guest",
serialize_with = "bool_to_yes_no",
deserialize_with = "yes_no_to_bool",
skip_serializing_if = "std::ops::Not::not",
default
)]
pub guest: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Credits {
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "director"
)]
pub directors: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "actor"
)]
pub actors: Vec<Actor>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "writer"
)]
pub writers: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "adapter"
)]
pub adapters: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "producer"
)]
pub producers: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "composer"
)]
pub composers: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "editor"
)]
pub editors: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "presenter"
)]
pub presenters: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "commentator"
)]
pub commentators: Vec<String>,
#[serde(
skip_serializing_if = "Vec::is_empty",
default = "Vec::new",
rename = "guest"
)]
pub guests: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EpisodeNum {
#[serde(rename = "$text")]
pub value: String,
#[serde(rename = "@system")]
pub system: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Video {
pub present: Option<TagWithOnlyText>, pub colour: Option<TagWithOnlyText>, pub aspect: Option<TagWithOnlyText>,
pub quality: Option<TagWithOnlyText>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TagWithOnlyText {
#[serde(rename = "$text")]
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Audio {
#[serde(rename = "@present", skip_serializing_if = "Option::is_none")]
pub present: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo: Option<TagWithOnlyText>, }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PreviouslyShown {
#[serde(skip_serializing_if = "Option::is_none", rename = "@start")]
pub start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@channel")]
pub channel: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Subtitles {
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<ValueAndLang>,
#[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>, }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Rating {
pub value: String, #[serde(
rename = "icon",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub icons: Vec<Icon>,
#[serde(rename = "@system", skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct StarRating {
pub value: String, #[serde(
rename = "icon",
skip_serializing_if = "Vec::is_empty",
default = "Vec::new"
)]
pub icons: Vec<Icon>,
#[serde(rename = "@system", skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Review {
#[serde(rename = "$text")]
pub value: String,
#[serde(rename = "@type")]
pub r#type: String,
#[serde(rename = "@source", skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(rename = "@reviewer", skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
#[serde(rename = "@lang", skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Icon {
#[serde(rename = "@src")]
pub src: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "@width")]
pub width: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "@height")]
pub height: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Length {
#[serde(rename = "$text")]
pub length: u32,
#[serde(rename = "@units")]
pub units: Units,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[repr(u8)]
pub enum Units {
Seconds,
Minutes,
Hours,
}
impl Length {
pub fn to_hms(&self) -> (u8, u8, u8) {
let (h, m, s) = match self.units {
Units::Seconds => {
let (m, s) = divmod(self.length, 60);
let (h, m) = divmod(m, 60);
(h, m, s)
}
Units::Minutes => {
let (h, m) = divmod(self.length, 60);
(h, m, 0)
}
Units::Hours => (self.length, 0, 0),
};
(h.min(u32::from(u8::MAX)) as u8, m as u8, s as u8)
}
}
impl std::fmt::Display for Units {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Units::Seconds => fmt.write_str("seconds"),
Units::Minutes => fmt.write_str("minutes"),
Units::Hours => fmt.write_str("hours"),
}
}
}
fn divmod(x: u32, y: u32) -> (u32, u32) {
(x / y, x % y)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Timelike, Utc};
#[test]
fn test_programme_date_parsing() {
let prog = Programme {
start: "20231027205000 +0200".to_string(),
stop: Some("20231027223000 +0200".to_string()),
..Default::default()
};
let start = prog.start_datetime().expect("Should parse start date");
let stop = prog.stop_datetime().expect("Should parse stop date");
assert_eq!(start.hour(), 18);
assert_eq!(start.minute(), 50);
assert_eq!(stop.hour(), 20);
assert_eq!(stop.minute(), 30);
}
#[test]
fn test_programme_is_live() {
let now = Utc::now();
let start = now - chrono::Duration::minutes(30);
let stop = now + chrono::Duration::minutes(30);
let prog = Programme {
start: start.format("%Y%m%d%H%M%S +0000").to_string(),
stop: Some(stop.format("%Y%m%d%H%M%S +0000").to_string()),
..Default::default()
};
assert!(prog.is_live(), "Programme should be live");
}
#[test]
fn test_generate_hash_stability() {
let prog1 = Programme {
channel: "TF1.fr".to_string(),
start: "20231027205000".to_string(),
titles: vec![ValueAndLang {
value: "Journal".to_string(),
lang: None,
}],
..Default::default()
};
let prog2 = Programme {
channel: "TF1.fr".to_string(),
start: "20231027205000".to_string(),
titles: vec![ValueAndLang {
value: "Journal".to_string(),
lang: None,
}],
..Default::default()
};
assert_eq!(
prog1.generate_hash(),
prog2.generate_hash(),
"Hash should be the same with same metadata."
);
}
#[test]
fn test_get_title_with_lang() {
let prog = Programme {
titles: vec![
ValueAndLang {
value: "News".to_string(),
lang: Some("en".to_string()),
},
ValueAndLang {
value: "Journal".to_string(),
lang: Some("fr".to_string()),
},
],
..Default::default()
};
assert_eq!(prog.get_title(Some("fr")), Some("Journal"));
assert_eq!(prog.get_title(Some("en")), Some("News"));
assert_eq!(prog.get_title(None), Some("News")); }
#[test]
fn test_tv_find_methods() {
let mut tv = Tv::default();
tv.channels.push(Channel {
id: "arte.tv".to_string(),
display_names: vec![NameAndLang {
name: "Arte".to_string(),
lang: None,
}],
..Default::default()
});
tv.programmes.push(Programme {
channel: "arte.tv".to_string(),
start: "20231027205000".to_string(),
..Default::default()
});
let chan = tv.get_channel("arte.tv");
assert!(chan.is_some());
assert_eq!(chan.unwrap().id, "arte.tv");
let progs = tv.find_by_channel("arte.tv");
assert_eq!(progs.len(), 1);
assert_eq!(progs[0].channel, "arte.tv");
}
#[test]
fn test_tv_cleanse() {
let mut prog = Programme {
channel: "TF1".to_string(),
categories: vec![NameAndLang {
name: "Sport".to_string(),
lang: None,
}],
credits: Some(Credits::default()),
..Default::default()
};
prog.cleanse(&["categories".to_string(), "credits".to_string()]);
assert!(prog.categories.is_empty());
assert!(prog.credits.is_none());
}
}