use std::borrow::Cow; use std::collections::HashSet; use std::convert::TryFrom; use std::fmt; use derive_builder::Builder; use crate::line::{Line, Lines, Tag}; use crate::tags::{ ExtM3u, ExtXIndependentSegments, ExtXMedia, ExtXSessionData, ExtXSessionKey, ExtXStart, ExtXVersion, VariantStream, }; use crate::types::{ClosedCaptions, MediaType, ProtocolVersion}; use crate::utils::{tag, BoolExt}; use crate::{Error, RequiredVersion}; /// The master playlist describes all of the available variants for your /// content. /// /// Each variant is a version of the stream at a particular bitrate and is /// contained in a separate playlist called [`MediaPlaylist`]. /// /// # Examples /// /// A [`MasterPlaylist`] can be parsed from a `str`: /// /// ``` /// use core::convert::TryFrom; /// use hls_m3u8::MasterPlaylist; /// /// // the concat! macro joins multiple `&'static str`. /// let master_playlist = MasterPlaylist::try_from(concat!( /// "#EXTM3U\n", /// "#EXT-X-STREAM-INF:", /// "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", /// "http://example.com/low/index.m3u8\n", /// "#EXT-X-STREAM-INF:", /// "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", /// "http://example.com/lo_mid/index.m3u8\n", /// "#EXT-X-STREAM-INF:", /// "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", /// "http://example.com/hi_mid/index.m3u8\n", /// "#EXT-X-STREAM-INF:", /// "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", /// "http://example.com/high/index.m3u8\n", /// "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", /// "http://example.com/audio/index.m3u8\n" /// ))?; /// /// println!("{}", master_playlist.has_independent_segments); /// # Ok::<(), hls_m3u8::Error>(()) /// ``` /// /// or it can be constructed through a builder /// /// ``` /// # use hls_m3u8::MasterPlaylist; /// use hls_m3u8::tags::{ExtXStart, VariantStream}; /// use hls_m3u8::types::{Float, StreamData}; /// /// MasterPlaylist::builder() /// .variant_streams(vec![ /// VariantStream::ExtXStreamInf { /// uri: "http://example.com/low/index.m3u8".into(), /// frame_rate: None, /// audio: None, /// subtitles: None, /// closed_captions: None, /// stream_data: StreamData::builder() /// .bandwidth(150000) /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) /// .resolution((416, 234)) /// .build() /// .unwrap(), /// }, /// VariantStream::ExtXStreamInf { /// uri: "http://example.com/lo_mid/index.m3u8".into(), /// frame_rate: None, /// audio: None, /// subtitles: None, /// closed_captions: None, /// stream_data: StreamData::builder() /// .bandwidth(240000) /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) /// .resolution((416, 234)) /// .build() /// .unwrap(), /// }, /// ]) /// .has_independent_segments(true) /// .start(ExtXStart::new(Float::new(1.23))) /// .build()?; /// # Ok::<(), Box>(()) /// ``` /// /// [`MediaPlaylist`]: crate::MediaPlaylist #[derive(Builder, Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[builder(build_fn(validate = "Self::validate"))] #[builder(setter(into, strip_option))] #[non_exhaustive] pub struct MasterPlaylist<'a> { /// Indicates that all media samples in a [`MediaSegment`] can be /// decoded without information from other segments. /// /// ### Note /// /// This field is optional and by default `false`. If the field is `true`, /// it applies to every [`MediaSegment`] in every [`MediaPlaylist`] of this /// [`MasterPlaylist`]. /// /// [`MediaSegment`]: crate::MediaSegment /// [`MediaPlaylist`]: crate::MediaPlaylist #[builder(default)] pub has_independent_segments: bool, /// A preferred point at which to start playing a playlist. /// /// ### Note /// /// This field is optional and by default the playlist should be played from /// the start. #[builder(default)] pub start: Option, /// A list of all [`ExtXMedia`] tags, which describe an alternative /// rendition. /// /// For example, three [`ExtXMedia`] tags can be used to identify audio-only /// [`MediaPlaylist`]s, that contain English, French, and Spanish /// renditions of the same presentation. Or, two [`ExtXMedia`] tags can /// be used to identify video-only [`MediaPlaylist`]s that show two /// different camera angles. /// /// ### Note /// /// This field is optional. /// /// [`MediaPlaylist`]: crate::MediaPlaylist #[builder(default)] pub media: Vec>, /// A list of all streams of this [`MasterPlaylist`]. /// /// ### Note /// /// This field is optional. #[builder(default)] pub variant_streams: Vec>, /// The [`ExtXSessionData`] tag allows arbitrary session data to be /// carried in a [`MasterPlaylist`]. /// /// ### Note /// /// This field is optional. #[builder(default)] pub session_data: Vec>, /// A list of [`ExtXSessionKey`]s, that allows the client to preload /// these keys without having to read the [`MediaPlaylist`]s first. /// /// ### Note /// /// This field is optional. /// /// [`MediaPlaylist`]: crate::MediaPlaylist #[builder(default)] pub session_keys: Vec>, /// A list of all tags that could not be identified while parsing the input. /// /// ### Note /// /// This field is optional. #[builder(default)] pub unknown_tags: Vec>, } impl<'a> MasterPlaylist<'a> { /// Returns a builder for a [`MasterPlaylist`]. /// /// # Example /// /// ``` /// # use hls_m3u8::MasterPlaylist; /// use hls_m3u8::tags::{ExtXStart, VariantStream}; /// use hls_m3u8::types::{Float, StreamData}; /// /// MasterPlaylist::builder() /// .variant_streams(vec![ /// VariantStream::ExtXStreamInf { /// uri: "http://example.com/low/index.m3u8".into(), /// frame_rate: None, /// audio: None, /// subtitles: None, /// closed_captions: None, /// stream_data: StreamData::builder() /// .bandwidth(150000) /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) /// .resolution((416, 234)) /// .build() /// .unwrap(), /// }, /// VariantStream::ExtXStreamInf { /// uri: "http://example.com/lo_mid/index.m3u8".into(), /// frame_rate: None, /// audio: None, /// subtitles: None, /// closed_captions: None, /// stream_data: StreamData::builder() /// .bandwidth(240000) /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) /// .resolution((416, 234)) /// .build() /// .unwrap(), /// }, /// ]) /// .has_independent_segments(true) /// .start(ExtXStart::new(Float::new(1.23))) /// .build()?; /// # Ok::<(), Box>(()) /// ``` #[must_use] #[inline] pub fn builder() -> MasterPlaylistBuilder<'a> { MasterPlaylistBuilder::default() } /// Returns all streams, which have an audio group id. pub fn audio_streams(&self) -> impl Iterator> { self.variant_streams .iter() .filter(|stream| matches!(stream, VariantStream::ExtXStreamInf { audio: Some(_), .. })) } /// Returns all streams, which have a video group id. pub fn video_streams(&self) -> impl Iterator> { self.variant_streams.iter().filter(|stream| { if let VariantStream::ExtXStreamInf { stream_data, .. } = stream { stream_data.video().is_some() } else if let VariantStream::ExtXIFrame { stream_data, .. } = stream { stream_data.video().is_some() } else { false } }) } /// Returns all streams, which have no group id. pub fn unassociated_streams(&self) -> impl Iterator> { self.variant_streams.iter().filter(|stream| { if let VariantStream::ExtXStreamInf { stream_data, audio: None, subtitles: None, closed_captions: None, .. } = stream { stream_data.video().is_none() } else if let VariantStream::ExtXIFrame { stream_data, .. } = stream { stream_data.video().is_none() } else { false } }) } /// Returns all `ExtXMedia` tags, associated with the provided stream. pub fn associated_with<'b>( &'b self, stream: &'b VariantStream<'_>, ) -> impl Iterator> + 'b { self.media .iter() .filter(move |media| stream.is_associated(media)) } /// Makes the struct independent of its lifetime, by taking ownership of all /// internal [`Cow`]s. /// /// # Note /// /// This is a relatively expensive operation. #[must_use] #[allow(clippy::redundant_closure_for_method_calls)] pub fn into_owned(self) -> MasterPlaylist<'static> { MasterPlaylist { has_independent_segments: self.has_independent_segments, start: self.start, media: self.media.into_iter().map(|v| v.into_owned()).collect(), variant_streams: self .variant_streams .into_iter() .map(|v| v.into_owned()) .collect(), session_data: self .session_data .into_iter() .map(|v| v.into_owned()) .collect(), session_keys: self .session_keys .into_iter() .map(|v| v.into_owned()) .collect(), unknown_tags: self .unknown_tags .into_iter() .map(|v| Cow::Owned(v.into_owned())) .collect(), } } } impl<'a> RequiredVersion for MasterPlaylist<'a> { fn required_version(&self) -> ProtocolVersion { required_version![ self.has_independent_segments .athen_some(ExtXIndependentSegments), self.start, self.media, self.variant_streams, self.session_data, self.session_keys ] } } impl<'a> MasterPlaylistBuilder<'a> { fn validate(&self) -> Result<(), String> { if let Some(variant_streams) = &self.variant_streams { self.validate_variants(variant_streams) .map_err(|e| e.to_string())?; } self.validate_session_data_tags() .map_err(|e| e.to_string())?; Ok(()) } fn validate_variants(&self, variant_streams: &[VariantStream<'_>]) -> crate::Result<()> { let mut closed_captions_none = false; for variant in variant_streams { match &variant { VariantStream::ExtXStreamInf { audio, subtitles, closed_captions, stream_data, .. } => { if let Some(group_id) = &audio { if !self.check_media_group(MediaType::Audio, group_id) { return Err(Error::unmatched_group(group_id)); } } if let Some(group_id) = &stream_data.video() { if !self.check_media_group(MediaType::Video, group_id) { return Err(Error::unmatched_group(group_id)); } } if let Some(group_id) = &subtitles { if !self.check_media_group(MediaType::Subtitles, group_id) { return Err(Error::unmatched_group(group_id)); } } if let Some(closed_captions) = &closed_captions { match &closed_captions { ClosedCaptions::GroupId(group_id) => { if closed_captions_none { return Err(Error::custom("ClosedCaptions has to be `None`")); } if !self.check_media_group(MediaType::ClosedCaptions, group_id) { return Err(Error::unmatched_group(group_id)); } } _ => { if !closed_captions_none { closed_captions_none = true; } } } } } VariantStream::ExtXIFrame { stream_data, .. } => { if let Some(group_id) = stream_data.video() { if !self.check_media_group(MediaType::Video, group_id) { return Err(Error::unmatched_group(group_id)); } } } } } Ok(()) } fn validate_session_data_tags(&self) -> crate::Result<()> { let mut set = HashSet::new(); if let Some(values) = &self.session_data { set.reserve(values.len()); for tag in values { if !set.insert((tag.data_id(), tag.language())) { return Err(Error::custom(format!("conflict: {}", tag))); } } } Ok(()) } fn check_media_group>(&self, media_type: MediaType, group_id: T) -> bool { self.media.as_ref().map_or(false, |value| { value.iter().any(|media| { media.media_type == media_type && media.group_id().as_ref() == group_id.as_ref() }) }) } } impl<'a> RequiredVersion for MasterPlaylistBuilder<'a> { fn required_version(&self) -> ProtocolVersion { // TODO: the .flatten() can be removed as soon as `recursive traits` are // supported. (RequiredVersion is implemented for Option, but // not for Option>) // https://github.com/rust-lang/chalk/issues/12 required_version![ self.has_independent_segments .unwrap_or(false) .athen_some(ExtXIndependentSegments), self.start.flatten(), self.media, self.variant_streams, self.session_data, self.session_keys ] } } impl<'a> fmt::Display for MasterPlaylist<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", ExtM3u)?; if self.required_version() != ProtocolVersion::V1 { writeln!(f, "{}", ExtXVersion::new(self.required_version()))?; } for value in &self.media { writeln!(f, "{}", value)?; } for value in &self.variant_streams { writeln!(f, "{}", value)?; } for value in &self.session_data { writeln!(f, "{}", value)?; } for value in &self.session_keys { writeln!(f, "{}", value)?; } if self.has_independent_segments { writeln!(f, "{}", ExtXIndependentSegments)?; } if let Some(value) = &self.start { writeln!(f, "{}", value)?; } for value in &self.unknown_tags { writeln!(f, "{}", value)?; } Ok(()) } } impl<'a> TryFrom<&'a str> for MasterPlaylist<'a> { type Error = Error; fn try_from(input: &'a str) -> Result { let input = tag(input, ExtM3u::PREFIX)?; let mut builder = Self::builder(); let mut media = vec![]; let mut variant_streams = vec![]; let mut session_data = vec![]; let mut session_keys = vec![]; let mut unknown_tags = vec![]; for line in Lines::from(input) { match line? { Line::Tag(tag) => { match tag { Tag::ExtXVersion(_) => { // This tag can be ignored, because the // MasterPlaylist will automatically set the // ExtXVersion tag to the minimum required version // TODO: this might be verified? } Tag::ExtInf(_) | Tag::ExtXByteRange(_) | Tag::ExtXDiscontinuity(_) | Tag::ExtXKey(_) | Tag::ExtXMap(_) | Tag::ExtXProgramDateTime(_) | Tag::ExtXDateRange(_) | Tag::ExtXTargetDuration(_) | Tag::ExtXMediaSequence(_) | Tag::ExtXDiscontinuitySequence(_) | Tag::ExtXEndList(_) | Tag::PlaylistType(_) | Tag::ExtXIFramesOnly(_) => { return Err(Error::unexpected_tag(tag)); } Tag::ExtXMedia(t) => { media.push(t); } Tag::VariantStream(t) => { variant_streams.push(t); } Tag::ExtXSessionData(t) => { session_data.push(t); } Tag::ExtXSessionKey(t) => { session_keys.push(t); } Tag::ExtXIndependentSegments(_) => { builder.has_independent_segments(true); } Tag::ExtXStart(t) => { builder.start(t); } Tag::Unknown(value) => { // [6.3.1. General Client Responsibilities] // > ignore any unrecognized tags. unknown_tags.push(Cow::Borrowed(value)); } } } Line::Uri(uri) => { return Err(Error::custom(format!("unexpected uri: {:?}", uri))); } Line::Comment(_) => {} } } builder.media(media); builder.variant_streams(variant_streams); builder.session_data(session_data); builder.session_keys(session_keys); builder.unknown_tags(unknown_tags); builder.build().map_err(Error::builder) } } #[cfg(test)] mod tests { use super::*; use crate::types::StreamData; use pretty_assertions::assert_eq; #[test] fn test_audio_streams() { let astreams = vec![ VariantStream::ExtXStreamInf { uri: "http://example.com/low/index.m3u8".into(), frame_rate: None, audio: Some("ag0".into()), subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(150_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap(), }, VariantStream::ExtXStreamInf { uri: "http://example.com/lo_mid/index.m3u8".into(), frame_rate: None, audio: Some("ag1".into()), subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(240_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap(), }, ]; let master_playlist = MasterPlaylist::builder() .variant_streams(astreams.clone()) .media(vec![ ExtXMedia::builder() .media_type(MediaType::Audio) .uri("https://www.example.com/ag0.m3u8") .group_id("ag0") .language("english") .name("alternative rendition for ag0") .build() .unwrap(), ExtXMedia::builder() .media_type(MediaType::Audio) .uri("https://www.example.com/ag1.m3u8") .group_id("ag1") .language("english") .name("alternative rendition for ag1") .build() .unwrap(), ]) .build() .unwrap(); assert_eq!( master_playlist.variant_streams, master_playlist.audio_streams().collect::>() ); let mut audio_streams = master_playlist.audio_streams(); assert_eq!(audio_streams.next(), Some(&astreams[0])); assert_eq!(audio_streams.next(), Some(&astreams[1])); assert_eq!(audio_streams.next(), None); } #[test] fn test_parser() { assert_eq!( MasterPlaylist::try_from(concat!( "#EXTM3U\n", "#EXT-X-STREAM-INF:", "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/low/index.m3u8\n", "#EXT-X-STREAM-INF:", "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/lo_mid/index.m3u8\n", "#EXT-X-STREAM-INF:", "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/hi_mid/index.m3u8\n", "#EXT-X-STREAM-INF:", "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", "http://example.com/high/index.m3u8\n", "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", "http://example.com/audio/index.m3u8\n" )) .unwrap(), MasterPlaylist::builder() .variant_streams(vec![ VariantStream::ExtXStreamInf { uri: "http://example.com/low/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(150_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/lo_mid/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(240_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/hi_mid/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(440_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/high/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(640_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((640, 360)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/audio/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(64000) .codecs(&["mp4a.40.5"]) .build() .unwrap() }, ]) .build() .unwrap() ); } #[test] fn test_display() { assert_eq!( MasterPlaylist::builder() .variant_streams(vec![ VariantStream::ExtXStreamInf { uri: "http://example.com/low/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(150_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/lo_mid/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(240_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/hi_mid/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(440_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((416, 234)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/high/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(640_000) .codecs(&["avc1.42e00a", "mp4a.40.2"]) .resolution((640, 360)) .build() .unwrap() }, VariantStream::ExtXStreamInf { uri: "http://example.com/audio/index.m3u8".into(), frame_rate: None, audio: None, subtitles: None, closed_captions: None, stream_data: StreamData::builder() .bandwidth(64000) .codecs(&["mp4a.40.5"]) .build() .unwrap() }, ]) .build() .unwrap() .to_string(), concat!( "#EXTM3U\n", // "#EXT-X-STREAM-INF:", "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/low/index.m3u8\n", // "#EXT-X-STREAM-INF:", "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/lo_mid/index.m3u8\n", // "#EXT-X-STREAM-INF:", "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", "http://example.com/hi_mid/index.m3u8\n", // "#EXT-X-STREAM-INF:", "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", "http://example.com/high/index.m3u8\n", // "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", "http://example.com/audio/index.m3u8\n" ) .to_string() ); } }