xmltv 2.1.1

XMLTV for electronic program guide (EPG) parser and generator using serde.
Documentation
pub mod av;
pub mod channel;
pub mod common;
pub mod credits;
pub mod length;
pub mod programme;
pub mod ratings;
pub mod tv;
pub mod utils;
pub mod writer;

pub use av::{Audio, PreviouslyShown, SubtitleType, Subtitles, Video};
pub use channel::{Channel, Url};
pub use common::{EmptyTag, Icon, NameAndLang, TagWithOnlyText, ValueAndLang};
pub use credits::{Actor, Credits, EpisodeNum};
pub use length::{Length, Units};
pub use programme::Programme;
pub use ratings::{Rating, Review, StarRating};
pub use tv::Tv;
pub use writer::{TvWriter, WriteError};

#[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");

        // Check UTC conversion (20:50 +0200 -> 18:50 UTC)
        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")); // First by default
    }

    #[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()
        });

        // Test get_channel
        let chan = tv.get_channel("arte.tv");
        assert!(chan.is_some());
        assert_eq!(chan.unwrap().id, "arte.tv");

        // Test find_by_channel
        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());
    }

    // --- Length helpers ---

    #[test]
    fn test_length_to_seconds_from_seconds() {
        let l = Length {
            length: 3661,
            units: Units::Seconds,
        };
        assert_eq!(l.to_seconds(), 3661);
    }

    #[test]
    fn test_length_to_seconds_from_minutes() {
        let l = Length {
            length: 90,
            units: Units::Minutes,
        };
        assert_eq!(l.to_seconds(), 5400);
    }

    #[test]
    fn test_length_to_seconds_from_hours() {
        let l = Length {
            length: 2,
            units: Units::Hours,
        };
        assert_eq!(l.to_seconds(), 7200);
    }

    #[test]
    fn test_length_to_minutes_from_seconds() {
        // 90 seconds = 1 minute (integer division)
        let l = Length {
            length: 90,
            units: Units::Seconds,
        };
        assert_eq!(l.to_minutes(), 1);
    }

    #[test]
    fn test_length_to_minutes_from_minutes() {
        let l = Length {
            length: 120,
            units: Units::Minutes,
        };
        assert_eq!(l.to_minutes(), 120);
    }

    #[test]
    fn test_length_to_minutes_from_hours() {
        let l = Length {
            length: 2,
            units: Units::Hours,
        };
        assert_eq!(l.to_minutes(), 120);
    }

    // --- SubtitleType deserialization ---

    #[test]
    fn test_subtitle_type_deserialize_teletext() {
        let xml = r#"<subtitles type="teletext"/>"#;
        let s: Subtitles = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(s.r#type, Some(SubtitleType::Teletext));
    }

    #[test]
    fn test_subtitle_type_deserialize_onscreen() {
        let xml = r#"<subtitles type="onscreen"/>"#;
        let s: Subtitles = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(s.r#type, Some(SubtitleType::Onscreen));
    }

    #[test]
    fn test_subtitle_type_deserialize_deaf_signed() {
        let xml = r#"<subtitles type="deaf-signed"/>"#;
        let s: Subtitles = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(s.r#type, Some(SubtitleType::DeafSigned));
    }

    #[test]
    fn test_subtitle_type_serialize_roundtrip() {
        let original = Subtitles {
            r#type: Some(SubtitleType::DeafSigned),
            language: None,
        };
        let xml = quick_xml::se::to_string_with_root("subtitles", &original).unwrap();
        assert!(
            xml.contains("deaf-signed"),
            "expected 'deaf-signed' in: {xml}"
        );
        let roundtripped: Subtitles = quick_xml::de::from_str(&xml).unwrap();
        assert_eq!(roundtripped.r#type, original.r#type);
    }

    #[test]
    fn test_subtitle_type_absent_is_none() {
        let xml = r#"<subtitles/>"#;
        let s: Subtitles = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(s.r#type, None);
    }

    // --- Video present / colour as Option<bool> ---

    #[test]
    fn test_video_present_true() {
        let xml = r#"<video><present>yes</present></video>"#;
        let v: Video = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(v.present, Some(true));
    }

    #[test]
    fn test_video_colour_false() {
        let xml = r#"<video><colour>no</colour></video>"#;
        let v: Video = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(v.colour, Some(false));
    }

    #[test]
    fn test_video_present_absent_is_none() {
        let xml = r#"<video/>"#;
        let v: Video = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(v.present, None);
        assert_eq!(v.colour, None);
    }

    #[test]
    fn test_video_bool_serialize_roundtrip() {
        let original = Video {
            present: Some(true),
            colour: Some(false),
            ..Default::default()
        };
        let xml = quick_xml::se::to_string_with_root("video", &original).unwrap();
        assert!(xml.contains("<present>yes</present>"), "xml: {xml}");
        assert!(xml.contains("<colour>no</colour>"), "xml: {xml}");
        let rt: Video = quick_xml::de::from_str(&xml).unwrap();
        assert_eq!(rt.present, Some(true));
        assert_eq!(rt.colour, Some(false));
    }

    // --- Audio present as Option<bool> via child element ---

    #[test]
    fn test_audio_present_true() {
        let xml = r#"<audio><present>yes</present></audio>"#;
        let a: Audio = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(a.present, Some(true));
    }

    #[test]
    fn test_audio_present_false() {
        let xml = r#"<audio><present>no</present></audio>"#;
        let a: Audio = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(a.present, Some(false));
    }

    #[test]
    fn test_audio_present_absent_is_none() {
        let xml = r#"<audio/>"#;
        let a: Audio = quick_xml::de::from_str(xml).unwrap();
        assert_eq!(a.present, None);
    }

    #[test]
    fn test_audio_present_serialize_roundtrip() {
        let original = Audio {
            present: Some(true),
            stereo: None,
        };
        let xml = quick_xml::se::to_string_with_root("audio", &original).unwrap();
        assert!(xml.contains("<present>yes</present>"), "xml: {xml}");
        let rt: Audio = quick_xml::de::from_str(&xml).unwrap();
        assert_eq!(rt.present, Some(true));
    }
}