diff --git a/src/error.rs b/src/error.rs index 2a340d2..951bd18 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,7 +7,7 @@ use failure::{Backtrace, Context, Fail}; pub type Result = std::result::Result; /// The ErrorKind. -#[derive(Debug, Fail, Clone)] +#[derive(Debug, Fail, Clone, PartialEq, Eq)] pub enum ErrorKind { #[fail(display = "UnknownError: {}", _0)] /// An unknown error occured. diff --git a/src/line.rs b/src/line.rs index 67a7193..a5aa690 100644 --- a/src/line.rs +++ b/src/line.rs @@ -43,6 +43,7 @@ impl FromStr for Lines { } else if line.starts_with("#") { continue; // ignore comments } else { + // stream inf line needs special treatment if stream_inf { stream_inf = false; if let Some(first_line) = stream_inf_line { diff --git a/src/media_playlist.rs b/src/media_playlist.rs index 3ea878c..9d9778e 100644 --- a/src/media_playlist.rs +++ b/src/media_playlist.rs @@ -319,6 +319,7 @@ fn parse_media_playlist( let mut has_partial_segment = false; let mut has_discontinuity_tag = false; + let mut has_version = false; // m3u8 files without ExtXVersion tags are ProtocolVersion::V1 for (i, line) in input.parse::()?.into_iter().enumerate() { match line { @@ -333,6 +334,7 @@ fn parse_media_playlist( Tag::ExtM3u(_) => return Err(Error::invalid_input()), Tag::ExtXVersion(t) => { builder.version(t.version()); + has_version = true; } Tag::ExtInf(t) => { has_partial_segment = true; @@ -417,6 +419,9 @@ fn parse_media_playlist( if has_partial_segment { return Err(Error::invalid_input()); } + if !has_version { + builder.version(ProtocolVersion::V1); + } builder.segments(segments); builder.build().map_err(Error::builder_error) @@ -436,36 +441,37 @@ mod tests { #[test] fn too_large_segment_duration_test() { - let m3u8 = "#EXTM3U\n\ - #EXT-X-TARGETDURATION:8\n\ - #EXT-X-VERSION:3\n\ - #EXTINF:9.009,\n\ - http://media.example.com/first.ts\n\ - #EXTINF:9.509,\n\ - http://media.example.com/second.ts\n\ - #EXTINF:3.003,\n\ - http://media.example.com/third.ts\n\ - #EXT-X-ENDLIST"; + let playlist = r#" + #EXTM3U + #EXT-X-TARGETDURATION:8 + #EXT-X-VERSION:3 + #EXTINF:9.009, + http://media.example.com/first.ts + #EXTINF:9.509, + http://media.example.com/second.ts + #EXTINF:3.003, + http://media.example.com/third.ts + #EXT-X-ENDLIST"#; // Error (allowable segment duration = target duration = 8) - assert!(m3u8.parse::().is_err()); + assert!(playlist.parse::().is_err()); // Error (allowable segment duration = 9) assert!(MediaPlaylist::builder() .allowable_excess_duration(Duration::from_secs(1)) - .parse(m3u8) + .parse(playlist) .is_err()); // Ok (allowable segment duration = 10) MediaPlaylist::builder() .allowable_excess_duration(Duration::from_secs(2)) - .parse(m3u8) + .parse(playlist) .unwrap(); } #[test] - fn test_parser() { - let m3u8 = ""; - assert!(m3u8.parse::().is_err()); + fn test_empty_playlist() { + let playlist = ""; + assert!(playlist.parse::().is_err()); } } diff --git a/src/tags/media_playlist/playlist_type.rs b/src/tags/media_playlist/playlist_type.rs index f9911b7..221914f 100644 --- a/src/tags/media_playlist/playlist_type.rs +++ b/src/tags/media_playlist/playlist_type.rs @@ -1,40 +1,49 @@ use std::fmt; use std::str::FromStr; -use crate::types::{PlaylistType, ProtocolVersion}; +use crate::types::ProtocolVersion; use crate::utils::tag; use crate::Error; -/// [4.3.3.5. EXT-X-PLAYLIST-TYPE] +/// [4.3.3.5. EXT-X-PLAYLIST-TYPE](https://tools.ietf.org/html/rfc8216#section-4.3.3.5) /// -/// [4.3.3.5. EXT-X-PLAYLIST-TYPE]: https://tools.ietf.org/html/rfc8216#section-4.3.3.5 +/// The EXT-X-PLAYLIST-TYPE tag provides mutability information about the +/// Media Playlist. It applies to the entire Media Playlist. +/// It is OPTIONAL. Its format is: +/// +/// ```text +/// #EXT-X-PLAYLIST-TYPE: +/// ``` +/// +/// # Note +/// If the EXT-X-PLAYLIST-TYPE tag is omitted from a Media Playlist, the +/// Playlist can be updated according to the rules in Section 6.2.1 with +/// no additional restrictions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ExtXPlaylistType { - playlist_type: PlaylistType, +pub enum ExtXPlaylistType { + /// If the ExtXPlaylistType is Event, Media Segments can only be added to + /// the end of the Media Playlist. + Event, + /// If the ExtXPlaylistType is Video On Demand (Vod), + /// the Media Playlist cannot change. + Vod, } impl ExtXPlaylistType { pub(crate) const PREFIX: &'static str = "#EXT-X-PLAYLIST-TYPE:"; - /// Makes a new `ExtXPlaylistType` tag. - pub const fn new(playlist_type: PlaylistType) -> Self { - ExtXPlaylistType { playlist_type } - } - - /// Returns the type of the associated media playlist. - pub const fn playlist_type(self) -> PlaylistType { - self.playlist_type - } - /// Returns the protocol compatibility version that this tag requires. - pub const fn requires_version(self) -> ProtocolVersion { + pub const fn requires_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXPlaylistType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}{}", Self::PREFIX, self.playlist_type) + match &self { + Self::Event => write!(f, "{}EVENT", Self::PREFIX), + Self::Vod => write!(f, "{}VOD", Self::PREFIX), + } } } @@ -42,9 +51,12 @@ impl FromStr for ExtXPlaylistType { type Err = Error; fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?.parse()?; - - Ok(ExtXPlaylistType::new(input)) + let input = tag(input, Self::PREFIX)?; + match input { + "EVENT" => Ok(Self::Event), + "VOD" => Ok(Self::Vod), + _ => Err(Error::custom(format!("Unknown playlist type: {:?}", input))), + } } } @@ -53,11 +65,48 @@ mod test { use super::*; #[test] - fn ext_x_playlist_type() { - let tag = ExtXPlaylistType::new(PlaylistType::Vod); - let text = "#EXT-X-PLAYLIST-TYPE:VOD"; - assert_eq!(text.parse().ok(), Some(tag)); - assert_eq!(tag.to_string(), text); - assert_eq!(tag.requires_version(), ProtocolVersion::V1); + fn test_parser() { + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:VOD" + .parse::() + .unwrap(), + ExtXPlaylistType::Vod, + ); + + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:EVENT" + .parse::() + .unwrap(), + ExtXPlaylistType::Event, + ); + + assert!("#EXT-X-PLAYLIST-TYPE:H" + .parse::() + .is_err()); + } + + #[test] + fn test_display() { + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:VOD".to_string(), + ExtXPlaylistType::Vod.to_string(), + ); + + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:EVENT".to_string(), + ExtXPlaylistType::Event.to_string(), + ); + } + + #[test] + fn test_requires_version() { + assert_eq!( + ExtXPlaylistType::Vod.requires_version(), + ProtocolVersion::V1 + ); + assert_eq!( + ExtXPlaylistType::Event.requires_version(), + ProtocolVersion::V1 + ); } } diff --git a/src/tags/media_segment/inf.rs b/src/tags/media_segment/inf.rs index f90a654..9d2c5b7 100644 --- a/src/tags/media_segment/inf.rs +++ b/src/tags/media_segment/inf.rs @@ -6,10 +6,46 @@ use crate::types::{DecimalFloatingPoint, ProtocolVersion, SingleLineString}; use crate::utils::tag; use crate::Error; -/// [4.3.2.1. EXTINF] +/// [4.3.2.1. EXTINF](https://tools.ietf.org/html/rfc8216#section-4.3.2.1) /// -/// [4.3.2.1. EXTINF]: https://tools.ietf.org/html/rfc8216#section-4.3.2.1 -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// The [ExtInf] tag specifies the duration of a [Media Segment]. It applies +/// only to the next [Media Segment]. This tag is REQUIRED for each [Media Segment]. +/// +/// Its format is: +/// ```text +/// #EXTINF:,[] +/// ``` +/// The title is an optional informative title about the [Media Segment]. +/// +/// [Media Segment]: crate::media_segment::MediaSegment +/// +/// # Examples +/// Parsing from a String: +/// ``` +/// use std::time::Duration; +/// use hls_m3u8::tags::ExtInf; +/// +/// let ext_inf = "#EXTINF:8,".parse::<ExtInf>().expect("Failed to parse tag!"); +/// +/// assert_eq!(ext_inf.duration(), Duration::from_secs(8)); +/// assert_eq!(ext_inf.title(), None); +/// ``` +/// +/// Converting to a String: +/// ``` +/// use std::time::Duration; +/// use hls_m3u8::tags::ExtInf; +/// use hls_m3u8::types::SingleLineString; +/// +/// let ext_inf = ExtInf::with_title( +/// Duration::from_millis(88), +/// SingleLineString::new("title").unwrap() +/// ); +/// +/// assert_eq!(ext_inf.duration(), Duration::from_millis(88)); +/// assert_eq!(ext_inf.to_string(), "#EXTINF:0.088,title".to_string()); +/// ``` +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ExtInf { duration: Duration, title: Option<SingleLineString>, @@ -60,10 +96,10 @@ impl fmt::Display for ExtInf { let duration = (self.duration.as_secs() as f64) + (f64::from(self.duration.subsec_nanos()) / 1_000_000_000.0); - write!(f, "{}", duration)?; + write!(f, "{},", duration)?; if let Some(value) = &self.title { - write!(f, ",{}", value)?; + write!(f, "{}", value)?; } Ok(()) } @@ -74,44 +110,129 @@ impl FromStr for ExtInf { fn from_str(input: &str) -> Result<Self, Self::Err> { let input = tag(input, Self::PREFIX)?; - let mut tokens = input.splitn(2, ','); + dbg!(&input); + let tokens = input.splitn(2, ',').collect::<Vec<_>>(); - let seconds: DecimalFloatingPoint = tokens.next().expect("Never fails").parse()?; - let duration = seconds.to_duration(); + if tokens.len() == 0 { + return Err(Error::custom(format!( + "failed to parse #EXTINF tag, couldn't split input: {:?}", + input + ))); + } + + let duration = tokens[0].parse::<DecimalFloatingPoint>()?.to_duration(); let title = { - if let Some(title) = tokens.next() { - Some((SingleLineString::new(title))?) + if tokens.len() >= 2 { + if tokens[1].trim().is_empty() { + None + } else { + Some(SingleLineString::new(tokens[1])?) + } } else { None } }; + Ok(ExtInf { duration, title }) } } +impl From<Duration> for ExtInf { + fn from(value: Duration) -> Self { + Self::new(value) + } +} + #[cfg(test)] mod test { use super::*; #[test] - fn extinf() { - let tag = ExtInf::new(Duration::from_secs(5)); - assert_eq!("#EXTINF:5".parse().ok(), Some(tag.clone())); - assert_eq!(tag.to_string(), "#EXTINF:5"); - assert_eq!(tag.requires_version(), ProtocolVersion::V1); - - let tag = ExtInf::with_title( - Duration::from_secs(5), - SingleLineString::new("foo").unwrap(), + fn test_display() { + assert_eq!( + "#EXTINF:5,".to_string(), + ExtInf::new(Duration::from_secs(5)).to_string() ); - assert_eq!("#EXTINF:5,foo".parse().ok(), Some(tag.clone())); - assert_eq!(tag.to_string(), "#EXTINF:5,foo"); - assert_eq!(tag.requires_version(), ProtocolVersion::V1); + assert_eq!( + "#EXTINF:5.5,".to_string(), + ExtInf::new(Duration::from_millis(5500)).to_string() + ); + assert_eq!( + "#EXTINF:5.5,title".to_string(), + ExtInf::with_title( + Duration::from_millis(5500), + SingleLineString::new("title").unwrap() + ) + .to_string() + ); + assert_eq!( + "#EXTINF:5,title".to_string(), + ExtInf::with_title( + Duration::from_secs(5), + SingleLineString::new("title").unwrap() + ) + .to_string() + ); + } - let tag = ExtInf::new(Duration::from_millis(1234)); - assert_eq!("#EXTINF:1.234".parse().ok(), Some(tag.clone())); - assert_eq!(tag.to_string(), "#EXTINF:1.234"); - assert_eq!(tag.requires_version(), ProtocolVersion::V3); + #[test] + fn test_parser() { + // #EXTINF:<duration>,[<title>] + assert_eq!( + "#EXTINF:5".parse::<ExtInf>().unwrap(), + ExtInf::new(Duration::from_secs(5)) + ); + assert_eq!( + "#EXTINF:5,".parse::<ExtInf>().unwrap(), + ExtInf::new(Duration::from_secs(5)) + ); + assert_eq!( + "#EXTINF:5.5".parse::<ExtInf>().unwrap(), + ExtInf::new(Duration::from_millis(5500)) + ); + assert_eq!( + "#EXTINF:5.5,".parse::<ExtInf>().unwrap(), + ExtInf::new(Duration::from_millis(5500)) + ); + assert_eq!( + "#EXTINF:5.5,title".parse::<ExtInf>().unwrap(), + ExtInf::with_title( + Duration::from_millis(5500), + SingleLineString::new("title").unwrap() + ) + ); + assert_eq!( + "#EXTINF:5,title".parse::<ExtInf>().unwrap(), + ExtInf::with_title( + Duration::from_secs(5), + SingleLineString::new("title").unwrap() + ) + ); + } + + #[test] + fn test_title() { + assert_eq!(ExtInf::new(Duration::from_secs(5)).title(), None); + assert_eq!( + ExtInf::with_title( + Duration::from_secs(5), + SingleLineString::new("title").unwrap() + ) + .title(), + Some(&SingleLineString::new("title").unwrap()) + ); + } + + #[test] + fn test_requires_version() { + assert_eq!( + ExtInf::new(Duration::from_secs(4)).requires_version(), + ProtocolVersion::V1 + ); + assert_eq!( + ExtInf::new(Duration::from_millis(4400)).requires_version(), + ProtocolVersion::V3 + ); } } diff --git a/src/types/decimal_resolution.rs b/src/types/decimal_resolution.rs index 67128ff..4df01a2 100644 --- a/src/types/decimal_resolution.rs +++ b/src/types/decimal_resolution.rs @@ -1,5 +1,5 @@ use std::fmt; -use std::str::{self, FromStr}; +use std::str::FromStr; use crate::Error; @@ -10,11 +10,37 @@ use crate::Error; /// [4.2. Attribute Lists]: https://tools.ietf.org/html/rfc8216#section-4.2 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DecimalResolution { + width: usize, + height: usize, +} + +impl DecimalResolution { + /// Creates a new DecimalResolution. + pub const fn new(width: usize, height: usize) -> Self { + Self { width, height } + } + /// Horizontal pixel dimension. - pub width: usize, + pub const fn width(&self) -> usize { + self.width + } + + /// Sets Horizontal pixel dimension. + pub fn set_width(&mut self, value: usize) -> &mut Self { + self.width = value; + self + } /// Vertical pixel dimension. - pub height: usize, + pub const fn height(&self) -> usize { + self.height + } + + /// Sets Vertical pixel dimension. + pub fn set_height(&mut self, value: usize) -> &mut Self { + self.height = value; + self + } } impl fmt::Display for DecimalResolution { @@ -27,13 +53,21 @@ impl FromStr for DecimalResolution { type Err = Error; fn from_str(input: &str) -> Result<Self, Self::Err> { - let mut tokens = input.splitn(2, 'x'); - let width = tokens.next().ok_or(Error::missing_value("width"))?; - let height = tokens.next().ok_or(Error::missing_value("height"))?; + let tokens = input.splitn(2, 'x').collect::<Vec<_>>(); + + if tokens.len() != 2 { + return Err(Error::custom(format!( + "InvalidInput: Expected input format: [width]x[height] (ex. 1920x1080), got {:?}", + input, + ))); + } + + let width = tokens[0]; + let height = tokens[1]; Ok(DecimalResolution { - width: width.parse().map_err(|e| Error::custom(e))?, - height: height.parse().map_err(|e| Error::custom(e))?, + width: width.parse()?, + height: height.parse()?, }) } } @@ -44,37 +78,44 @@ mod tests { #[test] fn test_display() { - let decimal_resolution = DecimalResolution { - width: 1920, - height: 1080, - }; - assert_eq!(decimal_resolution.to_string(), "1920x1080".to_string()); + assert_eq!( + DecimalResolution::new(1920, 1080).to_string(), + "1920x1080".to_string() + ); - let decimal_resolution = DecimalResolution { - width: 1280, - height: 720, - }; - assert_eq!(decimal_resolution.to_string(), "1280x720".to_string()); + assert_eq!( + DecimalResolution::new(1280, 720).to_string(), + "1280x720".to_string() + ); } #[test] fn test_parse() { - let decimal_resolution = DecimalResolution { - width: 1920, - height: 1080, - }; assert_eq!( - decimal_resolution, + DecimalResolution::new(1920, 1080), "1920x1080".parse::<DecimalResolution>().unwrap() ); - let decimal_resolution = DecimalResolution { - width: 1280, - height: 720, - }; assert_eq!( - decimal_resolution, + DecimalResolution::new(1280, 720), "1280x720".parse::<DecimalResolution>().unwrap() ); + + assert!("1280".parse::<DecimalResolution>().is_err()); + } + + #[test] + fn test_width() { + assert_eq!(DecimalResolution::new(1920, 1080).width(), 1920); + assert_eq!(DecimalResolution::new(1920, 1080).set_width(12).width(), 12); + } + + #[test] + fn test_height() { + assert_eq!(DecimalResolution::new(1920, 1080).height(), 1080); + assert_eq!( + DecimalResolution::new(1920, 1080).set_height(12).height(), + 12 + ); } } diff --git a/src/types/mod.rs b/src/types/mod.rs index 1b1830d..90ff14c 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -10,7 +10,6 @@ mod hexadecimal_sequence; mod in_stream_id; mod initialization_vector; mod media_type; -mod playlist_type; mod protocol_version; mod session_data; mod signed_decimal_floating_point; @@ -27,7 +26,6 @@ pub use hexadecimal_sequence::*; pub use in_stream_id::*; pub use initialization_vector::*; pub use media_type::*; -pub use playlist_type::*; pub use protocol_version::*; pub use session_data::*; pub use signed_decimal_floating_point::*; diff --git a/src/types/playlist_type.rs b/src/types/playlist_type.rs index 29efa54..8b13789 100644 --- a/src/types/playlist_type.rs +++ b/src/types/playlist_type.rs @@ -1,37 +1 @@ -use std::fmt; -use std::str::FromStr; -use crate::Error; - -/// Playlist type. -/// -/// See: [4.3.3.5. EXT-X-PLAYLIST-TYPE] -/// -/// [4.3.3.5. EXT-X-PLAYLIST-TYPE]: https://tools.ietf.org/html/rfc8216#section-4.3.3.5 -#[allow(missing_docs)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PlaylistType { - Event, - Vod, -} - -impl fmt::Display for PlaylistType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - PlaylistType::Event => write!(f, "EVENT"), - PlaylistType::Vod => write!(f, "VOD"), - } - } -} - -impl FromStr for PlaylistType { - type Err = Error; - - fn from_str(input: &str) -> Result<Self, Self::Err> { - match input { - "EVENT" => Ok(PlaylistType::Event), - "VOD" => Ok(PlaylistType::Vod), - _ => Err(Error::custom(format!("Unknown playlist type: {:?}", input))), - } - } -} diff --git a/src/utils.rs b/src/utils.rs index 7f557ec..eeda232 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,6 @@ -use crate::{Error, Result}; +use crate::Error; -pub(crate) fn parse_yes_or_no<T: AsRef<str>>(s: T) -> Result<bool> { +pub(crate) fn parse_yes_or_no<T: AsRef<str>>(s: T) -> crate::Result<bool> { match s.as_ref() { "YES" => Ok(true), "NO" => Ok(false), @@ -8,7 +8,7 @@ pub(crate) fn parse_yes_or_no<T: AsRef<str>>(s: T) -> Result<bool> { } } -pub(crate) fn parse_u64<T: AsRef<str>>(s: T) -> Result<u64> { +pub(crate) fn parse_u64<T: AsRef<str>>(s: T) -> crate::Result<u64> { let n = s.as_ref().parse().map_err(Error::unknown)?; // TODO: Error::number Ok(n) } diff --git a/tests/playlist.rs b/tests/playlist.rs new file mode 100644 index 0000000..9b92ae5 --- /dev/null +++ b/tests/playlist.rs @@ -0,0 +1,47 @@ +//! Credits go to +//! - https://github.com/globocom/m3u8/blob/master/tests/playlists.py +use hls_m3u8::tags::*; +use hls_m3u8::types::*; +use hls_m3u8::MediaPlaylist; + +use std::time::Duration; + +#[test] +fn test_simple_playlist() { + let playlist = r#" + #EXTM3U + #EXT-X-TARGETDURATION:5220 + #EXTINF:0, + http://media.example.com/entire1.ts + #EXTINF:5220, + http://media.example.com/entire2.ts + #EXT-X-ENDLIST"#; + + let media_playlist = playlist.parse::<MediaPlaylist>().unwrap(); + assert_eq!( + media_playlist.target_duration_tag(), + ExtXTargetDuration::new(Duration::from_secs(5220)) + ); + + assert_eq!(media_playlist.segments().len(), 2); + + assert_eq!( + media_playlist.segments()[0].inf_tag(), + &ExtInf::new(Duration::from_secs(0)) + ); + + assert_eq!( + media_playlist.segments()[1].inf_tag(), + &ExtInf::new(Duration::from_secs(5220)) + ); + + assert_eq!( + media_playlist.segments()[0].uri(), + &SingleLineString::new("http://media.example.com/entire1.ts").unwrap() + ); + + assert_eq!( + media_playlist.segments()[1].uri(), + &SingleLineString::new("http://media.example.com/entire2.ts").unwrap() + ); +}