diff --git a/src/parser.rs b/src/parser.rs index f071c4d..ce113d0 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -13,6 +13,7 @@ use nom::IResult; use std::collections::HashMap; use std::f32; use std::fmt; +use std::fmt::Display; use std::result::Result; use std::str; use std::str::FromStr; @@ -134,7 +135,7 @@ pub fn is_master_playlist(input: &[u8]) -> bool { /// /// Returns `Some(true/false)` when a master/media tag is found. Otherwise returns `None`. /// -/// - None: Unkown tag or empty line +/// - None: Unknown tag or empty line /// - Some(true, tagstring): Line contains a master playlist tag /// - Some(false, tagstring): Line contains a media playlist tag fn contains_master_tag(input: &[u8]) -> Option<(bool, String)> { @@ -299,7 +300,7 @@ fn variant_i_frame_stream_tag(i: &[u8]) -> IResult<&[u8], VariantStream> { } fn alternative_media_tag(i: &[u8]) -> IResult<&[u8], AlternativeMedia> { - map(pair(tag("#EXT-X-MEDIA:"), key_value_pairs), |(_, media)| { + map_res(pair(tag("#EXT-X-MEDIA:"), key_value_pairs), |(_, media)| { AlternativeMedia::from_hashmap(media) })(i) } @@ -484,7 +485,7 @@ enum SegmentTag { Key(Key), Map(Map), ProgramDateTime(String), - DateRange(String), + DateRange(DateRange), Unknown(ExtTag), Comment(String), Uri(String), @@ -511,10 +512,9 @@ fn media_segment_tag(i: &[u8]) -> IResult<&[u8], SegmentTag> { pair(tag("#EXT-X-PROGRAM-DATE-TIME:"), consume_line), |(_, line)| SegmentTag::ProgramDateTime(line), ), - map( - pair(tag("#EXT-X-DATE-RANGE:"), consume_line), - |(_, line)| SegmentTag::DateRange(line), - ), + map(pair(tag("#EXT-X-DATERANGE:"), daterange), |(_, range)| { + SegmentTag::DateRange(range) + }), map(ext_tag, SegmentTag::Unknown), map(comment_tag, SegmentTag::Comment), map(consume_line, SegmentTag::Uri), @@ -535,23 +535,34 @@ fn duration_title_tag(i: &[u8]) -> IResult<&[u8], (f32, Option)> { } fn key(i: &[u8]) -> IResult<&[u8], Key> { - map(key_value_pairs, Key::from_hashmap)(i) + map_res(key_value_pairs, Key::from_hashmap)(i) +} + +fn daterange(i: &[u8]) -> IResult<&[u8], DateRange> { + map_res(key_value_pairs, DateRange::from_hashmap)(i) } fn extmap(i: &[u8]) -> IResult<&[u8], Map> { - map_res(key_value_pairs, |attrs| -> Result { - let uri = attrs.get("URI").cloned().unwrap_or_default(); + map_res(key_value_pairs, |mut attrs| -> Result { + let uri = match attrs.remove("URI") { + Some(QuotedOrUnquoted::Quoted(s)) => Ok(s), + Some(QuotedOrUnquoted::Unquoted(_)) => { + Err("Can't create URI attribute from unquoted string") + } + None => Err("URI is empty"), + }?; let byte_range = attrs - .get("BYTERANGE") + .remove("BYTERANGE") .map(|range| match byte_range_val(range.to_string().as_bytes()) { IResult::Ok((_, range)) => Ok(range), - IResult::Err(_) => Err("invalid byte range"), + IResult::Err(_) => Err("Invalid byte range"), }) .transpose()?; Ok(Map { - uri: uri.to_string(), + uri, byte_range, + other_attributes: attrs, }) })(i) } @@ -572,7 +583,7 @@ fn version_tag(i: &[u8]) -> IResult<&[u8], usize> { } fn start_tag(i: &[u8]) -> IResult<&[u8], Start> { - map( + map_res( pair(tag("#EXT-X-START:"), key_value_pairs), |(_, attributes)| Start::from_hashmap(attributes), )(i) @@ -654,22 +665,23 @@ impl QuotedOrUnquoted { impl From<&str> for QuotedOrUnquoted { fn from(s: &str) -> Self { if s.starts_with('"') && s.ends_with('"') { - return QuotedOrUnquoted::Quoted(s.trim_matches('"').to_string()); + return QuotedOrUnquoted::Quoted( + s.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or_default() + .to_string(), + ); } QuotedOrUnquoted::Unquoted(s.to_string()) } } -impl fmt::Display for QuotedOrUnquoted { +impl Display for QuotedOrUnquoted { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "{}", - match self { - QuotedOrUnquoted::Unquoted(s) => s, - QuotedOrUnquoted::Quoted(u) => u, - } - ) + match self { + QuotedOrUnquoted::Unquoted(s) => write!(f, "{}", s), + QuotedOrUnquoted::Quoted(u) => write!(f, "\"{}\"", u), + } } } @@ -790,6 +802,7 @@ mod tests { video: None, subtitles: None, closed_captions: None, + other_attributes: Default::default(), } )) ); @@ -808,9 +821,9 @@ mod tests { ("BANDWIDTH", "395000"), ("CODECS", "\"avc1.4d001f,mp4a.40.2\"") ] - .into_iter() - .map(|(k, v)| (String::from(k), v.into())) - .collect::>(), + .into_iter() + .map(|(k, v)| (String::from(k), v.into())) + .collect::>(), )), ); } @@ -828,8 +841,8 @@ mod tests { ("RESOLUTION", "\"1x1\""), ("VIDEO", "1") ].into_iter() - .map(|(k, v)| (String::from(k), v.into())) - .collect::>() + .map(|(k, v)| (String::from(k), v.into())) + .collect::>() )) ); } @@ -844,9 +857,9 @@ mod tests { ("BANDWIDTH", "300000"), ("CODECS", "\"avc1.42c015,mp4a.40.2\"") ] - .into_iter() - .map(|(k, v)| (String::from(k), v.into())) - .collect::>() + .into_iter() + .map(|(k, v)| (String::from(k), v.into())) + .collect::>() )) ); } @@ -862,9 +875,9 @@ mod tests { ("RESOLUTION", "22x22"), ("VIDEO", "1") ] - .into_iter() - .map(|(k, v)| (String::from(k), v.into())) - .collect::>() + .into_iter() + .map(|(k, v)| (String::from(k), v.into())) + .collect::>() )) ); } diff --git a/src/playlist.rs b/src/playlist.rs index 50e9fc5..1b8e50d 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -32,15 +32,110 @@ macro_rules! write_some_attribute { }; } -macro_rules! bool_default_false { - ($optional:expr) => { - match $optional { - Some(ref s) if s == "YES" => true, - Some(_) | None => false, +macro_rules! write_some_other_attributes { + ($w:expr, $attr:expr) => { + if let &Some(ref attributes) = $attr { + let mut status = std::io::Result::Ok(()); + for (name, val) in attributes { + let res = write!($w, ",{}={}", name, val); + if res.is_err() { + status = res; + break; + } + } + status + } else { + Ok(()) } }; } +macro_rules! is_yes { + ($attrs:expr, $attr:expr) => { + match $attrs.remove($attr) { + Some(QuotedOrUnquoted::Unquoted(ref s)) if s == "YES" => true, + Some(QuotedOrUnquoted::Unquoted(ref s)) if s == "NO" => false, + Some(ref s) => { + return Err(format!( + "Can't create bool from {} for {} attribute", + s, $attr + )) + } + None => false, + } + }; +} + +macro_rules! quoted_string { + ($attrs:expr, $attr:expr) => { + match $attrs.remove($attr) { + Some(QuotedOrUnquoted::Quoted(s)) => Some(s), + Some(QuotedOrUnquoted::Unquoted(_)) => { + return Err(format!( + "Can't create {} attribute from unquoted string", + $attr + )) + } + None => None, + } + }; +} + +macro_rules! unquoted_string { + ($attrs:expr, $attr:expr) => { + match $attrs.remove($attr) { + Some(QuotedOrUnquoted::Unquoted(s)) => Some(s), + Some(QuotedOrUnquoted::Quoted(_)) => { + return Err(format!( + "Can't create {} attribute from quoted string", + $attr + )) + } + None => None, + } + }; +} + +macro_rules! unquoted_string_parse { + ($attrs:expr, $attr:expr, $parse:expr) => { + match $attrs.remove($attr) { + Some(QuotedOrUnquoted::Unquoted(s)) => Some( + ($parse(s.as_str())).map_err(|_| format!("Can't create attribute {}", $attr))?, + ), + Some(QuotedOrUnquoted::Quoted(_)) => { + return Err(format!( + "Can't create {} attribute from quoted string", + $attr + )) + } + None => None, + } + }; + ($attrs:expr, $attr:expr) => { + unquoted_string_parse!($attrs, $attr, |s: &str| s.parse()) + }; +} + +macro_rules! quoted_string_parse { + ($attrs:expr, $attr:expr, $parse:expr) => { + match $attrs.remove($attr) { + Some(QuotedOrUnquoted::Quoted(s)) => Some( + ($parse(s.as_str())).map_err(|_| format!("Can't create attribute {}", $attr))?, + ), + Some(QuotedOrUnquoted::Unquoted(_)) => { + return Err(format!( + "Can't create {} attribute from unquoted string", + $attr + )) + } + None => None, + } + }; + ($attrs:expr, $attr:expr) => { + quoted_string_parse!($attrs, $attr, |s: &str| s.parse()) + }; +} + /// [Playlist](https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-4.1), /// can either be a `MasterPlaylist` or a `MediaPlaylist`. /// @@ -152,109 +247,37 @@ pub struct VariantStream { pub subtitles: Option, pub closed_captions: Option, // PROGRAM-ID tag was removed in protocol version 6 + pub other_attributes: Option>, } impl VariantStream { - pub fn from_hashmap( + pub(crate) fn from_hashmap( mut attrs: HashMap, is_i_frame: bool, ) -> Result { - let uri = attrs - .remove("URI") - .map(|c| { - c.as_quoted() - .ok_or_else(|| format!("URI attribute is an unquoted string")) - .map(|s| s.to_string()) - }) - .transpose()? - .unwrap_or_default(); - let bandwidth = attrs - .remove("BANDWIDTH") - .ok_or_else(|| String::from("Mandatory bandwidth attribute not included")) - .and_then(|s| { - s.as_unquoted() - .ok_or_else(|| String::from("Bandwidth attribute is a quoted string")) - .and_then(|s| { - s.trim() - .parse::() - .map_err(|err| format!("Failed to parse bandwidth attribute: {}", err)) - }) - })?; - let average_bandwidth = attrs - .remove("AVERAGE-BANDWIDTH") - .map(|s| { - s.as_unquoted() - .ok_or_else(|| String::from("Average bandwidth attribute is a quoted string")) - .and_then(|s| { - s.trim().parse::().map_err(|err| { - format!("Failed to parse average bandwidth attribute: {}", err) - }) - }) - }) - .transpose()?; - let codecs = attrs - .remove("CODECS") - .map(|c| { - c.as_quoted() - .ok_or_else(|| format!("Codecs attribute is an unquoted string")) - .map(|s| s.to_string()) - }) - .transpose()?; - let resolution = attrs - .remove("RESOLUTION") - .map(|r| { - r.as_unquoted() - .ok_or_else(|| format!("Resolution attribute is a quoted string")) - .and_then(|s| s.parse::()) - }) - .transpose()?; - let frame_rate = attrs - .remove("FRAME-RATE") - .map(|f| { - f.as_unquoted() - .ok_or_else(|| format!("Framerate attribute is a quoted string")) - .and_then(|s| { - s.parse::() - .map_err(|err| format!("Failed to parse framerate: {}", err)) - }) - }) - .transpose()?; - let hdcp_level = attrs - .remove("HDCP-LEVEL") - .map(|r| { - r.as_unquoted() - .ok_or_else(|| format!("HDCP level attribute is a quoted string")) - .and_then(|s| s.parse::()) - }) - .transpose()?; - let audio = attrs - .remove("AUDIO") - .map(|c| { - c.as_quoted() - .ok_or_else(|| format!("Audio attribute is an unquoted string")) - .map(|s| s.to_string()) - }) - .transpose()?; - let video = attrs - .remove("VIDEO") - .map(|c| { - c.as_quoted() - .ok_or_else(|| format!("Video attribute is an unquoted string")) - .map(|s| s.to_string()) - }) - .transpose()?; - let subtitles = attrs - .remove("SUBTITLES") - .map(|c| { - c.as_quoted() - .ok_or_else(|| format!("Subtitles attribute is an unquoted string")) - .map(|s| s.to_string()) - }) - .transpose()?; + let uri = quoted_string!(attrs, "URI").unwrap_or_default(); + // TODO: keep in attrs if parsing optional attributes fails + let bandwidth = unquoted_string_parse!(attrs, "BANDWIDTH", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse BANDWIDTH attribute: {}", err))) + .ok_or_else(|| String::from("EXT-X-STREAM-INF without mandatory BANDWIDTH attribute"))?; + let average_bandwidth = unquoted_string_parse!(attrs, "AVERAGE-BANDWIDTH", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse AVERAGE-BANDWIDTH: {}", err))); + let codecs = quoted_string!(attrs, "CODECS"); + let resolution = unquoted_string_parse!(attrs, "RESOLUTION"); + let frame_rate = unquoted_string_parse!(attrs, "FRAME-RATE", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse FRAME-RATE attribute: {}", err))); + let hdcp_level = unquoted_string_parse!(attrs, "HDCP-LEVEL"); + let audio = quoted_string!(attrs, "AUDIO"); + let video = quoted_string!(attrs, "VIDEO"); + let subtitles = quoted_string!(attrs, "SUBTITLES"); let closed_captions = attrs .remove("CLOSED-CAPTIONS") .map(|c| c.try_into()) .transpose()?; + let other_attributes = if attrs.is_empty() { None } else { Some(attrs) }; Ok(VariantStream { is_i_frame, @@ -269,10 +292,11 @@ impl VariantStream { video, subtitles, closed_captions, + other_attributes, }) } - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { if self.is_i_frame { write!(w, "#EXT-X-I-FRAME-STREAM-INF:")?; self.write_stream_inf_common_attributes(w)?; @@ -286,6 +310,7 @@ impl VariantStream { match closed_captions { ClosedCaptionGroupId::None => write!(w, ",CLOSED-CAPTIONS=NONE")?, ClosedCaptionGroupId::GroupId(s) => write!(w, ",CLOSED-CAPTIONS=\"{}\"", s)?, + ClosedCaptionGroupId::Other(s) => write!(w, ",CLOSED-CAPTIONS={}", s)?, } } writeln!(w)?; @@ -300,7 +325,9 @@ impl VariantStream { write_some_attribute!(w, ",RESOLUTION", &self.resolution)?; write_some_attribute!(w, ",FRAME-RATE", &self.frame_rate)?; write_some_attribute!(w, ",HDCP-LEVEL", &self.hdcp_level)?; - write_some_attribute_quoted!(w, ",VIDEO", &self.video) + write_some_attribute_quoted!(w, ",VIDEO", &self.video)?; + write_some_other_attributes!(w, &self.other_attributes)?; + Ok(()) } } @@ -310,7 +337,7 @@ pub struct Resolution { pub height: u64, } -impl fmt::Display for Resolution { +impl Display for Resolution { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}x{}", self.width, self.height) } @@ -324,22 +351,23 @@ impl FromStr for Resolution { Some((width, height)) => { let width = width .parse::() - .map_err(|err| format!("Can't parse resolution attribute: {}", err))?; + .map_err(|err| format!("Can't parse RESOLUTION attribute width: {}", err))?; let height = height .parse::() - .map_err(|err| format!("Can't parse resolution attribute: {}", err))?; + .map_err(|err| format!("Can't parse RESOLUTION attribute height: {}", err))?; Ok(Resolution { width, height }) } - None => Err(String::from("Invalid resolution attribute")), + None => Err(String::from("Invalid RESOLUTION attribute")), } } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub enum HDCPLevel { Type0, Type1, None, + Other(String), } impl FromStr for HDCPLevel { @@ -355,24 +383,27 @@ impl FromStr for HDCPLevel { } } -impl fmt::Display for HDCPLevel { +impl Display for HDCPLevel { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", - match *self { + match self { HDCPLevel::Type0 => "TYPE-0", HDCPLevel::Type1 => "TYPE-1", HDCPLevel::None => "NONE", + HDCPLevel::Other(s) => s, } ) } } +/// TODO docs #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub enum ClosedCaptionGroupId { None, GroupId(String), + Other(String), } impl TryFrom for ClosedCaptionGroupId { @@ -410,36 +441,79 @@ pub struct AlternativeMedia { pub default: bool, // Its absence indicates an implicit value of NO pub autoselect: bool, // Its absence indicates an implicit value of NO pub forced: bool, // Its absence indicates an implicit value of NO - pub instream_id: Option, + pub instream_id: Option, pub characteristics: Option, pub channels: Option, + pub other_attributes: Option>, } impl AlternativeMedia { - pub fn from_hashmap(mut attrs: HashMap) -> AlternativeMedia { - AlternativeMedia { - media_type: attrs - .get("TYPE") - .and_then(|s| AlternativeMediaType::from_str(s.to_string().as_str()).ok()) - .unwrap_or_default(), - uri: attrs.remove("URI").map(|u| u.to_string()), - group_id: attrs.remove("GROUP-ID").unwrap_or_default().to_string(), - language: attrs.remove("LANGUAGE").map(|l| l.to_string()), - assoc_language: attrs.remove("ASSOC-LANGUAGE").map(|a| a.to_string()), - name: attrs.remove("NAME").unwrap_or_default().to_string(), - default: bool_default_false!(attrs.remove("DEFAULT").map(|s| s.to_string())), - autoselect: bool_default_false!(attrs.remove("AUTOSELECT").map(|s| s.to_string())), - forced: bool_default_false!(attrs.remove("FORCED").map(|f| f.to_string())), - instream_id: attrs.remove("INSTREAM-ID").map(|i| i.to_string()), - characteristics: attrs.remove("CHARACTERISTICS").map(|c| c.to_string()), - channels: attrs.remove("CHANNELS").map(|c| c.to_string()), + pub(crate) fn from_hashmap( + mut attrs: HashMap, + ) -> Result { + let media_type = unquoted_string_parse!(attrs, "TYPE") + .ok_or_else(|| String::from("EXT-X-MEDIA without mandatory TYPE attribute"))?; + let uri = quoted_string!(attrs, "URI"); + + if media_type == AlternativeMediaType::ClosedCaptions && uri.is_some() { + return Err(String::from( + "URI attribute must not be included in CLOSED-CAPTIONS Alternative Medias", + )); } + + let group_id = quoted_string!(attrs, "GROUP-ID") + .ok_or_else(|| String::from("EXT-X-MEDIA without mandatory GROUP-ID attribute"))?; + let language = quoted_string!(attrs, "LANGUAGE"); + let assoc_language = quoted_string!(attrs, "ASSOC-LANGUAGE"); + let name = quoted_string!(attrs, "NAME") + .ok_or_else(|| String::from("EXT-X-MEDIA without mandatory NAME attribute"))?; + let default = is_yes!(attrs, "DEFAULT"); + let autoselect = is_yes!(attrs, "AUTOSELECT"); + + if media_type != AlternativeMediaType::Subtitles && attrs.contains_key("FORCED") { + return Err(String::from( + "FORCED attribute must not be included in non-SUBTITLE Alternative Medias", + )); + } + let forced = is_yes!(attrs, "FORCED"); + + if media_type != AlternativeMediaType::ClosedCaptions && attrs.contains_key("INSTREAM-ID") { + return Err(String::from("INSTREAM-ID attribute must not be included in non-CLOSED-CAPTIONS Alternative Medias")); + } else if media_type == AlternativeMediaType::ClosedCaptions + && !attrs.contains_key("INSTREAM-ID") + { + return Err(String::from( + "INSTREAM-ID attribute must be included in CLOSED-CAPTIONS Alternative Medias", + )); + } + let instream_id = quoted_string_parse!(attrs, "INSTREAM-ID"); + let characteristics = quoted_string!(attrs, "CHARACTERISTICS"); + let channels = quoted_string!(attrs, "CHANNELS"); + let other_attributes = if attrs.is_empty() { None } else { Some(attrs) }; + + Ok(AlternativeMedia { + media_type, + uri, + group_id, + language, + assoc_language, + name, + default, + autoselect, + forced, + instream_id, + characteristics, + channels, + other_attributes, + }) } - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { write!(w, "#EXT-X-MEDIA:")?; write!(w, "TYPE={}", self.media_type)?; - write_some_attribute_quoted!(w, ",URI", &self.uri)?; + if self.media_type != AlternativeMediaType::ClosedCaptions { + write_some_attribute_quoted!(w, ",URI", &self.uri)?; + } write!(w, ",GROUP-ID=\"{}\"", self.group_id)?; write_some_attribute_quoted!(w, ",LANGUAGE", &self.language)?; write_some_attribute_quoted!(w, ",ASSOC-LANGUAGE", &self.assoc_language)?; @@ -450,22 +524,27 @@ impl AlternativeMedia { if self.autoselect { write!(w, ",AUTOSELECT=YES")?; } - if self.forced { + if self.forced && self.media_type == AlternativeMediaType::Subtitles { write!(w, ",FORCED=YES")?; } - write_some_attribute_quoted!(w, ",INSTREAM-ID", &self.instream_id)?; + if self.media_type == AlternativeMediaType::ClosedCaptions { + // FIXME: Mandatory for closed captions + write_some_attribute_quoted!(w, ",INSTREAM-ID", &self.instream_id)?; + } write_some_attribute_quoted!(w, ",CHARACTERISTICS", &self.characteristics)?; write_some_attribute_quoted!(w, ",CHANNELS", &self.channels)?; + write_some_other_attributes!(w, &self.other_attributes)?; writeln!(w) } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub enum AlternativeMediaType { Audio, Video, Subtitles, ClosedCaptions, + Other(String), } impl FromStr for AlternativeMediaType { @@ -491,21 +570,59 @@ impl Default for AlternativeMediaType { } } -impl fmt::Display for AlternativeMediaType { +impl Display for AlternativeMediaType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", - match *self { + match self { AlternativeMediaType::Audio => "AUDIO", AlternativeMediaType::Video => "VIDEO", AlternativeMediaType::Subtitles => "SUBTITLES", AlternativeMediaType::ClosedCaptions => "CLOSED-CAPTIONS", + AlternativeMediaType::Other(s) => s.as_str(), } ) } } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] +pub enum InstreamId { + CC(u8), + Service(u8), + Other(String), +} + +impl FromStr for InstreamId { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some(cc) = s.strip_prefix("CC") { + let cc = cc + .parse::() + .map_err(|err| format!("Unable to create InstreamId from {:?}: {}", s, err))?; + Ok(InstreamId::CC(cc)) + } else if let Some(service) = s.strip_prefix("SERVICE") { + let service = service + .parse::() + .map_err(|err| format!("Unable to create InstreamId from {:?}: {}", s, err))?; + Ok(InstreamId::Service(service)) + } else { + Err(format!("Unable to create InstreamId from {:?}", s)) + } + } +} + +impl Display for InstreamId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InstreamId::CC(cc) => write!(f, "CC{}", cc), + InstreamId::Service(service) => write!(f, "SERVICE{}", service), + InstreamId::Other(s) => write!(f, "{}", s), + } + } +} + /// [`#EXT-X-SESSION-KEY:`](https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-4.3.4.5) /// The EXT-X-SESSION-KEY tag allows encryption keys from Media Playlists /// to be specified in a Master Playlist. This allows the client to @@ -514,7 +631,7 @@ impl fmt::Display for AlternativeMediaType { pub struct SessionKey(pub Key); impl SessionKey { - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { write!(w, "#EXT-X-SESSION-KEY:")?; self.0.write_attributes_to(w)?; writeln!(w) @@ -535,19 +652,18 @@ pub struct SessionData { pub data_id: String, pub field: SessionDataField, pub language: Option, + pub other_attributes: Option>, } impl SessionData { - pub fn from_hashmap( + pub(crate) fn from_hashmap( mut attrs: HashMap, ) -> Result { - let data_id = match attrs.remove("DATA-ID") { - Some(data_id) => data_id, - None => return Err("EXT-X-SESSION-DATA field without DATA-ID".to_string()), - }; + let data_id = quoted_string!(attrs, "DATA-ID") + .ok_or_else(|| String::from("EXT-X-SESSION-DATA field without DATA-ID attribute"))?; - let value = attrs.remove("VALUE").map(|v| v.to_string()); - let uri = attrs.remove("URI").map(|u| u.to_string()); + let value = quoted_string!(attrs, "VALUE"); + let uri = quoted_string!(attrs, "URI"); // SessionData must contain either a VALUE or a URI, // but not both https://tools.ietf.org/html/rfc8216#section-4.3.4.4 @@ -556,26 +672,30 @@ impl SessionData { (None, Some(uri)) => SessionDataField::Uri(uri), (Some(_), Some(_)) => { return Err(format![ - "EXT-X-SESSION-DATA tag {} contains both a value and a uri", + "EXT-X-SESSION-DATA tag {} contains both a value and an URI", data_id ]) } (None, None) => { return Err(format![ - "EXT-X-SESSION-DATA tag {} must contain either a value or a uri", + "EXT-X-SESSION-DATA tag {} must contain either a value or an URI", data_id ]) } }; + let language = quoted_string!(attrs, "LANGUAGE"); + let other_attributes = if attrs.is_empty() { None } else { Some(attrs) }; + Ok(SessionData { - data_id: data_id.to_string(), + data_id, field, - language: attrs.remove("LANGUAGE").map(|s| s.to_string()), + language, + other_attributes, }) } - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { write!(w, "#EXT-X-SESSION-DATA:")?; write!(w, "DATA-ID=\"{}\"", self.data_id)?; match &self.field { @@ -583,6 +703,7 @@ impl SessionData { SessionDataField::Uri(uri) => write!(w, ",URI=\"{}\"", uri)?, }; write_some_attribute_quoted!(w, ",LANGUAGE", &self.language)?; + write_some_other_attributes!(w, &self.other_attributes)?; writeln!(w) } } @@ -614,6 +735,8 @@ pub struct MediaPlaylist { pub start: Option, /// `#EXT-X-INDEPENDENT-SEGMENTS` pub independent_segments: bool, + /// Unknown tags before the first media segment + pub unknown_tags: Vec, } impl MediaPlaylist { @@ -659,10 +782,11 @@ impl MediaPlaylist { } /// [`#EXT-X-PLAYLIST-TYPE:`](https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-4.3.3.5) -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub enum MediaPlaylistType { Event, Vod, + Other(String), } impl FromStr for MediaPlaylistType { @@ -677,14 +801,15 @@ impl FromStr for MediaPlaylistType { } } -impl fmt::Display for MediaPlaylistType { +impl Display for MediaPlaylistType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", - match *self { + match self { MediaPlaylistType::Event => "EVENT", MediaPlaylistType::Vod => "VOD", + MediaPlaylistType::Other(s) => s, } ) } @@ -720,7 +845,7 @@ pub struct MediaSegment { /// `#EXT-X-PROGRAM-DATE-TIME:` pub program_date_time: Option, /// `#EXT-X-DATERANGE:` - pub daterange: Option, + pub daterange: Option, /// `#EXT-` pub unknown_tags: Vec, } @@ -730,7 +855,7 @@ impl MediaSegment { Default::default() } - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { if let Some(ref byte_range) = self.byte_range { write!(w, "#EXT-X-BYTERANGE:")?; byte_range.write_value_to(w)?; @@ -753,7 +878,9 @@ impl MediaSegment { writeln!(w, "#EXT-X-PROGRAM-DATE-TIME:{}", v)?; } if let Some(ref v) = self.daterange { - writeln!(w, "#EXT-X-DATERANGE:{}", v)?; + write!(w, "#EXT-X-DATERANGE:")?; + v.write_attributes_to(w)?; + writeln!(w)?; } for unknown_tag in &self.unknown_tags { writeln!(w, "{}", unknown_tag)?; @@ -771,6 +898,48 @@ impl MediaSegment { } } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] +pub enum KeyMethod { + None, + AES128, + SampleAES, + Other(String), +} + +impl Default for KeyMethod { + fn default() -> Self { + KeyMethod::None + } +} + +impl FromStr for KeyMethod { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "NONE" => Ok(KeyMethod::None), + "AES-128" => Ok(KeyMethod::AES128), + "SAMPLE-AES" => Ok(KeyMethod::SampleAES), + _ => Err(format!("Unable to create KeyMethod from {:?}", s)), + } + } +} + +impl Display for KeyMethod { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + KeyMethod::None => "NONE", + KeyMethod::AES128 => "AES-128", + KeyMethod::SampleAES => "SAMPLE-AES", + KeyMethod::Other(s) => s, + } + ) + } +} + /// [`#EXT-X-KEY:`](https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-4.3.2.4) /// /// Media Segments MAY be encrypted. The EXT-X-KEY tag specifies how to @@ -781,7 +950,7 @@ impl MediaSegment { /// same Media Segment if they ultimately produce the same decryption key. #[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct Key { - pub method: String, + pub method: KeyMethod, pub uri: Option, pub iv: Option, pub keyformat: Option, @@ -789,22 +958,35 @@ pub struct Key { } impl Key { - pub fn from_hashmap(mut attrs: HashMap) -> Key { - Key { - method: attrs.remove("METHOD").unwrap_or_default().to_string(), - uri: attrs.remove("URI").map(|u| u.to_string()), - iv: attrs.remove("IV").map(|i| i.to_string()), - keyformat: attrs.remove("KEYFORMAT").map(|k| k.to_string()), - keyformatversions: attrs.remove("KEYFORMATVERSIONS").map(|k| k.to_string()), + pub(crate) fn from_hashmap( + mut attrs: HashMap, + ) -> Result { + let method: KeyMethod = unquoted_string_parse!(attrs, "METHOD") + .ok_or_else(|| String::from("EXT-X-KEY without mandatory METHOD attribute"))?; + + let uri = quoted_string!(attrs, "URI"); + let iv = unquoted_string!(attrs, "IV"); + if method == KeyMethod::None && iv.is_none() { + return Err("IV is required unless METHOD is NONE".parse().unwrap()); } + let keyformat = quoted_string!(attrs, "KEYFORMAT"); + let keyformatversions = quoted_string!(attrs, "KEYFORMATVERSIONS"); + + Ok(Key { + method, + uri, + iv, + keyformat, + keyformatversions, + }) } pub fn write_attributes_to(&self, w: &mut T) -> std::io::Result<()> { write!(w, "METHOD={}", self.method)?; write_some_attribute_quoted!(w, ",URI", &self.uri)?; write_some_attribute!(w, ",IV", &self.iv)?; - write_some_attribute!(w, ",KEYFORMAT", &self.keyformat)?; - write_some_attribute!(w, ",KEYFORMATVERSIONS", &self.keyformatversions) + write_some_attribute_quoted!(w, ",KEYFORMAT", &self.keyformat)?; + write_some_attribute_quoted!(w, ",KEYFORMATVERSIONS", &self.keyformatversions) } } @@ -820,6 +1002,7 @@ impl Key { pub struct Map { pub uri: String, pub byte_range: Option, + pub other_attributes: HashMap, } impl Map { @@ -859,16 +1042,88 @@ impl ByteRange { /// The EXT-X-DATERANGE tag associates a Date Range (i.e. a range of time /// defined by a starting and ending date) with a set of attribute / /// value pairs. -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Clone)] pub struct DateRange { pub id: String, pub class: Option, pub start_date: String, pub end_date: Option, - pub duration: Option, - pub planned_duration: Option, - pub x_prefixed: Option, // X- + pub duration: Option, + pub planned_duration: Option, + pub x_prefixed: Option>, // X- pub end_on_next: bool, + pub other_attributes: Option>, +} + +impl DateRange { + pub fn from_hashmap(mut attrs: HashMap) -> Result { + let id = quoted_string!(attrs, "ID") + .ok_or_else(|| String::from("EXT-X-DATERANGE without mandatory ID attribute"))?; + let class = quoted_string!(attrs, "CLASS"); + let start_date = quoted_string!(attrs, "START-DATE").ok_or_else(|| { + String::from("EXT-X-DATERANGE without mandatory START-DATE attribute") + })?; + let end_date = quoted_string!(attrs, "END-DATE"); + let duration = unquoted_string_parse!(attrs, "DURATION", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse DURATION attribute: {}", err))); + let planned_duration = unquoted_string_parse!(attrs, "PLANNED-DURATION", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse PLANNED-DURATION attribute: {}", err))); + let end_on_next = is_yes!(attrs, "END-ON-NEXT"); + let mut x_prefixed = HashMap::new(); + let mut other_attributes = HashMap::new(); + for (k, v) in attrs.into_iter() { + if k.starts_with("X-") { + x_prefixed.insert(k, v); + } else { + other_attributes.insert(k, v); + } + } + + Ok(DateRange { + id, + class, + start_date, + end_date, + duration, + planned_duration, + x_prefixed: if x_prefixed.is_empty() { + None + } else { + Some(x_prefixed) + }, + end_on_next, + other_attributes: if other_attributes.is_empty() { + None + } else { + Some(other_attributes) + }, + }) + } + + pub fn write_attributes_to(&self, w: &mut T) -> std::io::Result<()> { + write_some_attribute_quoted!(w, "ID", &Some(&self.id))?; + write_some_attribute_quoted!(w, ",CLASS", &self.class)?; + write_some_attribute_quoted!(w, ",START-DATE", &Some(&self.start_date))?; + write_some_attribute_quoted!(w, ",END-DATE", &self.end_date)?; + write_some_attribute!(w, ",DURATION", &self.duration)?; + write_some_attribute!(w, ",PLANNED-DURATION", &self.planned_duration)?; + if let Some(x_prefixed) = &self.x_prefixed { + for (name, attr) in x_prefixed { + write!(w, ",{}={}", name, attr)?; + } + } + if self.end_on_next { + write!(w, ",END-ON-NEXT=YES")?; + } + if let Some(other_attributes) = &self.other_attributes { + for (name, attr) in other_attributes { + write!(w, ",{}={}", name, attr)?; + } + } + Ok(()) + } } // ----------------------------------------------------------------------------------------------- @@ -880,27 +1135,38 @@ pub struct DateRange { /// The EXT-X-START tag indicates a preferred point at which to start /// playing a Playlist. By default, clients SHOULD start playback at /// this point when beginning a playback session. -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Clone)] pub struct Start { - pub time_offset: String, - pub precise: Option, + pub time_offset: f64, + pub precise: Option, + pub other_attributes: HashMap, } impl Start { - pub fn from_hashmap(mut attrs: HashMap) -> Start { - Start { - time_offset: attrs.remove("TIME-OFFSET").unwrap_or_default().to_string(), - precise: attrs - .remove("PRECISE") - .map(|a| a.to_string()) - .or_else(|| Some("NO".to_string())), - } + pub(crate) fn from_hashmap( + mut attrs: HashMap, + ) -> Result { + let time_offset = unquoted_string_parse!(attrs, "TIME-OFFSET", |s: &str| s + .parse::() + .map_err(|err| format!("Failed to parse TIME-OFFSET attribute: {}", err))) + .ok_or_else(|| String::from("EXT-X-START without mandatory TIME-OFFSET attribute"))?; + Ok(Start { + time_offset, + precise: is_yes!(attrs, "PRECISE").into(), + other_attributes: attrs, + }) } - pub fn write_to(&self, w: &mut T) -> std::io::Result<()> { + pub(crate) fn write_to(&self, w: &mut T) -> std::io::Result<()> { write!(w, "#EXT-X-START:TIME-OFFSET={}", self.time_offset)?; - write_some_attribute!(w, ",PRECISE", &self.precise)?; - writeln!(w) + if let Some(precise) = self.precise { + if precise { + write!(w, ",PRECISE=YES")?; + } + } + writeln!(w)?; + + Ok(()) } } @@ -912,7 +1178,7 @@ pub struct ExtTag { } impl Display for ExtTag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "#EXT-{}", self.tag)?; if let Some(v) = &self.rest { write!(f, ":{}", v)?; diff --git a/tests/lib.rs b/tests/lib.rs index e858fed..8be5a7a 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -1,15 +1,16 @@ #![allow(unused_variables, unused_imports, dead_code)] +use m3u8_rs::QuotedOrUnquoted::Quoted; use m3u8_rs::*; use nom::AsBytes; use std::collections::HashMap; -use std::fs; use std::fs::File; use std::io::Read; use std::path; +use std::{fs, io}; fn all_sample_m3u_playlists() -> Vec { - let path: std::path::PathBuf = ["sample-playlists"].iter().collect(); + let path: path::PathBuf = ["sample-playlists"].iter().collect(); fs::read_dir(path.to_str().unwrap()) .unwrap() .filter_map(Result::ok) @@ -20,13 +21,13 @@ fn all_sample_m3u_playlists() -> Vec { fn getm3u(path: &str) -> String { let mut buf = String::new(); - let mut file = fs::File::open(path).unwrap_or_else(|_| panic!("Can't find m3u8: {}", path)); + let mut file = File::open(path).unwrap_or_else(|_| panic!("Can't find m3u8: {}", path)); let u = file.read_to_string(&mut buf).expect("Can't read file"); buf } fn get_sample_playlist(name: &str) -> String { - let path: std::path::PathBuf = ["sample-playlists", name].iter().collect(); + let path: path::PathBuf = ["sample-playlists", name].iter().collect(); getm3u(path.to_str().unwrap()) } @@ -38,7 +39,7 @@ fn print_parse_playlist_test(playlist_name: &str) -> bool { println!("Parsing playlist file: {:?}", playlist_name); let parsed = parse_playlist(input.as_bytes()); - if let Result::Ok((i, o)) = parsed { + if let Ok((i, o)) = &parsed { println!("{:?}", o); true } else { @@ -158,7 +159,7 @@ fn playlist_types() { println!("{:?} = {:?}", path, is_master); - assert!(path.to_lowercase().contains("master") == is_master); + assert_eq!(path.to_lowercase().contains("master"), is_master); } } @@ -200,20 +201,53 @@ fn create_and_parse_master_playlist_empty() { fn create_and_parse_master_playlist_full() { let mut playlist_original = Playlist::MasterPlaylist(MasterPlaylist { version: Some(6), - alternatives: vec![AlternativeMedia { - media_type: AlternativeMediaType::Audio, - uri: Some("alt-media-uri".into()), - group_id: "group-id".into(), - language: Some("language".into()), - assoc_language: Some("assoc-language".into()), - name: "Xmedia".into(), - default: true, // Its absence indicates an implicit value of NO - autoselect: true, // Its absence indicates an implicit value of NO - forced: true, // Its absence indicates an implicit value of NO - instream_id: Some("instream_id".into()), - characteristics: Some("characteristics".into()), - channels: Some("channels".into()), - }], + alternatives: vec![ + AlternativeMedia { + media_type: AlternativeMediaType::Audio, + uri: Some("alt-media-uri".into()), + group_id: "group-id".into(), + language: Some("language".into()), + assoc_language: Some("assoc-language".into()), + name: "Xmedia".into(), + default: true, // Its absence indicates an implicit value of NO + autoselect: true, // Its absence indicates an implicit value of NO + forced: false, // Its absence indicates an implicit value of NO + instream_id: None, + characteristics: Some("characteristics".into()), + channels: Some("channels".into()), + other_attributes: Default::default(), + }, + AlternativeMedia { + media_type: AlternativeMediaType::Subtitles, + uri: Some("alt-media-uri".into()), + group_id: "group-id".into(), + language: Some("language".into()), + assoc_language: Some("assoc-language".into()), + name: "Xmedia".into(), + default: true, // Its absence indicates an implicit value of NO + autoselect: true, // Its absence indicates an implicit value of NO + forced: true, // Its absence indicates an implicit value of NO + instream_id: None, + characteristics: Some("characteristics".into()), + channels: Some("channels".into()), + other_attributes: Default::default(), + }, + AlternativeMedia { + media_type: AlternativeMediaType::ClosedCaptions, + uri: None, + group_id: "group-id".into(), + language: Some("language".into()), + assoc_language: Some("assoc-language".into()), + name: "Xmedia".into(), + default: true, // Its absence indicates an implicit value of NO + autoselect: true, // Its absence indicates an implicit value of NO + forced: false, // Its absence indicates an implicit value of NO + instream_id: Some(InstreamId::CC(1)), + characteristics: Some("characteristics".into()), + channels: Some("channels".into()), + other_attributes: Default::default(), + }, + ], variants: vec![VariantStream { is_i_frame: false, uri: "masterplaylist-uri".into(), @@ -230,22 +264,25 @@ fn create_and_parse_master_playlist_full() { video: Some("video".into()), subtitles: Some("subtitles".into()), closed_captions: Some(ClosedCaptionGroupId::GroupId("closed_captions".into())), + other_attributes: Default::default(), }], session_data: vec![SessionData { data_id: "****".into(), field: SessionDataField::Value("%%%%".to_string()), language: Some("SessionDataLanguage".into()), + other_attributes: Default::default(), }], session_key: vec![SessionKey(Key { - method: "AES-128".into(), + method: KeyMethod::AES128, uri: Some("https://secure.domain.com".into()), iv: Some("0xb059217aa2649ce170b734".into()), keyformat: Some("xXkeyformatXx".into()), keyformatversions: Some("xXFormatVers".into()), })], start: Some(Start { - time_offset: "123123123".into(), - precise: Some("YES".into()), + time_offset: "123123123".parse().unwrap(), + precise: Some(true), + other_attributes: Default::default(), }), independent_segments: true, unknown_tags: vec![], @@ -290,8 +327,9 @@ fn create_and_parse_media_playlist_full() { playlist_type: Some(MediaPlaylistType::Vod), i_frames_only: true, start: Some(Start { - time_offset: "9999".into(), - precise: Some("YES".into()), + time_offset: "9999".parse().unwrap(), + precise: Some(true), + other_attributes: Default::default(), }), independent_segments: true, segments: vec![MediaSegment { @@ -304,7 +342,7 @@ fn create_and_parse_media_playlist_full() { }), discontinuity: true, key: Some(Key { - method: "AES-128".into(), + method: KeyMethod::None, uri: Some("https://secure.domain.com".into()), iv: Some("0xb059217aa2649ce170b734".into()), keyformat: Some("xXkeyformatXx".into()), @@ -316,14 +354,29 @@ fn create_and_parse_media_playlist_full() { length: 137116, offset: Some(4559), }), + other_attributes: Default::default(), }), program_date_time: Some("broodlordinfestorgg".into()), - daterange: None, + daterange: Some(DateRange { + id: "9999".into(), + class: Some("class".into()), + start_date: "2018-08-22T21:54:00.079Z".into(), + end_date: None, + duration: None, + planned_duration: Some("40.000".parse().unwrap()), + x_prefixed: Some(HashMap::from([( + "X-client-attribute".into(), + "whatever".into(), + )])), + end_on_next: false, + other_attributes: Default::default(), + }), unknown_tags: vec![ExtTag { tag: "X-CUE-OUT".into(), rest: Some("DURATION=2.002".into()), }], }], + unknown_tags: vec![], }); let playlist_parsed = print_create_and_parse_playlist(&mut playlist_original); assert_eq!(playlist_original, playlist_parsed);