diff --git a/Cargo.toml b/Cargo.toml index 4d5799e..02cb205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ keywords = ["hls", "parse", "m3u", "extm3u"] categories = ["parser-implementations"] [dependencies] +chrono = "0.4.38" serde = { version = "1.0.201", optional = true } serde_json = { version = "1.0.117", optional = true } +[dev-dependencies] +rstest = "0.19.0" + [features] -default = ["steering-manifest"] steering-manifest = ["dep:serde", "dep:serde_json"] diff --git a/src/lib.rs b/src/lib.rs index 0bbf1bd..d819dc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ #![warn(clippy::pedantic, clippy::nursery, clippy::enum_glob_use)] #![allow(clippy::module_name_repetitions, clippy::too_many_lines)] -use std::{num::NonZeroU8, time::SystemTime}; +use std::{collections::HashMap, num::NonZeroU8, time::SystemTime}; pub mod playlist; pub mod tags; @@ -98,10 +98,18 @@ pub struct StreamInf { /// Represents the average segment bit rate of the Stream. pub average_bandwidth_bits_per_second: Option, + /// An abstract, relative measure of the playback quality-of-experience + /// of the Variant Stream. + pub score: Option, + /// A list of formats, where each format specifies a media sample type /// that is present in the Stream. pub codecs: Vec, + /// Describes media samples with both a backward-compatible base layer + /// and a newer enhancement layer. + pub supplemental_codecs: Vec, + /// Describes the optimal pixel resolution at which to display the /// video in the Stream. pub resolution: Option, @@ -110,14 +118,6 @@ pub struct StreamInf { /// output is protected by High-bandwidth Digital Content Protection. pub hdcp_level: Option, - /// An abstract, relative measure of the playback quality-of-experience - /// of the Variant Stream. - pub score: Option, - - /// Describes media samples with both a backward-compatible base layer - /// and a newer enhancement layer. - pub supplemental_codecs: Vec, - /// Indicates that the playback of the stream containing encrypted /// `MediaSegments` is to be restricted to devices that guarantee /// a certain level of content protection robustness. @@ -140,18 +140,9 @@ pub struct StreamInf { /// Describes media samples with both a backward-compatible base layer /// and a newer enhancement layer. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum SupplementalCodec { - CodecOnly { - /// The base layer codec. - supplemental_codec: String, - }, - WithCompatibilityBrands { - /// The base layer codec. - supplemental_codec: String, - - /// Compatibility brands that pertain to the `supplemental_codec`'s bitstream. - compatibility_brands: Vec, - }, +pub struct SupplementalCodec { + supplemental_codec: String, + compatibility_brands: Vec, } /// The High-bandwidth Digital Content Protection level. @@ -177,11 +168,11 @@ pub struct Resolution { /// Represents required content protection robustness for a given `key_format` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContentProtectionConfiguration { - key_format: String, + pub key_format: String, /// Classes of playback device that implements the `key_format` /// with a certain level of content protection robustness. - cpc_label: Vec, + pub cpc_label: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -203,10 +194,10 @@ pub enum VideoChannelSpecifier { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionData { /// Identifies a particular `SessionData`. - data_id: String, + pub data_id: String, /// The data value. - value: crate::SessionDataValue, + pub value: crate::SessionDataValue, } /// Whether the data is stored inline or identified by a URI. @@ -272,9 +263,6 @@ pub enum EncryptionMethod { /// A URI that specifies how to obtain the key. uri: String, - /// Specifies an initialization vector to be used with the key. - iv: Option, - /// Which versions of the `key_format` are this key is in compliance with. key_format_versions: Vec, }, @@ -291,57 +279,62 @@ pub enum KeyFormat { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContentSteering { /// The URI identifying the [`crate::steering_manifest::SteeringManifest`]. - server_uri: String, - pathway_id: String, + pub server_uri: String, + pub pathway_id: Option, } /// A duration of time with specific attributes. #[derive(Debug, Clone, PartialEq)] pub struct DateRange { /// Uniquely identifies the `DateRange` in a given Playlist. - id: String, + pub id: String, + + /// Identifies some set of attributes and their associated value semantics + /// for `client_attributes`. + pub class: Option, /// The time at which the `DateRange` begins. - start_date: SystemTime, + pub start_date: SystemTime, /// Indicates when to trigger an action associated with the `DateRange`. - cue: Option, + pub cue: Option, /// The time at which the `DateRange` ends. - end_date: Option, + pub end_date: Option, /// The duration of the `DateRange` in seconds. - duration_seconds: Option, + pub duration_seconds: Option, /// The duration that the `DateRange` is expected to be in seconds. - planned_duration_seconds: Option, + pub planned_duration_seconds: Option, - /// Various client defined attributes. - client_attributes: Option>, + /// Various client defined attributes. Keys are prefixed with `X-` and + /// unprefixed on serialization and deserialization respectively. + pub client_attributes: HashMap, /// Used to carry SCTE-35 data. - scte35_cmd: Option>, + pub scte35_cmd: Option>, /// Used to carry SCTE-35 data. - scte35_in: Option>, + pub scte35_in: Option>, /// Used to carry SCTE-35 data - scte35_out: Option>, + pub scte35_out: Option>, /// Indicates that the end of the `DateRange` is equal to the `start_date` /// of the range that is closest in time after this `DateRange` and has the same schema /// of `client_attributes`. - end_on_next: bool, + pub end_on_next: bool, } /// When to trigger an action associated with a given `DateRange`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DateRangeCue { /// Indicates that an action is to be triggered once and never again. - once: bool, + pub once: bool, /// The relative time at which the action is to be triggered. - position: DateRangeCuePosition, + pub position: DateRangeCuePosition, } /// The relative time at which a given `DateRange` action is to be triggered. @@ -370,19 +363,19 @@ pub enum AttributeValue { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreloadHint { /// Whether the resource is a `PartialSegment` or a `MediaInitializationSection`. - hint_type: PreloadHintType, + pub hint_type: PreloadHintType, /// The URI to the hinted resource. - uri: String, + pub uri: String, /// The byte offset of the first byte of the hinted resource, from /// the beginning of the resource identified by the URI. - start_byte_offset: u64, + pub start_byte_offset: u64, /// If Some, the value is the length of the hinted resource. /// If None, the last byte of the hinted resource is the last byte of the /// resource identified by the URI. - length_in_bytes: Option, + pub length_in_bytes: Option, } /// Whether a given resource is a `PartialSegment` or a `MediaInitializationSection`. @@ -428,11 +421,11 @@ pub enum PlaylistType { /// Information about the server's playlist delta update capabilities. #[derive(Debug, Clone, PartialEq)] pub struct DeltaUpdateInfo { - skip_boundary_seconds: f64, + pub skip_boundary_seconds: f64, /// if the Server can produce Playlist Delta Updates that skip /// older EXT-X-DATERANGE tags in addition to Media Segments. - can_skip_dateranges: bool, + pub can_skip_dateranges: bool, } // TODO: Can we fill in these fields when deserializing a playlist? @@ -452,3 +445,24 @@ pub struct RenditionReport { /// `last_sequence_number`. pub last_part_index: Option, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DefinitionType { + /// The variable is defined here. + Inline { name: String, value: String }, + + /// Use a variable defined in the Multivariant Playlist that referenced + /// this playlist. + Import { name: String }, + + /// Use the value of the query parameter named `name` from the current + /// playlist's URI. If the URI is redirected, look for the query + /// parameter in the 30x response URI. + QueryParameter { name: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FloatOrInteger { + Float(f64), + Integer(u64), +} diff --git a/src/playlist.rs b/src/playlist.rs index 61f4250..55c748a 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -14,13 +14,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::time::SystemTime; +use std::{io, time::SystemTime}; -/// Either a `MultivariantPlaylist` or `MediaPlaylist`. -pub enum Playlist { - Master(MultivariantPlaylist), - Media(MediaPlaylist), -} +use crate::tags::Tag; /// A playlist representing a list of renditions and variants of a given piece of media. pub struct MultivariantPlaylist { @@ -34,7 +30,7 @@ pub struct MultivariantPlaylist { /// 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, /// Groups of renditions that are all alternative renditions of the same content. pub renditions_groups: Vec, @@ -100,106 +96,106 @@ pub enum RenditionGroup { #[derive(Debug, Clone, PartialEq, Eq)] pub struct VideoRendition { /// Information about this rendition. - info: RenditionInfo, + pub info: RenditionInfo, /// The URI that identifies the Media Playlist file. - uri: Option, + pub uri: Option, } /// A audio rendition. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AudioRendition { /// The audio bit depth of the rendition. - bit_depth: Option, + pub bit_depth: Option, /// The audio sample rate of the rendition. - sample_rate: Option, + pub sample_rate: Option, /// Information about the audio channels in the rendition. - channels: Option, + pub channels: Option, /// Information about this rendition. info: RenditionInfo, /// The URI that identifies the Media Playlist file. - uri: Option, + pub uri: Option, } /// A subtitle rendition. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubtitleRendition { /// Information about this rendition. - info: RenditionInfo, + pub info: RenditionInfo, /// The URI that identifies the Media Playlist file. - uri: String, + pub uri: String, } /// A closed caption rendition. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClosedCaptionRendition { - in_stream_id: crate::InStreamId, + pub in_stream_id: crate::InStreamId, /// The priority in which this rendition should be chosen over another rendition. - priority: crate::RenditionPlaybackPriority, + pub priority: crate::RenditionPlaybackPriority, /// Information about this rendition. - info: RenditionInfo, + pub info: RenditionInfo, } /// Information about a given rendition. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenditionInfo { /// A RFC5646 tag which identifies the primary language used in the Rendition. - language: Option, + pub language: Option, /// A RFC5646 tag which identifies a language that is associated with the Rendition. - assoc_language: Option, + pub assoc_language: Option, /// A human-readable description of the Rendition. - name: String, + pub name: String, /// Indicates that the client may choose to play this Rendition in the absence /// of explicit user preference because it matches the current playback environment, /// such as chosen system language. - can_auto_select: bool, + pub can_auto_select: bool, /// Media Characteristic Tags that indicate individual characteristics of this Rendition. - characteristics: Vec, + pub characteristics: Vec, /// Allows the URI of a Rendition to change between two distinct downloads of /// the `MultivariantPlaylist`. - stable_rendition_id: Option, + pub stable_rendition_id: Option, } /// A set of Renditions that can be combined to play the presentation. #[derive(Debug, Clone, PartialEq)] pub struct VariantStream { /// Metadata for the stream. - stream_info: crate::StreamInf, + pub stream_info: crate::StreamInf, /// Describes the maximum frame rate for all the video in the /// `VariantStream`. - frame_rate: Option, + pub frame_rate: Option, /// The group id of the audio [`RenditionGroup`] that should be used when /// playing the presentation. - audio_group_id: Option, + pub audio_group_id: Option, /// The group id of the video [`RenditionGroup`] that should be used when /// playing the presentation. - video_group_id: Option, + pub video_group_id: Option, /// The group id of the subtitle [`RenditionGroup`] that should be used when /// playing the presentation. - subtitles_group_id: Option, + pub subtitles_group_id: Option, /// The group id of the closed caption [`RenditionGroup`] that should be used when /// playing the presentation. - closed_captions_group_id: Option, + pub closed_captions_group_id: Option, /// The `MediaPlaylist` that carries a Rendition of the Variant Stream. - uri: String, + pub uri: String, } /// Identifies a `MediaPlaylist` containing the I-frames of a multimedia @@ -207,14 +203,14 @@ pub struct VariantStream { #[derive(Debug, Clone, PartialEq)] pub struct IFrameStream { /// The metadata for this stream. - stream_info: crate::StreamInf, + pub stream_info: crate::StreamInf, /// The group id of the video [`RenditionGroup`] that should be used when /// playing the presentation. - video_group_id: Option, + pub video_group_id: Option, /// The URI that identifies the I-frame `MediaPlaylist` file. - uri: String, + pub uri: String, } /// A playlist representing a list of `MediaSegment`s and relevant information. @@ -242,11 +238,6 @@ pub struct MediaPlaylist { /// less than or equal to the Target Duration. pub target_duration: u64, - /// 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: Option, - /// The media sequence number of the first segment in [`MediaPlaylist::segments`]. pub first_media_sequence_number: u64, // pub discontinuity_sequence_number: todo!(), // TODO: What is this? @@ -263,11 +254,6 @@ pub struct MediaPlaylist { /// If None, this is set to `target_duration * 3`. pub hold_back_seconds: Option, - /// If Some, 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: Option, - /// True if each Media Segment in the Playlist describes a single I-frame. pub iframes_only: bool, @@ -277,94 +263,105 @@ pub struct MediaPlaylist { /// True if the server supports blocking playlist reloads. pub supports_blocking_playlist_reloads: bool, + /// Information about the `PartialSegments` in this playlist. + pub part_information: Option, + /// Information about the playlist that is not associated with /// specific Media Segments. pub metadata: MediaMetadata, } +/// Information about `PartialSegments` in a given playlist. +#[derive(Debug, Clone, PartialEq)] +pub struct PartInformation { + /// If Some, 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, + + /// 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, +} + /// Information about the playlist that is not associated with /// specific Media Segments. #[derive(Debug, Clone, PartialEq)] pub struct MediaMetadata { /// A duration of time with specific attributes. - date_ranges: Vec, + pub date_ranges: Vec, /// If Some, this indicates information about skipped `MediaSegments`. /// If None, there are no skipped `MediaSegments`. - skip: Option, + pub skip: Option, /// Hints that the client should request a resource before /// it is available to be delivered. - preload_hints: Vec, + pub preload_hints: Vec, /// Information about an associated Renditions that is as up-to-date as /// the Playlist that contains the report. - rendition_reports: Vec, + pub rendition_reports: Vec, } /// Information about skipped `MediaSegments`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkipInformation { /// The number of `MediaSegments` that have been skipped. - number_of_skipped_segments: u64, + pub number_of_skipped_segments: u64, /// The list of [`crate::DateRange`] IDs that have been removed /// from the Playlist recently. - recently_removed_dataranges: Vec, + pub recently_removed_dataranges: Vec, } /// A segment of the larger media file. #[derive(Debug, Clone, PartialEq)] pub struct MediaSegment { /// The URI Identifying the media resource. - uri: String, + pub uri: String, /// The duration of this `MediaSegment`. - duration_seconds: FloatOrInteger, + pub duration_seconds: crate::FloatOrInteger, /// An optional human-readable informative title of the Media Segment. - title: Option, + pub title: Option, /// This may contain either a byte range or bitrate, but not both, because they are /// mutually exclusive - byte_range_or_bitrate: Option, + pub byte_range_or_bitrate: Option, /// True if `MediaSegment` is a discontinuity between the Media Segment /// that follows it and the one that preceded it. - is_discontinuity: bool, + pub is_discontinuity: bool, /// If Some, represents the encryption method used for this `MediaSegment`. /// If None, no encryption is used. - encryption: Option, + pub encryption: Option, /// If Some, this `MediaSegment` requires a Media Initialization Section /// and the value describes how to acquire it. - media_initialization_section: Option, + pub media_initialization_section: Option, /// If Some, the first sample of the `MediaSegment` is associated with this /// time. - absolute_time: Option, + pub absolute_time: Option, /// If true, this `MediaSegment` does not contain media data /// and should not be loaded by clients. - is_gap: bool, + pub is_gap: bool, /// The partial segments for this `MediaSegment`. - parts: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum FloatOrInteger { - Float(f64), - Integer(u64), + pub parts: Vec, } /// A common sequence of bytes to initialize the parser before /// `MediaSegments` can be parsed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MediaInitializationSection { - uri: String, - range: Option, + pub uri: String, + pub range: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -373,7 +370,8 @@ pub enum ByteRangeOrBitrate { /// identified by its URI. ByteRange(crate::ByteRange), - /// The approximate segment bit rate of this `MediaSegment`. + /// The approximate segment bit rate of this `MediaSegment` + /// in kbps. Bitrate(u64), } @@ -381,19 +379,19 @@ pub enum ByteRangeOrBitrate { #[derive(Debug, Clone, PartialEq)] pub struct PartialSegment { /// The URI for this `PartialSegment`. - uri: String, + pub uri: String, /// The duration of this `PartialSegment`. - duration_in_seconds: f64, + pub duration_in_seconds: f64, /// True if this `PartialSegment` contains an independent frame. - is_independent: bool, + pub is_independent: bool, - /// True if this `PartialSegment` is a sub-range of the resource specified by the URI. - byte_range: Option, + /// Some if this `PartialSegment` is a sub-range of the resource specified by the URI. + pub byte_range: Option, /// True if this `PartialSegment` is not available. - is_gap: bool, + pub is_gap: bool, } /// A preferred point at which to start playing a Playlist. @@ -411,3 +409,202 @@ pub struct StartOffset { /// every media sample in that segment. 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(), + "#EXTM3U +#EXT-X-VERSION:todo" + ); + } +} diff --git a/src/steering_manifest.rs b/src/steering_manifest.rs index 5d0432e..2fc759d 100644 --- a/src/steering_manifest.rs +++ b/src/steering_manifest.rs @@ -28,30 +28,30 @@ use serde::Serialize; pub struct SteeringManifest { /// Specifies how many seconds the client must wait before /// reloading the Steering Manifest. - ttl_seconds: u64, + pub ttl_seconds: u64, /// Specifies the URI the client must use the /// next time it obtains the Steering Manifest. - reload_uri: Option, + pub reload_uri: Option, /// A list of pathway IDs order to most preferred to least preferred. - pathway_priority: HashSet, + pub pathway_priority: HashSet, /// A list of novel pathways made by cloning existing ones. - pathway_clones: Vec, + pub pathway_clones: Vec, } /// A way to introduce novel Pathways by cloning existing ones. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathwayClone { /// The ID of the base pathway, which this clone is based on. - base_id: String, + pub base_id: String, /// The ID of this new pathway. - id: String, + pub id: String, /// URI Replacement rules. - uri_replacement: UriReplacement, + pub uri_replacement: UriReplacement, } /// URI replacement rules. @@ -59,27 +59,28 @@ pub struct PathwayClone { pub struct UriReplacement { /// If Some, replace the hostname of every rendition URI /// in the new pathway. - host: Option, + pub host: Option, /// URI params to append to every rendition URI in the new /// pathway. - query_parameters: Option>, + pub query_parameters: Option>, /// If the `stable_variant_id` of a `VariantStream` on the new /// pathway appears in the map, set its URI to be the entry's value. - per_variant_uris: Option>, + pub per_variant_uris: Option>, /// Key value pairs. If the `stable_rendition_id` of a rendition referred to by a /// `VariantStream` on the new pathway appears in the map, set /// its URI to be the entry's value. - per_rendition_uris: Option>, + pub per_rendition_uris: Option>, } -// TODO: Check invariants impl SteeringManifest { /// Serializes the manifest into it's json representation. /// Guaranteed to write valid UTF-8 only. /// + /// This does not percent encode [`UriReplacement::query_parameters`]. + /// /// # Errors /// /// May return `Err` when encountering an io error on `output`. @@ -92,20 +93,12 @@ impl SteeringManifest { /// /// * [`SteeringManifest::pathway_priority`] must be non-empty. /// - /// * [`SteeringManifest::pathway_priority`] must only contain items that contain - /// characters from the set [a..z], [A..Z], [0..9], '.', '-', and '_', aka - /// none of the items contain characters not contained in - /// [`SteeringManifest::PATHWAY_ID_ALLOWED_CHARACTERS`]. - /// /// * [`UriReplacement::host`], if `Some`, must be non-empty. /// /// * [`UriReplacement::query_parameters`] must not contain a key which is empty. pub fn serialize(&self, output: impl io::Write) -> Result<(), serde_json::Error> { serde_json::to_writer(output, self) } - - pub const PATHWAY_ID_ALLOWED_CHARACTERS: &'static str = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"; } impl Serialize for SteeringManifest { @@ -132,14 +125,6 @@ impl Serialize for SteeringManifest { !self.pathway_priority.is_empty(), "Found an empty pathway priority list while serializing." ); - for id in &self.pathway_priority { - if !id - .chars() - .all(|x| Self::PATHWAY_ID_ALLOWED_CHARACTERS.contains(x)) - { - panic!("Found a pathway ID that contains disallowed characters while serializing.") - } - } manifest.serialize_field("PATHWAY-PRIORITY", &self.pathway_priority)?; if self.pathway_clones.is_empty() { diff --git a/src/tags.rs b/src/tags.rs index 886ca53..9133366 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -14,16 +14,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod serialize; + use std::time::SystemTime; /// A representation of all possible tags. #[derive(Debug, Clone, PartialEq)] pub enum Tag { - MediaPlaylistTag(MediaPlaylistTag), - MediaSegmentTag(MediaSegmentTag), - MediaMetadataTag(MediaMetadataTag), - MultivariantPlaylistTag(MultivariantPlaylistTag), - /// The EXT-X-VERSION tag indicates the compatibility version of the /// Playlist file, its associated media, and its server. XVersion { @@ -35,7 +32,7 @@ pub enum Tag { /// The EXT-X-DEFINE tag provides a Playlist variable definition or /// declaration. - XDefine(DefinitionType), + XDefine(crate::DefinitionType), /// The EXT-X-START tag indicates a preferred point at which to start /// playing a Playlist. @@ -48,30 +45,11 @@ pub enum Tag { /// in a Media Segment can be decoded without information from other /// segments. XIndependentSegments, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DefinitionType { - /// The variable is defined here. - Inline { name: String, value: String }, - - /// Use a variable defined in the Multivariant Playlist that referenced - /// this playlist. - Import { name: String }, - - /// Use the value of the query parameter named `name` from the current - /// playlist's URI. If the URI is redirected, look for the query - /// parameter in the 30x response URI. - QueryParameter { name: String }, -} -/// A tag applying to a `MediaSegment` -#[derive(Debug, Clone, PartialEq)] -pub enum MediaSegmentTag { /// The EXTINF tag specifies the duration of a Media Segment. Inf { - duration_seconds: f64, - title: Option, + duration_seconds: crate::FloatOrInteger, + title: String, }, /// The EXT-X-BYTERANGE tag indicates that a Media Segment is a sub-range @@ -113,25 +91,24 @@ pub enum MediaSegmentTag { byte_range: Option, is_gap: bool, }, -} - -/// Media Playlist tags describe global parameters of the Media Playlist. -/// There MUST NOT be more than one Media Playlist tag of each type in -/// any Media Playlist. -#[derive(Debug, Clone, PartialEq)] -pub enum MediaPlaylistTag { /// The EXT-X-TARGETDURATION tag specifies the maximum Media Segment /// duration. - XTargetDuration { target_duration_seconds: u64 }, + XTargetDuration { + target_duration_seconds: u64, + }, /// The EXT-X-MEDIA-SEQUENCE tag indicates the Media Sequence Number of /// the first Media Segment that appears in a Playlist file. - XMediaSequence { sequence_number: u64 }, + XMediaSequence { + sequence_number: u64, + }, /// The EXT-X-DISCONTINUITY-SEQUENCE tag allows synchronization between /// different Renditions of the same Variant Stream or different Variant /// Streams that have EXT-X-DISCONTINUITY tags in their Media Playlists. - XDiscontinuitySequence { sequence_number: u64 }, + XDiscontinuitySequence { + sequence_number: u64, + }, /// The EXT-X-ENDLIST tag indicates that no more Media Segments will be /// added to the Media Playlist file. @@ -147,7 +124,9 @@ pub enum MediaPlaylistTag { /// The EXT-X-PART-INF tag provides information about the Partial /// Segments in the Playlist. - XPartInf { part_target_duration_seconds: f64 }, + XPartInf { + part_target_duration_seconds: f64, + }, /// The EXT-X-SERVER-CONTROL tag allows the Server to indicate support /// for Delivery Directives. @@ -157,12 +136,7 @@ pub enum MediaPlaylistTag { part_hold_back: Option, can_block_reload: bool, }, -} -/// Multivariant Playlist tags define the variant streams, renditions, and -/// other global parameters of the presentation. -#[derive(Debug, Clone, PartialEq)] -pub enum MultivariantPlaylistTag { /// The EXT-X-MEDIA tag is used to relate Media Playlists that contain /// alternative Renditions of the same content. XMedia { @@ -173,7 +147,7 @@ pub enum MultivariantPlaylistTag { name: String, stable_rendition_id: Option, playback_priority: crate::RenditionPlaybackPriority, - characteristics: Option>, + characteristics: Vec, }, /// The EXT-X-STREAM-INF tag specifies a Variant Stream, which is a set @@ -207,31 +181,7 @@ pub enum MultivariantPlaylistTag { /// The EXT-X-CONTENT-STEERING tag allows a server to provide a Content /// Steering (Section 7) Manifest. XContentSteering(crate::ContentSteering), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MediaType { - Audio { - uri: Option, - channels: Option, - bit_depth: Option, - sample_rate: Option, - }, - Video { - uri: Option, - }, - Subtitles { - uri: String, - forced: bool, - }, - ClosedCaptions { - in_stream_id: crate::InStreamId, - }, -} -/// A tag describing metadata about a given `MediaPlaylist`. -#[derive(Debug, Clone, PartialEq)] -pub enum MediaMetadataTag { /// 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. @@ -255,8 +205,22 @@ pub enum MediaMetadataTag { XRenditionReport(crate::RenditionReport), } -// impl Tag { -// pub fn serialize(&self, output: impl Write) { -// todo!() -// } -// } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MediaType { + Audio { + uri: Option, + channels: Option, + bit_depth: Option, + sample_rate: Option, + }, + Video { + uri: Option, + }, + Subtitles { + uri: String, + forced: bool, + }, + ClosedCaptions { + in_stream_id: crate::InStreamId, + }, +} diff --git a/src/tags/serialize.rs b/src/tags/serialize.rs new file mode 100644 index 0000000..f4c639f --- /dev/null +++ b/src/tags/serialize.rs @@ -0,0 +1,998 @@ +use std::io; + +use super::Tag; + +impl Tag { + /// Serializes the `Tag` as a extended M3U playlist tag 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<()> { + match self { + Self::XVersion { version } => write!(output, "#EXT-X-VERSION:{version}")?, + Self::M3u => output.write_all(b"#EXTM3U")?, + Self::XDefine(_) => todo!(), + Self::XStart { + offset_seconds, + is_precise, + } => { + write!(output, "#EXT-X-START:TIME-OFFSET={offset_seconds}")?; + if *is_precise { + write!(output, ",PRECISE=YES")?; + } + } + Self::XIndependentSegments => output.write_all(b"#EXT-X-INDEPENDENT-SEGMENTS")?, + Self::Inf { + duration_seconds, + title, + } => todo!(), + Self::XByterange(_) => todo!(), + Self::XDiscontinuity => todo!(), + Self::XKey(_) => todo!(), + Self::XMap { uri, range } => todo!(), + Self::XProgramDateTime(_) => todo!(), + Self::XGap => todo!(), + Self::XBitrate { kbps } => todo!(), + Self::XPart { + uri, + duration_seconds, + is_independent, + byte_range, + is_gap, + } => todo!(), + Self::XTargetDuration { + target_duration_seconds, + } => todo!(), + Self::XMediaSequence { sequence_number } => todo!(), + Self::XDiscontinuitySequence { sequence_number } => todo!(), + Self::XEndList => todo!(), + Self::XPlaylistType(_) => todo!(), + Self::XIFramesOnly => todo!(), + Self::XPartInf { + part_target_duration_seconds, + } => todo!(), + Self::XServerControl { + delta_update_info, + hold_back, + part_hold_back, + can_block_reload, + } => todo!(), + Self::XMedia { + media_type, + group_id, + language, + assoc_language, + name, + stable_rendition_id, + playback_priority, + characteristics, + } => todo!(), + Self::XStreamInf { + stream_inf, + frame_rate, + audio_group_id, + video_group_id, + subtitles_group_id, + closed_captions_group_id, + uri, + } => todo!(), + Self::XIFrameStreamInf { + stream_inf, + video_group_id, + uri, + } => todo!(), + Self::XSessionData(_) => todo!(), + Self::XSessionKey(_) => todo!(), + Self::XContentSteering(_) => todo!(), + Self::XDateRange(_) => todo!(), + Self::XSkip { + number_of_skipped_segments, + recently_removed_dataranges, + } => todo!(), + Self::XPreloadHint(_) => todo!(), + Self::XRenditionReport(_) => todo!(), + }; + + output.write_all(b"\n")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, time::Duration}; + + use rstest::*; + + use crate::{ + tags::MediaType, AttributeValue, ContentProtectionConfiguration, EncryptionMethod, + RenditionPlaybackPriority, SupplementalCodec, VideoChannelSpecifier, + }; + + use super::*; + + #[fixture] + pub fn output() -> Vec { + Vec::new() + } + + #[rstest] + fn serialize_m3u(mut output: Vec) { + Tag::M3u.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXTM3U\n"); + } + + #[rstest] + fn serialize_x_version(mut output: Vec) { + Tag::XVersion { version: 12 } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-VERSION:12\n"); + } + + #[rstest] + fn serialize_x_independent_segments(mut output: Vec) { + Tag::XIndependentSegments.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-INDEPENDENT-SEGMENTS\n"); + } + + #[rstest] + fn serialize_x_start(mut output: Vec) { + Tag::XStart { + offset_seconds: -84.0, + is_precise: false, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-START:TIME-OFFSET=-84\n"); + + output.clear(); + Tag::XStart { + offset_seconds: 5.0053, + is_precise: true, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-START:TIME-OFFSET=5.0053,PRECISE=YES\n"); + } + + #[rstest] + fn serialize_x_define(mut output: Vec) { + Tag::XDefine(crate::DefinitionType::Inline { + name: "cool-param_A0".into(), + value: "I am so cool".into(), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-DEFINE:NAME=\"cool-param_A0\",VALUE=\"I am so cool\"\n" + ); + + output.clear(); + Tag::XDefine(crate::DefinitionType::Import { + name: "foobar-_A0".into(), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-DEFINE:IMPORT=\"foobar-_A0\"\n"); + + output.clear(); + Tag::XDefine(crate::DefinitionType::QueryParameter { + name: "bAz-_42".into(), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-DEFINE:QUERYPARAM=\"bAz-_42\"\n"); + } + + #[allow(clippy::unreadable_literal)] + #[rstest] + fn serialize_x_target_duration(mut output: Vec) { + Tag::XTargetDuration { + target_duration_seconds: 6942042, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-TARGETDURATION:6942042\n"); + } + + #[rstest] + fn serialize_x_media_sequence(mut output: Vec) { + Tag::XMediaSequence { + sequence_number: 42, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA-SEQUENCE:42\n"); + } + + #[rstest] + fn serialize_x_discontinuity_sequence(mut output: Vec) { + Tag::XDiscontinuitySequence { + sequence_number: 420, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-DISCONTINUITY-SEQUENCE:420\n"); + } + + #[rstest] + fn serialize_x_endlist(mut output: Vec) { + Tag::XEndList.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-ENDLIST\n"); + } + + #[rstest] + fn serialize_x_playlist_type(mut output: Vec) { + Tag::XPlaylistType(crate::PlaylistType::Event) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-PLAYLIST-TYPE:EVENT\n"); + + output.clear(); + Tag::XPlaylistType(crate::PlaylistType::Vod) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-PLAYLIST-TYPE:VOD\n"); + } + + #[rstest] + fn serialize_x_i_frames_only(mut output: Vec) { + Tag::XIFramesOnly.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-I-FRAMES-ONLY\n"); + } + + #[rstest] + fn serialize_x_part_inf(mut output: Vec) { + Tag::XPartInf { + part_target_duration_seconds: 2.5, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-PART-INF:PART-TARGET=2.5\n"); + } + + #[rstest] + fn serialize_x_server_control(mut output: Vec) { + Tag::XServerControl { + delta_update_info: Some(crate::DeltaUpdateInfo { + skip_boundary_seconds: 20.873, + can_skip_dateranges: true, + }), + hold_back: Some(10.0), + part_hold_back: Some(10.285), + can_block_reload: true, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=20.873,CAN-SKIP-DATERANGES=YES,HOLD-BACK=10,PART-HOLD-BACK=10.285,CAN-BLOCK-RELOAD=YES\n"); + + output.clear(); + Tag::XServerControl { + delta_update_info: Some(crate::DeltaUpdateInfo { + skip_boundary_seconds: 20.873, + can_skip_dateranges: false, + }), + hold_back: Some(10.0), + part_hold_back: Some(10.285), + can_block_reload: true, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=20.873,HOLD-BACK=10,PART-HOLD-BACK=10.285,CAN-BLOCK-RELOAD=YES\n"); + + output.clear(); + Tag::XServerControl { + delta_update_info: None, + hold_back: None, + part_hold_back: None, + can_block_reload: false, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SERVER-CONTROL:\n"); + } + + #[rstest] + fn serialize_inf(mut output: Vec) { + Tag::Inf { + duration_seconds: crate::FloatOrInteger::Float(5.340), + title: String::new(), + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXTINF:5.34,\n"); + + output.clear(); + Tag::Inf { + duration_seconds: crate::FloatOrInteger::Integer(5), + title: "super cool title".into(), + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXTINF:5,super cool title\n"); + } + + #[rstest] + fn serialize_x_byterange(mut output: Vec) { + Tag::XByterange(crate::ByteRange { + length_bytes: 1200, + start_offset_bytes: None, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-BYTERANGE:1200\n"); + + output.clear(); + Tag::XByterange(crate::ByteRange { + length_bytes: 1200, + start_offset_bytes: Some(158), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-BYTERANGE:1200@158\n"); + } + + #[rstest] + fn serialize_x_discontinuity(mut output: Vec) { + Tag::XDiscontinuity.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-DISCONTINUITY\n"); + } + + #[rstest] + fn serialize_x_key(mut output: Vec) { + Tag::XKey(None).serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-KEY:METHOD=NONE\n"); + + output.clear(); + Tag::XKey(Some(EncryptionMethod::Aes128 { + uri: "https://example.com/foo.key".into(), + iv: Some(0x0F91_DC05), + key_format: crate::KeyFormat::Other("super cool key format".into()), + key_format_versions: vec![1, 16], + })) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-KEY:METHOD=AES-128,URI=\"https://example.com/foo.key\",IV=0x0F91DC05,KEYFORMAT=\"super cool key format\",KEYFORMATVERSIONS=\"1/16\"\n"); + + output.clear(); + Tag::XKey(Some(EncryptionMethod::Aes128 { + uri: "https://example.com/foo.key".into(), + iv: None, + key_format: crate::KeyFormat::Identity, + key_format_versions: vec![], + })) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-KEY:METHOD=AES-128,URI=\"https://example.com/foo.key\"\n" + ); + + output.clear(); + Tag::XKey(Some(EncryptionMethod::SampleAes { + uri: "https://example.com/foo.key".into(), + iv: Some(0x0F91_DC05), + key_format_versions: vec![1, 16], + })) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://example.com/foo.key\",IV=0x0F91DC05,KEYFORMATVERSIONS=\"1/16\"\n"); + + output.clear(); + Tag::XKey(Some(EncryptionMethod::SampleAesCtr { + uri: "https://example.com/foo.key".into(), + key_format_versions: vec![1, 16], + })) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI=\"https://example.com/foo.key\",KEYFORMATVERSIONS=\"1/16\"\n"); + } + + #[rstest] + fn serialize_x_map(mut output: Vec) { + Tag::XMap { + uri: "https://example.com/0.mp4".into(), + range: Some(crate::ByteRangeWithOffset { + length_bytes: 400, + start_offset_bytes: 0, + }), + } + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-MAP:URI=\"https://example.com/0.mp4\",BYTERANGE=400@0\n" + ); + + output.clear(); + Tag::XMap { + uri: "https://example.com/0.mp4".into(), + range: None, + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-MAP:URI=\"https://example.com/0.mp4\"\n"); + } + + #[rstest] + fn serialize_x_program_date_time(mut output: Vec) { + let time = chrono::DateTime::parse_from_rfc3339("2010-02-19T14:54:23.031+08:00").unwrap(); + Tag::XProgramDateTime(time.into()) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00\n" + ); + } + + #[rstest] + fn serialize_x_gap(mut output: Vec) { + Tag::XGap.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-GAP\n"); + } + + #[rstest] + fn serialize_x_bitrate(mut output: Vec) { + Tag::XBitrate { kbps: 8000 }.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-BITRATE:8000\n"); + } + + #[rstest] + fn serialize_x_part(mut output: Vec) { + Tag::XPart { + uri: "https://example.com/1.mp4".into(), + duration_seconds: 2.5, + is_independent: true, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: Some(0), + }), + is_gap: true, + } + .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"); + + output.clear(); + Tag::XPart { + uri: "https://example.com/1.mp4".into(), + duration_seconds: 2.5, + is_independent: false, + byte_range: Some(crate::ByteRange { + length_bytes: 400, + start_offset_bytes: None, + }), + is_gap: false, + } + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-PART:URI=\"https://example.com/1.mp4\",DURATION=2.5,BYTERANGE=400\n" + ); + } + + #[rstest] + fn serialize_x_daterange(mut output: Vec) { + let time = chrono::DateTime::parse_from_rfc3339("2010-02-19T14:54:23.031+08:00") + .unwrap() + .into(); + Tag::XDateRange(crate::DateRange { + id: "This is my favorite data range".into(), + class: Some("private.cool.example".into()), + start_date: time, + cue: Some(crate::DateRangeCue { + once: true, + position: crate::DateRangeCuePosition::Post, + }), + end_date: Some(time + Duration::from_millis(500)), + duration_seconds: Some(0.5), + planned_duration_seconds: Some(0.52318), + client_attributes: HashMap::from([ + ( + "EXAMPLE-STRING".into(), + AttributeValue::String("I wonder what this does!".into()), + ), + ( + "EXAMPLE-BYTES".into(), + AttributeValue::Bytes(vec![0x63, 0x8, 0x8F]), + ), + ("EXAMPLE-FLOAT".into(), AttributeValue::Float(0.42)), + ]), + scte35_cmd: Some(vec![0x98, 0xA9, 0x1A, 0xFB, 0x81, 0x20, 0x5]), + scte35_in: Some(vec![0x98, 0xA2, 0x72, 0x4C, 0x20, 0x5]), + scte35_out: Some(vec![0x0]), + end_on_next: true, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-DATERANGE:ID=\"This is my favorite data range\",CLASS=\"private.cool.example\",START-DATE=\"2010-02-19T14:54:23.031+08:00\",CUE=\"ONCE,POST\",END-DATE=\"TODO\",DURATION=0.5,PLANNED-DURATION=0.52318,X-EXAMPLE-STRING=\"I wonder what this does!\",X-EXAMPLE-BYTES=0x63088F,X-EXAMPLE-FLOAT=0.42,SCTE35-CMD=0x98A91AFB812005,SCTE35-OUT=0x0,SCTE35-IN=0x98A2724C2005,END-ON-NEXT=YES\n" + ); + + output.clear(); + Tag::XDateRange(crate::DateRange { + id: "This is my favorite data range".into(), + class: None, + start_date: time, + cue: None, + end_date: None, + duration_seconds: Some(0.5), + planned_duration_seconds: None, + client_attributes: HashMap::new(), + scte35_cmd: None, + scte35_in: None, + scte35_out: None, + end_on_next: false, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-DATERANGE:ID=\"This is my favorite data range\",START-DATE=\"2010-02-19T14:54:23.031+08:00\",DURATION=0.5\n" + ); + } + + #[rstest] + fn serialize_x_skip(mut output: Vec) { + Tag::XSkip { + number_of_skipped_segments: 42, + recently_removed_dataranges: vec![ + "This is my favorite data range".into(), + "I hate this one though".into(), + ], + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SKIP:SKIPPED-SEGMENTS=42,RECENTLY-REMOVED-DATERANGES=\"This is my favorite data range\tI hate this one though\"\n"); + + output.clear(); + Tag::XSkip { + number_of_skipped_segments: 68, + recently_removed_dataranges: vec![], + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SKIP:SKIPPED-SEGMENTS=42\n"); + } + + #[rstest] + fn serialize_x_preload_hint(mut output: Vec) { + Tag::XPreloadHint(crate::PreloadHint { + hint_type: crate::PreloadHintType::Part, + uri: "https://example.com/1.mp4".into(), + start_byte_offset: 400, + length_in_bytes: Some(400), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"https://example.com/1.mp4\",BYTERANGE-START=400,BYTERANGE-LENGTH=400\n"); + + output.clear(); + Tag::XPreloadHint(crate::PreloadHint { + hint_type: crate::PreloadHintType::Map, + uri: "https://example.com/0.mp4".into(), + start_byte_offset: 0, + length_in_bytes: None, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-PRELOAD-HINT:TYPE=MAP,URI=\"https://example.com/0.mp4\"\n" + ); + } + + #[rstest] + fn serialize_x_rendition_report(mut output: Vec) { + Tag::XRenditionReport(crate::RenditionReport { + uri: Some("/2.m3u8".into()), + last_sequence_number: Some(420), + last_part_index: Some(1), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-RENDITION-REPORT:URI=\"/2.m3u8\",LAST-MSN=420,LAST-PART=1\n" + ); + + output.clear(); + Tag::XRenditionReport(crate::RenditionReport { + uri: None, + last_sequence_number: None, + last_part_index: None, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-RENDITION-REPORT:\n"); + } + + #[rstest] + fn serialize_x_media(mut output: Vec) { + let mut tag = Tag::XMedia { + media_type: MediaType::Audio { + uri: Some("https://example.com/1.m3u8".into()), + channels: Some( + crate::AudioChannelInformation::WithSpecialUsageIdentifiers { + number_of_channels: 2, + audio_coding_identifiers: vec!["idk".into(), "This is kinda weird".into()], + binaural: true, + immersive: true, + downmix: true, + }, + ), + bit_depth: Some(16), + sample_rate: Some(40000), + }, + group_id: "really cool group".into(), + language: Some("en-US".into()), + assoc_language: Some("de".into()), + name: "english audio".into(), + stable_rendition_id: Some("azBY09+/=.-_".into()), + playback_priority: crate::RenditionPlaybackPriority::Default, + characteristics: vec![ + "public.accessibility.describes-video".into(), + "private.cool.example".into(), + ], + }; + tag.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"https://example.com/1.m3u8\",GROUP-ID=\"really cool group\",LANGUAGE=\"en-US\",ASSOC-LANGUAGE=\"de\",NAME=\"english audio\",STABLE-RENDITION-ID=\"azBY09+/=.-_\",DEFAULT=YES,AUTOSELECT=YES,BIT-DEPTH=16,SAMPLE-RATE=40000,CHARACTERISTICS=\"public.accessibility.describes-video,private.cool.example\",CHANNELS=\"2/idk,This is kinda weird/BINAURAL,IMMERSIVE,DOWNMIX\"\n"); + + output.clear(); + if let Tag::XMedia { + media_type: MediaType::Audio { channels, .. }, + .. + } = &mut tag + { + *channels = Some( + crate::AudioChannelInformation::WithSpecialUsageIdentifiers { + number_of_channels: 14, + audio_coding_identifiers: vec!["This is kinda weird".into()], + binaural: false, + immersive: false, + downmix: false, + }, + ); + }; + tag.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"https://example.com/1.m3u8\",GROUP-ID=\"really cool group\",LANGUAGE=\"en-US\",ASSOC-LANGUAGE=\"de\",NAME=\"english audio\",STABLE-RENDITION-ID=\"azBY09+/=.-_\",DEFAULT=YES,AUTOSELECT=YES,BIT-DEPTH=16,SAMPLE-RATE=40000,CHARACTERISTICS=\"public.accessibility.describes-video,private.cool.example\",CHANNELS=\"14/This is kinda weird/\"\n"); + + output.clear(); + if let Tag::XMedia { + media_type: + MediaType::Audio { + channels, + uri, + bit_depth, + sample_rate, + }, + .. + } = &mut tag + { + *channels = Some(crate::AudioChannelInformation::WithAudioCodingIdentifiers { + number_of_channels: 6, + audio_coding_identifiers: vec![], + }); + *uri = None; + *bit_depth = None; + *sample_rate = None; + }; + tag.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"really cool group\",LANGUAGE=\"en-US\",ASSOC-LANGUAGE=\"de\",NAME=\"english audio\",STABLE-RENDITION-ID=\"azBY09+/=.-_\",DEFAULT=YES,AUTOSELECT=YES,CHARACTERISTICS=\"public.accessibility.describes-video,private.cool.example\",CHANNELS=\"6/-\"\n"); + + output.clear(); + if let Tag::XMedia { + media_type, + language, + assoc_language, + stable_rendition_id, + playback_priority, + characteristics, + .. + } = &mut tag + { + *media_type = MediaType::ClosedCaptions { + in_stream_id: crate::InStreamId::Cc2, + }; + *language = None; + *assoc_language = None; + *stable_rendition_id = None; + *playback_priority = RenditionPlaybackPriority::AutoSelect; + *characteristics = vec![]; + }; + tag.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"really cool group\",NAME=\"english audio\",AUTOSELECT=YES,INSTREAM-ID=\"CC2\"\n"); + + output.clear(); + if let Tag::XMedia { media_type, .. } = &mut tag { + *media_type = MediaType::Subtitles { + uri: "whyeven.mp4".into(), + forced: true, + }; + }; + tag.serialize(&mut output).unwrap(); + assert_eq!(output, b"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"whyeven.mp4\",GROUP-ID=\"really cool group\",NAME=\"english audio\",AUTOSELECT=YES,FORCED=YES\n"); + + output.clear(); + if let Tag::XMedia { + media_type, + playback_priority, + .. + } = &mut tag + { + *media_type = MediaType::Video { uri: None }; + *playback_priority = RenditionPlaybackPriority::None; + }; + tag.serialize(&mut output).unwrap(); + assert_eq!( + output, + b"#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"really cool group\",NAME=\"english audio\"\n" + ); + } + + #[rstest] + fn serialize_x_stream_inf(mut output: Vec) { + Tag::XStreamInf { + stream_inf: crate::StreamInf { + bandwidth_bits_per_second: 82006, + average_bandwidth_bits_per_second: Some(80000), + score: Some(2.0), + codecs: vec!["mp4a.40.2".into(), "avc1.4d401e".into()], + supplemental_codecs: vec![ + SupplementalCodec { + supplemental_codec: "somethin".into(), + compatibility_brands: vec![], + }, + SupplementalCodec { + supplemental_codec: "dvh1.08.07".into(), + compatibility_brands: vec!["db4h".into(), "idk".into()], + }, + ], + resolution: Some(crate::Resolution { + width: 1080, + height: 1920, + }), + hdcp_level: Some(crate::HdcpLevel::Type1), + allowed_cpc: vec![ + ContentProtectionConfiguration { + key_format: "com.example.drm1".into(), + cpc_label: vec!["SMART-TV".into(), "PC".into()], + }, + ContentProtectionConfiguration { + key_format: "com.example.drm2".into(), + cpc_label: vec![], + }, + ], + video_range: crate::VideoRange::Pq, + required_video_layout: vec![ + VideoChannelSpecifier::Stereo, + VideoChannelSpecifier::Mono, + ], + stable_variant_id: Some("azBY09+/=.-_".into()), + pathway_id: Some("cool-pathway".into()), + }, + frame_rate: Some(59.94258), + audio_group_id: Some("great-audio".into()), + video_group_id: Some("great-video".into()), + subtitles_group_id: Some("great-subtitles".into()), + closed_captions_group_id: Some("great-closed-captions".into()), + uri: "great-playlist.m3u8".into(), + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-STREAM-INF:BANDWIDTH=82006,AVERAGE-BANDWIDTH=80000,SCORE=2,CODECS=\"mp4a.40.2,avc1.4d401e\",SUPPLEMENTAL-CODECS=\"somethin,dvh1.08.07/db4h/idk\",RESOLUTION=1080x1920,FRAME-RATE=59.942,HDCP-LEVEL=TYPE-1,ALLOWED-CPC=\"com.example.drm1:SMART-TV/PC,com.example.drm2:\",VIDEO-RANGE=PQ,REQ-VIDEO-LAYOUT=\"CH-STEREO,CH-MONO\",STABLE-VARIANT-ID=\"azBY09+/=.-_\",AUDIO=\"great-audio\",VIDEO=\"great-video\",SUBTITLES=\"great-subtitles\",CLOSED-CAPTIONS=\"great-closed-captions\",PATHWAY-ID=\"cool-pathway\"\ngreat-playlist.m3u8\n"); + + output.clear(); + Tag::XStreamInf { + stream_inf: crate::StreamInf { + bandwidth_bits_per_second: 82006, + average_bandwidth_bits_per_second: None, + score: None, + codecs: vec![], + supplemental_codecs: vec![], + resolution: None, + hdcp_level: None, + allowed_cpc: vec![], + video_range: crate::VideoRange::Sdr, + required_video_layout: vec![VideoChannelSpecifier::Mono], + stable_variant_id: None, + pathway_id: None, + }, + frame_rate: None, + audio_group_id: None, + video_group_id: None, + subtitles_group_id: None, + closed_captions_group_id: None, + uri: "great-playlist.m3u8".into(), + } + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-STREAM-INF:BANDWIDTH=82006\ngreat-playlist.m3u8\n" + ); + } + + #[rstest] + fn serialize_x_i_frame_stream_inf(mut output: Vec) { + Tag::XIFrameStreamInf { + stream_inf: crate::StreamInf { + bandwidth_bits_per_second: 82006, + average_bandwidth_bits_per_second: Some(80000), + score: Some(2.0), + codecs: vec!["mp4a.40.2".into(), "avc1.4d401e".into()], + supplemental_codecs: vec![ + SupplementalCodec { + supplemental_codec: "somethin".into(), + compatibility_brands: vec![], + }, + SupplementalCodec { + supplemental_codec: "dvh1.08.07".into(), + compatibility_brands: vec!["db4h".into(), "idk".into()], + }, + ], + resolution: Some(crate::Resolution { + width: 1080, + height: 1920, + }), + hdcp_level: Some(crate::HdcpLevel::Type1), + allowed_cpc: vec![ + ContentProtectionConfiguration { + key_format: "com.example.drm1".into(), + cpc_label: vec!["SMART-TV".into(), "PC".into()], + }, + ContentProtectionConfiguration { + key_format: "com.example.drm2".into(), + cpc_label: vec![], + }, + ], + video_range: crate::VideoRange::Pq, + required_video_layout: vec![ + VideoChannelSpecifier::Stereo, + VideoChannelSpecifier::Mono, + ], + stable_variant_id: Some("azBY09+/=.-_".into()), + pathway_id: Some("cool-pathway".into()), + }, + video_group_id: Some("great-video".into()), + uri: "https://example.com/example.m3u8".into(), + } + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-STREAM-INF:BANDWIDTH=82006,AVERAGE-BANDWIDTH=80000,SCORE=2,CODECS=\"mp4a.40.2,avc1.4d401e\",SUPPLEMENTAL-CODECS=\"somethin,dvh1.08.07/db4h/idk\",RESOLUTION=1080x1920,HDCP-LEVEL=TYPE-1,ALLOWED-CPC=\"com.example.drm1:SMART-TV/PC,com.example.drm2:\",VIDEO-RANGE=PQ,REQ-VIDEO-LAYOUT=\"CH-STEREO,CH-MONO\",STABLE-VARIANT-ID=\"azBY09+/=.-_\",VIDEO=\"great-video\",PATHWAY-ID=\"cool-pathway\"\ngreat-playlist.m3u8\n"); + } + + #[rstest] + fn serialize_x_session_data(mut output: Vec) { + Tag::XSessionData(crate::SessionData { + data_id: "com.example.movie.title".into(), + value: crate::SessionDataValue::Value { + value: "I'm important".into(), + language: Some("en".into()), + }, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-DATA:DATA-ID=\"com.example.movie.title\",VALUE=\"I'm important\",LANGUAGE=\"en\"\n"); + + output.clear(); + Tag::XSessionData(crate::SessionData { + data_id: "com.example.movie.title".into(), + value: crate::SessionDataValue::Value { + value: "I'm important".into(), + language: None, + }, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-SESSION-DATA:DATA-ID=\"com.example.movie.title\",VALUE=\"I'm important\"\n" + ); + + output.clear(); + Tag::XSessionData(crate::SessionData { + data_id: "com.example.movie.title".into(), + value: crate::SessionDataValue::Uri { + uri: "/important.json".into(), + format: crate::UriFormat::Json, + }, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-DATA:DATA-ID=\"com.example.movie.title\",URI=\"/important.json\",FORMAT=JSON\n"); + + output.clear(); + Tag::XSessionData(crate::SessionData { + data_id: "com.example.movie.title".into(), + value: crate::SessionDataValue::Uri { + uri: "/important.bin".into(), + format: crate::UriFormat::Raw, + }, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-DATA:DATA-ID=\"com.example.movie.title\",URI=\"/important.bin\",FORMAT=RAW\n"); + } + + #[rstest] + fn serialize_x_session_key(mut output: Vec) { + output.clear(); + Tag::XSessionKey(EncryptionMethod::Aes128 { + uri: "https://example.com/foo.key".into(), + iv: Some(0x0F91_DC05), + key_format: crate::KeyFormat::Other("super cool key format".into()), + key_format_versions: vec![1, 16], + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://example.com/foo.key\",IV=0x0F91DC05,KEYFORMAT=\"super cool key format\",KEYFORMATVERSIONS=\"1/16\"\n"); + + output.clear(); + Tag::XSessionKey(EncryptionMethod::Aes128 { + uri: "https://example.com/foo.key".into(), + iv: None, + key_format: crate::KeyFormat::Identity, + key_format_versions: vec![], + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://example.com/foo.key\"\n" + ); + + output.clear(); + Tag::XSessionKey(EncryptionMethod::SampleAes { + uri: "https://example.com/foo.key".into(), + iv: Some(0x0F91_DC05), + key_format_versions: vec![1, 16], + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI=\"https://example.com/foo.key\",IV=0x0F91DC05,KEYFORMATVERSIONS=\"1/16\"\n"); + + output.clear(); + Tag::XSessionKey(EncryptionMethod::SampleAesCtr { + uri: "https://example.com/foo.key".into(), + key_format_versions: vec![1, 16], + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"https://example.com/foo.key\",KEYFORMATVERSIONS=\"1/16\"\n"); + } + + #[rstest] + fn serialize_x_content_steering(mut output: Vec) { + Tag::XContentSteering(crate::ContentSteering { + server_uri: "https://example.com/manifest.json".into(), + pathway_id: Some("hi".into()), + }) + .serialize(&mut output) + .unwrap(); + assert_eq!(output, b"#EXT-X-CONTENT-STEERING:SERVER-URI=\"https://example.com/manifest.json\",PATHWAY-ID=\"hi\"\n"); + + output.clear(); + Tag::XContentSteering(crate::ContentSteering { + server_uri: "https://example.com/manifest.json".into(), + pathway_id: None, + }) + .serialize(&mut output) + .unwrap(); + assert_eq!( + output, + b"#EXT-X-CONTENT-STEERING:SERVER-URI=\"https://example.com/manifest.json\"\n" + ); + } +}