diff --git a/README.md b/README.md index 60a462e..a9aacf3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ assert_eq!(String::from_utf8(output).unwrap(), "\ "); ``` +## Features + +* `steering-manifest`: Enables support for serializing and deserializing steering manifests. + ## Roadmap This library is 100% finished and feature-complete as far as serializing tags goes, but I'd like to eventually implement a serializer for the higher level playlist representation, and also deserialization. diff --git a/src/lib.rs b/src/lib.rs index 2d90796..957576e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -440,7 +440,7 @@ pub struct DeltaUpdateInfo { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenditionReport { /// The URI for the `MediaPlaylist` of the specified rendition. - pub uri: Option, + pub uri: String, /// The media sequence number of the last `MediaSegment` currently /// in the specified Rendition. diff --git a/src/playlist.rs b/src/playlist.rs index c724058..d9d4a2b 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -14,6 +14,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::tags::Tag; +use std::{cmp::max, io}; + /// A playlist representing a list of renditions and variants of a given piece of media. pub struct MultivariantPlaylist { /// True if all media samples in a Media Segment can be decoded without information @@ -222,7 +225,7 @@ pub struct MediaPlaylist { /// A list of name value pairs where the name can be substituted for the /// value (e.g. `{$}`) in URI lines, quoted string attribute list /// values, and hexadecimal-sequence attribute values. - pub variables: Vec<(String, String)>, + pub variables: Vec, /// True if all media samples in a Media Segment can be decoded without information /// from other segments. @@ -275,7 +278,7 @@ pub struct MediaPlaylist { /// Information about `PartialSegments` in a given playlist. #[derive(Debug, Clone, PartialEq)] pub struct PartInformation { - /// If Some, indicates the server-recommended minimum distance from + /// Indicates the server-recommended minimum distance from /// the end of the Playlist at which clients should begin to play /// or to which they should seek when playing in Low-Latency Mode. pub part_hold_back_seconds: f64, @@ -283,7 +286,7 @@ pub struct PartInformation { /// An upper bound on the duration of all Partial Segments in the Playlist. /// The duration of each Media Segment in a Playlist file, when rounded /// to the nearest integer, MUST be less than or equal to the Target Duration. - pub part_target_duration: u64, + pub part_target_duration: f64, } /// Information about the playlist that is not associated with @@ -327,7 +330,8 @@ pub struct MediaSegment { pub duration_seconds: crate::FloatOrInteger, /// An optional human-readable informative title of the Media Segment. - pub title: Option, + /// Empty string for no title. + pub title: String, /// This may contain either a byte range or bitrate, but not both, because they are /// mutually exclusive @@ -411,200 +415,512 @@ pub struct StartOffset { pub is_precise: bool, } -// impl MediaPlaylist { -// /// Serializes the `MediaPlaylist` as a extended M3U playlist into `output`. -// /// Guaranteed to write valid UTF-8 only. -// /// -// /// This method makes lots of small calls to write on `output`. If the implementation -// /// of write on `output` makes a syscall, like with a `TcpStream`, you should wrap it -// /// in a [`std::io::BufWriter`]. -// /// -// /// # Errors -// /// -// /// May return `Err` when encountering an io error on `output`. -// pub fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { -// Tag::M3u.serialize(&mut output)?; -// todo!(); -// } -// } - -// #[cfg(test)] -// mod tests { -// use crate::{EncryptionMethod, FloatOrInteger, PreloadHint}; - -// use super::*; - -// #[test] -// fn serialize_media_playlist() { -// let mut output = Vec::new(); - -// let playlist = MediaPlaylist { -// segments: vec![ -// MediaSegment { -// uri: "https://example.com/1.mp4".into(), -// duration_seconds: FloatOrInteger::Float(5.045), -// title: Some("This is the first thingy!".into()), -// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), -// is_discontinuity: false, -// encryption: Some(EncryptionMethod::Aes128 { -// uri: "https://example.com/key.key".into(), -// iv: Some(0x0F91_DC05), -// key_format: crate::KeyFormat::Identity, -// key_format_versions: vec![1, 7, 6], -// }), -// media_initialization_section: Some(MediaInitializationSection { -// uri: "https://example.com/1.mp4".into(), -// range: Some(crate::ByteRangeWithOffset { -// length_bytes: 400, -// start_offset_bytes: 0, -// }), -// }), -// absolute_time: Some(SystemTime::now()), -// is_gap: false, -// parts: vec![ -// PartialSegment { -// uri: "https://example.com/1.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: true, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: None, -// }), -// is_gap: false, -// }, -// PartialSegment { -// uri: "https://example.com/1.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: false, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: Some(400), -// }), -// is_gap: false, -// }, -// ], -// }, -// MediaSegment { -// uri: "https://example.com/2.mp4".into(), -// duration_seconds: FloatOrInteger::Float(5.045), -// title: Some("This is the second thingy!".into()), -// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), -// is_discontinuity: false, -// encryption: Some(EncryptionMethod::Aes128 { -// uri: "https://example.com/key.key".into(), -// iv: Some(0x0F91_DC05), -// key_format: crate::KeyFormat::Identity, -// key_format_versions: vec![1, 7, 6], -// }), -// media_initialization_section: Some(MediaInitializationSection { -// uri: "https://example.com/1.mp4".into(), -// range: Some(crate::ByteRangeWithOffset { -// length_bytes: 400, -// start_offset_bytes: 0, -// }), -// }), -// absolute_time: None, -// is_gap: false, -// parts: vec![ -// PartialSegment { -// uri: "https://example.com/2.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: true, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: None, -// }), -// is_gap: false, -// }, -// PartialSegment { -// uri: "https://example.com/2.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: false, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: Some(400), -// }), -// is_gap: false, -// }, -// ], -// }, -// MediaSegment { -// uri: "https://example.com/3.mp4".into(), -// duration_seconds: FloatOrInteger::Float(5.045), -// title: Some("This is the third thingy!".into()), -// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(5000)), -// is_discontinuity: false, -// encryption: None, -// media_initialization_section: None, -// absolute_time: None, -// is_gap: false, -// parts: vec![ -// PartialSegment { -// uri: "https://example.com/3.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: true, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: None, -// }), -// is_gap: false, -// }, -// PartialSegment { -// uri: "https://example.com/3.mp4".into(), -// duration_in_seconds: 5.045 / 2.0, -// is_independent: false, -// byte_range: Some(crate::ByteRange { -// length_bytes: 400, -// start_offset_bytes: Some(400), -// }), -// is_gap: false, -// }, -// ], -// }, -// ], -// start_offset: Some(StartOffset { -// offset_in_seconds: 2.0, -// is_precise: false, -// }), -// variables: vec![("cool".into(), "foo".into())], -// is_independent_segments: false, -// target_duration: 5, -// first_media_sequence_number: 0, -// finished: false, -// playlist_type: Some(crate::PlaylistType::Event), -// hold_back_seconds: None, -// part_information: Some(PartInformation { -// part_hold_back_seconds: 3.0 * 3.0, -// part_target_duration: 3, -// }), -// iframes_only: false, -// playlist_delta_updates_information: Some(crate::DeltaUpdateInfo { -// skip_boundary_seconds: 3.0 * 6.0, -// can_skip_dateranges: true, -// }), -// supports_blocking_playlist_reloads: true, -// metadata: MediaMetadata { -// date_ranges: vec![], -// skip: None, -// preload_hints: vec![PreloadHint { -// hint_type: crate::PreloadHintType::Part, -// uri: "https://example.com/4.mp4".into(), -// start_byte_offset: 0, -// length_in_bytes: Some(400), -// }], -// rendition_reports: vec![crate::RenditionReport { -// uri: Some("https://example.com/different.m3u8".into()), -// last_sequence_number: None, -// last_part_index: None, -// }], -// }, -// }; - -// playlist.serialize(&mut output).unwrap(); - -// assert_eq!( -// String::from_utf8(output).unwrap(), -// "TODO" -// ); -// } -// } +impl MediaPlaylist { + /// Serializes the `MediaPlaylist` as a extended M3U playlist into `output`. + /// Guaranteed to write valid UTF-8 only. + /// + /// This method makes lots of small calls to write on `output`. If the implementation + /// of write on `output` makes a syscall, like with a `TcpStream`, you should wrap it + /// in a [`std::io::BufWriter`]. + /// + /// # Note + /// + /// This method is not guaranteed to write a valid M3U playlist. It's your job to create + /// valid input. + /// + /// # Errors + /// + /// May return `Err` when encountering an io error on `output`. + pub fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { + Tag::M3u.serialize(&mut output)?; + Tag::XVersion { + version: self.get_version(), + } + .serialize(&mut output)?; + + for variable in &self.variables { + Tag::XDefine(variable.clone()).serialize(&mut output)?; + } + if self.is_independent_segments { + Tag::XIndependentSegments.serialize(&mut output)?; + } + if let Some(offset) = &self.start_offset { + Tag::XStart { + offset_seconds: offset.offset_in_seconds, + is_precise: offset.is_precise, + } + .serialize(&mut output)?; + } + + Tag::XTargetDuration { + target_duration_seconds: self.target_duration, + } + .serialize(&mut output)?; + if self.first_media_sequence_number != 0 { + Tag::XMediaSequence { + sequence_number: self.first_media_sequence_number, + } + .serialize(&mut output)?; + } + if self.discontinuity_sequence_number != 0 { + Tag::XDiscontinuitySequence { + sequence_number: self.discontinuity_sequence_number, + } + .serialize(&mut output)?; + } + if self.finished { + Tag::XEndList.serialize(&mut output)?; + } + if let Some(playlist_type) = &self.playlist_type { + Tag::XPlaylistType(playlist_type.clone()).serialize(&mut output)?; + } + if self.iframes_only { + Tag::XIFramesOnly.serialize(&mut output)?; + } + if let Some(part_information) = &self.part_information { + Tag::XPartInf { + part_target_duration_seconds: part_information.part_target_duration, + } + .serialize(&mut output)?; + } + if self.playlist_delta_updates_information.is_some() + || self.hold_back_seconds.is_some() + || self.part_information.is_some() + || self.supports_blocking_playlist_reloads + { + Tag::XServerControl { + delta_update_info: self.playlist_delta_updates_information.clone(), + hold_back: self.hold_back_seconds, + part_hold_back: self + .part_information + .clone() + .map(|info| info.part_hold_back_seconds), + can_block_reload: self.supports_blocking_playlist_reloads, + } + .serialize(&mut output)?; + } + + self.metadata.serialize(&mut output)?; + + let mut last_media_segment = &MediaSegment { + uri: String::new(), + duration_seconds: crate::FloatOrInteger::Integer(0), + title: String::new(), + byte_range_or_bitrate: None, + is_discontinuity: false, + encryption: None, + media_initialization_section: None, + absolute_time: None, + is_gap: false, + parts: vec![], + }; + for segment in &self.segments { + segment.serialize(last_media_segment, &mut output)?; + last_media_segment = segment; + } + + Ok(()) + } + + fn get_version(&self) -> u8 { + let mut version = 1; + + let mut has_map = false; + for segment in &self.segments { + if let Some(method) = &segment.encryption { + if let crate::EncryptionMethod::Aes128 { iv, key_format, .. } = method { + if iv.is_some() { + version = max(version, 2); + } + + if let crate::KeyFormat::Other(_) = key_format { + version = 5; + } + } else if let crate::EncryptionMethod::SampleAes { .. } = method { + version = 5; + } + + let (crate::EncryptionMethod::Aes128 { + key_format_versions, + .. + } + | crate::EncryptionMethod::SampleAes { + key_format_versions, + .. + } + | crate::EncryptionMethod::SampleAesCtr { + key_format_versions, + .. + }) = method; + for key_version in key_format_versions { + if *key_version != 1 { + version = 5; + break; + } + } + } + + if let crate::FloatOrInteger::Float(_) = segment.duration_seconds { + version = max(version, 3); + } + + if let Some(ByteRangeOrBitrate::ByteRange(_)) = segment.byte_range_or_bitrate { + version = max(version, 4); + } + + if segment.media_initialization_section.is_some() { + has_map = true; + version = 5; + break; + } + } + + if self.iframes_only { + version = max(version, 4); + } else if has_map { + version = 6; + } + + // NOTE: Might be wrong? This is just checking whether we define any + // variables, not if we use variable substitution. And what if we use + // variable substitution, but define no variables? Should be a parse + // error anyways right? But maybe not in the lower versions? + if !self.variables.is_empty() { + version = 8; + } + + if let Some(skip_information) = &self.metadata.skip { + if skip_information.recently_removed_dataranges.is_empty() { + version = 9; + } else { + version = 10; + } + } + + for variable in &self.variables { + if let crate::DefinitionType::QueryParameter { .. } = variable { + version = 11; + } + } + + version + } +} + +impl MediaMetadata { + fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { + for date_range in &self.date_ranges { + Tag::XDateRange(date_range.clone()).serialize(&mut output)?; + } + + if let Some(skip) = &self.skip { + Tag::XSkip { + number_of_skipped_segments: skip.number_of_skipped_segments, + recently_removed_dataranges: skip.recently_removed_dataranges.clone(), + } + .serialize(&mut output)?; + } + + for hint in &self.preload_hints { + Tag::XPreloadHint(hint.clone()).serialize(&mut output)?; + } + + for report in &self.rendition_reports { + Tag::XRenditionReport(report.clone()).serialize(&mut output)?; + } + + Ok(()) + } +} + +impl MediaSegment { + fn serialize(&self, last_media_segment: &Self, mut output: impl io::Write) -> io::Result<()> { + if self.is_discontinuity { + Tag::XDiscontinuity.serialize(&mut output)?; + } + + Tag::Inf { + duration_seconds: self.duration_seconds.clone(), + title: self.title.clone(), + } + .serialize(&mut output)?; + + if let Some(byte_range_or_bitrate) = &self.byte_range_or_bitrate { + match byte_range_or_bitrate { + ByteRangeOrBitrate::ByteRange(byte_range) => { + Tag::XByterange(byte_range.clone()).serialize(&mut output)?; + } + ByteRangeOrBitrate::Bitrate(kbps) => { + if self.byte_range_or_bitrate != last_media_segment.byte_range_or_bitrate { + Tag::XBitrate { kbps: *kbps }.serialize(&mut output)?; + } + } + } + } + + if self.encryption != last_media_segment.encryption { + Tag::XKey(self.encryption.clone()).serialize(&mut output)?; + } + + if let Some(map) = &self.media_initialization_section { + if self.media_initialization_section != last_media_segment.media_initialization_section + { + Tag::XMap { + uri: map.uri.clone(), + range: map.range.clone(), + } + .serialize(&mut output)?; + } + } + + if let Some(time) = self.absolute_time { + Tag::XProgramDateTime(time).serialize(&mut output)?; + } + + if self.is_gap { + Tag::XGap.serialize(&mut output)?; + } + + for part in &self.parts { + Tag::XPart { + uri: part.uri.clone(), + duration_seconds: part.duration_in_seconds, + is_independent: part.is_independent, + byte_range: part.byte_range.clone(), + is_gap: part.is_gap, + } + .serialize(&mut output)?; + } + + writeln!(output, "{}", self.uri)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::{EncryptionMethod, FloatOrInteger, PreloadHint}; + + use super::*; + + #[test] + fn serialize_media_playlist() { + let mut output = Vec::new(); + + let playlist = MediaPlaylist { + segments: vec![ + MediaSegment { + uri: "https://example.com/1.mp4".into(), + duration_seconds: FloatOrInteger::Float(5.045), + title: "This is the first thingy!".into(), + byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), + is_discontinuity: false, + encryption: Some(EncryptionMethod::Aes128 { + uri: "https://example.com/key.key".into(), + iv: Some(0x0F91_DC05), + key_format: crate::KeyFormat::Identity, + key_format_versions: vec![1, 7, 6], + }), + media_initialization_section: Some(MediaInitializationSection { + uri: "https://example.com/1.mp4".into(), + range: Some(crate::ByteRangeWithOffset { + length_bytes: 400, + start_offset_bytes: 0, + }), + }), + absolute_time: Some( + chrono::DateTime::parse_from_rfc3339("2010-02-19T14:54:23.031+08:00") + .unwrap(), + ), + is_gap: false, + parts: vec![ + PartialSegment { + uri: "https://example.com/1.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: true, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: None, + }), + is_gap: false, + }, + PartialSegment { + uri: "https://example.com/1.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: false, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: Some(400), + }), + is_gap: false, + }, + ], + }, + MediaSegment { + uri: "https://example.com/2.mp4".into(), + duration_seconds: FloatOrInteger::Float(5.045), + title: "This is the second thingy!".into(), + byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), + is_discontinuity: false, + encryption: Some(EncryptionMethod::Aes128 { + uri: "https://example.com/key.key".into(), + iv: Some(0x0F91_DC05), + key_format: crate::KeyFormat::Identity, + key_format_versions: vec![1, 7, 6], + }), + media_initialization_section: Some(MediaInitializationSection { + uri: "https://example.com/1.mp4".into(), + range: Some(crate::ByteRangeWithOffset { + length_bytes: 400, + start_offset_bytes: 0, + }), + }), + absolute_time: None, + is_gap: false, + parts: vec![ + PartialSegment { + uri: "https://example.com/2.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: true, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: None, + }), + is_gap: false, + }, + PartialSegment { + uri: "https://example.com/2.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: false, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: Some(400), + }), + is_gap: false, + }, + ], + }, + MediaSegment { + uri: "https://example.com/3.mp4".into(), + duration_seconds: FloatOrInteger::Float(5.045), + title: String::new(), + byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(5000)), + is_discontinuity: false, + encryption: None, + media_initialization_section: None, + absolute_time: None, + is_gap: false, + parts: vec![ + PartialSegment { + uri: "https://example.com/3.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: true, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: None, + }), + is_gap: false, + }, + PartialSegment { + uri: "https://example.com/3.mp4".into(), + duration_in_seconds: 5.045 / 2.0, + is_independent: false, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: Some(400), + }), + is_gap: false, + }, + ], + }, + ], + start_offset: Some(StartOffset { + offset_in_seconds: 2.0, + is_precise: false, + }), + variables: vec![ + crate::DefinitionType::Inline { + name: "cool".into(), + value: "foo".into(), + }, + crate::DefinitionType::Import { + name: "not_cool".into(), + }, + crate::DefinitionType::QueryParameter { + name: "super_cool_actually".into(), + }, + ], + is_independent_segments: false, + target_duration: 5, + first_media_sequence_number: 0, + discontinuity_sequence_number: 12, + finished: false, + playlist_type: Some(crate::PlaylistType::Event), + hold_back_seconds: None, + part_information: Some(PartInformation { + part_hold_back_seconds: 3.0 * 3.0, + part_target_duration: 3.0, + }), + iframes_only: false, + playlist_delta_updates_information: Some(crate::DeltaUpdateInfo { + skip_boundary_seconds: 3.0 * 6.0, + can_skip_dateranges: true, + }), + supports_blocking_playlist_reloads: true, + metadata: MediaMetadata { + date_ranges: vec![], + skip: None, + preload_hints: vec![PreloadHint { + hint_type: crate::PreloadHintType::Part, + uri: "https://example.com/4.mp4".into(), + start_byte_offset: 0, + length_in_bytes: Some(400), + }], + rendition_reports: vec![crate::RenditionReport { + uri: "/different.m3u8".into(), + last_sequence_number: None, + last_part_index: None, + }], + }, + }; + + playlist.serialize(&mut output).unwrap(); + + assert_eq!( + String::from_utf8(output).unwrap(), + "#EXTM3U +#EXT-X-VERSION:11 +#EXT-X-DEFINE:NAME=\"cool\",VALUE=\"foo\" +#EXT-X-DEFINE:IMPORT=\"not_cool\" +#EXT-X-DEFINE:QUERYPARAM=\"super_cool_actually\" +#EXT-X-START:TIME-OFFSET=2 +#EXT-X-TARGETDURATION:5 +#EXT-X-DISCONTINUITY-SEQUENCE:12 +#EXT-X-PLAYLIST-TYPE:EVENT +#EXT-X-PART-INF:PART-TARGET=3 +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=18,CAN-SKIP-DATERANGES=YES,PART-HOLD-BACK=9,CAN-BLOCK-RELOAD=YES +#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"https://example.com/4.mp4\",BYTERANGE-LENGTH=400 +#EXT-X-RENDITION-REPORT:URI=\"/different.m3u8\" +#EXTINF:5.045,This is the first thingy! +#EXT-X-BITRATE:8000 +#EXT-X-KEY:METHOD=AES-128,URI=\"https://example.com/key.key\",IV=0xF91DC05,KEYFORMATVERSIONS=\"1/7/6\" +#EXT-X-MAP:URI=\"https://example.com/1.mp4\",BYTERANGE=\"400@0\" +#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00 +#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5225,INDEPENDENT=YES,BYTERANGE=\"400\" +#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5225,BYTERANGE=\"400@400\" +https://example.com/1.mp4 +#EXTINF:5.045,This is the second thingy! +#EXT-X-PART:URI=\"https://example.com/2.mp4\",DURATION=2.5225,INDEPENDENT=YES,BYTERANGE=\"400\" +#EXT-X-PART:URI=\"https://example.com/2.mp4\",DURATION=2.5225,BYTERANGE=\"400@400\" +https://example.com/2.mp4 +#EXTINF:5.045 +#EXT-X-BITRATE:5000 +#EXT-X-KEY:METHOD=NONE +#EXT-X-PART:URI=\"https://example.com/3.mp4\",DURATION=2.5225,INDEPENDENT=YES,BYTERANGE=\"400\" +#EXT-X-PART:URI=\"https://example.com/3.mp4\",DURATION=2.5225,BYTERANGE=\"400@400\" +https://example.com/3.mp4 +" + ); + } +} diff --git a/src/steering_manifest.rs b/src/steering_manifest.rs index 2fc759d..bcdb5d5 100644 --- a/src/steering_manifest.rs +++ b/src/steering_manifest.rs @@ -1,4 +1,8 @@ //! A representation of a HLS steering manifest. +//! +//! Content steering allows content producers to group redundant +//! variant streams into "pathways" and to dynamically prioritize +//! access to different pathways. // Copyright 2024 Logan Wemyss // diff --git a/src/tags/serialize.rs b/src/tags/serialize.rs index fd6cccf..1a62272 100644 --- a/src/tags/serialize.rs +++ b/src/tags/serialize.rs @@ -32,7 +32,7 @@ impl Tag { /// # Note /// /// This method is not guaranteed to write valid M3U tags. It's your job to create - /// valid cinput. + /// valid input. /// /// # Errors /// @@ -65,12 +65,17 @@ impl Tag { Self::Inf { duration_seconds, title, - } => match duration_seconds { - crate::FloatOrInteger::Float(float) => write!(output, "#EXTINF:{float},{title}")?, - crate::FloatOrInteger::Integer(integer) => { - write!(output, "#EXTINF:{integer},{title}")?; + } => { + match duration_seconds { + crate::FloatOrInteger::Float(float) => write!(output, "#EXTINF:{float}")?, + crate::FloatOrInteger::Integer(integer) => { + write!(output, "#EXTINF:{integer}")?; + } + }; + if !title.is_empty() { + write!(output, ",{title}")?; } - }, + } Self::XByterange(byte_range) => { write!(output, "#EXT-X-BYTERANGE:")?; byte_range.serialize(&mut output)?; @@ -88,8 +93,9 @@ impl Tag { Self::XMap { uri, range } => { write!(output, "#EXT-X-MAP:URI=\"{uri}\"")?; if let Some(range) = range { - write!(output, ",BYTERANGE=")?; + write!(output, ",BYTERANGE=\"")?; range.serialize(&mut output)?; + write!(output, "\"")?; } } Self::XProgramDateTime(time) => { @@ -508,31 +514,15 @@ impl Tag { mut output: impl io::Write, report: &RenditionReport, ) -> io::Result<()> { - let mut has_written_attribute = false; - write!(output, "#EXT-X-RENDITION-REPORT:")?; - - if let Some(uri) = &report.uri { - has_written_attribute = true; - - write!(output, "URI=\"{uri}\"")?; - } + let uri = &report.uri; + write!(output, "#EXT-X-RENDITION-REPORT:URI=\"{uri}\"")?; if let Some(last_sequence_number) = report.last_sequence_number { - if has_written_attribute { - write!(output, ",LAST-MSN={last_sequence_number}")?; - } else { - write!(output, "LAST-MSN={last_sequence_number}")?; - } - - has_written_attribute = true; + write!(output, ",LAST-MSN={last_sequence_number}")?; } if let Some(last_part_index) = report.last_part_index { - if has_written_attribute { - write!(output, ",LAST-PART={last_part_index}")?; - } else { - write!(output, "LAST-PART={last_part_index}")?; - } + write!(output, ",LAST-PART={last_part_index}")?; } Ok(()) @@ -704,8 +694,9 @@ impl Tag { } if let Some(byte_range) = byte_range { - write!(output, ",BYTERANGE=")?; + write!(output, ",BYTERANGE=\"")?; byte_range.serialize(&mut output)?; + write!(output, "\"")?; } if is_gap { @@ -973,7 +964,7 @@ mod tests { } .serialize(&mut output) .unwrap(); - assert_eq!(output, b"#EXTINF:5.34,\n"); + assert_eq!(output, b"#EXTINF:5.34\n"); output.clear(); Tag::Inf { @@ -1074,7 +1065,7 @@ mod tests { .unwrap(); assert_eq!( output, - b"#EXT-X-MAP:URI=\"https://example.com/0.mp4\",BYTERANGE=400@0\n" + b"#EXT-X-MAP:URI=\"https://example.com/0.mp4\",BYTERANGE=\"400@0\"\n" ); output.clear(); @@ -1123,7 +1114,7 @@ mod tests { } .serialize(&mut output) .unwrap(); - assert_eq!(output, b"#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5,INDEPENDENT=YES,BYTERANGE=400@0,GAP=YES\n"); + assert_eq!(output, b"#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5,INDEPENDENT=YES,BYTERANGE=\"400@0\",GAP=YES\n"); output.clear(); Tag::XPart { @@ -1140,7 +1131,7 @@ mod tests { .unwrap(); assert_eq!( output, - b"#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5,BYTERANGE=400\n" + b"#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5,BYTERANGE=\"400\"\n" ); } @@ -1197,7 +1188,7 @@ mod tests { #[rstest] fn serialize_x_rendition_report(mut output: Vec) { Tag::XRenditionReport(crate::RenditionReport { - uri: Some("/2.m3u8".into()), + uri: "/2.m3u8".into(), last_sequence_number: Some(420), last_part_index: Some(1), }) @@ -1210,13 +1201,13 @@ mod tests { output.clear(); Tag::XRenditionReport(crate::RenditionReport { - uri: None, + uri: "/2.m3u8".into(), last_sequence_number: None, last_part_index: None, }) .serialize(&mut output) .unwrap(); - assert_eq!(output, b"#EXT-X-RENDITION-REPORT:\n"); + assert_eq!(output, b"#EXT-X-RENDITION-REPORT:URI=\"/2.m3u8\"\n"); } #[rstest]