diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..4974b63 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,14 @@ +name: Security audit +on: + push: + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' +jobs: + security_audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ec69f4a..de17cff 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,31 +1,36 @@ -name: Rust +name: rust # Trigger the workflow on push or pull request on: [push, pull_request] jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions-rs/toolchain@v1 - with: - toolchain: nightly - - run: rustup component add rustfmt - - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check + rustfmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly + override: true + components: rustfmt + - uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check - clippy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - - run: rustup component add clippy - - uses: actions-rs/cargo@v1 - with: - command: clippy - # args: -- -D warnings + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly + override: true + components: clippy + - uses: actions-rs/cargo@v1 + with: + command: clippy diff --git a/.travis.yml b/.travis.yml index 1a30fe2..68edb9f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,13 @@ language: rust cache: cargo -before_cache: | - cargo install cargo-tarpaulin || echo "cargo-tarpaulin already installed" - cargo install cargo-update || echo "cargo-update already installed" - cargo install cargo-audit || echo "cargo-audit already installed" - cargo install-update --all +before_cache: + - cargo install cargo-tarpaulin || echo "cargo-tarpaulin already installed" + - cargo install cargo-update || echo "cargo-update already installed" + - cargo install cargo-audit || echo "cargo-audit already installed" + - cargo install-update --all + # Travis can't cache files that are not readable by "others" + - chmod -R a+r $HOME/.cargo # before_cache: # - rm -rf /home/travis/.cargo/registry @@ -19,19 +21,22 @@ matrix: allow_failures: - rust: nightly -script: | - cargo clean - cargo build - cargo test +script: + - cargo clean + - cargo build + - cargo test + - cargo test --features chrono + - cargo test --features backtrace # it's enough to run this once: - if [[ "$TRAVIS_RUST_VERSION" == stable ]]; then - cargo audit - fi + - | + if [[ "$TRAVIS_RUST_VERSION" == stable ]]; then + cargo audit + fi after_success: | # this does require a -Z flag for Doctests, which is unstable! if [[ "$TRAVIS_RUST_VERSION" == nightly ]]; then - cargo tarpaulin --run-types Tests Doctests --out Xml + cargo tarpaulin -f --ignore-panics --ignore-tests --run-types Tests Doctests --out Xml bash <(curl -s https://codecov.io/bash) fi diff --git a/Cargo.toml b/Cargo.toml index f790b1d..77d2627 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hls_m3u8" -version = "0.2.1" +version = "0.2.1" # remember to update html_root_url authors = ["Takeru Ohta "] description = "HLS m3u8 parser/generator" homepage = "https://github.com/sile/hls_m3u8" @@ -11,18 +11,24 @@ keywords = ["hls", "m3u8"] edition = "2018" categories = ["parser"] +[features] +default = [] [badges] -travis-ci = { repository = "sile/hls_m3u8" } codecov = { repository = "sile/hls_m3u8" } +travis-ci = { repository = "sile/hls_m3u8" } [dependencies] -failure = "0.1.5" -derive_builder = "0.8.0" -chrono = "0.4.9" -strum = { version = "0.16.0", features = ["derive"] } -derive_more = "0.15.0" -hex = "0.4.0" +chrono = { version = "0.4", optional = true } +backtrace = { version = "0.3", features = ["std"], optional = true } + +derive_builder = "0.9" +hex = "0.4" +thiserror = "1.0" + +derive_more = "0.99" +shorthand = "0.1" +strum = { version = "0.17", features = ["derive"] } [dev-dependencies] -clap = "2.33.0" -pretty_assertions = "0.6.1" +pretty_assertions = "0.6" +version-sync = "0.9" diff --git a/README.md b/README.md index 23aa5c1..5979af8 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@ hls_m3u8 [![Documentation](https://docs.rs/hls_m3u8/badge.svg)](https://docs.rs/hls_m3u8) [![Build Status](https://travis-ci.org/sile/hls_m3u8.svg?branch=master)](https://travis-ci.org/sile/hls_m3u8) [![Code Coverage](https://codecov.io/gh/sile/hls_m3u8/branch/master/graph/badge.svg)](https://codecov.io/gh/sile/hls_m3u8/branch/master) -[![License: Apache](https://img.shields.io/badge/License-Apache%202.0-red.svg)](LICENSE-APACHE) -OR -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +![Crates.io](https://img.shields.io/crates/l/hls_m3u8) [HLS] m3u8 parser/generator. diff --git a/examples/data/rfc8216_8-1.m3u8 b/examples/data/rfc8216_8-1.m3u8 deleted file mode 100644 index 549fa9e..0000000 --- a/examples/data/rfc8216_8-1.m3u8 +++ /dev/null @@ -1,12 +0,0 @@ -#EXTM3U -#EXT-X-TARGETDURATION:10 -#EXT-X-VERSION:3 -#EXTINF:9.009, -http://media.example.com/first.ts -#EXTINF:9.009, -http://media.example.com/second.ts -#EXTINF:3.003, -http://media.example.com/third.ts -#EXT-X-ENDLIST - -# 8.1. Simple Media Playlist diff --git a/examples/data/rfc8216_8-2.m3u8 b/examples/data/rfc8216_8-2.m3u8 deleted file mode 100644 index 33dfbe6..0000000 --- a/examples/data/rfc8216_8-2.m3u8 +++ /dev/null @@ -1,13 +0,0 @@ -#EXTM3U -#EXT-X-VERSION:3 -#EXT-X-TARGETDURATION:8 -#EXT-X-MEDIA-SEQUENCE:2680 - -#EXTINF:7.975, -https://priv.example.com/fileSequence2680.ts -#EXTINF:7.941, -https://priv.example.com/fileSequence2681.ts -#EXTINF:7.975, -https://priv.example.com/fileSequence2682.ts - -# 8.2. Live Media Playlist Using HTTPS diff --git a/examples/data/rfc8216_8-3.m3u8 b/examples/data/rfc8216_8-3.m3u8 deleted file mode 100644 index 970a2d4..0000000 --- a/examples/data/rfc8216_8-3.m3u8 +++ /dev/null @@ -1,20 +0,0 @@ -#EXTM3U -#EXT-X-VERSION:3 -#EXT-X-MEDIA-SEQUENCE:7794 -#EXT-X-TARGETDURATION:15 - -#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" - -#EXTINF:2.833, -http://media.example.com/fileSequence52-A.ts -#EXTINF:15.0, -http://media.example.com/fileSequence52-B.ts -#EXTINF:13.333, -http://media.example.com/fileSequence52-C.ts - -#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53" - -#EXTINF:15.0, -http://media.example.com/fileSequence53-A.ts - -# 8.3. Playlist with Encrypted Media Segments diff --git a/examples/data/rfc8216_8-4.m3u8 b/examples/data/rfc8216_8-4.m3u8 deleted file mode 100644 index e81a437..0000000 --- a/examples/data/rfc8216_8-4.m3u8 +++ /dev/null @@ -1,11 +0,0 @@ -#EXTM3U -#EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000 -http://example.com/low.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000 -http://example.com/mid.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000 -http://example.com/hi.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" -http://example.com/audio-only.m3u8 - -# 8.4. Master Playlist \ No newline at end of file diff --git a/examples/data/rfc8216_8-5.m3u8 b/examples/data/rfc8216_8-5.m3u8 deleted file mode 100644 index 4f99863..0000000 --- a/examples/data/rfc8216_8-5.m3u8 +++ /dev/null @@ -1,14 +0,0 @@ -#EXTM3U -#EXT-X-STREAM-INF:BANDWIDTH=1280000 -low/audio-video.m3u8 -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI="low/iframe.m3u8" -#EXT-X-STREAM-INF:BANDWIDTH=2560000 -mid/audio-video.m3u8 -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8" -#EXT-X-STREAM-INF:BANDWIDTH=7680000 -hi/audio-video.m3u8 -#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI="hi/iframe.m3u8" -#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" -audio-only.m3u8 - -# 8.5. Master Playlist with I-Frames diff --git a/examples/data/rfc8216_8-6.m3u8 b/examples/data/rfc8216_8-6.m3u8 deleted file mode 100644 index d90c82f..0000000 --- a/examples/data/rfc8216_8-6.m3u8 +++ /dev/null @@ -1,14 +0,0 @@ -#EXTM3U -#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",URI="main/english-audio.m3u8" -#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Deutsch",DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="de",URI="main/german-audio.m3u8" -#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Commentary",DEFAULT=NO,AUTOSELECT=NO,LANGUAGE="en",URI="commentary/audio-only.m3u8" -#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",AUDIO="aac" -low/video-only.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",AUDIO="aac" -mid/video-only.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",AUDIO="aac" -hi/video-only.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5",AUDIO="aac" -main/english-audio.m3u8 - -# 8.6. Master Playlist with Alternative Audio diff --git a/examples/data/rfc8216_8-7.m3u8 b/examples/data/rfc8216_8-7.m3u8 deleted file mode 100644 index 4d06be6..0000000 --- a/examples/data/rfc8216_8-7.m3u8 +++ /dev/null @@ -1,23 +0,0 @@ -#EXTM3U -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Main",DEFAULT=YES,URI="low/main/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Centerfield",DEFAULT=NO,URI="low/centerfield/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Dugout",DEFAULT=NO,URI="low/dugout/audio-video.m3u8" - -#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="...",VIDEO="low" -low/main/audio-video.m3u8 - -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Main",DEFAULT=YES,URI="mid/main/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Centerfield",DEFAULT=NO,URI="mid/centerfield/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Dugout",DEFAULT=NO,URI="mid/dugout/audio-video.m3u8" - -#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS="...",VIDEO="mid" -mid/main/audio-video.m3u8 - -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Main",DEFAULT=YES,URI="hi/main/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Centerfield",DEFAULT=NO,URI="hi/centerfield/audio-video.m3u8" -#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Dugout",DEFAULT=NO,URI="hi/dugout/audio-video.m3u8" - -#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS="...",VIDEO="hi" -hi/main/audio-video.m3u8 - -# 8.7. Master Playlist with Alternative Video diff --git a/examples/parse.rs b/examples/parse.rs deleted file mode 100644 index 226a22d..0000000 --- a/examples/parse.rs +++ /dev/null @@ -1,32 +0,0 @@ -extern crate clap; -extern crate hls_m3u8; - -use clap::{App, Arg}; -use hls_m3u8::{MasterPlaylist, MediaPlaylist}; -use std::io::{self, Read}; - -fn main() { - let matches = App::new("parse") - .arg( - Arg::with_name("M3U8_TYPE") - .long("m3u8-type") - .takes_value(true) - .default_value("media") - .possible_values(&["media", "master"]), - ) - .get_matches(); - let mut m3u8 = String::new(); - io::stdin().read_to_string(&mut m3u8).unwrap(); - - match matches.value_of("M3U8_TYPE").unwrap() { - "media" => { - let playlist: MediaPlaylist = m3u8.parse().unwrap(); - println!("{}", playlist); - } - "master" => { - let playlist: MasterPlaylist = m3u8.parse().unwrap(); - println!("{}", playlist); - } - _ => unreachable!(), - } -} diff --git a/rustfmt.toml b/rustfmt.toml index 4a92eff..78ef888 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -2,9 +2,14 @@ error_on_unformatted = true edition = "2018" fn_single_line = true force_multiline_blocks = true + format_code_in_doc_comments = true format_macro_matchers = true +format_macro_bodies = true + match_arm_blocks = true reorder_impl_items = true use_field_init_shorthand = true wrap_comments = true +condense_wildcard_suffixes = true +unstable_features = true diff --git a/src/attribute.rs b/src/attribute.rs index 95f99a6..b3961b1 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -1,148 +1,208 @@ -use std::collections::HashMap; -use std::ops::{Deref, DerefMut}; -use std::str::FromStr; +use core::iter::FusedIterator; -use crate::Error; - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct AttributePairs(HashMap); - -impl AttributePairs { - pub fn new() -> Self { Self::default() } +#[derive(Clone, Debug)] +pub(crate) struct AttributePairs<'a> { + string: &'a str, + index: usize, } -impl Deref for AttributePairs { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { &self.0 } +impl<'a> AttributePairs<'a> { + pub const fn new(string: &'a str) -> Self { Self { string, index: 0 } } } -impl DerefMut for AttributePairs { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} +impl<'a> Iterator for AttributePairs<'a> { + type Item = (&'a str, &'a str); -impl IntoIterator for AttributePairs { - type IntoIter = ::std::collections::hash_map::IntoIter; - type Item = (String, String); + fn next(&mut self) -> Option { + // return `None`, if there are no more bytes + self.string.as_bytes().get(self.index + 1)?; - fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } -} + let key = { + // the position in the string: + let start = self.index; + // the key ends at an `=`: + let end = self + .string + .char_indices() + .skip_while(|(i, _)| *i < self.index) + .find_map(|(i, c)| if c == '=' { Some(i) } else { None })?; -impl<'a> IntoIterator for &'a AttributePairs { - type IntoIter = ::std::collections::hash_map::Iter<'a, String, String>; - type Item = (&'a String, &'a String); + // advance the index to the char after the end of the key (to skip the `=`) + // NOTE: it is okay to add 1 to the index, because an `=` is exactly 1 byte. + self.index = end + 1; - fn into_iter(self) -> Self::IntoIter { self.0.iter() } -} + ::core::str::from_utf8(&self.string.as_bytes()[start..end]).unwrap() + }; -impl FromStr for AttributePairs { - type Err = Error; + let value = { + let start = self.index; - fn from_str(input: &str) -> Result { - let mut result = Self::new(); + // find the end of the value by searching for `,`. + // it should ignore `,` that are inside double quotes. + let mut inside_quotes = false; - for line in split(input, ',') { - let pair = split(line.trim(), '='); + let end = { + let mut result = self.string.len(); - if pair.len() < 2 { - continue; - } - - let key = pair[0].trim().to_uppercase(); - let value = pair[1].trim().to_string(); - if value.is_empty() { - continue; - } - - result.insert(key.trim().to_string(), value.trim().to_string()); - } - - #[cfg(test)] // this is very useful, when a test fails! - dbg!(&result); - Ok(result) - } -} - -fn split(value: &str, terminator: char) -> Vec { - let mut result = vec![]; - - let mut inside_quotes = false; - let mut temp_string = String::with_capacity(1024); - - for c in value.chars() { - match c { - '"' => { - inside_quotes = !inside_quotes; - temp_string.push(c); - } - k if (k == terminator) => { - if inside_quotes { - temp_string.push(c); - } else { - result.push(temp_string); - temp_string = String::with_capacity(1024); + for (i, c) in self + .string + .char_indices() + .skip_while(|(i, _)| *i < self.index) + { + // if a quote is encountered + if c == '"' { + // update variable + inside_quotes = !inside_quotes; + // terminate if a comma is encountered, which is not in a + // quote + } else if c == ',' && !inside_quotes { + // move the index past the comma + self.index += 1; + // the result is the index of the comma (comma is not included in the + // resulting string) + result = i; + break; + } } - } - _ => { - temp_string.push(c); + + result + }; + + self.index += end; + self.index -= start; + + ::core::str::from_utf8(&self.string.as_bytes()[start..end]).unwrap() + }; + + Some((key.trim(), value.trim())) + } + + fn size_hint(&self) -> (usize, Option) { + let mut remaining = 0; + + // each `=` in the remaining str is an iteration + // this also ignores `=` inside quotes! + let mut inside_quotes = false; + + for (_, c) in self + .string + .char_indices() + .skip_while(|(i, _)| *i < self.index) + { + if c == '=' && !inside_quotes { + remaining += 1; + } else if c == '"' { + inside_quotes = !inside_quotes; } } - } - result.push(temp_string); - result + (remaining, Some(remaining)) + } } +impl<'a> ExactSizeIterator for AttributePairs<'a> {} +impl<'a> FusedIterator for AttributePairs<'a> {} + #[cfg(test)] mod test { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_attributes() { + let mut attributes = AttributePairs::new("KEY=VALUE,PAIR=YES"); + + assert_eq!((2, Some(2)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("KEY", "VALUE"))); + + assert_eq!((1, Some(1)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("PAIR", "YES"))); + + assert_eq!((0, Some(0)), attributes.size_hint()); + assert_eq!(attributes.next(), None); + + let mut attributes = AttributePairs::new("garbage"); + assert_eq!((0, Some(0)), attributes.size_hint()); + assert_eq!(attributes.next(), None); + + let mut attributes = AttributePairs::new("KEY=,=VALUE,=,"); + + assert_eq!((3, Some(3)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("KEY", ""))); + + assert_eq!((2, Some(2)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("", "VALUE"))); + + assert_eq!((1, Some(1)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("", ""))); + + assert_eq!((0, Some(0)), attributes.size_hint()); + assert_eq!(attributes.next(), None); + + // test quotes: + let mut attributes = AttributePairs::new("KEY=\"VALUE,\","); + + assert_eq!((1, Some(1)), attributes.size_hint()); + assert_eq!(attributes.next(), Some(("KEY", "\"VALUE,\""))); + + assert_eq!((0, Some(0)), attributes.size_hint()); + assert_eq!(attributes.next(), None); + + // test with chars, that are larger, than 1 byte + let mut attributes = AttributePairs::new(concat!( + "LANGUAGE=\"fre\",", + "NAME=\"Français\",", + "AUTOSELECT=YES" + )); + + assert_eq!(attributes.next(), Some(("LANGUAGE", "\"fre\""))); + assert_eq!(attributes.next(), Some(("NAME", "\"Français\""))); + assert_eq!(attributes.next(), Some(("AUTOSELECT", "YES"))); + } + #[test] fn test_parser() { - let pairs = "FOO=BAR,BAR=\"baz,qux\",ABC=12.3" - .parse::() - .unwrap(); + let mut pairs = AttributePairs::new("FOO=BAR,BAR=\"baz,qux\",ABC=12.3"); - let mut iterator = pairs.iter(); - assert!(iterator.any(|(k, v)| k == "FOO" && "BAR" == v)); + assert_eq!(pairs.next(), Some(("FOO", "BAR"))); + assert_eq!(pairs.next(), Some(("BAR", "\"baz,qux\""))); + assert_eq!(pairs.next(), Some(("ABC", "12.3"))); + assert_eq!(pairs.next(), None); - let mut iterator = pairs.iter(); - assert!(iterator.any(|(k, v)| k == "BAR" && v == "\"baz,qux\"")); + // stress test with foreign input + // got it from https://generator.lorem-ipsum.info/_chinese - let mut iterator = pairs.iter(); - assert!(iterator.any(|(k, v)| k == "ABC" && v == "12.3")); - - let mut pairs = AttributePairs::new(); - pairs.insert("FOO".to_string(), "BAR".to_string()); - - assert_eq!("FOO=BAR,VAL".parse::().unwrap(), pairs); - } - - #[test] - fn test_iterator() { - let mut attrs = AttributePairs::new(); - attrs.insert("key_01".to_string(), "value_01".to_string()); - attrs.insert("key_02".to_string(), "value_02".to_string()); - - let mut iterator = attrs.iter(); - assert!(iterator.any(|(k, v)| k == "key_01" && v == "value_01")); - - let mut iterator = attrs.iter(); - assert!(iterator.any(|(k, v)| k == "key_02" && v == "value_02")); - } - - #[test] - fn test_into_iter() { - let mut map = HashMap::new(); - map.insert("k".to_string(), "v".to_string()); - - let mut attrs = AttributePairs::new(); - attrs.insert("k".to_string(), "v".to_string()); + let mut pairs = AttributePairs::new(concat!( + "載抗留囲軽来実基供全必式覧領意度振。=著地内方満職控努作期投綱研本模,", + "後文図様改表宮能本園半参裁報作神掲索=\"針支年得率新賞現報発援白少動面。矢拉年世掲注索政平定他込\",", + "ध्वनि स्थिति और्४५० नीचे =देखने लाभो द्वारा करके(विशेष" + )); + assert_eq!((3, Some(3)), pairs.size_hint()); assert_eq!( - attrs.into_iter().collect::>(), - map.into_iter().collect::>() + pairs.next(), + Some(( + "載抗留囲軽来実基供全必式覧領意度振。", + "著地内方満職控努作期投綱研本模" + )) ); + + assert_eq!((2, Some(2)), pairs.size_hint()); + assert_eq!( + pairs.next(), + Some(( + "後文図様改表宮能本園半参裁報作神掲索", + "\"針支年得率新賞現報発援白少動面。矢拉年世掲注索政平定他込\"" + )) + ); + + assert_eq!((1, Some(1)), pairs.size_hint()); + assert_eq!( + pairs.next(), + Some(("ध्वनि स्थिति और्४५० नीचे", "देखने लाभो द्वारा करके(विशेष")) + ); + + assert_eq!((0, Some(0)), pairs.size_hint()); + assert_eq!(pairs.next(), None); } } diff --git a/src/error.rs b/src/error.rs index c64e031..1e30f59 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,39 +1,36 @@ use std::fmt; -use failure::{Backtrace, Context, Fail}; +#[cfg(feature = "backtrace")] +use backtrace::Backtrace; +use thiserror::Error; + +//use crate::types::ProtocolVersion; /// This crate specific `Result` type. pub type Result = std::result::Result; -/// The [`ErrorKind`]. -#[derive(Debug, Fail, Clone, PartialEq, Eq)] -pub enum ErrorKind { - #[fail(display = "ChronoParseError: {}", _0)] - /// An error from the [Chrono](chrono) crate. - ChronoParseError(String), +#[derive(Debug, Error, Clone, PartialEq)] +#[non_exhaustive] +enum ErrorKind { + #[error("a value is missing for the attribute {value}")] + MissingValue { value: String }, - #[fail(display = "UnknownError: {}", _0)] - /// An unknown error occured. - UnknownError(String), - - #[fail(display = "A value is missing for the attribute {}", _0)] - /// A required value is missing. - MissingValue(String), - - #[fail(display = "Invalid Input")] - /// Error for anything. + #[error("invalid input")] InvalidInput, - #[fail(display = "ParseIntError: {}", _0)] - /// Failed to parse a String to int. - ParseIntError(String), + #[error("{source}: {input:?}")] + ParseIntError { + input: String, + source: ::std::num::ParseIntError, + }, - #[fail(display = "ParseFloatError: {}", _0)] - /// Failed to parse a String to float. - ParseFloatError(String), + #[error("{source}: {input:?}")] + ParseFloatError { + input: String, + source: ::std::num::ParseFloatError, + }, - #[fail(display = "MissingTag: Expected {} at the start of {:?}", tag, input)] - /// A tag is missing, that is required at the start of the input. + #[error("expected `{tag}` at the start of {input:?}")] MissingTag { /// The required tag. tag: String, @@ -41,100 +38,111 @@ pub enum ErrorKind { input: String, }, - #[fail(display = "CustomError: {}", _0)] - /// A custom error. + #[error("{0}")] Custom(String), - #[fail(display = "Unmatched Group: {:?}", _0)] - /// Unmatched Group + #[error("unmatched group: {0:?}")] UnmatchedGroup(String), - #[fail(display = "Unknown Protocol version: {:?}", _0)] - /// Unknown m3u8 version. This library supports up to ProtocolVersion 7. + #[error("unknown protocol version {0:?}")] UnknownProtocolVersion(String), - #[fail(display = "IoError: {}", _0)] - /// Some io error - Io(String), + // #[error("required_version: {:?}, specified_version: {:?}", _0, _1)] + // VersionError(ProtocolVersion, ProtocolVersion), + #[error("missing attribute: {attribute:?}")] + MissingAttribute { attribute: String }, - #[fail( - display = "VersionError: required_version: {:?}, specified_version: {:?}", - _0, _1 - )] - /// This error occurs, if there is a ProtocolVersion mismatch. - VersionError(String, String), + #[error("unexpected attribute: {attribute:?}")] + UnexpectedAttribute { attribute: String }, - #[fail(display = "BuilderError: {}", _0)] - /// An Error from a Builder. - BuilderError(String), + #[error("unexpected tag: {tag:?}")] + UnexpectedTag { tag: String }, - #[fail(display = "Missing Attribute: {}", _0)] - /// An attribute is missing. - MissingAttribute(String), + #[error("{source}")] + #[cfg(feature = "chrono")] + Chrono { source: chrono::ParseError }, - #[fail(display = "Unexpected Attribute: {:?}", _0)] - /// An unexpected value. - UnexpectedAttribute(String), + #[error("builder error: {message}")] + Builder { message: String }, - #[fail(display = "Unexpected Tag: {:?}", _0)] - /// An unexpected tag. - UnexpectedTag(String), - - /// Hints that destructuring should not be exhaustive. - /// - /// This enum may grow additional variants, so this makes sure clients - /// don't count on exhaustive matching. (Otherwise, adding a new variant - /// could break existing code.) - #[doc(hidden)] - #[fail(display = "Invalid error")] - __Nonexhaustive, + #[error("{source}")] + Hex { source: hex::FromHexError }, } -#[derive(Debug)] /// The Error type of this library. +#[derive(Debug)] pub struct Error { - inner: Context, + inner: ErrorKind, + #[cfg(feature = "backtrace")] + backtrace: Backtrace, } -impl Fail for Error { - fn cause(&self) -> Option<&dyn Fail> { self.inner.cause() } - - fn backtrace(&self) -> Option<&Backtrace> { self.inner.backtrace() } +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { self.inner == other.inner } } +impl std::error::Error for Error {} + impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.inner.fmt(f) } -} - -impl From for Error { - fn from(kind: ErrorKind) -> Self { Self::from(Context::new(kind)) } -} - -impl From> for Error { - fn from(inner: Context) -> Self { Self { inner } } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.inner.fmt(f) } } +#[allow(clippy::needless_pass_by_value)] impl Error { + fn new(inner: ErrorKind) -> Self { + Self { + inner, + #[cfg(feature = "backtrace")] + backtrace: Backtrace::new(), + } + } + + pub(crate) fn custom(value: T) -> Self { + Self::new(ErrorKind::Custom(value.to_string())) + } + pub(crate) fn missing_value(value: T) -> Self { - Self::from(ErrorKind::MissingValue(value.to_string())) + Self::new(ErrorKind::MissingValue { + value: value.to_string(), + }) + } + + pub(crate) fn missing_field(strct: D, field: T) -> Self { + Self::new(ErrorKind::Custom(format!( + "the field `{}` is missing for `{}`", + field, strct + ))) } pub(crate) fn unexpected_attribute(value: T) -> Self { - Self::from(ErrorKind::UnexpectedAttribute(value.to_string())) + Self::new(ErrorKind::UnexpectedAttribute { + attribute: value.to_string(), + }) } pub(crate) fn unexpected_tag(value: T) -> Self { - Self::from(ErrorKind::UnexpectedTag(value.to_string())) + Self::new(ErrorKind::UnexpectedTag { + tag: value.to_string(), + }) } - pub(crate) fn invalid_input() -> Self { Self::from(ErrorKind::InvalidInput) } + pub(crate) fn invalid_input() -> Self { Self::new(ErrorKind::InvalidInput) } - pub(crate) fn parse_int_error(value: T) -> Self { - Self::from(ErrorKind::ParseIntError(value.to_string())) + pub(crate) fn parse_int(input: T, source: ::std::num::ParseIntError) -> Self { + Self::new(ErrorKind::ParseIntError { + input: input.to_string(), + source, + }) } - pub(crate) fn parse_float_error(value: T) -> Self { - Self::from(ErrorKind::ParseFloatError(value.to_string())) + pub(crate) fn parse_float( + input: T, + source: ::std::num::ParseFloatError, + ) -> Self { + Self::new(ErrorKind::ParseFloatError { + input: input.to_string(), + source, + }) } pub(crate) fn missing_tag(tag: T, input: U) -> Self @@ -142,76 +150,82 @@ impl Error { T: ToString, U: ToString, { - Self::from(ErrorKind::MissingTag { + Self::new(ErrorKind::MissingTag { tag: tag.to_string(), input: input.to_string(), }) } pub(crate) fn unmatched_group(value: T) -> Self { - Self::from(ErrorKind::UnmatchedGroup(value.to_string())) - } - - pub(crate) fn custom(value: T) -> Self - where - T: fmt::Display, - { - Self::from(ErrorKind::Custom(value.to_string())) + Self::new(ErrorKind::UnmatchedGroup(value.to_string())) } pub(crate) fn unknown_protocol_version(value: T) -> Self { - Self::from(ErrorKind::UnknownProtocolVersion(value.to_string())) + Self::new(ErrorKind::UnknownProtocolVersion(value.to_string())) } - pub(crate) fn io(value: T) -> Self { Self::from(ErrorKind::Io(value.to_string())) } - - pub(crate) fn builder_error(value: T) -> Self { - Self::from(ErrorKind::BuilderError(value.to_string())) - } - - pub(crate) fn chrono(value: T) -> Self { - Self::from(ErrorKind::ChronoParseError(value.to_string())) + pub(crate) fn builder(value: T) -> Self { + Self::new(ErrorKind::Builder { + message: value.to_string(), + }) } pub(crate) fn missing_attribute(value: T) -> Self { - Self::from(ErrorKind::MissingAttribute(value.to_string())) + Self::new(ErrorKind::MissingAttribute { + attribute: value.to_string(), + }) + } + + // third party crates: + #[cfg(feature = "chrono")] + pub(crate) fn chrono(source: chrono::format::ParseError) -> Self { + Self::new(ErrorKind::Chrono { source }) + } + + pub(crate) fn hex(source: hex::FromHexError) -> Self { + // + Self::new(ErrorKind::Hex { source }) + } + + pub(crate) fn strum(value: strum::ParseError) -> Self { + Self::new(ErrorKind::Custom(value.to_string())) } } -impl From<::std::num::ParseIntError> for Error { - fn from(value: ::std::num::ParseIntError) -> Self { Self::parse_int_error(value) } -} - -impl From<::std::num::ParseFloatError> for Error { - fn from(value: ::std::num::ParseFloatError) -> Self { Self::parse_float_error(value) } -} - -impl From<::std::io::Error> for Error { - fn from(value: ::std::io::Error) -> Self { Self::io(value) } -} - -impl From<::chrono::ParseError> for Error { - fn from(value: ::chrono::ParseError) -> Self { Self::chrono(value) } -} - +#[doc(hidden)] impl From<::strum::ParseError> for Error { - fn from(value: ::strum::ParseError) -> Self { - Self::custom(value) // TODO! - } + fn from(value: ::strum::ParseError) -> Self { Self::strum(value) } } -impl From for Error { - fn from(value: String) -> Self { Self::custom(value) } -} +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; -impl From<::core::convert::Infallible> for Error { - fn from(_: ::core::convert::Infallible) -> Self { - Self::custom("An Infallible error has been returned! (this should never happen!)") - } -} - -impl From<::hex::FromHexError> for Error { - fn from(value: ::hex::FromHexError) -> Self { - Self::custom(value) // TODO! + #[test] + fn test_parse_float_error() { + assert_eq!( + Error::parse_float( + "1.x234", + "1.x234" + .parse::() + .expect_err("this should not parse as a float!") + ) + .to_string(), + "invalid float literal: \"1.x234\"".to_string() + ); + } + + #[test] + fn test_parse_int_error() { + assert_eq!( + Error::parse_int( + "1x", + "1x".parse::() + .expect_err("this should not parse as an usize!") + ) + .to_string(), + "invalid digit found in string: \"1x\"".to_string() + ); } } diff --git a/src/lib.rs b/src/lib.rs index 30fe3cc..e991a96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,22 +1,43 @@ +#![doc(html_root_url = "https://docs.rs/hls_m3u8/0.2.1")] #![forbid(unsafe_code)] -#![feature(option_flattening)] +#![warn(rust_2018_idioms)] #![warn( - //clippy::pedantic, + clippy::pedantic, // clippy::nursery, - clippy::cargo + clippy::cargo, + clippy::inline_always, +)] +#![allow( + clippy::multiple_crate_versions, + clippy::module_name_repetitions, + clippy::default_trait_access, + clippy::unnecessary_operation // temporary until derive-builder uses #[allow(clippy::all)] +)] +#![warn( + clippy::clone_on_ref_ptr, + clippy::decimal_literal_representation, + clippy::get_unwrap, + clippy::option_expect_used, + clippy::unneeded_field_pattern, + clippy::wrong_pub_self_convention +)] +// those should not be present in production code: +#![deny( + clippy::print_stdout, + clippy::todo, + clippy::unimplemented, + clippy::dbg_macro, + clippy::use_debug )] -#![allow(clippy::multiple_crate_versions)] #![warn( missing_docs, missing_copy_implementations, missing_debug_implementations, - trivial_casts, // TODO (needed?) + trivial_casts, trivial_numeric_casts )] //! [HLS] m3u8 parser/generator. //! -//! [HLS]: https://tools.ietf.org/html/rfc8216 -//! //! # Examples //! //! ``` @@ -35,12 +56,72 @@ //! //! assert!(m3u8.parse::().is_ok()); //! ``` +//! +//! ## Crate Feature Flags +//! +//! The following crate feature flags are available: +//! +//! - [`backtrace`] (optional) +//! - Enables the backtrace feature for the `Error` type. +//! - This feature depends on the following dependencies: +//! - [`backtrace`] +//! - [`chrono`] (optional) +//! - Enables parsing dates and verifying them. +//! - This feature depends on the following dependencies: +//! - [`chrono`] +//! - The following things will change: +//! - [`ExtXProgramDateTime::date_time`] will change from [`String`] to +//! `DateTime` +//! - [`ExtXDateRange::start_date`] will change from [`String`] to +//! `DateTime` +//! - [`ExtXDateRange::end_date`] will change from [`String`] to +//! `DateTime` +//! +//! They are configured in your `Cargo.toml` and can be enabled like this +//! +//! ```toml +//! hls_m3u8 = { version = "0.3", features = ["chrono", "backtrace"] } +//! ``` +//! +//! [`ExtXProgramDateTime::date_time`]: +//! crate::tags::ExtXProgramDateTime::date_time +//! [`ExtXDateRange::start_date`]: +//! crate::tags::ExtXDateRange::start_date +//! [`ExtXDateRange::end_date`]: +//! crate::tags::ExtXDateRange::end_date +//! [`chrono`]: https://github.com/chronotope/chrono +//! [`backtrace`]: https://github.com/rust-lang/backtrace-rs +//! [HLS]: https://tools.ietf.org/html/rfc8216 pub use error::Error; pub use master_playlist::MasterPlaylist; pub use media_playlist::MediaPlaylist; pub use media_segment::MediaSegment; +/// Builder structs +pub mod builder { + pub use crate::master_playlist::MasterPlaylistBuilder; + pub use crate::media_playlist::MediaPlaylistBuilder; + pub use crate::media_segment::MediaSegmentBuilder; + + /// Builder structs for tags + pub mod tags { + // master playlist + pub use crate::tags::master_playlist::media::ExtXMediaBuilder; + pub use crate::tags::master_playlist::session_data::ExtXSessionDataBuilder; + + // media segment + pub use crate::tags::media_segment::date_range::ExtXDateRangeBuilder; + + // media playlist + } + + /// Builder structs for types + pub mod types { + pub use crate::types::decryption_key::DecryptionKeyBuilder; + pub use crate::types::stream_data::StreamDataBuilder; + } +} pub mod tags; pub mod types; diff --git a/src/line.rs b/src/line.rs index f36782a..c684585 100644 --- a/src/line.rs +++ b/src/line.rs @@ -1,94 +1,64 @@ -use std::fmt; -use std::ops::{Deref, DerefMut}; -use std::str::FromStr; +use core::convert::TryFrom; +use core::iter::FusedIterator; +use core::str::FromStr; + +use derive_more::Display; use crate::tags; +use crate::types::PlaylistType; use crate::Error; -#[derive(Debug, Default)] -pub struct Lines(Vec); - -impl Lines { - pub fn new() -> Self { Self::default() } +#[derive(Debug, Clone)] +pub(crate) struct Lines<'a> { + lines: ::core::iter::FilterMap<::core::str::Lines<'a>, fn(&'a str) -> Option<&'a str>>, } -impl FromStr for Lines { - type Err = Error; +impl<'a> Iterator for Lines<'a> { + type Item = crate::Result>; - fn from_str(input: &str) -> Result { - let mut result = Self::new(); + fn next(&mut self) -> Option { + let line = self.lines.next()?; - let mut stream_inf = false; - let mut stream_inf_line = None; + if line.starts_with(tags::VariantStream::PREFIX_EXTXSTREAMINF) { + let uri = self.lines.next()?; - for l in input.lines() { - let raw_line = l.trim(); - - if raw_line.is_empty() { - continue; - } - - let line = { - if raw_line.starts_with(tags::ExtXStreamInf::PREFIX) { - stream_inf = true; - stream_inf_line = Some(raw_line); - - continue; - } else if raw_line.starts_with("#EXT") { - Line::Tag(raw_line.parse()?) - } else if raw_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 { - let res = Line::Tag(format!("{}\n{}", first_line, raw_line).parse()?); - stream_inf_line = None; - res - } else { - continue; - } - } else { - Line::Uri(raw_line.to_string()) - } - } - }; - - result.push(line); + Some( + tags::VariantStream::from_str(&format!("{}\n{}", line, uri)) + .map(|v| Line::Tag(Tag::VariantStream(v))), + ) + } else if line.starts_with("#EXT") { + Some(Tag::try_from(line).map(Line::Tag)) + } else if line.starts_with('#') { + Some(Ok(Line::Comment(line))) + } else { + Some(Ok(Line::Uri(line))) } - - Ok(result) } } -impl IntoIterator for Lines { - type IntoIter = ::std::vec::IntoIter; - type Item = Line; +impl<'a> FusedIterator for Lines<'a> {} - fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } -} - -impl Deref for Lines { - type Target = Vec; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for Lines { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } +impl<'a> From<&'a str> for Lines<'a> { + fn from(buffer: &'a str) -> Self { + Self { + lines: buffer + .lines() + .filter_map(|line| Some(line.trim()).filter(|v| !v.is_empty())), + } + } } #[derive(Debug, Clone, PartialEq)] -pub enum Line { - Tag(Tag), - Uri(String), +pub(crate) enum Line<'a> { + Tag(Tag<'a>), + Comment(&'a str), + Uri(&'a str), } #[allow(clippy::large_enum_variant)] -#[derive(Debug, Clone, PartialEq)] -pub enum Tag { - ExtM3u(tags::ExtM3u), +#[derive(Debug, Clone, PartialEq, Display)] +#[display(fmt = "{}")] +pub(crate) enum Tag<'a> { ExtXVersion(tags::ExtXVersion), ExtInf(tags::ExtInf), ExtXByteRange(tags::ExtXByteRange), @@ -101,55 +71,22 @@ pub enum Tag { ExtXMediaSequence(tags::ExtXMediaSequence), ExtXDiscontinuitySequence(tags::ExtXDiscontinuitySequence), ExtXEndList(tags::ExtXEndList), - ExtXPlaylistType(tags::ExtXPlaylistType), + PlaylistType(PlaylistType), ExtXIFramesOnly(tags::ExtXIFramesOnly), ExtXMedia(tags::ExtXMedia), - ExtXStreamInf(tags::ExtXStreamInf), - ExtXIFrameStreamInf(tags::ExtXIFrameStreamInf), ExtXSessionData(tags::ExtXSessionData), ExtXSessionKey(tags::ExtXSessionKey), ExtXIndependentSegments(tags::ExtXIndependentSegments), ExtXStart(tags::ExtXStart), - Unknown(String), + VariantStream(tags::VariantStream), + Unknown(&'a str), } -impl fmt::Display for Tag { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match &self { - Self::ExtM3u(value) => value.fmt(f), - Self::ExtXVersion(value) => value.fmt(f), - Self::ExtInf(value) => value.fmt(f), - Self::ExtXByteRange(value) => value.fmt(f), - Self::ExtXDiscontinuity(value) => value.fmt(f), - Self::ExtXKey(value) => value.fmt(f), - Self::ExtXMap(value) => value.fmt(f), - Self::ExtXProgramDateTime(value) => value.fmt(f), - Self::ExtXDateRange(value) => value.fmt(f), - Self::ExtXTargetDuration(value) => value.fmt(f), - Self::ExtXMediaSequence(value) => value.fmt(f), - Self::ExtXDiscontinuitySequence(value) => value.fmt(f), - Self::ExtXEndList(value) => value.fmt(f), - Self::ExtXPlaylistType(value) => value.fmt(f), - Self::ExtXIFramesOnly(value) => value.fmt(f), - Self::ExtXMedia(value) => value.fmt(f), - Self::ExtXStreamInf(value) => value.fmt(f), - Self::ExtXIFrameStreamInf(value) => value.fmt(f), - Self::ExtXSessionData(value) => value.fmt(f), - Self::ExtXSessionKey(value) => value.fmt(f), - Self::ExtXIndependentSegments(value) => value.fmt(f), - Self::ExtXStart(value) => value.fmt(f), - Self::Unknown(value) => value.fmt(f), - } - } -} +impl<'a> TryFrom<&'a str> for Tag<'a> { + type Error = Error; -impl FromStr for Tag { - type Err = Error; - - fn from_str(input: &str) -> Result { - if input.starts_with(tags::ExtM3u::PREFIX) { - input.parse().map(Self::ExtM3u) - } else if input.starts_with(tags::ExtXVersion::PREFIX) { + fn try_from(input: &'a str) -> Result { + if input.starts_with(tags::ExtXVersion::PREFIX) { input.parse().map(Self::ExtXVersion) } else if input.starts_with(tags::ExtInf::PREFIX) { input.parse().map(Self::ExtInf) @@ -173,16 +110,16 @@ impl FromStr for Tag { input.parse().map(Self::ExtXDiscontinuitySequence) } else if input.starts_with(tags::ExtXEndList::PREFIX) { input.parse().map(Self::ExtXEndList) - } else if input.starts_with(tags::ExtXPlaylistType::PREFIX) { - input.parse().map(Self::ExtXPlaylistType) + } else if input.starts_with(PlaylistType::PREFIX) { + input.parse().map(Self::PlaylistType) } else if input.starts_with(tags::ExtXIFramesOnly::PREFIX) { input.parse().map(Self::ExtXIFramesOnly) } else if input.starts_with(tags::ExtXMedia::PREFIX) { - input.parse().map(Self::ExtXMedia).map_err(Error::custom) - } else if input.starts_with(tags::ExtXStreamInf::PREFIX) { - input.parse().map(Self::ExtXStreamInf) - } else if input.starts_with(tags::ExtXIFrameStreamInf::PREFIX) { - input.parse().map(Self::ExtXIFrameStreamInf) + input.parse().map(Self::ExtXMedia) + } else if input.starts_with(tags::VariantStream::PREFIX_EXTXIFRAME) + || input.starts_with(tags::VariantStream::PREFIX_EXTXSTREAMINF) + { + input.parse().map(Self::VariantStream) } else if input.starts_with(tags::ExtXSessionData::PREFIX) { input.parse().map(Self::ExtXSessionData) } else if input.starts_with(tags::ExtXSessionKey::PREFIX) { @@ -192,7 +129,7 @@ impl FromStr for Tag { } else if input.starts_with(tags::ExtXStart::PREFIX) { input.parse().map(Self::ExtXStart) } else { - Ok(Self::Unknown(input.to_string())) + Ok(Self::Unknown(input)) } } } diff --git a/src/master_playlist.rs b/src/master_playlist.rs index 04b2d99..df28c74 100644 --- a/src/master_playlist.rs +++ b/src/master_playlist.rs @@ -6,295 +6,384 @@ use derive_builder::Builder; use crate::line::{Line, Lines, Tag}; use crate::tags::{ - ExtM3u, ExtXIFrameStreamInf, ExtXIndependentSegments, ExtXMedia, ExtXSessionData, - ExtXSessionKey, ExtXStart, ExtXStreamInf, ExtXVersion, + ExtM3u, ExtXIndependentSegments, ExtXMedia, ExtXSessionData, ExtXSessionKey, ExtXStart, + ExtXVersion, VariantStream, }; use crate::types::{ClosedCaptions, MediaType, ProtocolVersion}; +use crate::utils::{tag, BoolExt}; use crate::{Error, RequiredVersion}; -#[derive(Debug, Clone, Builder, PartialEq)] +/// The master playlist describes all of the available variants for your +/// content. +/// +/// Each variant is a version of the stream at a particular bitrate and is +/// contained in a separate playlist called [`MediaPlaylist`]. +/// +/// # Examples +/// +/// A [`MasterPlaylist`] can be parsed from a `str`: +/// +/// ``` +/// use core::str::FromStr; +/// use hls_m3u8::MasterPlaylist; +/// +/// // the concat! macro joins multiple `&'static str`. +/// let master_playlist = concat!( +/// "#EXTM3U\n", +/// "#EXT-X-STREAM-INF:", +/// "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", +/// "http://example.com/low/index.m3u8\n", +/// "#EXT-X-STREAM-INF:", +/// "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", +/// "http://example.com/lo_mid/index.m3u8\n", +/// "#EXT-X-STREAM-INF:", +/// "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", +/// "http://example.com/hi_mid/index.m3u8\n", +/// "#EXT-X-STREAM-INF:", +/// "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", +/// "http://example.com/high/index.m3u8\n", +/// "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", +/// "http://example.com/audio/index.m3u8\n" +/// ) +/// .parse::()?; +/// +/// println!("{}", master_playlist.has_independent_segments); +/// # Ok::<(), hls_m3u8::Error>(()) +/// ``` +/// +/// or it can be constructed through a builder +/// +/// ``` +/// # use hls_m3u8::MasterPlaylist; +/// use hls_m3u8::tags::{ExtXStart, VariantStream}; +/// use hls_m3u8::types::{Float, StreamData}; +/// +/// MasterPlaylist::builder() +/// .variant_streams(vec![ +/// VariantStream::ExtXStreamInf { +/// uri: "http://example.com/low/index.m3u8".into(), +/// frame_rate: None, +/// audio: None, +/// subtitles: None, +/// closed_captions: None, +/// stream_data: StreamData::builder() +/// .bandwidth(150000) +/// .codecs(&["avc1.42e00a", "mp4a.40.2"]) +/// .resolution((416, 234)) +/// .build() +/// .unwrap(), +/// }, +/// VariantStream::ExtXStreamInf { +/// uri: "http://example.com/lo_mid/index.m3u8".into(), +/// frame_rate: None, +/// audio: None, +/// subtitles: None, +/// closed_captions: None, +/// stream_data: StreamData::builder() +/// .bandwidth(240000) +/// .codecs(&["avc1.42e00a", "mp4a.40.2"]) +/// .resolution((416, 234)) +/// .build() +/// .unwrap(), +/// }, +/// ]) +/// .has_independent_segments(true) +/// .start(ExtXStart::new(Float::new(1.23))) +/// .build()?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// [`MediaPlaylist`]: crate::MediaPlaylist +#[derive(Builder, Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[builder(build_fn(validate = "Self::validate"))] #[builder(setter(into, strip_option))] -/// Master playlist. +#[non_exhaustive] pub struct MasterPlaylist { - #[builder(default)] - /// Sets the [`ExtXIndependentSegments`] tag. + /// Indicates that all media samples in a [`MediaSegment`] can be + /// decoded without information from other segments. /// - /// # Note - /// This tag is optional. - independent_segments_tag: Option, - #[builder(default)] - /// Sets the [`ExtXStart`] tag. + /// ### Note /// - /// # Note - /// This tag is optional. - start_tag: Option, - #[builder(default)] - /// Sets the [`ExtXMedia`] tag. + /// This field is optional and by default `false`. If the field is `true`, + /// it applies to every [`MediaSegment`] in every [`MediaPlaylist`] of this + /// [`MasterPlaylist`]. /// - /// # Note - /// This tag is optional. - media_tags: Vec, + /// [`MediaSegment`]: crate::MediaSegment + /// [`MediaPlaylist`]: crate::MediaPlaylist #[builder(default)] - /// Sets all [`ExtXStreamInf`] tags. + pub has_independent_segments: bool, + /// A preferred point at which to start playing a playlist. /// - /// # Note - /// This tag is optional. - stream_inf_tags: Vec, + /// ### Note + /// + /// This field is optional and by default the playlist should be played from + /// the start. #[builder(default)] - /// Sets all [`ExtXIFrameStreamInf`] tags. + pub start: Option, + /// A list of all [`ExtXMedia`] tags, which describe an alternative + /// rendition. /// - /// # Note - /// This tag is optional. - i_frame_stream_inf_tags: Vec, + /// For example, three [`ExtXMedia`] tags can be used to identify audio-only + /// [`MediaPlaylist`]s, that contain English, French, and Spanish + /// renditions of the same presentation. Or, two [`ExtXMedia`] tags can + /// be used to identify video-only [`MediaPlaylist`]s that show two + /// different camera angles. + /// + /// ### Note + /// + /// This field is optional. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist #[builder(default)] - /// Sets all [`ExtXSessionData`] tags. + pub media: Vec, + /// A list of all streams of this [`MasterPlaylist`]. /// - /// # Note - /// This tag is optional. - session_data_tags: Vec, + /// ### Note + /// + /// This field is optional. #[builder(default)] - /// Sets all [`ExtXSessionKey`] tags. + pub variant_streams: Vec, + /// The [`ExtXSessionData`] tag allows arbitrary session data to be + /// carried in a [`MasterPlaylist`]. /// - /// # Note - /// This tag is optional. - session_key_tags: Vec, + /// ### Note + /// + /// This field is optional. + #[builder(default)] + pub session_data: Vec, + /// A list of [`ExtXSessionKey`]s, that allows the client to preload + /// these keys without having to read the [`MediaPlaylist`]s first. + /// + /// ### Note + /// + /// This field is optional. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + #[builder(default)] + pub session_keys: Vec, + /// A list of all tags that could not be identified while parsing the input. + /// + /// ### Note + /// + /// This field is optional. + #[builder(default)] + pub unknown_tags: Vec, } impl MasterPlaylist { - /// Returns a Builder for a [`MasterPlaylist`]. + /// Returns a builder for a [`MasterPlaylist`]. /// /// # Example - /// ``` - /// use hls_m3u8::tags::ExtXStart; - /// use hls_m3u8::MasterPlaylist; /// - /// # fn main() -> Result<(), hls_m3u8::Error> { - /// MasterPlaylist::builder() - /// .start_tag(ExtXStart::new(20.123456)) - /// .build()?; - /// # Ok(()) - /// # } /// ``` + /// # use hls_m3u8::MasterPlaylist; + /// use hls_m3u8::tags::{ExtXStart, VariantStream}; + /// use hls_m3u8::types::{Float, StreamData}; + /// + /// MasterPlaylist::builder() + /// .variant_streams(vec![ + /// VariantStream::ExtXStreamInf { + /// uri: "http://example.com/low/index.m3u8".into(), + /// frame_rate: None, + /// audio: None, + /// subtitles: None, + /// closed_captions: None, + /// stream_data: StreamData::builder() + /// .bandwidth(150000) + /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) + /// .resolution((416, 234)) + /// .build() + /// .unwrap(), + /// }, + /// VariantStream::ExtXStreamInf { + /// uri: "http://example.com/lo_mid/index.m3u8".into(), + /// frame_rate: None, + /// audio: None, + /// subtitles: None, + /// closed_captions: None, + /// stream_data: StreamData::builder() + /// .bandwidth(240000) + /// .codecs(&["avc1.42e00a", "mp4a.40.2"]) + /// .resolution((416, 234)) + /// .build() + /// .unwrap(), + /// }, + /// ]) + /// .has_independent_segments(true) + /// .start(ExtXStart::new(Float::new(1.23))) + /// .build()?; + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] + #[inline] pub fn builder() -> MasterPlaylistBuilder { MasterPlaylistBuilder::default() } - /// Returns the [`ExtXIndependentSegments`] tag contained in the playlist. - pub const fn independent_segments(&self) -> Option { - self.independent_segments_tag + /// Returns all streams, which have an audio group id. + pub fn audio_streams(&self) -> impl Iterator { + self.variant_streams.iter().filter(|stream| { + if let VariantStream::ExtXStreamInf { audio: Some(_), .. } = stream { + true + } else { + false + } + }) } - /// Sets the [`ExtXIndependentSegments`] tag contained in the playlist. - pub fn set_independent_segments(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.independent_segments_tag = value.map(Into::into); - self + /// Returns all streams, which have a video group id. + pub fn video_streams(&self) -> impl Iterator { + self.variant_streams.iter().filter(|stream| { + if let VariantStream::ExtXStreamInf { stream_data, .. } = stream { + stream_data.video().is_some() + } else if let VariantStream::ExtXIFrame { stream_data, .. } = stream { + stream_data.video().is_some() + } else { + false + } + }) } - /// Returns the [`ExtXStart`] tag contained in the playlist. - pub const fn start(&self) -> Option { self.start_tag } - - /// Sets the [`ExtXStart`] tag contained in the playlist. - pub fn set_start(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.start_tag = value.map(Into::into); - self + /// Returns all streams, which have no group id. + pub fn unassociated_streams(&self) -> impl Iterator { + self.variant_streams.iter().filter(|stream| { + if let VariantStream::ExtXStreamInf { + stream_data, + audio: None, + subtitles: None, + closed_captions: None, + .. + } = stream + { + stream_data.video().is_none() + } else if let VariantStream::ExtXIFrame { stream_data, .. } = stream { + stream_data.video().is_none() + } else { + false + } + }) } - /// Returns the [`ExtXMedia`] tags contained in the playlist. - pub const fn media_tags(&self) -> &Vec { &self.media_tags } - - /// Appends an [`ExtXMedia`]. - pub fn push_media_tag(&mut self, value: ExtXMedia) -> &mut Self { - self.media_tags.push(value); - self - } - - /// Sets the [`ExtXMedia`] tags contained in the playlist. - pub fn set_media_tags(&mut self, value: Vec) -> &mut Self - where - T: Into, - { - self.media_tags = value.into_iter().map(Into::into).collect(); - self - } - - /// Returns the [`ExtXStreamInf`] tags contained in the playlist. - pub const fn stream_inf_tags(&self) -> &Vec { &self.stream_inf_tags } - - /// Appends an [`ExtXStreamInf`]. - pub fn push_stream_inf(&mut self, value: ExtXStreamInf) -> &mut Self { - self.stream_inf_tags.push(value); - self - } - - /// Sets the [`ExtXStreamInf`] tags contained in the playlist. - pub fn set_stream_inf_tags(&mut self, value: Vec) -> &mut Self - where - T: Into, - { - self.stream_inf_tags = value.into_iter().map(Into::into).collect(); - self - } - - /// Returns the [`ExtXIFrameStreamInf`] tags contained in the playlist. - pub const fn i_frame_stream_inf_tags(&self) -> &Vec { - &self.i_frame_stream_inf_tags - } - - /// Appends an [`ExtXIFrameStreamInf`]. - pub fn push_i_frame_stream_inf(&mut self, value: ExtXIFrameStreamInf) -> &mut Self { - self.i_frame_stream_inf_tags.push(value); - self - } - - /// Sets the [`ExtXIFrameStreamInf`] tags contained in the playlist. - pub fn set_i_frame_stream_inf_tags(&mut self, value: Vec) -> &mut Self - where - T: Into, - { - self.i_frame_stream_inf_tags = value.into_iter().map(Into::into).collect(); - self - } - - /// Returns the [`ExtXSessionData`] tags contained in the playlist. - pub const fn session_data_tags(&self) -> &Vec { &self.session_data_tags } - - /// Appends an [`ExtXSessionData`]. - pub fn push_session_data(&mut self, value: ExtXSessionData) -> &mut Self { - self.session_data_tags.push(value); - self - } - - /// Sets the [`ExtXSessionData`] tags contained in the playlist. - pub fn set_session_data_tags(&mut self, value: Vec) -> &mut Self - where - T: Into, - { - self.session_data_tags = value.into_iter().map(Into::into).collect(); - self - } - - /// Returns the [`ExtXSessionKey`] tags contained in the playlist. - pub const fn session_key_tags(&self) -> &Vec { &self.session_key_tags } - - /// Appends an [`ExtXSessionKey`]. - pub fn push_session_key(&mut self, value: ExtXSessionKey) -> &mut Self { - self.session_key_tags.push(value); - self - } - - /// Sets the [`ExtXSessionKey`] tags contained in the playlist. - pub fn set_session_key_tags(&mut self, value: Vec) -> &mut Self - where - T: Into, - { - self.session_key_tags = value.into_iter().map(Into::into).collect(); - self + /// Returns all `ExtXMedia` tags, associated with the provided stream. + pub fn associated_with<'a>( + &'a self, + stream: &'a VariantStream, + ) -> impl Iterator + 'a { + self.media + .iter() + .filter(move |media| stream.is_associated(media)) } } impl RequiredVersion for MasterPlaylist { fn required_version(&self) -> ProtocolVersion { required_version![ - self.independent_segments_tag, - self.start_tag, - self.media_tags, - self.stream_inf_tags, - self.i_frame_stream_inf_tags, - self.session_data_tags, - self.session_key_tags + self.has_independent_segments + .athen_some(ExtXIndependentSegments), + self.start, + self.media, + self.variant_streams, + self.session_data, + self.session_keys ] } } impl MasterPlaylistBuilder { fn validate(&self) -> Result<(), String> { - self.validate_stream_inf_tags().map_err(|e| e.to_string())?; - self.validate_i_frame_stream_inf_tags() - .map_err(|e| e.to_string())?; + if let Some(variant_streams) = &self.variant_streams { + self.validate_variants(variant_streams) + .map_err(|e| e.to_string())?; + } + self.validate_session_data_tags() .map_err(|e| e.to_string())?; Ok(()) } - fn validate_stream_inf_tags(&self) -> crate::Result<()> { - if let Some(value) = &self.stream_inf_tags { - let mut has_none_closed_captions = false; + fn validate_variants(&self, variant_streams: &[VariantStream]) -> crate::Result<()> { + let mut closed_captions_none = false; - for t in value { - if let Some(group_id) = t.audio() { - if !self.check_media_group(MediaType::Audio, group_id) { - return Err(Error::unmatched_group(group_id)); - } - } - if let Some(group_id) = t.video() { - if !self.check_media_group(MediaType::Video, group_id) { - return Err(Error::unmatched_group(group_id)); - } - } - if let Some(group_id) = t.subtitles() { - if !self.check_media_group(MediaType::Subtitles, group_id) { - return Err(Error::unmatched_group(group_id)); - } - } - match t.closed_captions() { - &Some(ClosedCaptions::GroupId(ref group_id)) => { - if !self.check_media_group(MediaType::ClosedCaptions, group_id) { + for variant in variant_streams { + match &variant { + VariantStream::ExtXStreamInf { + audio, + subtitles, + closed_captions, + stream_data, + .. + } => { + if let Some(group_id) = &audio { + if !self.check_media_group(MediaType::Audio, group_id) { return Err(Error::unmatched_group(group_id)); } } - &Some(ClosedCaptions::None) => { - has_none_closed_captions = true; - } - None => {} - } - } - if has_none_closed_captions - && !value - .iter() - .all(|t| t.closed_captions() == &Some(ClosedCaptions::None)) - { - return Err(Error::invalid_input()); - } - } - Ok(()) - } - fn validate_i_frame_stream_inf_tags(&self) -> crate::Result<()> { - if let Some(value) = &self.i_frame_stream_inf_tags { - for t in value { - if let Some(group_id) = t.video() { - if !self.check_media_group(MediaType::Video, group_id) { - return Err(Error::unmatched_group(group_id)); + if let Some(group_id) = &stream_data.video() { + if !self.check_media_group(MediaType::Video, group_id) { + return Err(Error::unmatched_group(group_id)); + } + } + + if let Some(group_id) = &subtitles { + if !self.check_media_group(MediaType::Subtitles, group_id) { + return Err(Error::unmatched_group(group_id)); + } + } + + if let Some(closed_captions) = &closed_captions { + match &closed_captions { + ClosedCaptions::GroupId(group_id) => { + if closed_captions_none { + return Err(Error::custom("ClosedCaptions has to be `None`")); + } + + if !self.check_media_group(MediaType::ClosedCaptions, group_id) { + return Err(Error::unmatched_group(group_id)); + } + } + _ => { + if !closed_captions_none { + closed_captions_none = true; + } + } + } + } + } + + VariantStream::ExtXIFrame { stream_data, .. } => { + if let Some(group_id) = stream_data.video() { + if !self.check_media_group(MediaType::Video, group_id) { + return Err(Error::unmatched_group(group_id)); + } } } } } + Ok(()) } fn validate_session_data_tags(&self) -> crate::Result<()> { let mut set = HashSet::new(); - if let Some(value) = &self.session_data_tags { - for t in value { - if !set.insert((t.data_id(), t.language())) { - return Err(Error::custom(format!("Conflict: {}", t))); + + if let Some(values) = &self.session_data { + set.reserve(values.len()); + + for tag in values { + if !set.insert((tag.data_id(), tag.language())) { + return Err(Error::custom(format!("conflict: {}", tag))); } } } + Ok(()) } - fn check_media_group(&self, media_type: MediaType, group_id: T) -> bool { - if let Some(value) = &self.media_tags { - value - .iter() - .any(|t| t.media_type() == media_type && t.group_id() == &group_id.to_string()) + fn check_media_group>(&self, media_type: MediaType, group_id: T) -> bool { + if let Some(value) = &self.media { + value.iter().any(|media| { + media.media_type == media_type && media.group_id().as_str() == group_id.as_ref() + }) } else { false } @@ -308,44 +397,54 @@ impl RequiredVersion for MasterPlaylistBuilder { // not for Option>) // https://github.com/rust-lang/chalk/issues/12 required_version![ - self.independent_segments_tag.flatten(), - self.start_tag.flatten(), - self.media_tags, - self.stream_inf_tags, - self.i_frame_stream_inf_tags, - self.session_data_tags, - self.session_key_tags + self.has_independent_segments + .unwrap_or(false) + .athen_some(ExtXIndependentSegments), + self.start.flatten(), + self.media, + self.variant_streams, + self.session_data, + self.session_keys ] } } impl fmt::Display for MasterPlaylist { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", ExtM3u)?; + if self.required_version() != ProtocolVersion::V1 { writeln!(f, "{}", ExtXVersion::new(self.required_version()))?; } - for t in &self.media_tags { - writeln!(f, "{}", t)?; - } - for t in &self.stream_inf_tags { - writeln!(f, "{}", t)?; - } - for t in &self.i_frame_stream_inf_tags { - writeln!(f, "{}", t)?; - } - for t in &self.session_data_tags { - writeln!(f, "{}", t)?; - } - for t in &self.session_key_tags { - writeln!(f, "{}", t)?; - } - if let Some(value) = &self.independent_segments_tag { + + for value in &self.media { writeln!(f, "{}", value)?; } - if let Some(value) = &self.start_tag { + + for value in &self.variant_streams { writeln!(f, "{}", value)?; } + + for value in &self.session_data { + writeln!(f, "{}", value)?; + } + + for value in &self.session_keys { + writeln!(f, "{}", value)?; + } + + if self.has_independent_segments { + writeln!(f, "{}", ExtXIndependentSegments)?; + } + + if let Some(value) = &self.start { + writeln!(f, "{}", value)?; + } + + for value in &self.unknown_tags { + writeln!(f, "{}", value)?; + } + Ok(()) } } @@ -354,31 +453,24 @@ impl FromStr for MasterPlaylist { type Err = Error; fn from_str(input: &str) -> Result { + let input = tag(input, ExtM3u::PREFIX)?; let mut builder = Self::builder(); - let mut media_tags = vec![]; - let mut stream_inf_tags = vec![]; - let mut i_frame_stream_inf_tags = vec![]; - let mut session_data_tags = vec![]; - let mut session_key_tags = vec![]; + let mut media = vec![]; + let mut variant_streams = vec![]; + let mut session_data = vec![]; + let mut session_keys = vec![]; + let mut unknown_tags = vec![]; - for (i, line) in input.parse::()?.into_iter().enumerate() { - match line { + for line in Lines::from(input) { + match line? { Line::Tag(tag) => { - if i == 0 { - if tag != Tag::ExtM3u(ExtM3u) { - return Err(Error::invalid_input()); - } - continue; - } match tag { - Tag::ExtM3u(_) => { - return Err(Error::invalid_input()); - } Tag::ExtXVersion(_) => { // This tag can be ignored, because the // MasterPlaylist will automatically set the - // ExtXVersion tag to correct version! + // ExtXVersion tag to the minimum required version + // TODO: this might be verified? } Tag::ExtInf(_) | Tag::ExtXByteRange(_) @@ -391,94 +483,314 @@ impl FromStr for MasterPlaylist { | Tag::ExtXMediaSequence(_) | Tag::ExtXDiscontinuitySequence(_) | Tag::ExtXEndList(_) - | Tag::ExtXPlaylistType(_) + | Tag::PlaylistType(_) | Tag::ExtXIFramesOnly(_) => { - return Err(Error::custom(format!( - "This tag isn't allowed in a master playlist: {}", - tag - ))); + return Err(Error::unexpected_tag(tag)); } Tag::ExtXMedia(t) => { - media_tags.push(t); + media.push(t); } - Tag::ExtXStreamInf(t) => { - stream_inf_tags.push(t); - } - Tag::ExtXIFrameStreamInf(t) => { - i_frame_stream_inf_tags.push(t); + Tag::VariantStream(t) => { + variant_streams.push(t); } Tag::ExtXSessionData(t) => { - session_data_tags.push(t); + session_data.push(t); } Tag::ExtXSessionKey(t) => { - session_key_tags.push(t); + session_keys.push(t); } - Tag::ExtXIndependentSegments(t) => { - builder.independent_segments_tag(t); + Tag::ExtXIndependentSegments(_) => { + builder.has_independent_segments(true); } Tag::ExtXStart(t) => { - builder.start_tag(t); + builder.start(t); } _ => { // [6.3.1. General Client Responsibilities] // > ignore any unrecognized tags. - // TODO: collect custom tags + unknown_tags.push(tag.to_string()); } } } Line::Uri(uri) => { - return Err(Error::custom(format!("Unexpected URI: {:?}", uri))); + return Err(Error::custom(format!("unexpected uri: {:?}", uri))); } + _ => {} } } - builder.media_tags(media_tags); - builder.stream_inf_tags(stream_inf_tags); - builder.i_frame_stream_inf_tags(i_frame_stream_inf_tags); - builder.session_data_tags(session_data_tags); - builder.session_key_tags(session_key_tags); + builder.media(media); + builder.variant_streams(variant_streams); + builder.session_data(session_data); + builder.session_keys(session_keys); + builder.unknown_tags(unknown_tags); - builder.build().map_err(Error::builder_error) + builder.build().map_err(Error::builder) } } #[cfg(test)] mod tests { use super::*; + use crate::types::StreamData; use pretty_assertions::assert_eq; #[test] - fn test_parser() { - "#EXTM3U\n\ - #EXT-X-STREAM-INF:BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/low/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/lo_mid/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/hi_mid/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n\ - http://example.com/high/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n\ - http://example.com/audio/index.m3u8\n" - .parse::() + fn test_audio_streams() { + let astreams = vec![ + VariantStream::ExtXStreamInf { + uri: "http://example.com/low/index.m3u8".into(), + frame_rate: None, + audio: Some("ag0".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(150000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap(), + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/lo_mid/index.m3u8".into(), + frame_rate: None, + audio: Some("ag1".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(240000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap(), + }, + ]; + + let master_playlist = MasterPlaylist::builder() + .variant_streams(astreams.clone()) + .media(vec![ + ExtXMedia::builder() + .media_type(MediaType::Audio) + .uri("https://www.example.com/ag0.m3u8") + .group_id("ag0") + .language("english") + .name("alternative rendition for ag0") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Audio) + .uri("https://www.example.com/ag1.m3u8") + .group_id("ag1") + .language("english") + .name("alternative rendition for ag1") + .build() + .unwrap(), + ]) + .build() .unwrap(); + + assert_eq!( + master_playlist.variant_streams, + master_playlist.audio_streams().collect::>() + ); + + let mut audio_streams = master_playlist.audio_streams(); + + assert_eq!(audio_streams.next(), Some(&astreams[0])); + assert_eq!(audio_streams.next(), Some(&astreams[1])); + assert_eq!(audio_streams.next(), None); + } + + #[test] + fn test_parser() { + assert_eq!( + concat!( + "#EXTM3U\n", + "#EXT-X-STREAM-INF:", + "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/low/index.m3u8\n", + "#EXT-X-STREAM-INF:", + "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/lo_mid/index.m3u8\n", + "#EXT-X-STREAM-INF:", + "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/hi_mid/index.m3u8\n", + "#EXT-X-STREAM-INF:", + "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", + "http://example.com/high/index.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", + "http://example.com/audio/index.m3u8\n" + ) + .parse::() + .unwrap(), + MasterPlaylist::builder() + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "http://example.com/low/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(150000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/lo_mid/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(240000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/hi_mid/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(440000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/high/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(640000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((640, 360)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/audio/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(64000) + .codecs(&["mp4a.40.5"]) + .build() + .unwrap() + }, + ]) + .build() + .unwrap() + ); } #[test] fn test_display() { - let input = "#EXTM3U\n\ - #EXT-X-STREAM-INF:BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/low/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/lo_mid/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n\ - http://example.com/hi_mid/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n\ - http://example.com/high/index.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n\ - http://example.com/audio/index.m3u8\n"; - - let playlist = input.parse::().unwrap(); - assert_eq!(playlist.to_string(), input); + assert_eq!( + MasterPlaylist::builder() + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "http://example.com/low/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(150000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/lo_mid/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(240000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/hi_mid/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(440000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((416, 234)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/high/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(640000) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .resolution((640, 360)) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/audio/index.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(64000) + .codecs(&["mp4a.40.5"]) + .build() + .unwrap() + }, + ]) + .build() + .unwrap() + .to_string(), + concat!( + "#EXTM3U\n", + // + "#EXT-X-STREAM-INF:", + "BANDWIDTH=150000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/low/index.m3u8\n", + // + "#EXT-X-STREAM-INF:", + "BANDWIDTH=240000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/lo_mid/index.m3u8\n", + // + "#EXT-X-STREAM-INF:", + "BANDWIDTH=440000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=416x234\n", + "http://example.com/hi_mid/index.m3u8\n", + // + "#EXT-X-STREAM-INF:", + "BANDWIDTH=640000,CODECS=\"avc1.42e00a,mp4a.40.2\",RESOLUTION=640x360\n", + "http://example.com/high/index.m3u8\n", + // + "#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS=\"mp4a.40.5\"\n", + "http://example.com/audio/index.m3u8\n" + ) + .to_string() + ); } } diff --git a/src/media_playlist.rs b/src/media_playlist.rs index a334650..eb89d75 100644 --- a/src/media_playlist.rs +++ b/src/media_playlist.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::str::FromStr; use std::time::Duration; @@ -7,59 +8,133 @@ use derive_builder::Builder; use crate::line::{Line, Lines, Tag}; use crate::media_segment::MediaSegment; use crate::tags::{ - ExtM3u, ExtXDiscontinuitySequence, ExtXEndList, ExtXIFramesOnly, ExtXIndependentSegments, - ExtXMediaSequence, ExtXPlaylistType, ExtXStart, ExtXTargetDuration, ExtXVersion, + ExtM3u, ExtXByteRange, ExtXDiscontinuitySequence, ExtXEndList, ExtXIFramesOnly, + ExtXIndependentSegments, ExtXKey, ExtXMediaSequence, ExtXStart, ExtXTargetDuration, + ExtXVersion, }; -use crate::types::ProtocolVersion; -use crate::{Encrypted, Error, RequiredVersion}; +use crate::types::{ + DecryptionKey, EncryptionMethod, InitializationVector, KeyFormat, PlaylistType, ProtocolVersion, +}; +use crate::utils::{tag, BoolExt}; +use crate::{Error, RequiredVersion}; /// Media playlist. -#[derive(Debug, Clone, Builder, PartialEq, PartialOrd)] -#[builder(build_fn(validate = "Self::validate"))] -#[builder(setter(into, strip_option))] +#[derive(Builder, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[builder(build_fn(skip), setter(strip_option))] +#[non_exhaustive] pub struct MediaPlaylist { - /// Sets the [`ExtXTargetDuration`] tag. - target_duration_tag: ExtXTargetDuration, + /// Specifies the maximum [`MediaSegment::duration`]. A typical target + /// duration is 10 seconds. + /// + /// ### Note + /// + /// This field is required. + pub target_duration: Duration, + /// The [`MediaSegment::number`] of the first [`MediaSegment`] that + /// appears in a [`MediaPlaylist`]. + /// + /// ### Note + /// + /// This field is optional and by default a value of 0 is assumed. #[builder(default)] - /// Sets the [`ExtXMediaSequence`] tag. - media_sequence_tag: Option, + pub media_sequence: usize, + /// Allows synchronization between different renditions of the same + /// [`VariantStream`]. + /// + /// ### Note + /// + /// This field is optional and by default a vaule of 0 is assumed. + /// + /// [`VariantStream`]: crate::tags::VariantStream #[builder(default)] - /// Sets the [`ExtXDiscontinuitySequence`] tag. - discontinuity_sequence_tag: Option, + pub discontinuity_sequence: usize, + /// Provides mutability information about a [`MediaPlaylist`]. + /// + /// - [`PlaylistType::Vod`] indicates that the playlist must not change. + /// + /// - [`PlaylistType::Event`] indicates that the server does not change or + /// delete any part of the playlist, but may append new lines to it. + /// + /// ### Note + /// + /// This field is optional. + #[builder(default, setter(into))] + pub playlist_type: Option, + /// Indicates that each [`MediaSegment`] in the playlist describes a single + /// I-frame. I-frames are encoded video frames, whose decoding does not + /// depend on any other frame. I-frame Playlists can be used for trick + /// play, such as fast forward, rapid reverse, and scrubbing. + /// + /// ### Note + /// + /// This field is optional. #[builder(default)] - /// Sets the [`ExtXPlaylistType`] tag. - playlist_type_tag: Option, + pub has_i_frames_only: bool, + /// This indicates that all media samples in a [`MediaSegment`] can be + /// decoded without information from other segments. + /// + /// ### Note + /// + /// This field is optional and by default `false`. If the value is `true` it + /// applies to every [`MediaSegment`] in this [`MediaPlaylist`]. #[builder(default)] - /// Sets the [`ExtXIFramesOnly`] tag. - i_frames_only_tag: Option, + pub has_independent_segments: bool, + /// Indicates a preferred point at which to start playing a playlist. By + /// default, clients should start playback at this point when beginning a + /// playback session. + /// + /// ### Note + /// + /// This field is optional. + #[builder(default, setter(into))] + pub start: Option, + /// Indicates that no more [`MediaSegment`]s will be added to the + /// [`MediaPlaylist`] file. + /// + /// ### Note + /// + /// This field is optional and by default `false`. + /// A `false` indicates that the client should reload the [`MediaPlaylist`] + /// from the server, until a playlist is encountered, where this field is + /// `true`. #[builder(default)] - /// Sets the [`ExtXIndependentSegments`] tag. - independent_segments_tag: Option, - #[builder(default)] - /// Sets the [`ExtXStart`] tag. - start_tag: Option, - #[builder(default)] - /// Sets the [`ExtXEndList`] tag. - end_list_tag: Option, - /// Sets all [`MediaSegment`]s. - segments: Vec, - /// Sets the allowable excess duration of each media segment in the + pub has_end_list: bool, + /// A list of all [`MediaSegment`]s. + /// + /// ### Note + /// + /// This field is required. + #[builder(setter(custom))] + pub segments: BTreeMap, + /// The allowable excess duration of each media segment in the /// associated playlist. /// - /// # Error + /// ### Error + /// /// If there is a media segment of which duration exceeds /// `#EXT-X-TARGETDURATION + allowable_excess_duration`, /// the invocation of `MediaPlaylistBuilder::build()` method will fail. /// - /// The default value is `Duration::from_secs(0)`. + /// + /// ### Note + /// + /// This field is optional and the default value is + /// `Duration::from_secs(0)`. #[builder(default = "Duration::from_secs(0)")] - allowable_excess_duration: Duration, + pub allowable_excess_duration: Duration, + /// A list of unknown tags. + /// + /// ### Note + /// + /// This field is optional. + #[builder(default, setter(into))] + pub unknown: Vec, } impl MediaPlaylistBuilder { fn validate(&self) -> Result<(), String> { - if let Some(target_duration) = &self.target_duration_tag { - self.validate_media_segments(target_duration.duration()) + if let Some(target_duration) = &self.target_duration { + self.validate_media_segments(*target_duration) .map_err(|e| e.to_string())?; } @@ -68,25 +143,55 @@ impl MediaPlaylistBuilder { fn validate_media_segments(&self, target_duration: Duration) -> crate::Result<()> { let mut last_range_uri = None; - if let Some(segments) = &self.segments { - for s in segments { - // CHECK: `#EXT-X-TARGETDURATION` - let segment_duration = s.inf_tag().duration(); - let rounded_segment_duration = { - if segment_duration.subsec_nanos() < 500_000_000 { - Duration::from_secs(segment_duration.as_secs()) - } else { - Duration::from_secs(segment_duration.as_secs() + 1) - } - }; - let max_segment_duration = { - if let Some(value) = &self.allowable_excess_duration { - target_duration + *value - } else { - target_duration + if let Some(segments) = &self.segments { + // verify the independent segments + if self.has_independent_segments.unwrap_or(false) { + // If the encryption METHOD is AES-128 and the Playlist contains an EXT- + // X-I-FRAMES-ONLY tag, the entire resource MUST be encrypted using + // AES-128 CBC with PKCS7 padding [RFC5652]. + // + // from the rfc: https://tools.ietf.org/html/rfc8216#section-6.2.3 + + let is_aes128 = segments + .values() + // convert iterator of segments to iterator of keys + .flat_map(|s| s.keys.iter()) + // filter out all empty keys + .filter_map(ExtXKey::as_ref) + .any(|k| k.method == EncryptionMethod::Aes128); + + if is_aes128 { + for key in segments.values().flat_map(|s| s.keys.iter()) { + if let ExtXKey(Some(key)) = key { + if key.method != EncryptionMethod::Aes128 { + return Err(Error::custom(concat!( + "if any independent segment is encrypted with Aes128,", + " all must be encrypted with Aes128" + ))); + } + } else { + return Err(Error::custom(concat!( + "if any independent segment is encrypted with Aes128,", + " all must be encrypted with Aes128" + ))); + } } - }; + } + } + + for segment in segments.values() { + // CHECK: `#EXT-X-TARGETDURATION` + let segment_duration = segment.duration.duration(); + + // round the duration if it is .5s + let rounded_segment_duration = + Duration::from_secs(segment_duration.as_secs_f64().round() as u64); + + let max_segment_duration = self + .allowable_excess_duration + .as_ref() + .map_or(target_duration, |value| target_duration + *value); if rounded_segment_duration > max_segment_duration { return Err(Error::custom(format!( @@ -94,35 +199,43 @@ impl MediaPlaylistBuilder { segment_duration, max_segment_duration, target_duration, - s.uri() + segment.uri() ))); } // CHECK: `#EXT-X-BYTE-RANGE` - if let Some(tag) = s.byte_range_tag() { - if tag.to_range().start().is_none() { - let last_uri = last_range_uri.ok_or_else(Error::invalid_input)?; - if last_uri != s.uri() { + if let Some(range) = &segment.byte_range { + if range.start().is_none() { + // TODO: error messages + if last_range_uri.ok_or_else(Error::invalid_input)? != segment.uri() { return Err(Error::invalid_input()); } } else { - last_range_uri = Some(s.uri()); + last_range_uri = Some(segment.uri()); } } else { last_range_uri = None; } } } + Ok(()) } - /// Adds a media segment to the resulting playlist. - pub fn push_segment>(&mut self, value: VALUE) -> &mut Self { - if let Some(segments) = &mut self.segments { - segments.push(value.into()); - } else { - self.segments = Some(vec![value.into()]); - } + /// Adds a media segment to the resulting playlist and assigns the next free + /// [`MediaSegment::number`] to the segment. + pub fn push_segment(&mut self, segment: MediaSegment) -> &mut Self { + let segments = self.segments.get_or_insert_with(BTreeMap::new); + + let number = { + if segment.explicit_number { + segment.number + } else { + segments.keys().last().copied().unwrap_or(0) + 1 + } + }; + + segments.insert(number, segment); self } @@ -130,19 +243,157 @@ impl MediaPlaylistBuilder { pub fn parse(&mut self, input: &str) -> crate::Result { parse_media_playlist(input, self) } + + /// Adds segments to the resulting playlist and assigns a + /// [`MediaSegment::number`] to each segment. + /// + /// ## Note + /// + /// The [`MediaSegment::number`] will be assigned based on the order of the + /// input (e.g. the first element will be 0, second element 1, ..) or if a + /// number has been set explicitly. This function assumes, that all segments + /// will be present in the final media playlist and the following is only + /// possible if the segment is marked with `ExtXDiscontinuity`. + pub fn segments(&mut self, segments: Vec) -> &mut Self { + // media segments are numbered starting at either 0 or the discontinuity + // sequence, but it might not be available at the moment. + // + // -> final numbering will be applied in the build function + self.segments = Some(segments.into_iter().enumerate().collect()); + self + } + + /// Builds a new `MediaPlaylist`. + /// + /// # Errors + /// + /// If a required field has not been initialized. + pub fn build(&self) -> Result { + // validate builder + self.validate()?; + + let sequence_number = self.media_sequence.unwrap_or(0); + + let segments = self + .segments + .as_ref() + .ok_or_else(|| "missing field `segments`".to_string())?; + + // insert all explictly numbered segments into the result + let mut result_segments = segments + .iter() + .filter_map(|(_, s)| s.explicit_number.athen(|| (s.number, s.clone()))) + .collect::>(); + + // no segment should exist before the sequence_number + if let Some(first_segment) = result_segments.keys().min() { + if sequence_number > *first_segment { + return Err(format!( + "there should be no segment ({}) before the sequence_number ({})", + first_segment, sequence_number, + )); + } + } + + let mut position = sequence_number; + let mut previous_range: Option = None; + + for segment in segments + .iter() + .filter_map(|(_, s)| if s.explicit_number { None } else { Some(s) }) + { + while result_segments.contains_key(&position) { + position += 1; + } + + let mut segment = segment.clone(); + segment.number = position; + + // add the segment number as iv, if the iv is missing: + for key in &mut segment.keys { + if let ExtXKey(Some(DecryptionKey { + method, iv, format, .. + })) = key + { + if *method == EncryptionMethod::Aes128 && *iv == InitializationVector::Missing { + if format.is_none() { + *iv = InitializationVector::Number(segment.number as u128); + } else if let Some(KeyFormat::Identity) = format { + *iv = InitializationVector::Number(segment.number as u128); + } + } + } + } + + // add the lower bound to the byterange automatically + if let Some(range) = &mut segment.byte_range { + if range.start().is_none() { + if let Some(previous_range) = previous_range { + // the end of the previous_range is the start of the next range + *range = range.saturating_add(previous_range.end()); + range.set_start(Some(previous_range.end())); + } else { + // assume that the byte range starts at zero + range.set_start(Some(0)); + } + } + + previous_range = segment.byte_range; + } + + result_segments.insert(segment.number, segment); + position += 1; + } + + let mut previous_n = None; + + for n in result_segments.keys() { + if let Some(previous_n) = previous_n { + if previous_n + 1 != *n { + return Err(format!("missing segment ({})", previous_n + 1)); + } + } + + previous_n = Some(n); + } + + Ok(MediaPlaylist { + target_duration: self + .target_duration + .ok_or_else(|| "missing field `target_duration`".to_string())?, + media_sequence: self.media_sequence.unwrap_or(0), + discontinuity_sequence: self.discontinuity_sequence.unwrap_or(0), + playlist_type: self.playlist_type.unwrap_or(None), + has_i_frames_only: self.has_i_frames_only.unwrap_or(false), + has_independent_segments: self.has_independent_segments.unwrap_or(false), + start: self.start.unwrap_or(None), + has_end_list: self.has_end_list.unwrap_or(false), + segments: result_segments, + allowable_excess_duration: self + .allowable_excess_duration + .unwrap_or_else(|| Duration::from_secs(0)), + unknown: self.unknown.clone().unwrap_or_else(Vec::new), + }) + } } impl RequiredVersion for MediaPlaylistBuilder { fn required_version(&self) -> ProtocolVersion { required_version![ - self.target_duration_tag, - self.media_sequence_tag, - self.discontinuity_sequence_tag, - self.playlist_type_tag, - self.i_frames_only_tag, - self.independent_segments_tag, - self.start_tag, - self.end_list_tag, + self.target_duration.map(ExtXTargetDuration), + (self.media_sequence.unwrap_or(0) != 0) + .athen(|| ExtXMediaSequence(self.media_sequence.unwrap_or(0))), + (self.discontinuity_sequence.unwrap_or(0) != 0) + .athen(|| ExtXDiscontinuitySequence(self.discontinuity_sequence.unwrap_or(0))), + self.playlist_type, + self.has_i_frames_only + .unwrap_or(false) + .athen_some(ExtXIFramesOnly), + self.has_independent_segments + .unwrap_or(false) + .athen_some(ExtXIndependentSegments), + self.start, + self.has_end_list.unwrap_or(false).athen_some(ExtXEndList), self.segments ] } @@ -150,88 +401,137 @@ impl RequiredVersion for MediaPlaylistBuilder { impl MediaPlaylist { /// Returns a builder for [`MediaPlaylist`]. + #[must_use] + #[inline] pub fn builder() -> MediaPlaylistBuilder { MediaPlaylistBuilder::default() } - /// Returns the [`ExtXTargetDuration`] tag contained in the playlist. - pub const fn target_duration_tag(&self) -> ExtXTargetDuration { self.target_duration_tag } - - /// Returns the `EXT-X-MEDIA-SEQUENCE` tag contained in the playlist. - pub const fn media_sequence_tag(&self) -> Option { self.media_sequence_tag } - - /// Returns the [`ExtXDiscontinuitySequence`] tag contained in the - /// playlist. - pub const fn discontinuity_sequence_tag(&self) -> Option { - self.discontinuity_sequence_tag + /// Computes the `Duration` of the [`MediaPlaylist`], by adding each segment + /// duration together. + #[must_use] + pub fn duration(&self) -> Duration { + self.segments.values().map(|s| s.duration.duration()).sum() } - - /// Returns the [`ExtXPlaylistType`] tag contained in the playlist. - pub const fn playlist_type_tag(&self) -> Option { self.playlist_type_tag } - - /// Returns the [`ExtXIFramesOnly`] tag contained in the playlist. - pub const fn i_frames_only_tag(&self) -> Option { self.i_frames_only_tag } - - /// Returns the [`ExtXIndependentSegments`] tag contained in the playlist. - pub const fn independent_segments_tag(&self) -> Option { - self.independent_segments_tag - } - - /// Returns the [`ExtXStart`] tag contained in the playlist. - pub const fn start_tag(&self) -> Option { self.start_tag } - - /// Returns the [`ExtXEndList`] tag contained in the playlist. - pub const fn end_list_tag(&self) -> Option { self.end_list_tag } - - /// Returns the [`MediaSegment`]s contained in the playlist. - pub const fn segments(&self) -> &Vec { &self.segments } } impl RequiredVersion for MediaPlaylist { fn required_version(&self) -> ProtocolVersion { required_version![ - self.target_duration_tag, - self.media_sequence_tag, - self.discontinuity_sequence_tag, - self.playlist_type_tag, - self.i_frames_only_tag, - self.independent_segments_tag, - self.start_tag, - self.end_list_tag, + ExtXTargetDuration(self.target_duration), + (self.media_sequence != 0).athen(|| ExtXMediaSequence(self.media_sequence)), + (self.discontinuity_sequence != 0) + .athen(|| ExtXDiscontinuitySequence(self.discontinuity_sequence)), + self.playlist_type, + self.has_i_frames_only.athen_some(ExtXIFramesOnly), + self.has_independent_segments + .athen_some(ExtXIndependentSegments), + self.start, + self.has_end_list.athen_some(ExtXEndList), self.segments ] } } impl fmt::Display for MediaPlaylist { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", ExtM3u)?; + if self.required_version() != ProtocolVersion::V1 { writeln!(f, "{}", ExtXVersion::new(self.required_version()))?; } - writeln!(f, "{}", self.target_duration_tag)?; - if let Some(value) = &self.media_sequence_tag { + + writeln!(f, "{}", ExtXTargetDuration(self.target_duration))?; + + if self.media_sequence != 0 { + writeln!(f, "{}", ExtXMediaSequence(self.media_sequence))?; + } + + if self.discontinuity_sequence != 0 { + writeln!( + f, + "{}", + ExtXDiscontinuitySequence(self.discontinuity_sequence) + )?; + } + + if let Some(value) = &self.playlist_type { writeln!(f, "{}", value)?; } - if let Some(value) = &self.discontinuity_sequence_tag { + + if self.has_i_frames_only { + writeln!(f, "{}", ExtXIFramesOnly)?; + } + + if self.has_independent_segments { + writeln!(f, "{}", ExtXIndependentSegments)?; + } + + if let Some(value) = &self.start { writeln!(f, "{}", value)?; } - if let Some(value) = &self.playlist_type_tag { - writeln!(f, "{}", value)?; - } - if let Some(value) = &self.i_frames_only_tag { - writeln!(f, "{}", value)?; - } - if let Some(value) = &self.independent_segments_tag { - writeln!(f, "{}", value)?; - } - if let Some(value) = &self.start_tag { - writeln!(f, "{}", value)?; - } - for segment in &self.segments { + + let mut available_keys = HashSet::::new(); + + for segment in self.segments.values() { + for key in &segment.keys { + if let ExtXKey(Some(decryption_key)) = key { + // next segment will be encrypted, so the segment can not have an empty key + available_keys.remove(&ExtXKey::empty()); + + let mut decryption_key = decryption_key.clone(); + let key = { + if let InitializationVector::Number(_) = decryption_key.iv { + // set the iv from a segment number to missing + // this does reduce the output size and the correct iv + // is automatically set, when parsing. + decryption_key.iv = InitializationVector::Missing; + } + + ExtXKey(Some(decryption_key.clone())) + }; + + // only do something if a key has been overwritten + if available_keys.insert(key.clone()) { + let mut remove_key = None; + + // an old key might be removed: + for k in &available_keys { + if let ExtXKey(Some(dk)) = k { + if dk.format == decryption_key.format && key != *k { + remove_key = Some(k.clone()); + break; + } + } else { + unreachable!("empty keys should not exist in `available_keys`"); + } + } + + if let Some(k) = remove_key { + // this should always be true: + let res = available_keys.remove(&k); + debug_assert!(res); + } + + writeln!(f, "{}", key)?; + } + } else { + // the next segment is not encrypted, so remove all available keys + available_keys.clear(); + available_keys.insert(ExtXKey::empty()); + writeln!(f, "{}", key)?; + } + } + write!(f, "{}", segment)?; } - if let Some(value) = &self.end_list_tag { + + for value in &self.unknown { writeln!(f, "{}", value)?; } + + if self.has_end_list { + writeln!(f, "{}", ExtXEndList)?; + } + Ok(()) } } @@ -240,132 +540,154 @@ fn parse_media_playlist( input: &str, builder: &mut MediaPlaylistBuilder, ) -> crate::Result { + let input = tag(input, "#EXTM3U")?; + let mut segment = MediaSegment::builder(); let mut segments = vec![]; let mut has_partial_segment = false; let mut has_discontinuity_tag = false; + let mut unknown = vec![]; + let mut available_keys = HashSet::new(); - let mut available_key_tags: Vec = vec![]; - - for (i, line) in input.parse::()?.into_iter().enumerate() { - match line { + for line in Lines::from(input) { + match line? { Line::Tag(tag) => { - if i == 0 { - if tag != Tag::ExtM3u(ExtM3u) { - return Err(Error::custom("m3u8 doesn't start with #EXTM3U")); - } - continue; - } match tag { - Tag::ExtM3u(_) => return Err(Error::invalid_input()), Tag::ExtInf(t) => { has_partial_segment = true; - segment.inf_tag(t); + segment.duration(t); } Tag::ExtXByteRange(t) => { has_partial_segment = true; - segment.byte_range_tag(t); + segment.byte_range(t); } - Tag::ExtXDiscontinuity(t) => { + Tag::ExtXDiscontinuity(_) => { has_discontinuity_tag = true; has_partial_segment = true; - segment.discontinuity_tag(t); + segment.has_discontinuity(true); } - Tag::ExtXKey(t) => { + Tag::ExtXKey(key) => { has_partial_segment = true; - if available_key_tags.is_empty() { - // An ExtXKey applies to every MediaSegment and to every Media - // Initialization Section declared by an EXT-X-MAP tag, that appears - // between it and the next EXT-X-KEY tag in the Playlist file with the - // same KEYFORMAT attribute (or the end of the Playlist file). - available_key_tags = available_key_tags - .into_iter() - .map(|k| { - if t.key_format() == k.key_format() { - t.clone() - } else { - k + + // An ExtXKey applies to every MediaSegment and to every Media + // Initialization Section declared by an ExtXMap tag, that appears + // between it and the next ExtXKey tag in the Playlist file with the + // same KEYFORMAT attribute (or the end of the Playlist file). + + let mut is_new_key = true; + let mut remove = None; + + if let ExtXKey(Some(decryption_key)) = &key { + for old_key in &available_keys { + if let ExtXKey(Some(old_decryption_key)) = &old_key { + if old_decryption_key.format == decryption_key.format { + // remove the old key + remove = Some(old_key.clone()); + + // there are no keys with the same format in + // available_keys so the loop can stop here: + break; } - }) - .collect(); + } else { + // remove an empty key + remove = Some(ExtXKey::empty()); + break; + } + } } else { - available_key_tags.push(t); + available_keys.clear(); + available_keys.insert(ExtXKey::empty()); + is_new_key = false; + } + + if let Some(key) = &remove { + available_keys.remove(key); + } + + if is_new_key { + available_keys.insert(key); } } Tag::ExtXMap(mut t) => { has_partial_segment = true; - t.set_keys(available_key_tags.clone()); - segment.map_tag(t); + t.keys = available_keys.iter().cloned().collect(); + segment.map(t); } Tag::ExtXProgramDateTime(t) => { has_partial_segment = true; - segment.program_date_time_tag(t); + segment.program_date_time(t); } Tag::ExtXDateRange(t) => { has_partial_segment = true; - segment.date_range_tag(t); + segment.date_range(t); } Tag::ExtXTargetDuration(t) => { - builder.target_duration_tag(t); + builder.target_duration(t.0); } Tag::ExtXMediaSequence(t) => { - builder.media_sequence_tag(t); + builder.media_sequence(t.0); } Tag::ExtXDiscontinuitySequence(t) => { if segments.is_empty() { return Err(Error::invalid_input()); } + if has_discontinuity_tag { return Err(Error::invalid_input()); } - builder.discontinuity_sequence_tag(t); + + builder.discontinuity_sequence(t.0); } - Tag::ExtXEndList(t) => { - builder.end_list_tag(t); + Tag::ExtXEndList(_) => { + builder.has_end_list(true); } - Tag::ExtXPlaylistType(t) => { - builder.playlist_type_tag(t); + Tag::PlaylistType(t) => { + builder.playlist_type(t); } - Tag::ExtXIFramesOnly(t) => { - builder.i_frames_only_tag(t); + Tag::ExtXIFramesOnly(_) => { + builder.has_i_frames_only(true); } Tag::ExtXMedia(_) - | Tag::ExtXStreamInf(_) - | Tag::ExtXIFrameStreamInf(_) + | Tag::VariantStream(_) | Tag::ExtXSessionData(_) | Tag::ExtXSessionKey(_) => { return Err(Error::unexpected_tag(tag)); } - Tag::ExtXIndependentSegments(t) => { - builder.independent_segments_tag(t); + Tag::ExtXIndependentSegments(_) => { + builder.has_independent_segments(true); } Tag::ExtXStart(t) => { - builder.start_tag(t); + builder.start(t); } - Tag::Unknown(_) | Tag::ExtXVersion(_) => { + Tag::ExtXVersion(_) => {} + Tag::Unknown(_) => { // [6.3.1. General Client Responsibilities] // > ignore any unrecognized tags. + unknown.push(tag.to_string()); } } } Line::Uri(uri) => { segment.uri(uri); - segment.keys(available_key_tags.clone()); - segments.push(segment.build().map_err(Error::builder_error)?); + segment.keys(available_keys.iter().cloned().collect::>()); + segments.push(segment.build().map_err(Error::builder)?); + segment = MediaSegment::builder(); has_partial_segment = false; } + _ => {} } } if has_partial_segment { - return Err(Error::invalid_input()); + return Err(Error::custom("Missing URI for the last `MediaSegment`")); } + builder.unknown(unknown); builder.segments(segments); - builder.build().map_err(Error::builder_error) + builder.build().map_err(Error::builder) } impl FromStr for MediaPlaylist { @@ -383,17 +705,18 @@ mod tests { #[test] fn too_large_segment_duration_test() { - 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"#; + let playlist = concat!( + "#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\n" + ); // Error (allowable segment duration = target duration = 8) assert!(playlist.parse::().is_err()); @@ -405,10 +728,98 @@ mod tests { .is_err()); // Ok (allowable segment duration = 10) - MediaPlaylist::builder() + assert_eq!( + MediaPlaylist::builder() + .allowable_excess_duration(Duration::from_secs(2)) + .parse(playlist) + .unwrap(), + MediaPlaylist::builder() + .allowable_excess_duration(Duration::from_secs(2)) + .target_duration(Duration::from_secs(8)) + .segments(vec![ + MediaSegment::builder() + .duration(Duration::from_secs_f64(9.009)) + .uri("http://media.example.com/first.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(9.509)) + .uri("http://media.example.com/second.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(3.003)) + .uri("http://media.example.com/third.ts") + .build() + .unwrap(), + ]) + .has_end_list(true) + .build() + .unwrap() + ); + } + + #[test] + fn test_segment_number_simple() { + let playlist = MediaPlaylist::builder() .allowable_excess_duration(Duration::from_secs(2)) - .parse(playlist) + .target_duration(Duration::from_secs(8)) + .segments(vec![ + MediaSegment::builder() + .duration(Duration::from_secs_f64(9.009)) + .uri("http://media.example.com/first.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(9.509)) + .uri("http://media.example.com/second.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(3.003)) + .uri("http://media.example.com/third.ts") + .build() + .unwrap(), + ]) + .build() .unwrap(); + + let mut segments = playlist.segments.into_iter().map(|(k, v)| (k, v.number)); + assert_eq!(segments.next(), Some((0, 0))); + assert_eq!(segments.next(), Some((1, 1))); + assert_eq!(segments.next(), Some((2, 2))); + assert_eq!(segments.next(), None); + } + + #[test] + fn test_segment_number_sequence() { + let playlist = MediaPlaylist::builder() + .target_duration(Duration::from_secs(8)) + .media_sequence(2680) + .segments(vec![ + MediaSegment::builder() + .duration(Duration::from_secs_f64(7.975)) + .uri("https://priv.example.com/fileSequence2680.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(7.941)) + .uri("https://priv.example.com/fileSequence2681.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(Duration::from_secs_f64(7.975)) + .uri("https://priv.example.com/fileSequence2682.ts") + .build() + .unwrap(), + ]) + .build() + .unwrap(); + let mut segments = playlist.segments.into_iter().map(|(k, v)| (k, v.number)); + assert_eq!(segments.next(), Some((2680, 2680))); + assert_eq!(segments.next(), Some((2681, 2681))); + assert_eq!(segments.next(), Some((2682, 2682))); + assert_eq!(segments.next(), None); } #[test] diff --git a/src/media_segment.rs b/src/media_segment.rs index ea4f160..046b60b 100644 --- a/src/media_segment.rs +++ b/src/media_segment.rs @@ -1,169 +1,231 @@ use std::fmt; use derive_builder::Builder; +use shorthand::ShortHand; use crate::tags::{ ExtInf, ExtXByteRange, ExtXDateRange, ExtXDiscontinuity, ExtXKey, ExtXMap, ExtXProgramDateTime, }; -use crate::types::ProtocolVersion; -use crate::{Encrypted, RequiredVersion}; +use crate::types::{DecryptionKey, ProtocolVersion}; +use crate::{Decryptable, RequiredVersion}; -#[derive(Debug, Clone, Builder, PartialEq, PartialOrd)] -#[builder(setter(into, strip_option))] -/// Media segment. +/// A video is split into smaller chunks called [`MediaSegment`]s, which are +/// specified by a uri and optionally a byte range. +/// +/// Each `MediaSegment` must carry the continuation of the encoded bitstream +/// from the end of the segment with the previous [`MediaSegment::number`], +/// where values in a series such as timestamps and continuity counters must +/// continue uninterrupted. The only exceptions are the first [`MediaSegment`] +/// ever to appear in a [`MediaPlaylist`] and [`MediaSegment`]s that are +/// explicitly signaled as discontinuities. +/// Unmarked media discontinuities can trigger playback errors. +/// +/// Any `MediaSegment` that contains video should include enough information +/// to initialize a video decoder and decode a continuous set of frames that +/// includes the final frame in the segment; network efficiency is optimized if +/// there is enough information in the segment to decode all frames in the +/// segment. +/// +/// For example, any `MediaSegment` containing H.264 video should +/// contain an Instantaneous Decoding Refresh (IDR); frames prior to the first +/// IDR will be downloaded but possibly discarded. +/// +/// [`MediaPlaylist`]: crate::MediaPlaylist +#[derive(ShortHand, Debug, Clone, Builder, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[builder(setter(strip_option))] +#[shorthand(enable(must_use, skip))] pub struct MediaSegment { + /// Each [`MediaSegment`] has a number, which allows synchronization between + /// different variants. + /// + /// ## Note + /// + /// This number must not be specified, because it will be assigned + /// automatically by [`MediaPlaylistBuilder::segments`]. The first + /// [`MediaSegment::number`] in a [`MediaPlaylist`] will either be 0 or the + /// number returned by the [`ExtXDiscontinuitySequence`] if one is + /// provided. + /// The following segments will be the previous segment number + 1. + /// + /// [`MediaPlaylistBuilder::segments`]: + /// crate::builder::MediaPlaylistBuilder::segments + /// [`MediaPlaylist`]: crate::MediaPlaylist + /// [`ExtXMediaSequence`]: crate::tags::ExtXMediaSequence + /// [`ExtXDiscontinuitySequence`]: crate::tags::ExtXDiscontinuitySequence + #[builder(default, setter(custom))] + #[shorthand(disable(set, skip))] + pub(crate) number: usize, + #[builder(default, setter(custom))] + pub(crate) explicit_number: bool, + /// This field specifies how to decrypt a [`MediaSegment`], which can only + /// be encrypted with one [`EncryptionMethod`], using one [`DecryptionKey`] + /// and [`DecryptionKey::iv`]. + /// + /// However, a server may offer multiple ways to retrieve that key by + /// providing multiple keys with different [`DecryptionKey::format`]s. + /// + /// Any unencrypted segment that is preceded by an encrypted segment must + /// have an [`ExtXKey::empty`]. Otherwise, the client will misinterpret + /// those segments as encrypted. + /// + /// The server may set the HTTP Expires header in the key response to + /// indicate the duration for which the key can be cached. + /// + /// ## Note + /// + /// This field is optional and a missing value or an [`ExtXKey::empty()`] + /// indicates an unencrypted media segment. + /// + /// [`ExtXMap`]: crate::tags::ExtXMap + /// [`KeyFormat`]: crate::types::KeyFormat + /// [`EncryptionMethod`]: crate::types::EncryptionMethod + #[builder(default, setter(into))] + pub keys: Vec, + /// This field specifies how to obtain the Media Initialization Section + /// required to parse the applicable `MediaSegment`s. + /// + /// ## Note + /// + /// This field is optional, but should be specified for media segments in + /// playlists with an [`ExtXIFramesOnly`] tag when the first `MediaSegment` + /// in the playlist (or the first segment following a segment marked with + /// [`MediaSegment::has_discontinuity`]) does not immediately follow the + /// Media Initialization Section at the beginning of its resource. + /// + /// [`ExtXIFramesOnly`]: crate::tags::ExtXIFramesOnly #[builder(default)] - /// Sets all [`ExtXKey`] tags. - keys: Vec, + pub map: Option, + /// This field indicates that a `MediaSegment` is a sub-range of the + /// resource identified by its URI. + /// + /// ## Note + /// + /// This field is optional. + #[builder(default, setter(into))] + pub byte_range: Option, + /// This field associates a date-range (i.e., a range of time defined by a + /// starting and ending date) with a set of attribute/value pairs. + /// + /// ## Note + /// + /// This field is optional. #[builder(default)] - /// Sets an [`ExtXMap`] tag. - map_tag: Option, + pub date_range: Option, + /// This field indicates a discontinuity between the `MediaSegment` that + /// follows it and the one that preceded it. + /// + /// ## Note + /// + /// This field is required if any of the following characteristics change: + /// - file format + /// - number, type, and identifiers of tracks + /// - timestamp, sequence + /// + /// This field should be present if any of the following characteristics + /// change: + /// - encoding parameters + /// - encoding sequence #[builder(default)] - /// Sets an [`ExtXByteRange`] tag. - byte_range_tag: Option, + pub has_discontinuity: bool, + /// This field associates the first sample of a media segment with an + /// absolute date and/or time. + /// + /// ## Note + /// + /// This field is optional. #[builder(default)] - /// Sets an [`ExtXDateRange`] tag. - date_range_tag: Option, - #[builder(default)] - /// Sets an [`ExtXDiscontinuity`] tag. - discontinuity_tag: Option, - #[builder(default)] - /// Sets an [`ExtXProgramDateTime`] tag. - program_date_time_tag: Option, - /// Sets an [`ExtInf`] tag. - inf_tag: ExtInf, - /// Sets an `URI`. + pub program_date_time: Option, + /// This field indicates the duration of a media segment. + /// + /// ## Note + /// + /// This field is required. + #[builder(setter(into))] + pub duration: ExtInf, + /// The URI of a media segment. + /// + /// ## Note + /// + /// This field is required. + #[builder(setter(into))] + #[shorthand(enable(into), disable(skip))] uri: String, } impl MediaSegment { - /// Returns a Builder for a [`MasterPlaylist`]. + /// Returns a builder for a [`MediaSegment`]. /// - /// [`MasterPlaylist`]: crate::MasterPlaylist + /// # Example + /// + /// ``` + /// # use hls_m3u8::MediaSegment; + /// use hls_m3u8::tags::ExtXMap; + /// use std::time::Duration; + /// + /// let segment = MediaSegment::builder() + /// .map(ExtXMap::new("https://www.example.com/")) + /// .byte_range(5..25) + /// .has_discontinuity(true) + /// .duration(Duration::from_secs(4)) + /// .uri("http://www.uri.com/") + /// .build()?; + /// # Ok::<(), String>(()) + /// ``` + #[must_use] + #[inline] pub fn builder() -> MediaSegmentBuilder { MediaSegmentBuilder::default() } - - /// Returns the `URI` of the media segment. - pub const fn uri(&self) -> &String { &self.uri } - - /// Sets the `URI` of the media segment. - pub fn set_uri(&mut self, value: T) -> &mut Self - where - T: Into, - { - self.uri = value.into(); - self - } - - /// Returns the [`ExtInf`] tag associated with the media segment. - pub const fn inf_tag(&self) -> &ExtInf { &self.inf_tag } - - /// Sets the [`ExtInf`] tag associated with the media segment. - pub fn set_inf_tag(&mut self, value: T) -> &mut Self - where - T: Into, - { - self.inf_tag = value.into(); - self - } - - /// Returns the [`ExtXByteRange`] tag associated with the media segment. - pub const fn byte_range_tag(&self) -> Option { self.byte_range_tag } - - /// Sets the [`ExtXByteRange`] tag associated with the media segment. - pub fn set_byte_range_tag(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.byte_range_tag = value.map(Into::into); - self - } - - /// Returns the [`ExtXDateRange`] tag associated with the media segment. - pub const fn date_range_tag(&self) -> &Option { &self.date_range_tag } - - /// Sets the [`ExtXDateRange`] tag associated with the media segment. - pub fn set_date_range_tag(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.date_range_tag = value.map(Into::into); - self - } - - /// Returns the [`ExtXDiscontinuity`] tag associated with the media segment. - pub const fn discontinuity_tag(&self) -> Option { self.discontinuity_tag } - - /// Sets the [`ExtXDiscontinuity`] tag associated with the media segment. - pub fn set_discontinuity_tag(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.discontinuity_tag = value.map(Into::into); - self - } - - /// Returns the [`ExtXProgramDateTime`] tag associated with the media - /// segment. - pub const fn program_date_time_tag(&self) -> Option { - self.program_date_time_tag - } - - /// Sets the [`ExtXProgramDateTime`] tag associated with the media - /// segment. - pub fn set_program_date_time_tag(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.program_date_time_tag = value.map(Into::into); - self - } - - /// Returns the [`ExtXMap`] tag associated with the media segment. - pub const fn map_tag(&self) -> &Option { &self.map_tag } - - /// Sets the [`ExtXMap`] tag associated with the media segment. - pub fn set_map_tag(&mut self, value: Option) -> &mut Self - where - T: Into, - { - self.map_tag = value.map(Into::into); - self - } } impl MediaSegmentBuilder { /// Pushes an [`ExtXKey`] tag. - pub fn push_key_tag>(&mut self, value: VALUE) -> &mut Self { - if let Some(key_tags) = &mut self.keys { - key_tags.push(value.into()); + pub fn push_key>(&mut self, value: VALUE) -> &mut Self { + if let Some(keys) = &mut self.keys { + keys.push(value.into()); } else { self.keys = Some(vec![value.into()]); } + + self + } + + /// The number of a [`MediaSegment`]. Normally this should not be set + /// explicitly, because the [`MediaPlaylist::builder`] will automatically + /// apply the correct number. + /// + /// [`MediaPlaylist::builder`]: crate::MediaPlaylist::builder + pub fn number(&mut self, value: Option) -> &mut Self { + self.number = value; + self.explicit_number = Some(value.is_some()); + self } } impl fmt::Display for MediaSegment { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - for value in &self.keys { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // NOTE: self.keys will be printed by the `MediaPlaylist` to prevent redundance. + + if let Some(value) = &self.map { writeln!(f, "{}", value)?; } - if let Some(value) = &self.map_tag { + + if let Some(value) = &self.byte_range { writeln!(f, "{}", value)?; } - if let Some(value) = &self.byte_range_tag { + + if let Some(value) = &self.date_range { writeln!(f, "{}", value)?; } - if let Some(value) = &self.date_range_tag { + + if self.has_discontinuity { + writeln!(f, "{}", ExtXDiscontinuity)?; + } + + if let Some(value) = &self.program_date_time { writeln!(f, "{}", value)?; } - if let Some(value) = &self.discontinuity_tag { - writeln!(f, "{}", value)?; - } - if let Some(value) = &self.program_date_time_tag { - writeln!(f, "{}", value)?; - } - writeln!(f, "{}", self.inf_tag)?; // TODO: there might be a `,` missing + + writeln!(f, "{}", self.duration)?; writeln!(f, "{}", self.uri)?; Ok(()) } @@ -173,20 +235,27 @@ impl RequiredVersion for MediaSegment { fn required_version(&self) -> ProtocolVersion { required_version![ self.keys, - self.map_tag, - self.byte_range_tag, - self.date_range_tag, - self.discontinuity_tag, - self.program_date_time_tag, - self.inf_tag + self.map, + self.byte_range, + self.date_range, + { + if self.has_discontinuity { + Some(ExtXDiscontinuity) + } else { + None + } + }, + self.program_date_time, + self.duration ] } } -impl Encrypted for MediaSegment { - fn keys(&self) -> &Vec { &self.keys } - - fn keys_mut(&mut self) -> &mut Vec { &mut self.keys } +impl Decryptable for MediaSegment { + fn keys(&self) -> Vec<&DecryptionKey> { + // + self.keys.iter().filter_map(ExtXKey::as_ref).collect() + } } #[cfg(test)] @@ -199,23 +268,22 @@ mod tests { fn test_display() { assert_eq!( MediaSegment::builder() - .keys(vec![ExtXKey::empty()]) - .map_tag(ExtXMap::new("https://www.example.com/")) - .byte_range_tag(ExtXByteRange::new(20, Some(5))) - //.date_range_tag() // TODO! - .discontinuity_tag(ExtXDiscontinuity) - .inf_tag(ExtInf::new(Duration::from_secs(4))) + .map(ExtXMap::new("https://www.example.com/")) + .byte_range(ExtXByteRange::from(5..25)) + .has_discontinuity(true) + .duration(ExtInf::new(Duration::from_secs(4))) .uri("http://www.uri.com/") .build() .unwrap() .to_string(), - "#EXT-X-KEY:METHOD=NONE\n\ - #EXT-X-MAP:URI=\"https://www.example.com/\"\n\ - #EXT-X-BYTERANGE:20@5\n\ - #EXT-X-DISCONTINUITY\n\ - #EXTINF:4,\n\ - http://www.uri.com/\n" - .to_string() + concat!( + "#EXT-X-MAP:URI=\"https://www.example.com/\"\n", + "#EXT-X-BYTERANGE:20@5\n", + "#EXT-X-DISCONTINUITY\n", + "#EXTINF:4,\n", + "http://www.uri.com/\n" + ) + .to_string() ); } } diff --git a/src/tags/basic/m3u.rs b/src/tags/basic/m3u.rs index b1f8592..03bbf09 100644 --- a/src/tags/basic/m3u.rs +++ b/src/tags/basic/m3u.rs @@ -5,37 +5,15 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.1.1. EXTM3U] -/// /// The [`ExtM3u`] tag indicates that the file is an **Ext**ended **[`M3U`]** /// Playlist file. -/// It is the at the start of every [`Media Playlist`] and [`Master Playlist`]. +/// It is the at the start of every [`MediaPlaylist`] and [`MasterPlaylist`]. /// -/// # Examples -/// Parsing from a [`str`]: -/// ``` -/// # use failure::Error; -/// # use hls_m3u8::tags::ExtM3u; -/// # -/// # fn main() -> Result<(), Error> { -/// assert_eq!("#EXTM3U".parse::()?, ExtM3u); -/// # -/// # Ok(()) -/// # } -/// ``` -/// Converting to a [`str`]: -/// ``` -/// # use hls_m3u8::tags::ExtM3u; -/// # -/// assert_eq!("#EXTM3U".to_string(), ExtM3u.to_string()); -/// ``` -/// -/// [`Media Playlist`]: crate::MediaPlaylist -/// [`Master Playlist`]: crate::MasterPlaylist +/// [`MediaPlaylist`]: crate::MediaPlaylist +/// [`MasterPlaylist`]: crate::MasterPlaylist /// [`M3U`]: https://en.wikipedia.org/wiki/M3U -/// [4.3.1.1. EXTM3U]: https://tools.ietf.org/html/rfc8216#section-4.3.1.1 #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] -pub struct ExtM3u; +pub(crate) struct ExtM3u; impl ExtM3u { pub(crate) const PREFIX: &'static str = "#EXTM3U"; @@ -47,7 +25,7 @@ impl RequiredVersion for ExtM3u { } impl fmt::Display for ExtM3u { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", Self::PREFIX) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX) } } impl FromStr for ExtM3u { @@ -72,6 +50,7 @@ mod test { #[test] fn test_parser() { assert_eq!("#EXTM3U".parse::().unwrap(), ExtM3u); + assert!("#EXTM2U".parse::().is_err()); } #[test] diff --git a/src/tags/basic/mod.rs b/src/tags/basic/mod.rs index e23eb19..98e87f2 100644 --- a/src/tags/basic/mod.rs +++ b/src/tags/basic/mod.rs @@ -1,5 +1,5 @@ -mod m3u; -mod version; +pub(crate) mod m3u; +pub(crate) mod version; -pub use m3u::*; +pub(crate) use m3u::*; pub use version::*; diff --git a/src/tags/basic/version.rs b/src/tags/basic/version.rs index e39f079..6345cf7 100644 --- a/src/tags/basic/version.rs +++ b/src/tags/basic/version.rs @@ -5,44 +5,12 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.1.2. EXT-X-VERSION] +/// The compatibility version of a playlist. /// -/// The [`ExtXVersion`] tag indicates the compatibility version of the -/// [`Master Playlist`] or [`Media Playlist`] file. -/// It applies to the entire Playlist. +/// It applies to the entire [`MasterPlaylist`] or [`MediaPlaylist`]. /// -/// # Examples -/// Parsing from a [`str`]: -/// ``` -/// # use failure::Error; -/// # use hls_m3u8::tags::ExtXVersion; -/// # -/// # fn main() -> Result<(), Error> { -/// use hls_m3u8::types::ProtocolVersion; -/// -/// assert_eq!( -/// "#EXT-X-VERSION:5".parse::()?, -/// ExtXVersion::new(ProtocolVersion::V5) -/// ); -/// # -/// # Ok(()) -/// # } -/// ``` -/// Converting to a [`str`]: -/// ``` -/// # use hls_m3u8::tags::ExtXVersion; -/// # -/// use hls_m3u8::types::ProtocolVersion; -/// -/// assert_eq!( -/// "#EXT-X-VERSION:5".to_string(), -/// ExtXVersion::new(ProtocolVersion::V5).to_string() -/// ); -/// ``` -/// -/// [`Media Playlist`]: crate::MediaPlaylist -/// [`Master Playlist`]: crate::MasterPlaylist -/// [4.3.1.2. EXT-X-VERSION]: https://tools.ietf.org/html/rfc8216#section-4.3.1.2 +/// [`MediaPlaylist`]: crate::MediaPlaylist +/// [`MasterPlaylist`]: crate::MasterPlaylist #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] pub struct ExtXVersion(ProtocolVersion); @@ -52,17 +20,20 @@ impl ExtXVersion { /// Makes a new [`ExtXVersion`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXVersion; /// use hls_m3u8::types::ProtocolVersion; /// /// let version = ExtXVersion::new(ProtocolVersion::V2); /// ``` + #[must_use] pub const fn new(version: ProtocolVersion) -> Self { Self(version) } - /// Returns the [`ProtocolVersion`] of the playlist, containing this tag. + /// Returns the underlying [`ProtocolVersion`]. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXVersion; /// use hls_m3u8::types::ProtocolVersion; @@ -72,6 +43,7 @@ impl ExtXVersion { /// ProtocolVersion::V6 /// ); /// ``` + #[must_use] pub const fn version(self) -> ProtocolVersion { self.0 } } @@ -81,11 +53,14 @@ impl RequiredVersion for ExtXVersion { } impl fmt::Display for ExtXVersion { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}{}", Self::PREFIX, self.0) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // + write!(f, "{}{}", Self::PREFIX, self.0) + } } impl Default for ExtXVersion { - fn default() -> Self { Self(ProtocolVersion::V1) } + fn default() -> Self { Self(ProtocolVersion::default()) } } impl From for ExtXVersion { diff --git a/src/tags/master_playlist/i_frame_stream_inf.rs b/src/tags/master_playlist/i_frame_stream_inf.rs deleted file mode 100644 index 2e9c9f5..0000000 --- a/src/tags/master_playlist/i_frame_stream_inf.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::fmt; -use std::ops::{Deref, DerefMut}; -use std::str::FromStr; - -use crate::attribute::AttributePairs; -use crate::types::{HdcpLevel, ProtocolVersion, StreamInf, StreamInfBuilder}; -use crate::utils::{quote, tag, unquote}; -use crate::{Error, RequiredVersion}; - -/// # [4.3.5.3. EXT-X-I-FRAME-STREAM-INF] -/// -/// The [`ExtXIFrameStreamInf`] tag identifies a [`Media Playlist`] file, -/// containing the I-frames of a multimedia presentation. -/// -/// I-frames are encoded video frames, whose decoding -/// does not depend on any other frame. -/// -/// [`Master Playlist`]: crate::MasterPlaylist -/// [`Media Playlist`]: crate::MediaPlaylist -/// [4.3.5.3. EXT-X-I-FRAME-STREAM-INF]: https://tools.ietf.org/html/rfc8216#section-4.3.4.5 -#[derive(PartialOrd, Debug, Clone, PartialEq, Eq, Hash)] -pub struct ExtXIFrameStreamInf { - uri: String, - stream_inf: StreamInf, -} - -#[derive(Default, Debug, Clone, PartialEq)] -/// Builder for [`ExtXIFrameStreamInf`]. -pub struct ExtXIFrameStreamInfBuilder { - uri: Option, - stream_inf: StreamInfBuilder, -} - -impl ExtXIFrameStreamInfBuilder { - /// An `URI` to the [`MediaPlaylist`] file. - /// - /// [`MediaPlaylist`]: crate::MediaPlaylist - pub fn uri>(&mut self, value: T) -> &mut Self { - self.uri = Some(value.into()); - self - } - - /// The maximum bandwidth of the stream. - pub fn bandwidth(&mut self, value: u64) -> &mut Self { - self.stream_inf.bandwidth(value); - self - } - - /// The average bandwidth of the stream. - pub fn average_bandwidth(&mut self, value: u64) -> &mut Self { - self.stream_inf.average_bandwidth(value); - self - } - - /// Every media format in any of the renditions specified by the Variant - /// Stream. - pub fn codecs>(&mut self, value: T) -> &mut Self { - self.stream_inf.codecs(value); - self - } - - /// The resolution of the stream. - pub fn resolution(&mut self, value: (usize, usize)) -> &mut Self { - self.stream_inf.resolution(value); - self - } - - /// High-bandwidth Digital Content Protection - pub fn hdcp_level(&mut self, value: HdcpLevel) -> &mut Self { - self.stream_inf.hdcp_level(value); - self - } - - /// It indicates the set of video renditions, that should be used when - /// playing the presentation. - pub fn video>(&mut self, value: T) -> &mut Self { - self.stream_inf.video(value); - self - } - - /// Build an [`ExtXIFrameStreamInf`]. - pub fn build(&self) -> crate::Result { - Ok(ExtXIFrameStreamInf { - uri: self - .uri - .clone() - .ok_or_else(|| Error::missing_value("frame rate"))?, - stream_inf: self.stream_inf.build().map_err(Error::builder_error)?, - }) - } -} - -impl ExtXIFrameStreamInf { - pub(crate) const PREFIX: &'static str = "#EXT-X-I-FRAME-STREAM-INF:"; - - /// Makes a new [`ExtXIFrameStreamInf`] tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXIFrameStreamInf; - /// let stream = ExtXIFrameStreamInf::new("https://www.example.com", 20); - /// ``` - pub fn new(uri: T, bandwidth: u64) -> Self { - Self { - uri: uri.to_string(), - stream_inf: StreamInf::new(bandwidth), - } - } - - /// Returns a builder for [`ExtXIFrameStreamInf`]. - pub fn builder() -> ExtXIFrameStreamInfBuilder { ExtXIFrameStreamInfBuilder::default() } - - /// Returns the `URI`, that identifies the associated [`media playlist`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXIFrameStreamInf; - /// let stream = ExtXIFrameStreamInf::new("https://www.example.com", 20); - /// assert_eq!(stream.uri(), &"https://www.example.com".to_string()); - /// ``` - /// - /// [`media playlist`]: crate::MediaPlaylist - pub const fn uri(&self) -> &String { &self.uri } - - /// Sets the `URI`, that identifies the associated [`media playlist`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXIFrameStreamInf; - /// # - /// let mut stream = ExtXIFrameStreamInf::new("https://www.example.com", 20); - /// - /// stream.set_uri("../new/uri"); - /// assert_eq!(stream.uri(), &"../new/uri".to_string()); - /// ``` - /// - /// [`media playlist`]: crate::MediaPlaylist - pub fn set_uri(&mut self, value: T) -> &mut Self { - self.uri = value.to_string(); - self - } -} - -/// This tag requires [`ProtocolVersion::V1`]. -impl RequiredVersion for ExtXIFrameStreamInf { - fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } -} - -impl fmt::Display for ExtXIFrameStreamInf { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", Self::PREFIX)?; - write!(f, "URI={},{}", quote(&self.uri), self.stream_inf)?; - Ok(()) - } -} - -impl FromStr for ExtXIFrameStreamInf { - type Err = Error; - - fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?; - - let mut uri = None; - - for (key, value) in input.parse::()? { - if let "URI" = key.as_str() { - uri = Some(unquote(value)); - } - } - - let uri = uri.ok_or_else(|| Error::missing_value("URI"))?; - - Ok(Self { - uri, - stream_inf: input.parse()?, - }) - } -} - -impl Deref for ExtXIFrameStreamInf { - type Target = StreamInf; - - fn deref(&self) -> &Self::Target { &self.stream_inf } -} - -impl DerefMut for ExtXIFrameStreamInf { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.stream_inf } -} - -#[cfg(test)] -mod test { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_builder() { - let mut i_frame_stream_inf = - ExtXIFrameStreamInf::new("http://example.com/audio-only.m3u8", 200_000); - - i_frame_stream_inf - .set_average_bandwidth(Some(100_000)) - .set_codecs(Some("mp4a.40.5")) - .set_resolution(1920, 1080) - .set_hdcp_level(Some(HdcpLevel::None)) - .set_video(Some("video")); - - assert_eq!( - ExtXIFrameStreamInf::builder() - .uri("http://example.com/audio-only.m3u8") - .bandwidth(200_000) - .average_bandwidth(100_000) - .codecs("mp4a.40.5") - .resolution((1920, 1080)) - .hdcp_level(HdcpLevel::None) - .video("video") - .build() - .unwrap(), - i_frame_stream_inf - ); - } - - #[test] - fn test_display() { - assert_eq!( - ExtXIFrameStreamInf::new("foo", 1000).to_string(), - "#EXT-X-I-FRAME-STREAM-INF:URI=\"foo\",BANDWIDTH=1000".to_string() - ); - } - - #[test] - fn test_parser() { - assert_eq!( - "#EXT-X-I-FRAME-STREAM-INF:URI=\"foo\",BANDWIDTH=1000" - .parse::() - .unwrap(), - ExtXIFrameStreamInf::new("foo", 1000) - ); - - assert!("garbage".parse::().is_err()); - } - - #[test] - fn test_required_version() { - assert_eq!( - ExtXIFrameStreamInf::new("foo", 1000).required_version(), - ProtocolVersion::V1 - ); - } - - #[test] - fn test_deref() { - assert_eq!( - ExtXIFrameStreamInf::new("https://www.example.com", 20).average_bandwidth(), - None - ) - } - - #[test] - fn test_deref_mut() { - assert_eq!( - ExtXIFrameStreamInf::new("https://www.example.com", 20) - .set_average_bandwidth(Some(4)) - .average_bandwidth(), - Some(4) - ) - } -} diff --git a/src/tags/master_playlist/media.rs b/src/tags/master_playlist/media.rs index f1906e8..a6aa8a5 100644 --- a/src/tags/master_playlist/media.rs +++ b/src/tags/master_playlist/media.rs @@ -2,107 +2,216 @@ use std::fmt; use std::str::FromStr; use derive_builder::Builder; +use shorthand::ShortHand; use crate::attribute::AttributePairs; use crate::types::{Channels, InStreamId, MediaType, ProtocolVersion}; use crate::utils::{parse_yes_or_no, quote, tag, unquote}; use crate::{Error, RequiredVersion}; -/// # [4.4.5.1. EXT-X-MEDIA] +/// An [`ExtXMedia`] tag is an alternative rendition of a [`VariantStream`]. /// -/// The [`ExtXMedia`] tag is used to relate [`Media Playlist`]s, -/// that contain alternative Renditions of the same content. +/// For example an [`ExtXMedia`] tag can be used to specify different audio +/// languages (e.g. english is the default and there also exists an +/// [`ExtXMedia`] stream with a german audio). /// -/// For -/// example, three [`ExtXMedia`] tags can be used to identify audio-only -/// [`Media Playlist`]s, that contain English, French, and Spanish Renditions -/// of the same presentation. Or, two [`ExtXMedia`] tags can be used to -/// identify video-only [`Media Playlist`]s that show two different camera -/// angles. -/// -/// [`Media Playlist`]: crate::MediaPlaylist -/// [4.4.5.1. EXT-X-MEDIA]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-4.4.5.1 -#[derive(Builder, Debug, Clone, PartialEq, Eq, Hash)] +/// [`MediaPlaylist`]: crate::MediaPlaylist +/// [`VariantStream`]: crate::tags::VariantStream +#[derive(ShortHand, Builder, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[shorthand(enable(must_use, into))] #[builder(setter(into))] #[builder(build_fn(validate = "Self::validate"))] pub struct ExtXMedia { - /// Sets the [`MediaType`] of the rendition. + /// The [`MediaType`] associated with this tag. + /// + /// ### Note + /// + /// This field is required. + #[shorthand(enable(skip))] + pub media_type: MediaType, + /// An `URI` to a [`MediaPlaylist`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXMedia; + /// use hls_m3u8::types::MediaType; + /// + /// let mut media = ExtXMedia::new(MediaType::Audio, "ag1", "english audio channel"); + /// # assert_eq!(media.uri(), None); + /// + /// media.set_uri(Some("https://www.example.com/stream1.m3u8")); + /// + /// assert_eq!( + /// media.uri(), + /// Some(&"https://www.example.com/stream1.m3u8".to_string()) + /// ); + /// ``` /// /// # Note - /// This attribute is **required**. - media_type: MediaType, - #[builder(setter(strip_option), default)] - /// Sets the `URI` that identifies the [`Media Playlist`]. /// - /// # Note - /// - This attribute is **required**, if the [`MediaType`] is + /// - This field is required, if the [`ExtXMedia::media_type`] is /// [`MediaType::Subtitles`]. - /// - This attribute is **not allowed**, if the [`MediaType`] is + /// - This field is not allowed, if the [`ExtXMedia::media_type`] is /// [`MediaType::ClosedCaptions`]. /// - /// [`Media Playlist`]: crate::MediaPlaylist + /// An absent value indicates that the media data for this rendition is + /// included in the [`MediaPlaylist`] of any + /// [`VariantStream::ExtXStreamInf`] tag with the same `group_id` of + /// this [`ExtXMedia`] instance. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + /// [`VariantStream::ExtXStreamInf`]: + /// crate::tags::VariantStream::ExtXStreamInf + #[builder(setter(strip_option), default)] uri: Option, - /// Sets the identifier, that specifies the group to which the rendition + /// The identifier that specifies the group to which the rendition /// belongs. /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXMedia; + /// use hls_m3u8::types::MediaType; + /// + /// let mut media = ExtXMedia::new(MediaType::Audio, "ag1", "english audio channel"); + /// + /// media.set_group_id("ag2"); + /// + /// assert_eq!(media.group_id(), &"ag2".to_string()); + /// ``` + /// /// # Note - /// This attribute is **required**. + /// + /// This field is required. group_id: String, - #[builder(setter(strip_option), default)] - /// Sets the name of the primary language used in the rendition. + /// The name of the primary language used in the rendition. /// The value has to conform to [`RFC5646`]. /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXMedia; + /// use hls_m3u8::types::MediaType; + /// + /// let mut media = ExtXMedia::new(MediaType::Audio, "ag1", "english audio channel"); + /// + /// media.set_language(Some("en")); + /// + /// assert_eq!(media.language(), Some(&"en".to_string())); + /// ``` + /// /// # Note - /// This attribute is **optional**. + /// + /// This field is optional. /// /// [`RFC5646`]: https://tools.ietf.org/html/rfc5646 - language: Option, #[builder(setter(strip_option), default)] - /// Sets the name of a language associated with the rendition. + language: Option, + /// The name of a language associated with the rendition. + /// An associated language is often used in a different role, than the + /// language specified by the [`language`] field (e.g., written versus + /// spoken, or a fallback dialect). /// /// # Note - /// This attribute is **optional**. + /// + /// This field is optional. /// /// [`language`]: #method.language + #[builder(setter(strip_option), default)] assoc_language: Option, - /// Sets a human-readable description of the rendition. + /// A human-readable description of the rendition. /// /// # Note - /// This attribute is **required**. /// - /// If the [`language`] attribute is present, this attribute should be in + /// This field is required. + /// + /// If the [`language`] field is present, this field should be in /// that language. /// /// [`language`]: #method.language name: String, + /// The value of the `default` flag. + /// A value of `true` indicates, that the client should play + /// this rendition of the content in the absence of information + /// from the user indicating a different choice. + /// + /// ### Note + /// + /// This field is optional, its absence indicates an implicit value + /// of `false`. #[builder(default)] - /// Sets the value of the `default` flag. + #[shorthand(enable(skip))] + pub is_default: bool, + /// Whether the client may choose to play this rendition in the absence of + /// explicit user preference. + /// + /// ### Note + /// + /// This field is optional, its absence indicates an implicit value + /// of `false`. + #[builder(default)] + #[shorthand(enable(skip))] + pub is_autoselect: bool, + /// Whether the rendition contains content that is considered + /// essential to play. + #[builder(default)] + #[shorthand(enable(skip))] + pub is_forced: bool, + /// An [`InStreamId`] identifies a rendition within the + /// [`MediaSegment`]s in a [`MediaPlaylist`]. + /// + /// ### Note + /// + /// This field is required, if the media type is + /// [`MediaType::ClosedCaptions`]. For all other media types the + /// [`InStreamId`] must not be specified! + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + /// [`MediaSegment`]: crate::MediaSegment + #[builder(setter(strip_option), default)] + #[shorthand(enable(skip))] + pub instream_id: Option, + /// The characteristics field contains one or more Uniform Type + /// Identifiers ([`UTI`]) separated by a comma. + /// Each [`UTI`] indicates an individual characteristic of the Rendition. + /// + /// An `ExtXMedia` instance with [`MediaType::Subtitles`] may include the + /// following characteristics: + /// - `"public.accessibility.transcribes-spoken-dialog"`, + /// - `"public.accessibility.describes-music-and-sound"`, and + /// - `"public.easy-to-read"` (which indicates that the subtitles have + /// been edited for ease of reading). + /// + /// An `ExtXMedia` instance with [`MediaType::Audio`] may include the + /// following characteristic: + /// - `"public.accessibility.describes-video"` + /// + /// The characteristics field may include private UTIs. /// /// # Note - /// This attribute is **optional**, its absence indicates an implicit value - /// of `false`. - is_default: bool, - #[builder(default)] - /// Sets the value of the `autoselect` flag. /// - /// # Note - /// This attribute is **optional**, its absence indicates an implicit value - /// of `false`. - is_autoselect: bool, - #[builder(default)] - /// Sets the value of the `forced` flag. - is_forced: bool, + /// This field is optional. + /// + /// [`UTI`]: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#ref-UTI #[builder(setter(strip_option), default)] - /// Sets the identifier that specifies a rendition within the segments in - /// the media playlist. - instream_id: Option, - #[builder(setter(strip_option), default)] - /// Sets the string that represents uniform type identifiers (UTI). characteristics: Option, + /// A count of audio channels indicating the maximum number of independent, + /// simultaneous audio channels present in any [`MediaSegment`] in the + /// rendition. + /// + /// ### Note + /// + /// This field is optional, but every instance of [`ExtXMedia`] with + /// [`MediaType::Audio`] should have this field. If the [`MasterPlaylist`] + /// contains two renditions with the same codec, but a different number of + /// channels, then the channels field is required. + /// + /// [`MediaSegment`]: crate::MediaSegment + /// [`MasterPlaylist`]: crate::MasterPlaylist #[builder(setter(strip_option), default)] - /// Sets the parameters of the rendition. - channels: Option, + #[shorthand(enable(skip))] + pub channels: Option, } impl ExtXMediaBuilder { @@ -124,14 +233,17 @@ impl ExtXMediaBuilder { return Err(Error::missing_attribute("INSTREAM-ID").to_string()); } } else if self.instream_id.is_some() { - return Err(Error::unexpected_attribute("INSTREAM-ID").to_string()); + return Err(Error::custom( + "InStreamId should only be specified for an ExtXMedia tag with `MediaType::ClosedCaptions`" + ).to_string()); } - if self.is_default.unwrap_or(false) && !self.is_autoselect.unwrap_or(true) { - return Err( - Error::custom("If `DEFAULT` is true and `AUTOSELECT` is present, \ - `AUTOSELECT` has to be true too!").to_string(), - ); + if self.is_default.unwrap_or(false) && self.is_autoselect.map_or(false, |b| !b) { + return Err(Error::custom(format!( + "If `DEFAULT` is true, `AUTOSELECT` has to be true too, if present. Default: {:?}, Autoselect: {:?}!", + self.is_default, self.is_autoselect + )) + .to_string()); } if media_type != MediaType::Subtitles && self.is_forced.is_some() { @@ -145,15 +257,34 @@ impl ExtXMediaBuilder { impl ExtXMedia { pub(crate) const PREFIX: &'static str = "#EXT-X-MEDIA:"; - /// Makes a new [`ExtXMedia`] tag. - pub fn new(media_type: MediaType, group_id: T, name: T) -> Self { + /// Makes a new [`ExtXMedia`] tag with the associated [`MediaType`], the + /// identifier that specifies the group to which the rendition belongs + /// (group id) and a human-readable description of the rendition. If the + /// [`language`] is specified it should be in that language. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXMedia; + /// use hls_m3u8::types::MediaType; + /// + /// let media = ExtXMedia::new(MediaType::Video, "vg1", "1080p video stream"); + /// ``` + /// + /// [`language`]: #method.language + #[must_use] + pub fn new(media_type: MediaType, group_id: T, name: K) -> Self + where + T: Into, + K: Into, + { Self { media_type, uri: None, - group_id: group_id.to_string(), + group_id: group_id.into(), language: None, assoc_language: None, - name: name.to_string(), + name: name.into(), is_default: false, is_autoselect: false, is_forced: false, @@ -164,515 +295,84 @@ impl ExtXMedia { } /// Returns a builder for [`ExtXMedia`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXMedia; + /// use hls_m3u8::types::MediaType; + /// + /// let media = ExtXMedia::builder() + /// .media_type(MediaType::Subtitles) + /// .uri("french/ed.ttml") + /// .group_id("subs") + /// .language("fra") + /// .assoc_language("fra") + /// .name("French") + /// .is_autoselect(true) + /// .is_forced(true) + /// // concat! joins multiple `&'static str` + /// .characteristics(concat!( + /// "public.accessibility.transcribes-spoken-dialog,", + /// "public.accessibility.describes-music-and-sound" + /// )) + /// .build()?; + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] pub fn builder() -> ExtXMediaBuilder { ExtXMediaBuilder::default() } - - /// Returns the type of the media, associated with this tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// assert_eq!( - /// ExtXMedia::new(MediaType::Audio, "audio", "name").media_type(), - /// MediaType::Audio - /// ); - /// ``` - pub const fn media_type(&self) -> MediaType { self.media_type } - - /// Sets the type of the media, associated with this tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// - /// media.set_media_type(MediaType::Video); - /// - /// assert_eq!(media.media_type(), MediaType::Video); - /// ``` - pub fn set_media_type(&mut self, value: MediaType) -> &mut Self { - self.media_type = value; - self - } - - /// Returns the identifier that specifies the group to which the rendition - /// belongs. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// assert_eq!( - /// ExtXMedia::new(MediaType::Audio, "audio", "name").group_id(), - /// &"audio".to_string() - /// ); - /// ``` - pub const fn group_id(&self) -> &String { &self.group_id } - - /// Sets the identifier that specifies the group, to which the rendition - /// belongs. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// - /// media.set_group_id("video"); - /// - /// assert_eq!(media.group_id(), &"video".to_string()); - /// ``` - pub fn set_group_id>(&mut self, value: T) -> &mut Self { - self.group_id = value.into(); - self - } - - /// Returns a human-readable description of the rendition. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// assert_eq!( - /// ExtXMedia::new(MediaType::Audio, "audio", "name").name(), - /// &"name".to_string() - /// ); - /// ``` - pub const fn name(&self) -> &String { &self.name } - - /// Sets a human-readable description of the rendition. - /// - /// # Note - /// If the [`language`] attribute is present, this attribute should be in - /// that language. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// - /// media.set_name("new_name"); - /// - /// assert_eq!(media.name(), &"new_name".to_string()); - /// ``` - /// - /// [`language`]: #method.language - pub fn set_name>(&mut self, value: T) -> &mut Self { - self.name = value.into(); - self - } - - /// Returns the `URI`, that identifies the [`Media Playlist`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.uri(), &None); - /// - /// media.set_uri(Some("https://www.example.com/")); - /// - /// assert_eq!(media.uri(), &Some("https://www.example.com/".into())); - /// ``` - /// - /// [`Media Playlist`]: crate::MediaPlaylist - pub const fn uri(&self) -> &Option { &self.uri } - - /// Sets the `URI`, that identifies the [`Media Playlist`]. - /// - /// # Note - /// This attribute is **required**, if the [`MediaType`] is - /// [`MediaType::Subtitles`]. This attribute is **not allowed**, if the - /// [`MediaType`] is [`MediaType::ClosedCaptions`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.uri(), &None); - /// - /// media.set_uri(Some("https://www.example.com/")); - /// - /// assert_eq!(media.uri(), &Some("https://www.example.com/".into())); - /// ``` - /// - /// [`Media Playlist`]: crate::MediaPlaylist - pub fn set_uri>(&mut self, value: Option) -> &mut Self { - self.uri = value.map(Into::into); - self - } - - /// Returns the name of the primary language used in the rendition. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.language(), &None); - /// - /// media.set_language(Some("english")); - /// - /// assert_eq!(media.language(), &Some("english".into())); - /// ``` - pub const fn language(&self) -> &Option { &self.language } - - /// Sets the name of the primary language used in the rendition. - /// The value has to conform to [`RFC5646`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.language(), &None); - /// - /// media.set_language(Some("english")); - /// - /// assert_eq!(media.language(), &Some("english".into())); - /// ``` - /// - /// [`RFC5646`]: https://tools.ietf.org/html/rfc5646 - pub fn set_language>(&mut self, value: Option) -> &mut Self { - self.language = value.map(Into::into); - self - } - - /// Returns the name of a language associated with the rendition. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.assoc_language(), &None); - /// - /// media.set_assoc_language(Some("spanish")); - /// - /// assert_eq!(media.assoc_language(), &Some("spanish".into())); - /// ``` - pub const fn assoc_language(&self) -> &Option { &self.assoc_language } - - /// Sets the name of a language associated with the rendition. - /// An associated language is often used in a different role, than the - /// language specified by the [`language`] attribute (e.g., written versus - /// spoken, or a fallback dialect). - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.assoc_language(), &None); - /// - /// media.set_assoc_language(Some("spanish")); - /// - /// assert_eq!(media.assoc_language(), &Some("spanish".into())); - /// ``` - /// - /// [`language`]: #method.language - pub fn set_assoc_language>(&mut self, value: Option) -> &mut Self { - self.assoc_language = value.map(Into::into); - self - } - - /// Returns whether this is the `default` rendition. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_default(), false); - /// - /// media.set_default(true); - /// - /// assert_eq!(media.is_default(), true); - /// ``` - pub const fn is_default(&self) -> bool { self.is_default } - - /// Sets the `default` flag. - /// A value of `true` indicates, that the client should play - /// this rendition of the content in the absence of information - /// from the user indicating a different choice. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_default(), false); - /// - /// media.set_default(true); - /// - /// assert_eq!(media.is_default(), true); - /// ``` - pub fn set_default(&mut self, value: bool) -> &mut Self { - self.is_default = value; - self - } - - /// Returns whether the client may choose to - /// play this rendition in the absence of explicit user preference. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_autoselect(), false); - /// - /// media.set_autoselect(true); - /// - /// assert_eq!(media.is_autoselect(), true); - /// ``` - pub const fn is_autoselect(&self) -> bool { self.is_autoselect } - - /// Sets the `autoselect` flag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_autoselect(), false); - /// - /// media.set_autoselect(true); - /// - /// assert_eq!(media.is_autoselect(), true); - /// ``` - pub fn set_autoselect(&mut self, value: bool) -> &mut Self { - self.is_autoselect = value; - self - } - - /// Returns whether the rendition contains content that is considered - /// essential to play. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_forced(), false); - /// - /// media.set_forced(true); - /// - /// assert_eq!(media.is_forced(), true); - /// ``` - pub const fn is_forced(&self) -> bool { self.is_forced } - - /// Sets the `forced` flag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.is_forced(), false); - /// - /// media.set_forced(true); - /// - /// assert_eq!(media.is_forced(), true); - /// ``` - pub fn set_forced(&mut self, value: bool) -> &mut Self { - self.is_forced = value; - self - } - - /// Returns the identifier that specifies a rendition within the segments in - /// the [`Media Playlist`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::{InStreamId, MediaType}; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.instream_id(), None); - /// - /// media.set_instream_id(Some(InStreamId::Cc1)); - /// - /// assert_eq!(media.instream_id(), Some(InStreamId::Cc1)); - /// ``` - /// - /// [`Media Playlist`]: crate::MediaPlaylist - pub const fn instream_id(&self) -> Option { self.instream_id } - - /// Sets the [`InStreamId`], that specifies a rendition within the - /// segments in the [`Media Playlist`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::{InStreamId, MediaType}; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.instream_id(), None); - /// - /// media.set_instream_id(Some(InStreamId::Cc1)); - /// - /// assert_eq!(media.instream_id(), Some(InStreamId::Cc1)); - /// ``` - pub fn set_instream_id(&mut self, value: Option) -> &mut Self { - self.instream_id = value; - self - } - - /// Returns a string that represents uniform type identifiers (UTI). - /// - /// Each UTI indicates an individual characteristic of the rendition. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.characteristics(), &None); - /// - /// media.set_characteristics(Some("characteristic")); - /// - /// assert_eq!(media.characteristics(), &Some("characteristic".into())); - /// ``` - pub const fn characteristics(&self) -> &Option { &self.characteristics } - - /// Sets the characteristics attribute, containing one or more Uniform Type - /// Identifiers separated by comma. - /// Each [`UTI`] indicates an individual characteristic of the Rendition. - /// - /// A [`subtitles`] Rendition may include the following characteristics: - /// "public.accessibility.transcribes-spoken-dialog", - /// "public.accessibility.describes-music-and-sound", and - /// "public.easy-to-read" (which indicates that the subtitles have - /// been edited for ease of reading). - /// - /// An AUDIO Rendition MAY include the following characteristic: - /// "public.accessibility.describes-video". - /// - /// The characteristics attribute may include private UTIs. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::MediaType; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.characteristics(), &None); - /// - /// media.set_characteristics(Some("characteristic")); - /// - /// assert_eq!(media.characteristics(), &Some("characteristic".into())); - /// ``` - /// - /// [`UTI`]: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#ref-UTI - /// [`subtitles`]: crate::types::MediaType::Subtitles - pub fn set_characteristics>(&mut self, value: Option) -> &mut Self { - self.characteristics = value.map(Into::into); - self - } - - /// Returns the channels. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::{Channels, MediaType}; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.channels(), &None); - /// - /// media.set_channels(Some(Channels::new(6))); - /// - /// assert_eq!(media.channels(), &Some(Channels::new(6))); - /// ``` - pub const fn channels(&self) -> &Option { &self.channels } - - /// Sets the channels. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMedia; - /// use hls_m3u8::types::{Channels, MediaType}; - /// - /// let mut media = ExtXMedia::new(MediaType::Audio, "audio", "name"); - /// # assert_eq!(media.channels(), &None); - /// - /// media.set_channels(Some(Channels::new(6))); - /// - /// assert_eq!(media.channels(), &Some(Channels::new(6))); - /// ``` - pub fn set_channels>(&mut self, value: Option) -> &mut Self { - self.channels = value.map(Into::into); - self - } } +/// This tag requires either `ProtocolVersion::V1` or if there is an +/// `instream_id` it requires it's version. impl RequiredVersion for ExtXMedia { fn required_version(&self) -> ProtocolVersion { - match self.instream_id { - None - | Some(InStreamId::Cc1) - | Some(InStreamId::Cc2) - | Some(InStreamId::Cc3) - | Some(InStreamId::Cc4) => ProtocolVersion::V1, - _ => ProtocolVersion::V7, - } + self.instream_id + .map_or(ProtocolVersion::V1, |i| i.required_version()) } } impl fmt::Display for ExtXMedia { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "TYPE={}", self.media_type)?; + if let Some(value) = &self.uri { write!(f, ",URI={}", quote(value))?; } + write!(f, ",GROUP-ID={}", quote(&self.group_id))?; + if let Some(value) = &self.language { write!(f, ",LANGUAGE={}", quote(value))?; } + if let Some(value) = &self.assoc_language { write!(f, ",ASSOC-LANGUAGE={}", quote(value))?; } + write!(f, ",NAME={}", quote(&self.name))?; + if self.is_default { write!(f, ",DEFAULT=YES")?; } + if self.is_autoselect { write!(f, ",AUTOSELECT=YES")?; } + if self.is_forced { write!(f, ",FORCED=YES")?; } + if let Some(value) = &self.instream_id { write!(f, ",INSTREAM-ID={}", quote(value))?; } + if let Some(value) = &self.characteristics { write!(f, ",CHARACTERISTICS={}", quote(value))?; } + if let Some(value) = &self.channels { write!(f, ",CHANNELS={}", quote(value))?; } @@ -688,8 +388,8 @@ impl FromStr for ExtXMedia { let mut builder = Self::builder(); - for (key, value) in input.parse::()? { - match key.as_str() { + for (key, value) in AttributePairs::new(input) { + match key { "TYPE" => { builder.media_type(value.parse::()?); } @@ -734,7 +434,7 @@ impl FromStr for ExtXMedia { } } - builder.build().map_err(Error::builder_error) + builder.build().map_err(Error::builder) } } @@ -743,10 +443,46 @@ mod test { use super::*; use pretty_assertions::assert_eq; - #[test] - fn test_display() { - // TODO: https://developer.apple.com/documentation/http_live_streaming/example_playlists_for_http_live_streaming/adding_alternate_media_to_a_playlist - assert_eq!( + macro_rules! generate_tests { + ( $( { $struct:expr, $str:expr } ),+ $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($struct.to_string(), $str.to_string()); + )+ + } + + #[test] + fn test_parser() { + $( + assert_eq!($struct, $str.parse().unwrap()); + )+ + } + } + } + + generate_tests! { + { + ExtXMedia::builder() + .media_type(MediaType::Audio) + .group_id("audio") + .language("eng") + .name("English") + .is_default(true) + .uri("eng/prog_index.m3u8") + .build() + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"eng/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"eng\",", + "NAME=\"English\",", + "DEFAULT=YES", + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio") @@ -756,42 +492,40 @@ mod test { .is_default(true) .uri("eng/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"eng/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"eng/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"eng\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) + .uri("fre/prog_index.m3u8") .group_id("audio") .language("fre") .name("Français") - .is_autoselect(true) .is_default(false) - .uri("fre/prog_index.m3u8") + .is_autoselect(true) .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"fre/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"fre/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"fre\",", + "NAME=\"Français\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio") @@ -801,19 +535,18 @@ mod test { .is_default(false) .uri("sp/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"sp/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"sp\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .to_string() - ); - // ---- - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"sp/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"sp\",", + "NAME=\"Espanol\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-lo") @@ -823,20 +556,19 @@ mod test { .is_default(true) .uri("englo/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"englo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"englo/prog_index.m3u8\",", + "GROUP-ID=\"audio-lo\",", + "LANGUAGE=\"eng\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-lo") @@ -846,19 +578,18 @@ mod test { .is_default(false) .uri("frelo/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"frelo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"frelo/prog_index.m3u8\",", + "GROUP-ID=\"audio-lo\",", + "LANGUAGE=\"fre\",", + "NAME=\"Français\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-lo") @@ -868,19 +599,18 @@ mod test { .is_default(false) .uri("splo/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"splo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"es\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"splo/prog_index.m3u8\",", + "GROUP-ID=\"audio-lo\",", + "LANGUAGE=\"es\",", + "NAME=\"Espanol\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-hi") @@ -890,20 +620,19 @@ mod test { .is_default(true) .uri("eng/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"eng/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"eng/prog_index.m3u8\",", + "GROUP-ID=\"audio-hi\",", + "LANGUAGE=\"eng\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-hi") @@ -913,19 +642,18 @@ mod test { .is_default(false) .uri("fre/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"fre/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"fre/prog_index.m3u8\",", + "GROUP-ID=\"audio-hi\",", + "LANGUAGE=\"fre\",", + "NAME=\"Français\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-hi") @@ -935,19 +663,18 @@ mod test { .is_default(false) .uri("sp/prog_index.m3u8") .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"sp/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"es\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"sp/prog_index.m3u8\",", + "GROUP-ID=\"audio-hi\",", + "LANGUAGE=\"es\",", + "NAME=\"Espanol\",", + "AUTOSELECT=YES" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Audio) .group_id("audio-aacl-312") @@ -957,20 +684,19 @@ mod test { .is_default(true) .channels(Channels::new(2)) .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - GROUP-ID=\"audio-aacl-312\",\ - LANGUAGE=\"en\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES,\ - CHANNELS=\"2\"" - .to_string() - ); - - assert_eq!( + .unwrap(), + concat!( + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "GROUP-ID=\"audio-aacl-312\",", + "LANGUAGE=\"en\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES,", + "CHANNELS=\"2\"" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::Subtitles) .uri("french/ed.ttml") @@ -980,302 +706,27 @@ mod test { .name("French") .is_autoselect(true) .is_forced(true) - .characteristics("public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound") - .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=SUBTITLES,\ - URI=\"french/ed.ttml\",\ - GROUP-ID=\"subs\",\ - LANGUAGE=\"fra\",\ - ASSOC-LANGUAGE=\"fra\",\ - NAME=\"French\",\ - AUTOSELECT=YES,\ - FORCED=YES,\ - CHARACTERISTICS=\"public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound\"".to_string() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::ClosedCaptions) - .group_id("cc") - .language("sp") - .name("CC2") - .instream_id(InStreamId::Cc2) - .is_autoselect(true) - .build() - .unwrap() - .to_string(), - "#EXT-X-MEDIA:\ - TYPE=CLOSED-CAPTIONS,\ - GROUP-ID=\"cc\",\ - LANGUAGE=\"sp\",\ - NAME=\"CC2\",\ - AUTOSELECT=YES,\ - INSTREAM-ID=\"CC2\"" - .to_string() - ); - - // ---- - assert_eq!( - ExtXMedia::new(MediaType::Audio, "foo", "bar").to_string(), - "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"foo\",NAME=\"bar\"".to_string() - ) - } - - #[test] - fn test_parser() { - // TODO: https://developer.apple.com/documentation/http_live_streaming/example_playlists_for_http_live_streaming/adding_alternate_media_to_a_playlist - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio") - .language("eng") - .name("English") - .is_autoselect(true) - .is_default(true) - .uri("eng/prog_index.m3u8") + .characteristics("public.accessibility.transcribes-spoken\ + -dialog,public.accessibility.describes-music-and-sound") .build() .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"eng/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio") - .language("fre") - .name("Français") - .is_autoselect(true) - .is_default(false) - .uri("fre/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"fre/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio") - .language("sp") - .name("Espanol") - .is_autoselect(true) - .is_default(false) - .uri("sp/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"sp/prog_index.m3u8\",\ - GROUP-ID=\"audio\",\ - LANGUAGE=\"sp\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - // ---- - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-lo") - .language("eng") - .name("English") - .is_autoselect(true) - .is_default(true) - .uri("englo/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"englo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-lo") - .language("fre") - .name("Français") - .is_autoselect(true) - .is_default(false) - .uri("frelo/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"frelo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-lo") - .language("es") - .name("Espanol") - .is_autoselect(true) - .is_default(false) - .uri("splo/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"splo/prog_index.m3u8\",\ - GROUP-ID=\"audio-lo\",\ - LANGUAGE=\"es\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-hi") - .language("eng") - .name("English") - .is_autoselect(true) - .is_default(true) - .uri("eng/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"eng/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"eng\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-hi") - .language("fre") - .name("Français") - .is_autoselect(true) - .is_default(false) - .uri("fre/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"fre/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"fre\",\ - NAME=\"Français\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-hi") - .language("es") - .name("Espanol") - .is_autoselect(true) - .is_default(false) - .uri("sp/prog_index.m3u8") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - URI=\"sp/prog_index.m3u8\",\ - GROUP-ID=\"audio-hi\",\ - LANGUAGE=\"es\",\ - NAME=\"Espanol\",\ - AUTOSELECT=YES" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Audio) - .group_id("audio-aacl-312") - .language("en") - .name("English") - .is_autoselect(true) - .is_default(true) - .channels(Channels::new(2)) - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=AUDIO,\ - GROUP-ID=\"audio-aacl-312\",\ - LANGUAGE=\"en\",\ - NAME=\"English\",\ - DEFAULT=YES,\ - AUTOSELECT=YES,\ - CHANNELS=\"2\"" - .parse() - .unwrap() - ); - - assert_eq!( - ExtXMedia::builder() - .media_type(MediaType::Subtitles) - .uri("french/ed.ttml") - .group_id("subs") - .language("fra") - .assoc_language("fra") - .name("French") - .is_autoselect(true) - .characteristics("public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound") - .build() - .unwrap(), - "#EXT-X-MEDIA:\ - URI=\"french/ed.ttml\",\ - TYPE=SUBTITLES,\ - GROUP-ID=\"subs\",\ - LANGUAGE=\"fra\",\ - ASSOC-LANGUAGE=\"fra\",\ - NAME=\"French\",\ - AUTOSELECT=YES,\ - FORCED=NO,\ - CHARACTERISTICS=\"public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound\"".parse().unwrap() - ); - - assert_eq!( + concat!( + "#EXT-X-MEDIA:", + "TYPE=SUBTITLES,", + "URI=\"french/ed.ttml\",", + "GROUP-ID=\"subs\",", + "LANGUAGE=\"fra\",", + "ASSOC-LANGUAGE=\"fra\",", + "NAME=\"French\",", + "AUTOSELECT=YES,", + "FORCED=YES,", + "CHARACTERISTICS=\"", + "public.accessibility.transcribes-spoken-dialog,", + "public.accessibility.describes-music-and-sound", + "\"" + ) + }, + { ExtXMedia::builder() .media_type(MediaType::ClosedCaptions) .group_id("cc") @@ -1285,24 +736,20 @@ mod test { .is_autoselect(true) .build() .unwrap(), - "#EXT-X-MEDIA:\ - TYPE=CLOSED-CAPTIONS,\ - GROUP-ID=\"cc\",\ - LANGUAGE=\"sp\",\ - NAME=\"CC2\",\ - AUTOSELECT=YES,\ - INSTREAM-ID=\"CC2\",\ - UNKNOWN=TAG" - .parse() - .unwrap() - ); - // ---- - assert_eq!( + concat!( + "#EXT-X-MEDIA:", + "TYPE=CLOSED-CAPTIONS,", + "GROUP-ID=\"cc\",", + "LANGUAGE=\"sp\",", + "NAME=\"CC2\",", + "AUTOSELECT=YES,", + "INSTREAM-ID=\"CC2\"" + ) + }, + { ExtXMedia::new(MediaType::Audio, "foo", "bar"), "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"foo\",NAME=\"bar\"" - .parse() - .unwrap() - ); + }, } #[test] diff --git a/src/tags/master_playlist/mod.rs b/src/tags/master_playlist/mod.rs index 293509a..d2005d0 100644 --- a/src/tags/master_playlist/mod.rs +++ b/src/tags/master_playlist/mod.rs @@ -1,11 +1,9 @@ -mod i_frame_stream_inf; -mod media; -mod session_data; -mod session_key; -mod stream_inf; +pub(crate) mod media; +pub(crate) mod session_data; +pub(crate) mod session_key; +pub(crate) mod variant_stream; -pub use i_frame_stream_inf::*; -pub use media::*; -pub use session_data::*; +pub use media::ExtXMedia; +pub use session_data::{ExtXSessionData, SessionData}; pub use session_key::*; -pub use stream_inf::*; +pub use variant_stream::*; diff --git a/src/tags/master_playlist/session_data.rs b/src/tags/master_playlist/session_data.rs index a264158..f7a52bf 100644 --- a/src/tags/master_playlist/session_data.rs +++ b/src/tags/master_playlist/session_data.rs @@ -2,58 +2,66 @@ use std::fmt; use std::str::FromStr; use derive_builder::Builder; +use shorthand::ShortHand; use crate::attribute::AttributePairs; use crate::types::ProtocolVersion; use crate::utils::{quote, tag, unquote}; use crate::{Error, RequiredVersion}; -/// The data of an [`ExtXSessionData`] tag. -#[derive(Hash, Eq, Ord, Debug, PartialEq, Clone, PartialOrd)] +/// The data of [`ExtXSessionData`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum SessionData { - /// A String, that contains the data identified by - /// [`data_id`]. - /// If a [`language`] is specified, the value - /// should contain a human-readable string written in the specified - /// language. + /// Contains the data identified by the [`ExtXSessionData::data_id`]. + /// + /// If a [`language`] is specified, this variant should contain a + /// human-readable string written in the specified language. /// /// [`data_id`]: ExtXSessionData::data_id /// [`language`]: ExtXSessionData::language Value(String), - /// An [`uri`], which points to a [`json`]. + /// An [`URI`], which points to a [`json`] file. /// /// [`json`]: https://tools.ietf.org/html/rfc8259 - /// [`uri`]: https://tools.ietf.org/html/rfc3986 + /// [`URI`]: https://tools.ietf.org/html/rfc3986 Uri(String), } -/// # [4.3.4.4. EXT-X-SESSION-DATA] +/// Allows arbitrary session data to be carried in a [`MasterPlaylist`]. /// -/// The [`ExtXSessionData`] tag allows arbitrary session data to be -/// carried in a [`Master Playlist`]. -/// -/// [`Master Playlist`]: crate::MasterPlaylist -/// [4.3.4.4. EXT-X-SESSION-DATA]: https://tools.ietf.org/html/rfc8216#section-4.3.4.4 -#[derive(Builder, Hash, Eq, Ord, Debug, PartialEq, Clone, PartialOrd)] +/// [`MasterPlaylist`]: crate::MasterPlaylist +#[derive(ShortHand, Builder, Hash, Eq, Ord, Debug, PartialEq, Clone, PartialOrd)] #[builder(setter(into))] +#[shorthand(enable(must_use, into))] pub struct ExtXSessionData { - /// The identifier of the data. - /// For more information look [`here`]. + /// This should conform to a [reverse DNS] naming convention, such as + /// `com.example.movie.title`. /// /// # Note + /// + /// There is no central registration authority, so a value + /// should be choosen, that is unlikely to collide with others. + /// /// This field is required. /// - /// [`here`]: ExtXSessionData::set_data_id + /// [reverse DNS]: https://en.wikipedia.org/wiki/Reverse_domain_name_notation data_id: String, - /// The data associated with the [`data_id`]. - /// For more information look [`here`](SessionData). + /// The [`SessionData`] associated with the + /// [`data_id`](ExtXSessionData::data_id). /// /// # Note - /// This field is required. /// - /// [`data_id`]: ExtXSessionDataBuilder::data_id - data: SessionData, - /// The language of the [`data`](ExtXSessionDataBuilder::data). + /// This field is required. + #[shorthand(enable(skip))] + pub data: SessionData, + /// The `language` attribute identifies the language of the [`SessionData`]. + /// + /// # Note + /// + /// This field is optional and the provided value should conform to + /// [RFC5646]. + /// + /// [RFC5646]: https://tools.ietf.org/html/rfc5646 #[builder(setter(into, strip_option), default)] language: Option, } @@ -64,199 +72,78 @@ impl ExtXSessionData { /// Makes a new [`ExtXSessionData`] tag. /// /// # Example - /// ``` - /// use hls_m3u8::tags::{ExtXSessionData, SessionData}; /// - /// ExtXSessionData::new( + /// ``` + /// # use hls_m3u8::tags::ExtXSessionData; + /// use hls_m3u8::tags::SessionData; + /// + /// let session_data = ExtXSessionData::new( /// "com.example.movie.title", - /// SessionData::Uri("https://www.example.com/".to_string()), + /// SessionData::Uri("https://www.example.com/".into()), /// ); /// ``` - pub fn new(data_id: T, data: SessionData) -> Self { + #[must_use] + pub fn new>(data_id: T, data: SessionData) -> Self { Self { - data_id: data_id.to_string(), + data_id: data_id.into(), data, language: None, } } - /// Returns a new Builder for [`ExtXSessionData`]. + /// Returns a builder for [`ExtXSessionData`]. /// /// # Example + /// /// ``` - /// use hls_m3u8::tags::{ExtXSessionData, SessionData}; + /// # use hls_m3u8::tags::ExtXSessionData; + /// use hls_m3u8::tags::SessionData; /// /// let session_data = ExtXSessionData::builder() /// .data_id("com.example.movie.title") - /// .data(SessionData::Value("some data".to_string())) - /// .language("english") - /// .build() - /// .expect("Failed to build an ExtXSessionData tag."); - /// - /// assert_eq!( - /// session_data, - /// ExtXSessionData::with_language( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// "english" - /// ) - /// ); + /// .data(SessionData::Value("some data".into())) + /// .language("en") + /// .build()?; + /// # Ok::<(), String>(()) /// ``` + #[must_use] pub fn builder() -> ExtXSessionDataBuilder { ExtXSessionDataBuilder::default() } /// Makes a new [`ExtXSessionData`] tag, with the given language. /// /// # Example + /// /// ``` - /// use hls_m3u8::tags::{ExtXSessionData, SessionData}; + /// # use hls_m3u8::tags::ExtXSessionData; + /// use hls_m3u8::tags::SessionData; /// /// let session_data = ExtXSessionData::with_language( /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// "english", + /// SessionData::Value("some data".into()), + /// "en", /// ); /// ``` - pub fn with_language(data_id: T, data: SessionData, language: T) -> Self { + #[must_use] + pub fn with_language(data_id: T, data: SessionData, language: K) -> Self + where + T: Into, + K: Into, + { Self { - data_id: data_id.to_string(), + data_id: data_id.into(), data, - language: Some(language.to_string()), + language: Some(language.into()), } } - - /// Returns the `data_id`, that identifies a `data_value`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let data = ExtXSessionData::new( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()) - /// ); - /// - /// assert_eq!( - /// data.data_id(), - /// &"com.example.movie.title".to_string() - /// ) - /// ``` - pub const fn data_id(&self) -> &String { &self.data_id } - - /// Returns the `data`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let data = ExtXSessionData::new( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()) - /// ); - /// - /// assert_eq!( - /// data.data(), - /// &SessionData::Value("some data".to_string()) - /// ) - /// ``` - pub const fn data(&self) -> &SessionData { &self.data } - - /// Returns the `language` tag, that identifies the language of - /// [`SessionData`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let data = ExtXSessionData::with_language( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// "english" - /// ); - /// - /// assert_eq!( - /// data.language(), - /// &Some("english".to_string()) - /// ) - /// ``` - pub const fn language(&self) -> &Option { &self.language } - - /// Sets the `language` attribute, that identifies the language of - /// [`SessionData`]. See [rfc5646](https://tools.ietf.org/html/rfc5646). - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let mut data = ExtXSessionData::new( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// ); - /// - /// assert_eq!(data.language(), &None); - /// - /// data.set_language(Some("english")); - /// assert_eq!(data.language(), &Some("english".to_string())); - /// ``` - pub fn set_language(&mut self, value: Option) -> &mut Self { - self.language = value.map(|v| v.to_string()); - self - } - - /// Sets the `data_id` attribute, that should conform to a [reverse DNS] - /// naming convention, such as `com.example.movie.title`. - /// - /// # Note: - /// There is no central registration authority, so a value - /// should be choosen, that is unlikely to collide with others. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let mut data = ExtXSessionData::new( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// ); - /// - /// assert_eq!(data.data_id(), &"com.example.movie.title".to_string()); - /// - /// data.set_data_id("com.other.movie.title"); - /// assert_eq!(data.data_id(), &"com.other.movie.title".to_string()); - /// ``` - /// [reverse DNS]: https://en.wikipedia.org/wiki/Reverse_domain_name_notation - pub fn set_data_id(&mut self, value: T) -> &mut Self { - self.data_id = value.to_string(); - self - } - - /// Sets the [`data`](ExtXSessionData::data) of this tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::{ExtXSessionData, SessionData}; - /// # - /// let mut data = ExtXSessionData::new( - /// "com.example.movie.title", - /// SessionData::Value("some data".to_string()), - /// ); - /// - /// assert_eq!(data.data(), &SessionData::Value("some data".to_string())); - /// - /// data.set_data(SessionData::Value("new data".to_string())); - /// assert_eq!(data.data(), &SessionData::Value("new data".to_string())); - /// ``` - pub fn set_data(&mut self, value: SessionData) -> &mut Self { - self.data = value; - self - } } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXSessionData { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXSessionData { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "DATA-ID={}", quote(&self.data_id))?; @@ -284,8 +171,8 @@ impl FromStr for ExtXSessionData { let mut uri = None; let mut language = None; - for (key, value) in input.parse::()? { - match key.as_str() { + for (key, value) in AttributePairs::new(input) { + match key { "DATA-ID" => data_id = Some(unquote(value)), "VALUE" => session_value = Some(unquote(value)), "URI" => uri = Some(unquote(value)), @@ -299,17 +186,20 @@ impl FromStr for ExtXSessionData { } let data_id = data_id.ok_or_else(|| Error::missing_value("EXT-X-DATA-ID"))?; + let data = { if let Some(value) = session_value { if uri.is_some() { - return Err(Error::custom("Unexpected URI")); + return Err(Error::custom("unexpected URI")); } else { SessionData::Value(value) } } else if let Some(uri) = uri { SessionData::Uri(uri) } else { - return Err(Error::invalid_input()); + return Err(Error::custom( + "expected either `SessionData::Uri` or `SessionData::Value`", + )); } }; @@ -326,159 +216,85 @@ mod test { use super::*; use pretty_assertions::assert_eq; - #[test] - fn test_display() { - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.lyrics\",\ - URI=\"lyrics.json\"" - .to_string(), - ExtXSessionData::new( - "com.example.lyrics", - SessionData::Uri("lyrics.json".to_string()) - ) - .to_string() - ); + macro_rules! generate_tests { + ( $( { $struct:expr, $str:expr } ),+ $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($struct.to_string(), $str.to_string()); + )+ + } - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.title\",\ - VALUE=\"This is an example\",\ - LANGUAGE=\"en\"" - .to_string(), - ExtXSessionData::with_language( - "com.example.title", - SessionData::Value("This is an example".to_string()), - "en" - ) - .to_string() - ); + #[test] + fn test_parser() { + $( + assert_eq!($struct, $str.parse().unwrap()); + )+ - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.title\",\ - VALUE=\"Este es un ejemplo\",\ - LANGUAGE=\"es\"" - .to_string(), - ExtXSessionData::with_language( - "com.example.title", - SessionData::Value("Este es un ejemplo".to_string()), - "es" - ) - .to_string() - ); + assert!( + concat!( + "#EXT-X-SESSION-DATA:", + "DATA-ID=\"foo\",", + "LANGUAGE=\"baz\"" + ) + .parse::() + .is_err() + ); - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - VALUE=\"bar\"" - .to_string(), - ExtXSessionData::new("foo", SessionData::Value("bar".into())).to_string() - ); + assert!( + concat!( + "#EXT-X-SESSION-DATA:", + "DATA-ID=\"foo\",", + "LANGUAGE=\"baz\",", + "VALUE=\"VALUE\",", + "URI=\"https://www.example.com/\"" + ) + .parse::() + .is_err() + ); + } - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - URI=\"bar\"" - .to_string(), - ExtXSessionData::new("foo", SessionData::Uri("bar".into())).to_string() - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - VALUE=\"bar\",\ - LANGUAGE=\"baz\"" - .to_string(), - ExtXSessionData::with_language("foo", SessionData::Value("bar".into()), "baz") - .to_string() - ); + } } - #[test] - fn test_parser() { - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.lyrics\",\ - URI=\"lyrics.json\"" - .parse::() - .unwrap(), + generate_tests! { + { ExtXSessionData::new( "com.example.lyrics", - SessionData::Uri("lyrics.json".to_string()) + SessionData::Uri("lyrics.json".into()) + ), + concat!( + "#EXT-X-SESSION-DATA:", + "DATA-ID=\"com.example.lyrics\",", + "URI=\"lyrics.json\"" ) - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.title\",\ - LANGUAGE=\"en\",\ - VALUE=\"This is an example\"" - .parse::() - .unwrap(), + }, + { ExtXSessionData::with_language( "com.example.title", - SessionData::Value("This is an example".to_string()), + SessionData::Value("This is an example".into()), "en" + ), + concat!( + "#EXT-X-SESSION-DATA:", + "DATA-ID=\"com.example.title\",", + "VALUE=\"This is an example\",", + "LANGUAGE=\"en\"" ) - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"com.example.title\",\ - LANGUAGE=\"es\",\ - VALUE=\"Este es un ejemplo\"" - .parse::() - .unwrap(), + }, + { ExtXSessionData::with_language( "com.example.title", - SessionData::Value("Este es un ejemplo".to_string()), + SessionData::Value("Este es un ejemplo".into()), "es" + ), + concat!( + "#EXT-X-SESSION-DATA:", + "DATA-ID=\"com.example.title\",", + "VALUE=\"Este es un ejemplo\",", + "LANGUAGE=\"es\"" ) - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - VALUE=\"bar\"" - .parse::() - .unwrap(), - ExtXSessionData::new("foo", SessionData::Value("bar".into())) - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - URI=\"bar\"" - .parse::() - .unwrap(), - ExtXSessionData::new("foo", SessionData::Uri("bar".into())) - ); - - assert_eq!( - "#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - VALUE=\"bar\",\ - LANGUAGE=\"baz\",\ - UNKNOWN=TAG" - .parse::() - .unwrap(), - ExtXSessionData::with_language("foo", SessionData::Value("bar".into()), "baz") - ); - - assert!("#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - LANGUAGE=\"baz\"" - .parse::() - .is_err()); - - assert!("#EXT-X-SESSION-DATA:\ - DATA-ID=\"foo\",\ - LANGUAGE=\"baz\",\ - VALUE=\"VALUE\",\ - URI=\"https://www.example.com/\"" - .parse::() - .is_err()); + } } #[test] diff --git a/src/tags/master_playlist/session_key.rs b/src/tags/master_playlist/session_key.rs index 6bd3731..0534b0d 100644 --- a/src/tags/master_playlist/session_key.rs +++ b/src/tags/master_playlist/session_key.rs @@ -1,65 +1,71 @@ +use core::convert::TryFrom; use std::fmt; -use std::ops::{Deref, DerefMut}; use std::str::FromStr; -use crate::types::{DecryptionKey, EncryptionMethod, ProtocolVersion}; +use derive_more::{AsMut, AsRef, From}; + +use crate::tags::ExtXKey; +use crate::types::{DecryptionKey, ProtocolVersion}; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.4.5. EXT-X-SESSION-KEY] -/// The [`ExtXSessionKey`] tag allows encryption keys from [`Media Playlist`]s -/// to be specified in a [`Master Playlist`]. This allows the client to -/// preload these keys without having to read the [`Media Playlist`]s +/// The [`ExtXSessionKey`] tag allows encryption keys from [`MediaPlaylist`]s +/// to be specified in a [`MasterPlaylist`]. This allows the client to +/// preload these keys without having to read the [`MediaPlaylist`]s /// first. /// -/// Its format is: -/// ```text -/// #EXT-X-SESSION-KEY: -/// ``` +/// If an [`ExtXSessionKey`] is used, the values of [`DecryptionKey::method`], +/// [`DecryptionKey::format`] and [`DecryptionKey::versions`] must match any +/// [`ExtXKey`] with the same uri field. /// -/// [`Media Playlist`]: crate::MediaPlaylist -/// [`Master Playlist`]: crate::MasterPlaylist -/// [4.3.4.5. EXT-X-SESSION-KEY]: https://tools.ietf.org/html/rfc8216#section-4.3.4.5 -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ExtXSessionKey(DecryptionKey); +/// [`MediaPlaylist`]: crate::MediaPlaylist +/// [`MasterPlaylist`]: crate::MasterPlaylist +/// [`ExtXKey`]: crate::tags::ExtXKey +#[derive(AsRef, AsMut, From, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtXSessionKey(pub DecryptionKey); impl ExtXSessionKey { pub(crate) const PREFIX: &'static str = "#EXT-X-SESSION-KEY:"; /// Makes a new [`ExtXSessionKey`] tag. /// - /// # Panic - /// An [`ExtXSessionKey`] should only be used, - /// if the segments of the stream are encrypted. - /// Therefore this function will panic, - /// if the `method` is [`EncryptionMethod::None`]. - /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXSessionKey; - /// use hls_m3u8::types::EncryptionMethod; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod}; /// - /// let session_key = ExtXSessionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); + /// let session_key = ExtXSessionKey::new(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.com/", + /// )); /// ``` - pub fn new(method: EncryptionMethod, uri: T) -> Self { - if method == EncryptionMethod::None { - panic!("The EncryptionMethod is not allowed to be None"); - } + #[must_use] + #[inline] + pub const fn new(inner: DecryptionKey) -> Self { Self(inner) } +} - Self(DecryptionKey::new(method, uri)) +impl TryFrom for ExtXSessionKey { + type Error = Error; + + fn try_from(value: ExtXKey) -> Result { + if let ExtXKey(Some(inner)) = value { + Ok(Self(inner)) + } else { + Err(Error::custom("missing decryption key")) + } } } +/// This tag requires the same [`ProtocolVersion`] that is returned by +/// `DecryptionKey::required_version`. impl RequiredVersion for ExtXSessionKey { fn required_version(&self) -> ProtocolVersion { self.0.required_version() } } impl fmt::Display for ExtXSessionKey { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if self.0.method == EncryptionMethod::None { - return Err(fmt::Error); - } - write!(f, "{}{}", Self::PREFIX, self.0) + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", Self::PREFIX, self.0.to_string()) } } @@ -67,125 +73,84 @@ impl FromStr for ExtXSessionKey { type Err = Error; fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?; - Ok(Self(input.parse()?)) + Ok(Self(DecryptionKey::from_str(tag(input, Self::PREFIX)?)?)) } } -impl Deref for ExtXSessionKey { - type Target = DecryptionKey; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for ExtXSessionKey { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} - #[cfg(test)] mod test { use super::*; use crate::types::{EncryptionMethod, KeyFormat}; use pretty_assertions::assert_eq; - #[test] - fn test_display() { - let mut key = ExtXSessionKey::new( - EncryptionMethod::Aes128, - "https://www.example.com/hls-key/key.bin", - ); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); + macro_rules! generate_tests { + ( $( { $struct:expr, $str:expr } ),+ $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($struct.to_string(), $str.to_string()); + )+ + } - assert_eq!( - key.to_string(), - "#EXT-X-SESSION-KEY:METHOD=AES-128,\ - URI=\"https://www.example.com/hls-key/key.bin\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52" - .to_string() - ); + #[test] + fn test_parser() { + $( + assert_eq!($struct, $str.parse().unwrap()); + )+ + } + } } - #[test] - fn test_parser() { - assert_eq!( - r#"#EXT-X-SESSION-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52""# - .parse::() - .unwrap(), + generate_tests! { + { ExtXSessionKey::new( - EncryptionMethod::Aes128, - "https://priv.example.com/key.php?r=52" + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([ + 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, + ]) + .build() + .unwrap(), + ), + concat!( + "#EXT-X-SESSION-KEY:", + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52" ) - ); - - let mut key = ExtXSessionKey::new( - EncryptionMethod::Aes128, - "https://www.example.com/hls-key/key.bin", - ); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); - - assert_eq!( - "#EXT-X-SESSION-KEY:METHOD=AES-128,\ - URI=\"https://www.example.com/hls-key/key.bin\",\ - IV=0X10ef8f758ca555115584bb5b3c687f52" - .parse::() - .unwrap(), - key - ); - - key.set_key_format(Some(KeyFormat::Identity)); - - assert_eq!( - "#EXT-X-SESSION-KEY:\ - METHOD=AES-128,\ - URI=\"https://www.example.com/hls-key/key.bin\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52,\ - KEYFORMAT=\"identity\"" - .parse::() - .unwrap(), - key - ) + }, + { + ExtXSessionKey::new( + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([ + 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, + ]) + .format(KeyFormat::Identity) + .build() + .unwrap(), + ), + concat!( + "#EXT-X-SESSION-KEY:", + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52,", + "KEYFORMAT=\"identity\"", + ) + } } #[test] fn test_required_version() { assert_eq!( - ExtXSessionKey::new(EncryptionMethod::Aes128, "https://www.example.com/") - .required_version(), + ExtXSessionKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://www.example.com/" + )) + .required_version(), ProtocolVersion::V1 ); } - - #[test] - #[should_panic] - // ExtXSessionKey::new should panic, if the provided - // EncryptionMethod is None! - fn test_new_panic() { ExtXSessionKey::new(EncryptionMethod::None, ""); } - - #[test] - #[should_panic] - fn test_display_err() { - ExtXSessionKey(DecryptionKey::new(EncryptionMethod::None, "")).to_string(); - } - - #[test] - fn test_deref() { - let key = ExtXSessionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - - assert_eq!(key.method(), EncryptionMethod::Aes128); - assert_eq!(key.uri(), &Some("https://www.example.com/".into())); - } - - #[test] - fn test_deref_mut() { - let mut key = ExtXSessionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - - key.set_method(EncryptionMethod::None); - assert_eq!(key.method(), EncryptionMethod::None); - key.set_uri(Some("https://www.github.com/")); - assert_eq!(key.uri(), &Some("https://www.github.com/".into())); - } } diff --git a/src/tags/master_playlist/stream_inf.rs b/src/tags/master_playlist/stream_inf.rs deleted file mode 100644 index d8cc87d..0000000 --- a/src/tags/master_playlist/stream_inf.rs +++ /dev/null @@ -1,426 +0,0 @@ -use std::fmt; -use std::ops::{Deref, DerefMut}; -use std::str::FromStr; - -use crate::attribute::AttributePairs; -use crate::types::{ - ClosedCaptions, DecimalFloatingPoint, HdcpLevel, ProtocolVersion, StreamInf, StreamInfBuilder, -}; -use crate::utils::{quote, tag, unquote}; -use crate::{Error, RequiredVersion}; - -/// # [4.3.4.2. EXT-X-STREAM-INF] -/// -/// The [`ExtXStreamInf`] tag specifies a Variant Stream, which is a set -/// of Renditions that can be combined to play the presentation. The -/// attributes of the tag provide information about the Variant Stream. -/// -/// The URI line that follows the [`ExtXStreamInf`] tag specifies a Media -/// Playlist that carries a rendition of the Variant Stream. The URI -/// line is REQUIRED. Clients that do not support multiple video -/// Renditions SHOULD play this Rendition. -/// -/// [4.3.4.2. EXT-X-STREAM-INF]: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 -#[derive(PartialOrd, Debug, Clone, PartialEq)] -pub struct ExtXStreamInf { - uri: String, - frame_rate: Option, - audio: Option, - subtitles: Option, - closed_captions: Option, - stream_inf: StreamInf, -} - -#[derive(Default, Debug, Clone)] -/// Builder for [`ExtXStreamInf`]. -pub struct ExtXStreamInfBuilder { - uri: Option, - frame_rate: Option, - audio: Option, - subtitles: Option, - closed_captions: Option, - stream_inf: StreamInfBuilder, -} - -impl ExtXStreamInfBuilder { - /// An `URI` to the [`MediaPlaylist`] file. - /// - /// [`MediaPlaylist`]: crate::MediaPlaylist - pub fn uri>(&mut self, value: T) -> &mut Self { - self.uri = Some(value.into()); - self - } - - /// Maximum frame rate for all the video in the variant stream. - pub fn frame_rate(&mut self, value: f64) -> &mut Self { - self.frame_rate = Some(value.into()); - self - } - - /// The group identifier for the audio in the variant stream. - pub fn audio>(&mut self, value: T) -> &mut Self { - self.audio = Some(value.into()); - self - } - - /// The group identifier for the subtitles in the variant stream. - pub fn subtitles>(&mut self, value: T) -> &mut Self { - self.subtitles = Some(value.into()); - self - } - - /// The value of [`ClosedCaptions`] attribute. - pub fn closed_captions>(&mut self, value: T) -> &mut Self { - self.closed_captions = Some(value.into()); - self - } - - /// The maximum bandwidth of the stream. - pub fn bandwidth(&mut self, value: u64) -> &mut Self { - self.stream_inf.bandwidth(value); - self - } - - /// The average bandwidth of the stream. - pub fn average_bandwidth(&mut self, value: u64) -> &mut Self { - self.stream_inf.average_bandwidth(value); - self - } - - /// Every media format in any of the renditions specified by the Variant - /// Stream. - pub fn codecs>(&mut self, value: T) -> &mut Self { - self.stream_inf.codecs(value); - self - } - - /// The resolution of the stream. - pub fn resolution(&mut self, value: (usize, usize)) -> &mut Self { - self.stream_inf.resolution(value); - self - } - - /// High-bandwidth Digital Content Protection - pub fn hdcp_level(&mut self, value: HdcpLevel) -> &mut Self { - self.stream_inf.hdcp_level(value); - self - } - - /// It indicates the set of video renditions, that should be used when - /// playing the presentation. - pub fn video>(&mut self, value: T) -> &mut Self { - self.stream_inf.video(value); - self - } - - /// Build an [`ExtXStreamInf`]. - pub fn build(&self) -> crate::Result { - Ok(ExtXStreamInf { - uri: self - .uri - .clone() - .ok_or_else(|| Error::missing_value("frame rate"))?, - frame_rate: self.frame_rate, - audio: self.audio.clone(), - subtitles: self.subtitles.clone(), - closed_captions: self.closed_captions.clone(), - stream_inf: self.stream_inf.build().map_err(Error::builder_error)?, - }) - } -} - -impl ExtXStreamInf { - pub(crate) const PREFIX: &'static str = "#EXT-X-STREAM-INF:"; - - /// Creates a new [`ExtXStreamInf`] tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// ``` - pub fn new(uri: T, bandwidth: u64) -> Self { - Self { - uri: uri.to_string(), - frame_rate: None, - audio: None, - subtitles: None, - closed_captions: None, - stream_inf: StreamInf::new(bandwidth), - } - } - - /// Returns a builder for [`ExtXStreamInf`]. - pub fn builder() -> ExtXStreamInfBuilder { ExtXStreamInfBuilder::default() } - - /// Returns the `URI` that identifies the associated media playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// - /// assert_eq!(stream.uri(), &"https://www.example.com/".to_string()); - /// ``` - pub const fn uri(&self) -> &String { &self.uri } - - /// Sets the `URI` that identifies the associated media playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// - /// stream.set_uri("https://www.google.com/"); - /// assert_eq!(stream.uri(), &"https://www.google.com/".to_string()); - /// ``` - pub fn set_uri(&mut self, value: T) -> &mut Self { - self.uri = value.to_string(); - self - } - - /// Sets the maximum frame rate for all the video in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.frame_rate(), None); - /// - /// stream.set_frame_rate(Some(59.9)); - /// assert_eq!(stream.frame_rate(), Some(59.9)); - /// ``` - pub fn set_frame_rate(&mut self, value: Option) -> &mut Self { - self.frame_rate = value.map(Into::into); - self - } - - /// Returns the maximum frame rate for all the video in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.frame_rate(), None); - /// - /// stream.set_frame_rate(Some(59.9)); - /// assert_eq!(stream.frame_rate(), Some(59.9)); - /// ``` - pub fn frame_rate(&self) -> Option { self.frame_rate.map(|v| v.as_f64()) } - - /// Returns the group identifier for the audio in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.audio(), &None); - /// - /// stream.set_audio(Some("audio")); - /// assert_eq!(stream.audio(), &Some("audio".to_string())); - /// ``` - pub const fn audio(&self) -> &Option { &self.audio } - - /// Sets the group identifier for the audio in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.audio(), &None); - /// - /// stream.set_audio(Some("audio")); - /// assert_eq!(stream.audio(), &Some("audio".to_string())); - /// ``` - pub fn set_audio>(&mut self, value: Option) -> &mut Self { - self.audio = value.map(Into::into); - self - } - - /// Returns the group identifier for the subtitles in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.subtitles(), &None); - /// - /// stream.set_subtitles(Some("subs")); - /// assert_eq!(stream.subtitles(), &Some("subs".to_string())); - /// ``` - pub const fn subtitles(&self) -> &Option { &self.subtitles } - - /// Sets the group identifier for the subtitles in the variant stream. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.subtitles(), &None); - /// - /// stream.set_subtitles(Some("subs")); - /// assert_eq!(stream.subtitles(), &Some("subs".to_string())); - /// ``` - pub fn set_subtitles>(&mut self, value: Option) -> &mut Self { - self.subtitles = value.map(Into::into); - self - } - - /// Returns the value of [`ClosedCaptions`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// use hls_m3u8::types::ClosedCaptions; - /// - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.closed_captions(), &None); - /// - /// stream.set_closed_captions(Some(ClosedCaptions::None)); - /// assert_eq!(stream.closed_captions(), &Some(ClosedCaptions::None)); - /// ``` - pub const fn closed_captions(&self) -> &Option { &self.closed_captions } - - /// Sets the value of [`ClosedCaptions`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStreamInf; - /// use hls_m3u8::types::ClosedCaptions; - /// - /// let mut stream = ExtXStreamInf::new("https://www.example.com/", 20); - /// # assert_eq!(stream.closed_captions(), &None); - /// - /// stream.set_closed_captions(Some(ClosedCaptions::None)); - /// assert_eq!(stream.closed_captions(), &Some(ClosedCaptions::None)); - /// ``` - pub fn set_closed_captions(&mut self, value: Option) -> &mut Self { - self.closed_captions = value; - self - } -} - -/// This tag requires [`ProtocolVersion::V1`]. -impl RequiredVersion for ExtXStreamInf { - fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } -} - -impl fmt::Display for ExtXStreamInf { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}{}", Self::PREFIX, self.stream_inf)?; - if let Some(value) = &self.frame_rate { - write!(f, ",FRAME-RATE={:.3}", value.as_f64())?; - } - if let Some(value) = &self.audio { - write!(f, ",AUDIO={}", quote(value))?; - } - if let Some(value) = &self.subtitles { - write!(f, ",SUBTITLES={}", quote(value))?; - } - if let Some(value) = &self.closed_captions { - write!(f, ",CLOSED-CAPTIONS={}", value)?; - } - write!(f, "\n{}", self.uri)?; - Ok(()) - } -} - -impl FromStr for ExtXStreamInf { - type Err = Error; - - fn from_str(input: &str) -> Result { - let mut lines = input.lines(); - let first_line = lines - .next() - .ok_or_else(|| Error::missing_value("first_line"))?; - let uri = lines.next().ok_or_else(|| Error::missing_value("URI"))?; - - let input = tag(first_line, Self::PREFIX)?; - - let mut frame_rate = None; - let mut audio = None; - let mut subtitles = None; - let mut closed_captions = None; - - for (key, value) in input.parse::()? { - match key.as_str() { - "FRAME-RATE" => frame_rate = Some((value.parse())?), - "AUDIO" => audio = Some(unquote(value)), - "SUBTITLES" => subtitles = Some(unquote(value)), - "CLOSED-CAPTIONS" => closed_captions = Some(value.parse()?), - _ => {} - } - } - - Ok(Self { - uri: uri.to_string(), - frame_rate, - audio, - subtitles, - closed_captions, - stream_inf: input.parse()?, - }) - } -} - -impl Deref for ExtXStreamInf { - type Target = StreamInf; - - fn deref(&self) -> &Self::Target { &self.stream_inf } -} - -impl DerefMut for ExtXStreamInf { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.stream_inf } -} - -#[cfg(test)] -mod test { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_parser() { - let stream_inf = "#EXT-X-STREAM-INF:BANDWIDTH=1000\nhttp://www.example.com" - .parse::() - .unwrap(); - - assert_eq!( - stream_inf, - ExtXStreamInf::new("http://www.example.com", 1000) - ); - } - - #[test] - fn test_display() { - assert_eq!( - ExtXStreamInf::new("http://www.example.com/", 1000).to_string(), - "#EXT-X-STREAM-INF:BANDWIDTH=1000\nhttp://www.example.com/".to_string() - ); - } - - #[test] - fn test_required_version() { - assert_eq!( - ProtocolVersion::V1, - ExtXStreamInf::new("http://www.example.com", 1000).required_version() - ); - } - - #[test] - fn test_deref() { - assert_eq!( - ExtXStreamInf::new("http://www.example.com", 1000).bandwidth(), - 1000 - ); - } - - #[test] - fn test_deref_mut() { - assert_eq!( - ExtXStreamInf::new("http://www.example.com", 1000) - .set_bandwidth(1) - .bandwidth(), - 1 - ); - } -} diff --git a/src/tags/master_playlist/variant_stream.rs b/src/tags/master_playlist/variant_stream.rs new file mode 100644 index 0000000..64af9dd --- /dev/null +++ b/src/tags/master_playlist/variant_stream.rs @@ -0,0 +1,477 @@ +use core::fmt; +use core::ops::Deref; +use core::str::FromStr; + +use crate::attribute::AttributePairs; +use crate::tags::ExtXMedia; +use crate::traits::RequiredVersion; +use crate::types::{ClosedCaptions, MediaType, ProtocolVersion, StreamData, UFloat}; +use crate::utils::{quote, tag, unquote}; +use crate::Error; + +/// A server may offer multiple [`MediaPlaylist`] files to provide different +/// encodings of the same presentation. +/// +/// If it does so, it should provide +/// a [`MasterPlaylist`] that lists each [`VariantStream`] to allow +/// clients to switch between encodings dynamically. +/// +/// The server must meet the following constraints when producing +/// [`VariantStream`]s in order to allow clients to switch between them +/// seamlessly: +/// +/// - Each [`VariantStream`] must present the same content. +/// +/// - Matching content in [`VariantStream`]s must have matching timestamps. This +/// allows clients to synchronize the media. +/// +/// - Matching content in [`VariantStream`]s must have matching +/// [`ExtXDiscontinuitySequence`]. +/// +/// - Each [`MediaPlaylist`] in each [`VariantStream`] must have the same target +/// duration. The only exceptions are subtitle renditions and +/// [`MediaPlaylist`]s containing an [`ExtXIFramesOnly`] tag, which may have +/// different target durations if they have [`PlaylistType::Vod`]. +/// +/// - Content that appears in a [`MediaPlaylist`] of one [`VariantStream`] but +/// not in another must appear either at the beginning or at the end of the +/// [`MediaPlaylist`] and must not be longer than the target duration. +/// +/// - If any [`MediaPlaylist`]s have an [`PlaylistType`] tag, all +/// [`MediaPlaylist`]s must have an [`PlaylistType`] tag with the same value. +/// +/// - If the Playlist contains an [`PlaylistType`] tag with the value of VOD, +/// the first segment of every [`MediaPlaylist`] in every [`VariantStream`] +/// must start at the same media timestamp. +/// +/// - If any [`MediaPlaylist`] in a [`MasterPlaylist`] contains an +/// [`ExtXProgramDateTime`] tag, then all [`MediaPlaylist`]s in that +/// [`MasterPlaylist`] must contain [`ExtXProgramDateTime`] tags with +/// consistent mappings of date and time to media timestamps. +/// +/// - Each [`VariantStream`] must contain the same set of Date Ranges, each one +/// identified by an [`ExtXDateRange`] tag(s) with the same ID attribute value +/// and containing the same set of attribute/value pairs. +/// +/// In addition, for broadest compatibility, [`VariantStream`]s should +/// contain the same encoded audio bitstream. This allows clients to +/// switch between [`VariantStream`]s without audible glitching. +/// +/// [RFC6381]: https://tools.ietf.org/html/rfc6381 +/// [`ExtXDiscontinuitySequence`]: crate::tags::ExtXDiscontinuitySequence +/// [`PlaylistType::Vod`]: crate::types::PlaylistType::Vod +/// [`MediaPlaylist`]: crate::MediaPlaylist +/// [`MasterPlaylist`]: crate::MasterPlaylist +/// [`ExtXDateRange`]: crate::tags::ExtXDateRange +/// [`ExtXProgramDateTime`]: crate::tags::ExtXProgramDateTime +/// [`PlaylistType`]: crate::types::PlaylistType +/// [`ExtXIFramesOnly`]: crate::tags::ExtXIFramesOnly +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +pub enum VariantStream { + /// The [`VariantStream::ExtXIFrame`] variant identifies a [`MediaPlaylist`] + /// file containing the I-frames of a multimedia presentation. + /// It stands alone, in that it does not apply to a particular URI in the + /// [`MasterPlaylist`]. + /// + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`MediaPlaylist`]: crate::MediaPlaylist + ExtXIFrame { + /// The URI identifies the I-frame [`MediaPlaylist`] file. + /// That Playlist file must contain an [`ExtXIFramesOnly`] tag. + /// + /// # Note + /// + /// This field is required. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + /// [`ExtXIFramesOnly`]: crate::tags::ExtXIFramesOnly + uri: String, + /// Some fields are shared between [`VariantStream::ExtXStreamInf`] and + /// [`VariantStream::ExtXIFrame`]. + /// + /// # Note + /// + /// This field is optional. + stream_data: StreamData, + }, + /// [`VariantStream::ExtXStreamInf`] specifies a [`VariantStream`], which is + /// a set of renditions that can be combined to play the presentation. + ExtXStreamInf { + /// The URI specifies a [`MediaPlaylist`] that carries a rendition of + /// the [`VariantStream`]. Clients that do not support multiple video + /// renditions should play this rendition. + /// + /// # Note + /// + /// This field is required. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + uri: String, + /// The value is an unsigned float describing the maximum frame + /// rate for all the video in the [`VariantStream`]. + /// + /// # Note + /// + /// Specifying the frame rate is optional, but is recommended if the + /// [`VariantStream`] includes video. It should be specified if any + /// video exceeds 30 frames per second. + frame_rate: Option, + /// It indicates the set of audio renditions that should be used when + /// playing the presentation. + /// + /// It must match the value of the [`ExtXMedia::group_id`] of an + /// [`ExtXMedia`] tag elsewhere in the [`MasterPlaylist`] whose + /// [`ExtXMedia::media_type`] is [`MediaType::Audio`]. + /// + /// # Note + /// + /// This field is optional. + /// + /// [`ExtXMedia`]: crate::tags::ExtXMedia + /// [`ExtXMedia::group_id`]: crate::tags::ExtXMedia::group_id + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`ExtXMedia::media_type`]: crate::tags::ExtXMedia::media_type + /// [`MediaType::Audio`]: crate::types::MediaType::Audio + audio: Option, + /// It indicates the set of subtitle renditions that can be used when + /// playing the presentation. + /// + /// It must match the value of the [`ExtXMedia::group_id`] of an + /// [`ExtXMedia`] tag elsewhere in the [`MasterPlaylist`] whose + /// [`ExtXMedia::media_type`] is [`MediaType::Subtitles`]. + /// + /// # Note + /// + /// This field is optional. + /// + /// [`ExtXMedia`]: crate::tags::ExtXMedia + /// [`ExtXMedia::group_id`]: crate::tags::ExtXMedia::group_id + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`ExtXMedia::media_type`]: crate::tags::ExtXMedia::media_type + /// [`MediaType::Subtitles`]: crate::types::MediaType::Subtitles + subtitles: Option, + /// It indicates the set of closed-caption renditions that can be used + /// when playing the presentation. + /// + /// # Note + /// + /// This field is optional. + closed_captions: Option, + /// Some fields are shared between [`VariantStream::ExtXStreamInf`] and + /// [`VariantStream::ExtXIFrame`]. + /// + /// # Note + /// + /// This field is optional. + stream_data: StreamData, + }, +} + +impl VariantStream { + pub(crate) const PREFIX_EXTXIFRAME: &'static str = "#EXT-X-I-FRAME-STREAM-INF:"; + pub(crate) const PREFIX_EXTXSTREAMINF: &'static str = "#EXT-X-STREAM-INF:"; + + /// Checks if a [`VariantStream`] and an [`ExtXMedia`] element are + /// associated. + /// + /// # Example + /// + /// ``` + /// use hls_m3u8::tags::{ExtXMedia, VariantStream}; + /// use hls_m3u8::types::{ClosedCaptions, MediaType, StreamData}; + /// + /// let variant_stream = VariantStream::ExtXStreamInf { + /// uri: "https://www.example.com/init.bin".into(), + /// frame_rate: None, + /// audio: Some("ag1".into()), + /// subtitles: Some("sg1".into()), + /// closed_captions: Some(ClosedCaptions::group_id("cc1")), + /// stream_data: StreamData::builder() + /// .bandwidth(1_110_000) + /// .video("vg1") + /// .build() + /// .unwrap(), + /// }; + /// + /// assert!(variant_stream.is_associated( + /// &ExtXMedia::builder() + /// .media_type(MediaType::Audio) + /// .group_id("ag1") + /// .name("audio example") + /// .build() + /// .unwrap(), + /// )); + /// ``` + #[must_use] + pub fn is_associated(&self, media: &ExtXMedia) -> bool { + match &self { + Self::ExtXIFrame { stream_data, .. } => { + if let MediaType::Video = media.media_type { + if let Some(value) = stream_data.video() { + return value == media.group_id(); + } + } + + false + } + Self::ExtXStreamInf { + audio, + subtitles, + closed_captions, + stream_data, + .. + } => { + match media.media_type { + MediaType::Audio => audio.as_ref().map_or(false, |v| v == media.group_id()), + MediaType::Video => { + stream_data.video().map_or(false, |v| v == media.group_id()) + } + MediaType::Subtitles => { + subtitles.as_ref().map_or(false, |v| v == media.group_id()) + } + MediaType::ClosedCaptions => { + closed_captions + .as_ref() + .map_or(false, |v| v == media.group_id()) + } + } + } + } + } +} + +/// This tag requires [`ProtocolVersion::V1`]. +impl RequiredVersion for VariantStream { + fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } + + fn introduced_version(&self) -> ProtocolVersion { + match &self { + Self::ExtXStreamInf { + audio, + subtitles, + stream_data, + .. + } => { + if stream_data.introduced_version() >= ProtocolVersion::V4 { + stream_data.introduced_version() + } else if audio.is_some() || subtitles.is_some() { + ProtocolVersion::V4 + } else { + ProtocolVersion::V1 + } + } + Self::ExtXIFrame { stream_data, .. } => stream_data.introduced_version(), + } + } +} + +impl fmt::Display for VariantStream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::ExtXIFrame { uri, stream_data } => { + write!(f, "{}", Self::PREFIX_EXTXIFRAME)?; + write!(f, "URI={},{}", quote(uri), stream_data)?; + } + Self::ExtXStreamInf { + uri, + frame_rate, + audio, + subtitles, + closed_captions, + stream_data, + } => { + write!(f, "{}{}", Self::PREFIX_EXTXSTREAMINF, stream_data)?; + + if let Some(value) = frame_rate { + write!(f, ",FRAME-RATE={:.3}", value.as_f32())?; + } + + if let Some(value) = audio { + write!(f, ",AUDIO={}", quote(value))?; + } + + if let Some(value) = subtitles { + write!(f, ",SUBTITLES={}", quote(value))?; + } + + if let Some(value) = closed_captions { + write!(f, ",CLOSED-CAPTIONS={}", value)?; + } + + write!(f, "\n{}", uri)?; + } + } + + Ok(()) + } +} + +impl FromStr for VariantStream { + type Err = Error; + + fn from_str(input: &str) -> Result { + if let Ok(input) = tag(input, Self::PREFIX_EXTXIFRAME) { + let uri = AttributePairs::new(input) + .find_map(|(key, value)| { + if key == "URI" { + Some(unquote(value)) + } else { + None + } + }) + .ok_or_else(|| Error::missing_value("URI"))?; + + Ok(Self::ExtXIFrame { + uri, + stream_data: input.parse()?, + }) + } else if let Ok(input) = tag(input, Self::PREFIX_EXTXSTREAMINF) { + let mut lines = input.lines(); + let first_line = lines + .next() + .ok_or_else(|| Error::missing_value("first_line"))?; + let uri = lines.next().ok_or_else(|| Error::missing_value("URI"))?; + + let mut frame_rate = None; + let mut audio = None; + let mut subtitles = None; + let mut closed_captions = None; + + for (key, value) in AttributePairs::new(first_line) { + match key { + "FRAME-RATE" => frame_rate = Some(value.parse()?), + "AUDIO" => audio = Some(unquote(value)), + "SUBTITLES" => subtitles = Some(unquote(value)), + "CLOSED-CAPTIONS" => closed_captions = Some(value.parse().unwrap()), + _ => {} + } + } + + Ok(Self::ExtXStreamInf { + uri: uri.to_string(), + frame_rate, + audio, + subtitles, + closed_captions, + stream_data: first_line.parse()?, + }) + } else { + // TODO: custom error type? + attach input data + Err(Error::custom(format!( + "invalid start of input, expected either {:?} or {:?}", + Self::PREFIX_EXTXIFRAME, + Self::PREFIX_EXTXSTREAMINF + ))) + } + } +} + +impl Deref for VariantStream { + type Target = StreamData; + + fn deref(&self) -> &Self::Target { + match &self { + Self::ExtXIFrame { stream_data, .. } | Self::ExtXStreamInf { stream_data, .. } => { + stream_data + } + } + } +} + +impl PartialEq<&VariantStream> for VariantStream { + fn eq(&self, other: &&Self) -> bool { self.eq(*other) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InStreamId; + //use pretty_assertions::assert_eq; + + #[test] + fn test_required_version() { + assert_eq!( + VariantStream::ExtXStreamInf { + uri: "https://www.example.com/init.bin".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::new(1_110_000) + } + .required_version(), + ProtocolVersion::V1 + ); + } + + #[test] + fn test_is_associated() { + let mut variant_stream = VariantStream::ExtXStreamInf { + uri: "https://www.example.com/init.bin".into(), + frame_rate: None, + audio: Some("ag1".into()), + subtitles: Some("sg1".into()), + closed_captions: Some(ClosedCaptions::group_id("cc1")), + stream_data: StreamData::builder() + .bandwidth(1_110_000) + .video("vg1") + .build() + .unwrap(), + }; + + assert!(variant_stream.is_associated( + &ExtXMedia::builder() + .media_type(MediaType::Audio) + .group_id("ag1") + .name("audio example") + .build() + .unwrap(), + )); + + assert!(variant_stream.is_associated( + &ExtXMedia::builder() + .media_type(MediaType::Subtitles) + .uri("https://www.example.com/sg1.ssa") + .group_id("sg1") + .name("subtitle example") + .build() + .unwrap(), + )); + + assert!(variant_stream.is_associated( + &ExtXMedia::builder() + .media_type(MediaType::ClosedCaptions) + .group_id("cc1") + .name("closed captions example") + .instream_id(InStreamId::Cc1) + .build() + .unwrap(), + )); + + if let VariantStream::ExtXStreamInf { + closed_captions, .. + } = &mut variant_stream + { + *closed_captions = Some(ClosedCaptions::None); + } + + assert!(variant_stream.is_associated( + &ExtXMedia::builder() + .media_type(MediaType::ClosedCaptions) + .group_id("NONE") + .name("closed captions example") + .instream_id(InStreamId::Cc1) + .build() + .unwrap(), + )); + + assert!(variant_stream.is_associated( + &ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("vg1") + .name("video example") + .build() + .unwrap(), + )); + } +} diff --git a/src/tags/media_playlist/discontinuity_sequence.rs b/src/tags/media_playlist/discontinuity_sequence.rs index c49b9d7..9fcdde7 100644 --- a/src/tags/media_playlist/discontinuity_sequence.rs +++ b/src/tags/media_playlist/discontinuity_sequence.rs @@ -3,81 +3,39 @@ use std::str::FromStr; use crate::types::ProtocolVersion; use crate::utils::tag; -use crate::RequiredVersion; +use crate::{Error, RequiredVersion}; -/// # [4.4.3.3. EXT-X-DISCONTINUITY-SEQUENCE] +/// Allows synchronization between different renditions of the same +/// [`VariantStream`]. /// -/// The [`ExtXDiscontinuitySequence`] tag allows synchronization between -/// different Renditions of the same Variant Stream or different Variant -/// Streams that have [`ExtXDiscontinuity`] tags in their [`Media Playlist`]s. -/// -/// Its format is: -/// ```text -/// #EXT-X-DISCONTINUITY-SEQUENCE: -/// ``` -/// where `number` is a [u64]. -/// -/// [`ExtXDiscontinuity`]: crate::tags::ExtXDiscontinuity -/// [`Media Playlist`]: crate::MediaPlaylist -/// [4.4.3.3. EXT-X-DISCONTINUITY-SEQUENCE]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.3.3 -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] -pub struct ExtXDiscontinuitySequence(u64); +/// [`VariantStream`]: crate::tags::VariantStream +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub(crate) struct ExtXDiscontinuitySequence(pub usize); impl ExtXDiscontinuitySequence { pub(crate) const PREFIX: &'static str = "#EXT-X-DISCONTINUITY-SEQUENCE:"; - - /// Makes a new [ExtXDiscontinuitySequence] tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDiscontinuitySequence; - /// let discontinuity_sequence = ExtXDiscontinuitySequence::new(5); - /// ``` - pub const fn new(seq_num: u64) -> Self { Self(seq_num) } - - /// Returns the discontinuity sequence number of - /// the first media segment that appears in the associated playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDiscontinuitySequence; - /// let discontinuity_sequence = ExtXDiscontinuitySequence::new(5); - /// - /// assert_eq!(discontinuity_sequence.seq_num(), 5); - /// ``` - pub const fn seq_num(self) -> u64 { self.0 } - - /// Sets the sequence number. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDiscontinuitySequence; - /// let mut discontinuity_sequence = ExtXDiscontinuitySequence::new(5); - /// - /// discontinuity_sequence.set_seq_num(10); - /// assert_eq!(discontinuity_sequence.seq_num(), 10); - /// ``` - pub fn set_seq_num(&mut self, value: u64) -> &mut Self { - self.0 = value; - self - } } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXDiscontinuitySequence { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXDiscontinuitySequence { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}{}", Self::PREFIX, self.0) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // + write!(f, "{}{}", Self::PREFIX, self.0) + } } impl FromStr for ExtXDiscontinuitySequence { - type Err = crate::Error; + type Err = Error; fn from_str(input: &str) -> Result { - let seq_num = tag(input, Self::PREFIX)?.parse()?; - Ok(Self::new(seq_num)) + let input = tag(input, Self::PREFIX)?; + let seq_num = input.parse().map_err(|e| Error::parse_int(input, e))?; + + Ok(Self(seq_num)) } } @@ -89,7 +47,7 @@ mod test { #[test] fn test_display() { assert_eq!( - ExtXDiscontinuitySequence::new(123).to_string(), + ExtXDiscontinuitySequence(123).to_string(), "#EXT-X-DISCONTINUITY-SEQUENCE:123".to_string() ); } @@ -97,7 +55,7 @@ mod test { #[test] fn test_required_version() { assert_eq!( - ExtXDiscontinuitySequence::new(123).required_version(), + ExtXDiscontinuitySequence(123).required_version(), ProtocolVersion::V1 ) } @@ -105,16 +63,13 @@ mod test { #[test] fn test_parser() { assert_eq!( - ExtXDiscontinuitySequence::new(123), + ExtXDiscontinuitySequence(123), "#EXT-X-DISCONTINUITY-SEQUENCE:123".parse().unwrap() ); - } - #[test] - fn test_seq_num() { - let mut sequence = ExtXDiscontinuitySequence::new(123); - assert_eq!(sequence.seq_num(), 123); - sequence.set_seq_num(1); - assert_eq!(sequence.seq_num(), 1); + assert_eq!( + ExtXDiscontinuitySequence::from_str("#EXT-X-DISCONTINUITY-SEQUENCE:12A"), + Err(Error::parse_int("12A", "12A".parse::().expect_err(""))) + ); } } diff --git a/src/tags/media_playlist/end_list.rs b/src/tags/media_playlist/end_list.rs index 5463f5e..b57e93d 100644 --- a/src/tags/media_playlist/end_list.rs +++ b/src/tags/media_playlist/end_list.rs @@ -5,32 +5,25 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.4.3.4. EXT-X-ENDLIST] -/// The [`ExtXEndList`] tag indicates, that no more [`Media Segment`]s will be -/// added to the [`Media Playlist`] file. +/// Indicates that no more [`MediaSegment`]s will be added to the +/// [`MediaPlaylist`] file. /// -/// Its format is: -/// ```text -/// #EXT-X-ENDLIST -/// ``` -/// -/// [`Media Segment`]: crate::MediaSegment -/// [`Media Playlist`]: crate::MediaPlaylist -/// [4.4.3.4. EXT-X-ENDLIST]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.3.4 +/// [`MediaSegment`]: crate::MediaSegment +/// [`MediaPlaylist`]: crate::MediaPlaylist #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExtXEndList; +pub(crate) struct ExtXEndList; impl ExtXEndList { pub(crate) const PREFIX: &'static str = "#EXT-X-ENDLIST"; } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXEndList { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXEndList { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { Self::PREFIX.fmt(f) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Self::PREFIX.fmt(f) } } impl FromStr for ExtXEndList { diff --git a/src/tags/media_playlist/i_frames_only.rs b/src/tags/media_playlist/i_frames_only.rs index c9c4dc9..3e7225f 100644 --- a/src/tags/media_playlist/i_frames_only.rs +++ b/src/tags/media_playlist/i_frames_only.rs @@ -5,34 +5,20 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.4.3.6. EXT-X-I-FRAMES-ONLY] -/// The [`ExtXIFramesOnly`] tag indicates that each [`Media Segment`] in the -/// Playlist describes a single I-frame. I-frames are encoded video -/// frames, whose decoding does not depend on any other frame. I-frame -/// Playlists can be used for trick play, such as fast forward, rapid -/// reverse, and scrubbing. -/// -/// Its format is: -/// ```text -/// #EXT-X-I-FRAMES-ONLY -/// ``` -/// -/// [`Media Segment`]: crate::MediaSegment -/// [4.4.3.6. EXT-X-I-FRAMES-ONLY]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.3.6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExtXIFramesOnly; +pub(crate) struct ExtXIFramesOnly; impl ExtXIFramesOnly { pub(crate) const PREFIX: &'static str = "#EXT-X-I-FRAMES-ONLY"; } +/// This tag requires [`ProtocolVersion::V4`]. impl RequiredVersion for ExtXIFramesOnly { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V4 } } impl fmt::Display for ExtXIFramesOnly { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { Self::PREFIX.fmt(f) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Self::PREFIX.fmt(f) } } impl FromStr for ExtXIFramesOnly { diff --git a/src/tags/media_playlist/media_sequence.rs b/src/tags/media_playlist/media_sequence.rs index 5e26eb7..4e36f3a 100644 --- a/src/tags/media_playlist/media_sequence.rs +++ b/src/tags/media_playlist/media_sequence.rs @@ -5,54 +5,13 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.4.3.2. EXT-X-MEDIA-SEQUENCE] -/// The [`ExtXMediaSequence`] tag indicates the Media Sequence Number of -/// the first [`Media Segment`] that appears in a Playlist file. -/// -/// [Media Segment]: crate::MediaSegment -/// [4.4.3.2. EXT-X-MEDIA-SEQUENCE]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.3.2 -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] -pub struct ExtXMediaSequence(u64); +/// Indicates the Media Sequence Number of the first `MediaSegment` that +/// appears in a `MediaPlaylist`. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ExtXMediaSequence(pub usize); impl ExtXMediaSequence { pub(crate) const PREFIX: &'static str = "#EXT-X-MEDIA-SEQUENCE:"; - - /// Makes a new [`ExtXMediaSequence`] tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMediaSequence; - /// let media_sequence = ExtXMediaSequence::new(5); - /// ``` - pub const fn new(seq_num: u64) -> Self { Self(seq_num) } - - /// Returns the sequence number of the first media segment, - /// that appears in the associated playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMediaSequence; - /// let media_sequence = ExtXMediaSequence::new(5); - /// - /// assert_eq!(media_sequence.seq_num(), 5); - /// ``` - pub const fn seq_num(self) -> u64 { self.0 } - - /// Sets the sequence number. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMediaSequence; - /// let mut media_sequence = ExtXMediaSequence::new(5); - /// - /// media_sequence.set_seq_num(10); - /// assert_eq!(media_sequence.seq_num(), 10); - /// ``` - pub fn set_seq_num(&mut self, value: u64) -> &mut Self { - self.0 = value; - self - } } /// This tag requires [`ProtocolVersion::V1`]. @@ -61,15 +20,20 @@ impl RequiredVersion for ExtXMediaSequence { } impl fmt::Display for ExtXMediaSequence { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}{}", Self::PREFIX, self.0) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // + write!(f, "{}{}", Self::PREFIX, self.0) + } } impl FromStr for ExtXMediaSequence { type Err = Error; fn from_str(input: &str) -> Result { - let seq_num = tag(input, Self::PREFIX)?.parse()?; - Ok(Self::new(seq_num)) + let input = tag(input, Self::PREFIX)?; + let seq_num = input.parse().map_err(|e| Error::parse_int(input, e))?; + + Ok(Self(seq_num)) } } @@ -81,7 +45,7 @@ mod test { #[test] fn test_display() { assert_eq!( - ExtXMediaSequence::new(123).to_string(), + ExtXMediaSequence(123).to_string(), "#EXT-X-MEDIA-SEQUENCE:123".to_string() ); } @@ -89,7 +53,7 @@ mod test { #[test] fn test_required_version() { assert_eq!( - ExtXMediaSequence::new(123).required_version(), + ExtXMediaSequence(123).required_version(), ProtocolVersion::V1 ); } @@ -97,16 +61,8 @@ mod test { #[test] fn test_parser() { assert_eq!( - ExtXMediaSequence::new(123), + ExtXMediaSequence(123), "#EXT-X-MEDIA-SEQUENCE:123".parse().unwrap() ); } - - #[test] - fn test_seq_num() { - let mut sequence = ExtXMediaSequence::new(123); - assert_eq!(sequence.seq_num(), 123); - sequence.set_seq_num(1); - assert_eq!(sequence.seq_num(), 1); - } } diff --git a/src/tags/media_playlist/mod.rs b/src/tags/media_playlist/mod.rs index af04ff9..bb4e53a 100644 --- a/src/tags/media_playlist/mod.rs +++ b/src/tags/media_playlist/mod.rs @@ -1,13 +1,11 @@ -mod discontinuity_sequence; -mod end_list; -mod i_frames_only; -mod media_sequence; -mod playlist_type; -mod target_duration; +pub(crate) mod discontinuity_sequence; +pub(crate) mod end_list; +pub(crate) mod i_frames_only; +pub(crate) mod media_sequence; +pub(crate) mod target_duration; -pub use discontinuity_sequence::*; -pub use end_list::*; -pub use i_frames_only::*; -pub use media_sequence::*; -pub use playlist_type::*; -pub use target_duration::*; +pub(crate) use discontinuity_sequence::*; +pub(crate) use end_list::*; +pub(crate) use i_frames_only::*; +pub(crate) use media_sequence::*; +pub(crate) use target_duration::*; diff --git a/src/tags/media_playlist/playlist_type.rs b/src/tags/media_playlist/playlist_type.rs deleted file mode 100644 index 69c2e52..0000000 --- a/src/tags/media_playlist/playlist_type.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::fmt; -use std::str::FromStr; - -use crate::types::ProtocolVersion; -use crate::utils::tag; -use crate::{Error, RequiredVersion}; - -/// # [4.3.3.5. EXT-X-PLAYLIST-TYPE] -/// -/// The [`ExtXPlaylistType`] tag provides mutability information about the -/// [`Media Playlist`]. It applies to the entire [`Media Playlist`]. -/// -/// [`Media Playlist`]: crate::MediaPlaylist -/// [4.3.3.5. EXT-X-PLAYLIST-TYPE]: https://tools.ietf.org/html/rfc8216#section-4.3.3.5 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum ExtXPlaylistType { - /// If the [`ExtXPlaylistType`] is Event, [`Media Segment`]s - /// can only be added to the end of the [`Media Playlist`]. - /// - /// [`Media Segment`]: crate::MediaSegment - /// [`Media Playlist`]: crate::MediaPlaylist - Event, - /// If the [`ExtXPlaylistType`] is Video On Demand (Vod), - /// the [`Media Playlist`] cannot change. - /// - /// [`Media Playlist`]: crate::MediaPlaylist - Vod, -} - -impl ExtXPlaylistType { - pub(crate) const PREFIX: &'static str = "#EXT-X-PLAYLIST-TYPE:"; -} - -/// This tag requires [`ProtocolVersion::V1`]. -impl RequiredVersion for ExtXPlaylistType { - fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } -} - -impl fmt::Display for ExtXPlaylistType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match &self { - Self::Event => write!(f, "{}EVENT", Self::PREFIX), - Self::Vod => write!(f, "{}VOD", Self::PREFIX), - } - } -} - -impl FromStr for ExtXPlaylistType { - type Err = Error; - - fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?; - match input { - "EVENT" => Ok(Self::Event), - "VOD" => Ok(Self::Vod), - _ => Err(Error::custom(format!("Unknown playlist type: {:?}", input))), - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - 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()); - - assert!("garbage".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_required_version() { - assert_eq!( - ExtXPlaylistType::Vod.required_version(), - ProtocolVersion::V1 - ); - assert_eq!( - ExtXPlaylistType::Event.required_version(), - ProtocolVersion::V1 - ); - } -} diff --git a/src/tags/media_playlist/target_duration.rs b/src/tags/media_playlist/target_duration.rs index 652afb4..ad641c1 100644 --- a/src/tags/media_playlist/target_duration.rs +++ b/src/tags/media_playlist/target_duration.rs @@ -1,5 +1,4 @@ use std::fmt; -use std::ops::Deref; use std::str::FromStr; use std::time::Duration; @@ -7,44 +6,12 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.3.1. EXT-X-TARGETDURATION] -/// The [`ExtXTargetDuration`] tag specifies the maximum [`MediaSegment`] -/// duration. -/// -/// [`MediaSegment`]: crate::MediaSegment -/// [4.3.3.1. EXT-X-TARGETDURATION]: https://tools.ietf.org/html/rfc8216#section-4.3.3.1 +/// Specifies the maximum `MediaSegment` duration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)] -pub struct ExtXTargetDuration(Duration); +pub(crate) struct ExtXTargetDuration(pub Duration); impl ExtXTargetDuration { pub(crate) const PREFIX: &'static str = "#EXT-X-TARGETDURATION:"; - - /// Makes a new [`ExtXTargetDuration`] tag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXTargetDuration; - /// use std::time::Duration; - /// - /// let target_duration = ExtXTargetDuration::new(Duration::from_secs(20)); - /// ``` - /// - /// # Note - /// The nanoseconds part of the [`Duration`] will be discarded. - pub const fn new(duration: Duration) -> Self { Self(Duration::from_secs(duration.as_secs())) } - - /// Returns the maximum media segment duration. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXTargetDuration; - /// use std::time::Duration; - /// - /// let target_duration = ExtXTargetDuration::new(Duration::from_nanos(2_000_000_000)); - /// - /// assert_eq!(target_duration.duration(), Duration::from_secs(2)); - /// ``` - pub const fn duration(&self) -> Duration { self.0 } } /// This tag requires [`ProtocolVersion::V1`]. @@ -52,14 +19,8 @@ impl RequiredVersion for ExtXTargetDuration { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } -impl Deref for ExtXTargetDuration { - type Target = Duration; - - fn deref(&self) -> &Self::Target { &self.0 } -} - impl fmt::Display for ExtXTargetDuration { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}{}", Self::PREFIX, self.0.as_secs()) } } @@ -68,8 +29,11 @@ impl FromStr for ExtXTargetDuration { type Err = Error; fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?.parse()?; - Ok(Self::new(Duration::from_secs(input))) + let input = tag(input, Self::PREFIX)? + .parse() + .map_err(|e| Error::parse_int(input, e))?; + + Ok(Self(Duration::from_secs(input))) } } @@ -81,7 +45,7 @@ mod test { #[test] fn test_display() { assert_eq!( - ExtXTargetDuration::new(Duration::from_secs(5)).to_string(), + ExtXTargetDuration(Duration::from_secs(5)).to_string(), "#EXT-X-TARGETDURATION:5".to_string() ); } @@ -89,7 +53,7 @@ mod test { #[test] fn test_required_version() { assert_eq!( - ExtXTargetDuration::new(Duration::from_secs(5)).required_version(), + ExtXTargetDuration(Duration::from_secs(5)).required_version(), ProtocolVersion::V1 ); } @@ -97,13 +61,8 @@ mod test { #[test] fn test_parser() { assert_eq!( - ExtXTargetDuration::new(Duration::from_secs(5)), + ExtXTargetDuration(Duration::from_secs(5)), "#EXT-X-TARGETDURATION:5".parse().unwrap() ); } - - #[test] - fn test_deref() { - assert_eq!(ExtXTargetDuration::new(Duration::from_secs(5)).as_secs(), 5); - } } diff --git a/src/tags/media_segment/byte_range.rs b/src/tags/media_segment/byte_range.rs index f2aafb4..9bf5e52 100644 --- a/src/tags/media_segment/byte_range.rs +++ b/src/tags/media_segment/byte_range.rs @@ -1,74 +1,186 @@ use std::fmt; -use std::ops::{Deref, DerefMut}; use std::str::FromStr; +use core::ops::{Add, AddAssign, Sub, SubAssign}; + +use derive_more::{AsMut, AsRef, Deref, DerefMut, From}; + use crate::types::{ByteRange, ProtocolVersion}; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.4.2.2. EXT-X-BYTERANGE] +/// Indicates that a [`MediaSegment`] is a sub-range of the resource identified +/// by its `URI`. /// -/// The [`ExtXByteRange`] tag indicates that a [`Media Segment`] is a sub-range -/// of the resource identified by its `URI`. +/// # Example /// -/// Its format is: -/// ```text -/// #EXT-X-BYTERANGE:[@] +/// Constructing an [`ExtXByteRange`]: +/// +/// ``` +/// # use hls_m3u8::tags::ExtXByteRange; +/// assert_eq!(ExtXByteRange::from(22..55), ExtXByteRange::from(22..=54)); /// ``` /// -/// where `n` is a [usize] indicating the length of the sub-range in bytes. -/// If present, `o` is a [usize] indicating the start of the sub-range, -/// as a byte offset from the beginning of the resource. +/// It is also possible to omit the start, in which case it assumes that the +/// [`ExtXByteRange`] starts at the byte after the end of the previous +/// [`ExtXByteRange`] or 0 if there is no previous one. /// -/// [`Media Segment`]: crate::MediaSegment -/// [4.4.2.2. EXT-X-BYTERANGE]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.2.2 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +/// ``` +/// # use hls_m3u8::tags::ExtXByteRange; +/// assert_eq!(ExtXByteRange::from(..55), ExtXByteRange::from(..=54)); +/// ``` +/// +/// [`MediaSegment`]: crate::MediaSegment +#[derive( + AsRef, AsMut, From, Deref, DerefMut, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, +)] +#[from(forward)] pub struct ExtXByteRange(ByteRange); impl ExtXByteRange { pub(crate) const PREFIX: &'static str = "#EXT-X-BYTERANGE:"; - /// Makes a new [`ExtXByteRange`] tag. + /// Adds `num` to the `start` and `end` of the range. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXByteRange; - /// let byte_range = ExtXByteRange::new(20, Some(5)); + /// let range = ExtXByteRange::from(10..22); + /// let nrange = range.saturating_add(5); + /// + /// assert_eq!(nrange.len(), range.len()); + /// assert_eq!(nrange.start(), range.start().map(|c| c + 5)); /// ``` - pub const fn new(length: usize, start: Option) -> Self { - Self(ByteRange::new(length, start)) - } + /// + /// # Overflow + /// + /// If the range is saturated it will not overflow and instead + /// stay at it's current value. + /// + /// ``` + /// # use hls_m3u8::tags::ExtXByteRange; + /// let range = ExtXByteRange::from(5..usize::max_value()); + /// + /// // this would cause the end to overflow + /// let nrange = range.saturating_add(1); + /// + /// // but the range remains unchanged + /// assert_eq!(range, nrange); + /// ``` + /// + /// # Note + /// + /// The length of the range will remain unchanged, + /// if the `start` is `Some`. + #[inline] + #[must_use] + pub fn saturating_add(self, num: usize) -> Self { Self(self.0.saturating_add(num)) } - /// Converts the [`ExtXByteRange`] to a [`ByteRange`]. + /// Subtracts `num` from the `start` and `end` of the range. /// /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXByteRange; + /// let range = ExtXByteRange::from(10..22); + /// let nrange = range.saturating_sub(5); + /// + /// assert_eq!(nrange.len(), range.len()); + /// assert_eq!(nrange.start(), range.start().map(|c| c - 5)); + /// ``` + /// + /// # Underflow + /// + /// If the range is saturated it will not underflow and instead stay + /// at it's current value. + /// + /// ``` + /// # use hls_m3u8::tags::ExtXByteRange; + /// let range = ExtXByteRange::from(0..10); + /// + /// // this would cause the start to underflow + /// let nrange = range.saturating_sub(1); + /// + /// // but the range remains unchanged + /// assert_eq!(range, nrange); + /// ``` + /// + /// # Note + /// + /// The length of the range will remain unchanged, + /// if the `start` is `Some`. + #[inline] + #[must_use] + pub fn saturating_sub(self, num: usize) -> Self { Self(self.0.saturating_sub(num)) } + + /// Returns a shared reference to the underlying [`ByteRange`]. + /// + /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXByteRange; /// use hls_m3u8::types::ByteRange; /// - /// let byte_range = ExtXByteRange::new(20, Some(5)); - /// let range: ByteRange = byte_range.to_range(); + /// assert_eq!( + /// ExtXByteRange::from(2..11).as_byte_range(), + /// &ByteRange::from(2..11) + /// ); /// ``` - pub const fn to_range(&self) -> ByteRange { self.0 } + #[inline] + #[must_use] + pub const fn as_byte_range(&self) -> &ByteRange { &self.0 } } +/// This tag requires [`ProtocolVersion::V4`]. impl RequiredVersion for ExtXByteRange { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V4 } } -impl Deref for ExtXByteRange { - type Target = ByteRange; - - fn deref(&self) -> &Self::Target { &self.0 } +impl Into for ExtXByteRange { + fn into(self) -> ByteRange { self.0 } } -impl DerefMut for ExtXByteRange { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } +impl Sub for ExtXByteRange +where + ByteRange: Sub, +{ + type Output = Self; + + #[must_use] + #[inline] + fn sub(self, rhs: T) -> Self::Output { Self(self.0.sub(rhs)) } +} + +impl SubAssign for ExtXByteRange +where + ByteRange: SubAssign, +{ + #[inline] + fn sub_assign(&mut self, other: T) { self.0.sub_assign(other); } +} + +impl Add for ExtXByteRange +where + ByteRange: Add, +{ + type Output = Self; + + #[must_use] + #[inline] + fn add(self, rhs: T) -> Self::Output { Self(self.0.add(rhs)) } +} + +impl AddAssign for ExtXByteRange +where + ByteRange: AddAssign, +{ + #[inline] + fn add_assign(&mut self, other: T) { self.0.add_assign(other); } } impl fmt::Display for ExtXByteRange { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "{}", self.0)?; Ok(()) @@ -81,23 +193,7 @@ impl FromStr for ExtXByteRange { fn from_str(input: &str) -> Result { let input = tag(input, Self::PREFIX)?; - let tokens = input.splitn(2, '@').collect::>(); - - if tokens.is_empty() { - return Err(Error::invalid_input()); - } - - let length = tokens[0].parse()?; - - let start = { - if tokens.len() == 2 { - Some(tokens[1].parse()?) - } else { - None - } - }; - - Ok(Self::new(length, start)) + Ok(Self(ByteRange::from_str(input)?)) } } @@ -108,57 +204,52 @@ mod test { #[test] fn test_display() { - let byte_range = ExtXByteRange::new(0, Some(5)); - assert_eq!(byte_range.to_string(), "#EXT-X-BYTERANGE:0@5".to_string()); - - let byte_range = ExtXByteRange::new(99999, Some(2)); assert_eq!( - byte_range.to_string(), - "#EXT-X-BYTERANGE:99999@2".to_string() + ExtXByteRange::from(2..15).to_string(), + "#EXT-X-BYTERANGE:13@2".to_string() ); - let byte_range = ExtXByteRange::new(99999, None); - assert_eq!(byte_range.to_string(), "#EXT-X-BYTERANGE:99999".to_string()); + assert_eq!( + ExtXByteRange::from(..22).to_string(), + "#EXT-X-BYTERANGE:22".to_string() + ); } #[test] fn test_parser() { - let byte_range = ExtXByteRange::new(99999, Some(2)); assert_eq!( - byte_range, - "#EXT-X-BYTERANGE:99999@2".parse::().unwrap() + ExtXByteRange::from(2..15), + "#EXT-X-BYTERANGE:13@2".parse().unwrap() ); - let byte_range = ExtXByteRange::new(99999, None); assert_eq!( - byte_range, - "#EXT-X-BYTERANGE:99999".parse::().unwrap() + ExtXByteRange::from(..22), + "#EXT-X-BYTERANGE:22".parse().unwrap() ); } #[test] fn test_deref() { - let byte_range = ExtXByteRange::new(0, Some(22)); + let byte_range = ExtXByteRange::from(0..22); - assert_eq!(byte_range.length(), 0); - assert_eq!(byte_range.start(), Some(22)); + assert_eq!(byte_range.len(), 22); + assert_eq!(byte_range.start(), Some(0)); } #[test] fn test_deref_mut() { - let mut byte_range = ExtXByteRange::new(0, Some(22)); + let mut byte_range = ExtXByteRange::from(10..110); - byte_range.set_length(100); byte_range.set_start(Some(50)); - assert_eq!(byte_range.length(), 100); + assert_eq!(byte_range.len(), 60); assert_eq!(byte_range.start(), Some(50)); } #[test] fn test_required_version() { assert_eq!( - ExtXByteRange::new(20, Some(5)).required_version(), + ExtXByteRange::from(5..20).required_version(), ProtocolVersion::V4 ); } diff --git a/src/tags/media_segment/date_range.rs b/src/tags/media_segment/date_range.rs index 6586f8a..014dbea 100644 --- a/src/tags/media_segment/date_range.rs +++ b/src/tags/media_segment/date_range.rs @@ -3,107 +3,188 @@ use std::fmt; use std::str::FromStr; use std::time::Duration; +#[cfg(feature = "chrono")] use chrono::{DateTime, FixedOffset, SecondsFormat}; use derive_builder::Builder; +use shorthand::ShortHand; use crate::attribute::AttributePairs; use crate::types::{ProtocolVersion, Value}; use crate::utils::{quote, tag, unquote}; use crate::{Error, RequiredVersion}; -/// # [4.3.2.7. EXT-X-DATERANGE] -/// The [`ExtXDateRange`] 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. -/// -/// [4.3.2.7. EXT-X-DATERANGE]: https://tools.ietf.org/html/rfc8216#section-4.3.2.7 -#[derive(Builder, Debug, Clone, PartialEq, PartialOrd)] +/// The [`ExtXDateRange`] tag associates a date range (i.e., a range of time +/// defined by a starting and ending date) with a set of attribute/value pairs. +#[derive(ShortHand, Builder, Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[builder(setter(into))] +#[shorthand(enable(must_use, into))] pub struct ExtXDateRange { - /// A string that uniquely identifies an [`ExtXDateRange`] in the Playlist. + /// A string that uniquely identifies an [`ExtXDateRange`] in the playlist. /// - /// # Note - /// This attribute is required. + /// ## Note + /// + /// This field is required. id: String, - #[builder(setter(strip_option), default)] /// A client-defined string that specifies some set of attributes and their /// associated value semantics. All [`ExtXDateRange`]s with the same class /// attribute value must adhere to these semantics. /// - /// # Note - /// This attribute is optional. + /// ## Note + /// + /// This field is optional. + #[builder(setter(strip_option), default)] class: Option, /// The date at which the [`ExtXDateRange`] begins. /// - /// # Note - /// This attribute is required. - start_date: DateTime, + /// ## Note + /// + /// This field is required by the spec wording, but optional in examples + /// elsewhere in the same document. Some implementations omit it in + /// practise (e.g. for SCTE 'explicit-IN' markers) so it is optional + /// here. + #[cfg(feature = "chrono")] + #[shorthand(enable(copy), disable(into))] #[builder(setter(strip_option), default)] + start_date: Option>, + /// The date at which the [`ExtXDateRange`] begins. + /// + /// ## Note + /// + /// This field is required by the spec wording, but optional in examples + /// elsewhere in the same document. Some implementations omit it in + /// practise (e.g. for SCTE 'explicit-IN' markers) so it is optional + /// here. + #[cfg(not(feature = "chrono"))] + #[builder(setter(strip_option), default)] + start_date: Option, /// The date at which the [`ExtXDateRange`] ends. It must be equal to or /// later than the value of the [`start-date`] attribute. /// - /// # Note - /// This attribute is optional. + /// ## Note + /// + /// This field is optional. /// /// [`start-date`]: #method.start_date - end_date: Option>, + #[cfg(feature = "chrono")] + #[shorthand(enable(copy), disable(into))] #[builder(setter(strip_option), default)] + end_date: Option>, + /// The date at which the [`ExtXDateRange`] ends. It must be equal to or + /// later than the value of the start-date field. + /// + /// ## Note + /// + /// This field is optional. + /// + /// [`start-date`]: #method.start_date + #[cfg(not(feature = "chrono"))] + #[builder(setter(strip_option), default)] + end_date: Option, /// The duration of the [`ExtXDateRange`]. A single instant in time (e.g., /// crossing a finish line) should be represented with a duration of 0. /// - /// # Note - /// This attribute is optional. - duration: Option, - #[builder(setter(strip_option), default)] - /// The expected duration of the [`ExtXDateRange`]. - /// This attribute should be used to indicate the expected duration of a - /// [`ExtXDateRange`] whose actual duration is not yet known. + /// ## Note /// - /// # Note - /// This attribute is optional. - planned_duration: Option, + /// This field is optional. #[builder(setter(strip_option), default)] - /// https://tools.ietf.org/html/rfc8216#section-4.3.2.7.1 + #[shorthand(enable(skip))] + pub duration: Option, + /// This field indicates the expected duration of an [`ExtXDateRange`], + /// whose actual duration is not yet known. /// - /// # Note - /// This attribute is optional. + /// ## Note + /// + /// This field is optional. + #[builder(setter(strip_option), default)] + #[shorthand(enable(skip))] + pub planned_duration: Option, + /// SCTE-35 (ANSI/SCTE 35 2013) is a joint ANSI/Society of Cable and + /// Telecommunications Engineers standard that describes the inline + /// insertion of cue tones in mpeg-ts streams. + /// + /// SCTE-35 was originally used in the US to signal a local ad insertion + /// opportunity in the transport streams, and in Europe to insert local TV + /// programs (e.g. local news transmissions). It is now used to signal all + /// kinds of program and ad events in linear transport streams and in newer + /// ABR delivery formats such as HLS and DASH. + /// + /// + /// + /// ## Note + /// + /// This field is optional. + #[builder(setter(strip_option), default)] scte35_cmd: Option, - #[builder(setter(strip_option), default)] - /// https://tools.ietf.org/html/rfc8216#section-4.3.2.7.1 + /// SCTE-35 (ANSI/SCTE 35 2013) is a joint ANSI/Society of Cable and + /// Telecommunications Engineers standard that describes the inline + /// insertion of cue tones in mpeg-ts streams. /// - /// # Note - /// This attribute is optional. + /// SCTE-35 was originally used in the US to signal a local ad insertion + /// opportunity in the transport streams, and in Europe to insert local TV + /// programs (e.g. local news transmissions). It is now used to signal all + /// kinds of program and ad events in linear transport streams and in newer + /// ABR delivery formats such as HLS and DASH. + /// + /// + /// + /// ## Note + /// + /// This field is optional. + #[builder(setter(strip_option), default)] scte35_out: Option, + /// SCTE-35 (ANSI/SCTE 35 2013) is a joint ANSI/Society of Cable and + /// Telecommunications Engineers standard that describes the inline + /// insertion of cue tones in mpeg-ts streams. + /// + /// SCTE-35 was originally used in the US to signal a local ad insertion + /// opportunity in the transport streams, and in Europe to insert local TV + /// programs (e.g. local news transmissions). It is now used to signal all + /// kinds of program and ad events in linear transport streams and in newer + /// ABR delivery formats such as HLS and DASH. + /// + /// + /// + /// ## Note + /// + /// This field is optional. #[builder(setter(strip_option), default)] - /// https://tools.ietf.org/html/rfc8216#section-4.3.2.7.1 - /// - /// # Note - /// This attribute is optional. scte35_in: Option, - #[builder(default)] - /// This attribute indicates that the end of the range containing it is - /// equal to the [`start-date`] of its following range. The following range - /// is the [`ExtXDateRange`] of the same class, that has the earliest - /// [`start-date`] after the [`start-date`] of the range in question. + /// This field indicates that the [`ExtXDateRange::end_date`] is equal to + /// the [`ExtXDateRange::start_date`] of the following range. /// - /// # Note - /// This attribute is optional. - end_on_next: bool, + /// The following range is the [`ExtXDateRange`] with the same class, that + /// has the earliest start date after the start date of the range in + /// question. + /// + /// ## Note + /// + /// This field is optional. #[builder(default)] + #[shorthand(enable(skip))] + pub end_on_next: bool, /// The `"X-"` prefix defines a namespace reserved for client-defined - /// attributes. The client-attribute must be a uppercase characters. - /// Clients should use a reverse-DNS syntax when defining their own - /// attribute names to avoid collisions. An example of a client-defined - /// attribute is `X-COM-EXAMPLE-AD-ID="XYZ123"`. + /// attributes. /// - /// # Note - /// This attribute is optional. - client_attributes: BTreeMap, + /// A client-attribute can only consist of uppercase characters (A-Z), + /// numbers (0-9) and `-`. + /// + /// Clients should use a reverse-dns naming scheme, when defining + /// their own attribute names to avoid collisions. + /// + /// An example of a client-defined attribute is + /// `X-COM-EXAMPLE-AD-ID="XYZ123"`. + /// + /// ## Note + /// + /// This field is optional. + #[builder(default)] + #[shorthand(enable(collection_magic), disable(set, get))] + pub client_attributes: BTreeMap, } impl ExtXDateRangeBuilder { /// Inserts a key value pair. - pub fn insert_client_attribute>( + pub fn insert_client_attribute, V: Into>( &mut self, key: K, value: V, @@ -113,7 +194,7 @@ impl ExtXDateRangeBuilder { } if let Some(client_attributes) = &mut self.client_attributes { - client_attributes.insert(key.to_string(), value.into()); + client_attributes.insert(key.into(), value.into()); } else { unreachable!(); } @@ -127,25 +208,47 @@ impl ExtXDateRange { /// Makes a new [`ExtXDateRange`] tag. /// /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// ``` - pub fn new(id: T, start_date: DateTime) -> Self { + #[cfg_attr( + feature = "chrono", + doc = r#" +``` +# use hls_m3u8::tags::ExtXDateRange; +use chrono::offset::TimeZone; +use chrono::{DateTime, FixedOffset}; + +const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds + +let date_range = ExtXDateRange::new( + "id", + FixedOffset::east(8 * HOURS_IN_SECS) + .ymd(2010, 2, 19) + .and_hms_milli(14, 54, 23, 31), +); +``` +"# + )] + #[cfg_attr( + not(feature = "chrono"), + doc = r#" +``` +# use hls_m3u8::tags::ExtXDateRange; +let date_range = ExtXDateRange::new("id", "2010-02-19T14:54:23.031+08:00"); +``` + "# + )] + #[must_use] + pub fn new, #[cfg(not(feature = "chrono"))] I: Into>( + id: T, + #[cfg(feature = "chrono")] start_date: DateTime, + #[cfg(not(feature = "chrono"))] start_date: I, + ) -> Self { Self { - id: id.to_string(), + id: id.into(), class: None, - start_date, + #[cfg(feature = "chrono")] + start_date: Some(start_date), + #[cfg(not(feature = "chrono"))] + start_date: Some(start_date.into()), end_date: None, duration: None, planned_duration: None, @@ -158,536 +261,62 @@ impl ExtXDateRange { } /// Returns a builder for [`ExtXDateRange`]. + /// + /// # Example + #[cfg_attr( + feature = "chrono", + doc = r#" +``` +# use hls_m3u8::tags::ExtXDateRange; +use std::time::Duration; +use chrono::{FixedOffset, TimeZone}; +use hls_m3u8::types::Float; + +let date_range = ExtXDateRange::builder() + .id("test_id") + .class("test_class") + .start_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0)) + .end_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 16, 0)) + .duration(Duration::from_secs_f64(60.1)) + .planned_duration(Duration::from_secs_f64(59.993)) + .insert_client_attribute("X-CUSTOM", Float::new(45.3)) + .scte35_cmd("0xFC002F0000000000FF2") + .scte35_out("0xFC002F0000000000FF0") + .scte35_in("0xFC002F0000000000FF1") + .end_on_next(true) + .build()?; +# Ok::<(), String>(()) +``` +"# + )] + #[cfg_attr( + not(feature = "chrono"), + doc = r#" +``` +# use hls_m3u8::tags::ExtXDateRange; +use std::time::Duration; +use hls_m3u8::types::Float; + +let date_range = ExtXDateRange::builder() + .id("test_id") + .class("test_class") + .start_date("2014-03-05T11:15:00Z") + .end_date("2014-03-05T11:16:00Z") + .duration(Duration::from_secs_f64(60.1)) + .planned_duration(Duration::from_secs_f64(59.993)) + .insert_client_attribute("X-CUSTOM", Float::new(45.3)) + .scte35_cmd("0xFC002F0000000000FF2") + .scte35_out("0xFC002F0000000000FF0") + .scte35_in("0xFC002F0000000000FF1") + .end_on_next(true) + .build()?; +# Ok::<(), String>(()) +``` +"# + )] + #[must_use] + #[inline] pub fn builder() -> ExtXDateRangeBuilder { ExtXDateRangeBuilder::default() } - - /// A string that uniquely identifies an [`ExtXDateRange`] in the Playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// assert_eq!(date_range.id(), &"id".to_string()); - /// ``` - pub const fn id(&self) -> &String { &self.id } - - /// A string that uniquely identifies an [`ExtXDateRange`] in the Playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// date_range.set_id("new_id"); - /// assert_eq!(date_range.id(), &"new_id".to_string()); - /// ``` - pub fn set_id(&mut self, value: T) -> &mut Self { - self.id = value.to_string(); - self - } - - /// A client-defined string that specifies some set of attributes and their - /// associated value semantics. All [`ExtXDateRange`]s with the same class - /// attribute value must adhere to these semantics. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.class(), &None); - /// - /// date_range.set_class(Some("example_class")); - /// assert_eq!(date_range.class(), &Some("example_class".to_string())); - /// ``` - pub const fn class(&self) -> &Option { &self.class } - - /// A client-defined string that specifies some set of attributes and their - /// associated value semantics. All [`ExtXDateRange`]s with the same class - /// attribute value must adhere to these semantics. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.class(), &None); - /// - /// date_range.set_class(Some("example_class")); - /// assert_eq!(date_range.class(), &Some("example_class".to_string())); - /// ``` - pub fn set_class(&mut self, value: Option) -> &mut Self { - self.class = value.map(|v| v.to_string()); - self - } - - /// The date at which the [`ExtXDateRange`] begins. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// assert_eq!( - /// date_range.start_date(), - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31) - /// ); - /// ``` - pub const fn start_date(&self) -> DateTime { self.start_date } - - /// The date at which the [`ExtXDateRange`] begins. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// date_range.set_start_date( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10), - /// ); - /// assert_eq!( - /// date_range.start_date(), - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10) - /// ); - /// ``` - pub fn set_start_date(&mut self, value: T) -> &mut Self - where - T: Into>, - { - self.start_date = value.into(); - self - } - - /// The date at which the [`ExtXDateRange`] ends. It must be equal to or - /// later than the value of the [`start-date`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.end_date(), None); - /// - /// date_range.set_end_date(Some( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10), - /// )); - /// assert_eq!( - /// date_range.end_date(), - /// Some( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10) - /// ) - /// ); - /// ``` - pub const fn end_date(&self) -> Option> { self.end_date } - - /// The date at which the [`ExtXDateRange`] ends. It must be equal to or - /// later than the value of the [`start-date`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.end_date(), None); - /// - /// date_range.set_end_date(Some( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10), - /// )); - /// assert_eq!( - /// date_range.end_date(), - /// Some( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10) - /// ) - /// ); - /// ``` - pub fn set_end_date(&mut self, value: Option) -> &mut Self - where - T: Into>, - { - self.end_date = value.map(Into::into); - self - } - - /// The duration of the [`ExtXDateRange`]. A single instant in time (e.g., - /// crossing a finish line) should be represented with a duration of 0. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.duration(), None); - /// - /// date_range.set_duration(Some(Duration::from_secs_f64(1.234))); - /// assert_eq!(date_range.duration(), Some(Duration::from_secs_f64(1.234))); - /// ``` - pub const fn duration(&self) -> Option { self.duration } - - /// The duration of the [`ExtXDateRange`]. A single instant in time (e.g., - /// crossing a finish line) should be represented with a duration of 0. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.duration(), None); - /// - /// date_range.set_duration(Some(Duration::from_secs_f64(1.234))); - /// assert_eq!(date_range.duration(), Some(Duration::from_secs_f64(1.234))); - /// ``` - pub fn set_duration(&mut self, value: Option) -> &mut Self { - self.duration = value; - self - } - - /// The expected duration of the [`ExtXDateRange`]. - /// This attribute should be used to indicate the expected duration of a - /// [`ExtXDateRange`] whose actual duration is not yet known. - /// The date at which the [`ExtXDateRange`] ends. It must be equal to or - /// later than the value of the [`start-date`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.planned_duration(), None); - /// - /// date_range.set_planned_duration(Some(Duration::from_secs_f64(1.2345))); - /// assert_eq!( - /// date_range.planned_duration(), - /// Some(Duration::from_secs_f64(1.2345)) - /// ); - /// ``` - pub const fn planned_duration(&self) -> Option { self.planned_duration } - - /// The expected duration of the [`ExtXDateRange`]. - /// This attribute should be used to indicate the expected duration of a - /// [`ExtXDateRange`] whose actual duration is not yet known. - /// The date at which the [`ExtXDateRange`] ends. It must be equal to or - /// later than the value of the [`start-date`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.planned_duration(), None); - /// - /// date_range.set_planned_duration(Some(Duration::from_secs_f64(1.2345))); - /// assert_eq!( - /// date_range.planned_duration(), - /// Some(Duration::from_secs_f64(1.2345)) - /// ); - /// ``` - pub fn set_planned_duration(&mut self, value: Option) -> &mut Self { - self.planned_duration = value; - self - } - - /// See here for reference: https://www.scte.org/SCTEDocs/Standards/ANSI_SCTE%2035%202019r1.pdf - pub const fn scte35_cmd(&self) -> &Option { &self.scte35_cmd } - - /// See here for reference: https://www.scte.org/SCTEDocs/Standards/ANSI_SCTE%2035%202019r1.pdf - pub const fn scte35_in(&self) -> &Option { &self.scte35_in } - - /// See here for reference: https://www.scte.org/SCTEDocs/Standards/ANSI_SCTE%2035%202019r1.pdf - pub const fn scte35_out(&self) -> &Option { &self.scte35_out } - - /// This attribute indicates that the end of the range containing it is - /// equal to the [`start-date`] of its following range. The following range - /// is the [`ExtXDateRange`] of the same class, that has the earliest - /// [`start-date`] after the [`start-date`] of the range in question. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.end_on_next(), false); - /// - /// date_range.set_end_on_next(true); - /// assert_eq!(date_range.end_on_next(), true); - /// ``` - pub const fn end_on_next(&self) -> bool { self.end_on_next } - - /// This attribute indicates that the end of the range containing it is - /// equal to the [`start-date`] of its following range. The following range - /// is the [`ExtXDateRange`] of the same class, that has the earliest - /// [`start-date`] after the [`start-date`] of the range in question. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use std::time::Duration; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.end_on_next(), false); - /// - /// date_range.set_end_on_next(true); - /// assert_eq!(date_range.end_on_next(), true); - /// ``` - pub fn set_end_on_next(&mut self, value: bool) -> &mut Self { - self.end_on_next = value; - self - } - - /// The "X-" prefix defines a namespace reserved for client-defined - /// attributes. The client-attribute must be a uppercase characters. - /// Clients should use a reverse-DNS syntax when defining their own - /// attribute names to avoid collisions. An example of a client-defined - /// attribute is `X-COM-EXAMPLE-AD-ID="XYZ123"`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use std::collections::BTreeMap; - /// - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use hls_m3u8::types::Value; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.client_attributes(), &BTreeMap::new()); - /// - /// let mut attributes = BTreeMap::new(); - /// attributes.insert("X-COM-EXAMPLE-FLOAT".to_string(), Value::Float(1.1)); - /// - /// date_range.set_client_attributes(attributes.clone()); - /// assert_eq!(date_range.client_attributes(), &attributes); - /// ``` - pub const fn client_attributes(&self) -> &BTreeMap { &self.client_attributes } - - /// The "X-" prefix defines a namespace reserved for client-defined - /// attributes. The client-attribute must be a uppercase characters. - /// Clients should use a reverse-DNS syntax when defining their own - /// attribute names to avoid collisions. An example of a client-defined - /// attribute is `X-COM-EXAMPLE-AD-ID="XYZ123"`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use std::collections::BTreeMap; - /// - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use hls_m3u8::types::Value; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.client_attributes(), &BTreeMap::new()); - /// - /// let mut attributes = BTreeMap::new(); - /// attributes.insert("X-COM-EXAMPLE-FLOAT".to_string(), Value::Float(1.1)); - /// - /// date_range - /// .client_attributes_mut() - /// .insert("X-COM-EXAMPLE-FLOAT".to_string(), Value::Float(1.1)); - /// - /// assert_eq!(date_range.client_attributes(), &attributes); - /// ``` - pub fn client_attributes_mut(&mut self) -> &mut BTreeMap { - &mut self.client_attributes - } - - /// The "X-" prefix defines a namespace reserved for client-defined - /// attributes. The client-attribute must be a uppercase characters. - /// Clients should use a reverse-DNS syntax when defining their own - /// attribute names to avoid collisions. An example of a client-defined - /// attribute is `X-COM-EXAMPLE-AD-ID="XYZ123"`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXDateRange; - /// use std::collections::BTreeMap; - /// - /// use chrono::offset::TimeZone; - /// use chrono::{DateTime, FixedOffset}; - /// use hls_m3u8::types::Value; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut date_range = ExtXDateRange::new( - /// "id", - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// # assert_eq!(date_range.client_attributes(), &BTreeMap::new()); - /// - /// let mut attributes = BTreeMap::new(); - /// attributes.insert("X-COM-EXAMPLE-FLOAT".to_string(), Value::Float(1.1)); - /// - /// date_range.set_client_attributes(attributes.clone()); - /// assert_eq!(date_range.client_attributes(), &attributes); - /// ``` - pub fn set_client_attributes(&mut self, value: BTreeMap) -> &mut Self { - self.client_attributes = value; - self - } } /// This tag requires [`ProtocolVersion::V1`]. @@ -714,30 +343,62 @@ impl FromStr for ExtXDateRange { let mut client_attributes = BTreeMap::new(); - for (key, value) in input.parse::()? { - match key.as_str() { + for (key, value) in AttributePairs::new(input) { + match key { "ID" => id = Some(unquote(value)), "CLASS" => class = Some(unquote(value)), - "START-DATE" => start_date = Some(unquote(value)), - "END-DATE" => end_date = Some(unquote(value).parse()?), + "START-DATE" => { + #[cfg(feature = "chrono")] + { + start_date = Some(unquote(value).parse().map_err(Error::chrono)?) + } + #[cfg(not(feature = "chrono"))] + { + start_date = Some(unquote(value)) + } + } + "END-DATE" => { + #[cfg(feature = "chrono")] + { + end_date = Some(unquote(value).parse().map_err(Error::chrono)?) + } + #[cfg(not(feature = "chrono"))] + { + end_date = Some(unquote(value)) + } + } "DURATION" => { - duration = Some(Duration::from_secs_f64(value.parse()?)); + duration = Some(Duration::from_secs_f64( + value.parse().map_err(|e| Error::parse_float(value, e))?, + )); } "PLANNED-DURATION" => { - planned_duration = Some(Duration::from_secs_f64(value.parse()?)); + planned_duration = Some(Duration::from_secs_f64( + value.parse().map_err(|e| Error::parse_float(value, e))?, + )); } "SCTE35-CMD" => scte35_cmd = Some(unquote(value)), "SCTE35-OUT" => scte35_out = Some(unquote(value)), "SCTE35-IN" => scte35_in = Some(unquote(value)), "END-ON-NEXT" => { if value != "YES" { - return Err(Error::custom("The value of `END-ON-NEXT` has to be `YES`!")); + return Err(Error::custom("`END-ON-NEXT` must be `YES`")); } end_on_next = true; } _ => { if key.starts_with("X-") { - client_attributes.insert(key.to_ascii_uppercase(), value.parse()?); + if key.chars().any(|c| { + c.is_ascii_lowercase() + || !c.is_ascii() + || !(c.is_alphanumeric() || c == '-') + }) { + return Err(Error::custom( + "a client attribute can only consist of uppercase ascii characters, numbers or `-`", + )); + } + + client_attributes.insert(key.to_string(), value.parse()?); } else { // [6.3.1. General Client Responsibilities] // > ignore any attribute/value pair with an @@ -748,13 +409,32 @@ impl FromStr for ExtXDateRange { } let id = id.ok_or_else(|| Error::missing_value("ID"))?; - let start_date = start_date - .ok_or_else(|| Error::missing_value("START-DATE"))? - .parse()?; if end_on_next && class.is_none() { - return Err(Error::invalid_input()); + return Err(Error::missing_attribute("CLASS")); + } else if end_on_next && duration.is_some() { + return Err(Error::unexpected_attribute("DURATION")); + } else if end_on_next && end_date.is_some() { + return Err(Error::unexpected_attribute("END-DATE")); } + + // TODO: verify this without chrono? + // https://tools.ietf.org/html/rfc8216#section-4.3.2.7 + #[cfg(feature = "chrono")] + { + if let (Some(start_date), Some(Ok(duration)), Some(end_date)) = ( + start_date, + duration.map(chrono::Duration::from_std), + &end_date, + ) { + if start_date + duration != *end_date { + return Err(Error::custom( + "end_date must be equal to start_date + duration", + )); + } + } + } + Ok(Self { id, class, @@ -772,25 +452,44 @@ impl FromStr for ExtXDateRange { } impl fmt::Display for ExtXDateRange { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "ID={}", quote(&self.id))?; + if let Some(value) = &self.class { write!(f, ",CLASS={}", quote(value))?; } - write!( - f, - ",START-DATE={}", - quote(&self.start_date.to_rfc3339_opts(SecondsFormat::AutoSi, true)) - )?; + if let Some(value) = &self.start_date { + #[cfg(feature = "chrono")] + { + write!( + f, + ",START-DATE={}", + quote(&value.to_rfc3339_opts(SecondsFormat::AutoSi, true)) + )?; + } + + #[cfg(not(feature = "chrono"))] + { + write!(f, ",START-DATE={}", quote(&value))?; + } + } if let Some(value) = &self.end_date { - write!( - f, - ",END-DATE={}", - quote(value.to_rfc3339_opts(SecondsFormat::AutoSi, true)) - )?; + #[cfg(feature = "chrono")] + { + write!( + f, + ",END-DATE={}", + quote(&value.to_rfc3339_opts(SecondsFormat::AutoSi, true)) + )?; + } + + #[cfg(not(feature = "chrono"))] + { + write!(f, ",END-DATE={}", quote(&value))?; + } } if let Some(value) = &self.duration { @@ -828,126 +527,143 @@ impl fmt::Display for ExtXDateRange { #[cfg(test)] mod test { use super::*; + use crate::types::Float; + #[cfg(feature = "chrono")] use chrono::offset::TimeZone; use pretty_assertions::assert_eq; + #[cfg(feature = "chrono")] const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - #[test] - fn test_parser() { - assert_eq!( - "#EXT-X-DATERANGE:\ - ID=\"splice-6FFFFFF0\",\ - START-DATE=\"2014-03-05T11:15:00Z\",\ - PLANNED-DURATION=59.993,\ - SCTE35-OUT=0xFC002F0000000000FF000014056F\ - FFFFF000E011622DCAFF000052636200000000000\ - A0008029896F50000008700000000" - .parse::() - .unwrap(), - ExtXDateRange::builder() - .id("splice-6FFFFFF0") - .start_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0)) - .planned_duration(Duration::from_secs_f64(59.993)) - .scte35_out( - "0xFC002F0000000000FF00001\ - 4056FFFFFF000E011622DCAFF0\ - 00052636200000000000A00080\ - 29896F50000008700000000" + macro_rules! generate_tests { + ( $( { $left:expr, $right:expr } ),* $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($left.to_string(), $right.to_string()); + )* + } + + #[test] + fn test_parser() { + $( + assert_eq!($left, $right.parse().unwrap()); + )* + assert!("#EXT-X-DATERANGE:END-ON-NEXT=NO" + .parse::() + .is_err()); + + assert!("garbage".parse::().is_err()); + assert!("".parse::().is_err()); + + assert!(concat!( + "#EXT-X-DATERANGE:", + "ID=\"test_id\",", + "START-DATE=\"2014-03-05T11:15:00Z\",", + "END-ON-NEXT=YES" ) - .build() - .unwrap() - ); - - assert_eq!( - "#EXT-X-DATERANGE:\ - ID=\"test_id\",\ - CLASS=\"test_class\",\ - START-DATE=\"2014-03-05T11:15:00Z\",\ - END-DATE=\"2014-03-05T11:16:00Z\",\ - DURATION=60.1,\ - PLANNED-DURATION=59.993,\ - X-CUSTOM=45.3,\ - SCTE35-CMD=0xFC002F0000000000FF2,\ - SCTE35-OUT=0xFC002F0000000000FF0,\ - SCTE35-IN=0xFC002F0000000000FF1,\ - END-ON-NEXT=YES,\ - UNKNOWN=PHANTOM" .parse::() - .unwrap(), - ExtXDateRange::builder() - .id("test_id") - .class("test_class") - .start_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0)) - .end_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 16, 0)) - .duration(Duration::from_secs_f64(60.1)) - .planned_duration(Duration::from_secs_f64(59.993)) - .insert_client_attribute("X-CUSTOM", 45.3) - .scte35_cmd("0xFC002F0000000000FF2") - .scte35_out("0xFC002F0000000000FF0") - .scte35_in("0xFC002F0000000000FF1") - .end_on_next(true) - .build() - .unwrap() - ); - - assert!("#EXT-X-DATERANGE:END-ON-NEXT=NO" - .parse::() - .is_err()); - - assert!("garbage".parse::().is_err()); - assert!("".parse::().is_err()); - - assert!("#EXT-X-DATERANGE:\ - ID=\"test_id\",\ - START-DATE=\"2014-03-05T11:15:00Z\",\ - END-ON-NEXT=YES" - .parse::() - .is_err()); + .is_err()); + } + } } - #[test] - fn test_display() { - assert_eq!( + generate_tests! { + { + ExtXDateRange::builder() + .id("splice-6FFFFFF0") + .start_date({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0) + } + #[cfg(not(feature = "chrono"))] + { + "2014-03-05T11:15:00Z" + } + }) + .planned_duration(Duration::from_secs_f64(59.993)) + .scte35_out(concat!( + "0xFC002F0000000000FF00001", + "4056FFFFFF000E011622DCAFF0", + "00052636200000000000A00080", + "29896F50000008700000000" + )) + .build() + .unwrap(), + concat!( + "#EXT-X-DATERANGE:", + "ID=\"splice-6FFFFFF0\",", + "START-DATE=\"2014-03-05T11:15:00Z\",", + "PLANNED-DURATION=59.993,", + "SCTE35-OUT=0xFC002F0000000000FF000014056F", + "FFFFF000E011622DCAFF000052636200000000000", + "A0008029896F50000008700000000" + ) + }, + { ExtXDateRange::builder() .id("test_id") .class("test_class") - .start_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0)) - .end_date(FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 16, 0)) + .start_date({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(0).ymd(2014, 3, 5).and_hms(11, 15, 0) + } + #[cfg(not(feature = "chrono"))] + { + "2014-03-05T11:15:00Z" + } + }) + .end_date({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(0).ymd(2014, 3, 5).and_hms_milli(11, 16, 0, 100) + } + #[cfg(not(feature = "chrono"))] + { + "2014-03-05T11:16:00.100Z" + } + }) .duration(Duration::from_secs_f64(60.1)) .planned_duration(Duration::from_secs_f64(59.993)) - .insert_client_attribute("X-CUSTOM", 45.3) + .insert_client_attribute("X-CUSTOM", Float::new(45.3)) .scte35_cmd("0xFC002F0000000000FF2") .scte35_out("0xFC002F0000000000FF0") .scte35_in("0xFC002F0000000000FF1") - .end_on_next(true) .build() - .unwrap() - .to_string(), - "#EXT-X-DATERANGE:\ - ID=\"test_id\",\ - CLASS=\"test_class\",\ - START-DATE=\"2014-03-05T11:15:00Z\",\ - END-DATE=\"2014-03-05T11:16:00Z\",\ - DURATION=60.1,\ - PLANNED-DURATION=59.993,\ - SCTE35-CMD=0xFC002F0000000000FF2,\ - SCTE35-OUT=0xFC002F0000000000FF0,\ - SCTE35-IN=0xFC002F0000000000FF1,\ - X-CUSTOM=45.3,\ - END-ON-NEXT=YES" - ) + .unwrap(), + concat!( + "#EXT-X-DATERANGE:", + "ID=\"test_id\",", + "CLASS=\"test_class\",", + "START-DATE=\"2014-03-05T11:15:00Z\",", + "END-DATE=\"2014-03-05T11:16:00.100Z\",", + "DURATION=60.1,", + "PLANNED-DURATION=59.993,", + "SCTE35-CMD=0xFC002F0000000000FF2,", + "SCTE35-OUT=0xFC002F0000000000FF0,", + "SCTE35-IN=0xFC002F0000000000FF1,", + "X-CUSTOM=45.3", + ) + }, } #[test] fn test_required_version() { assert_eq!( - ExtXDateRange::new( - "id", - FixedOffset::east(8 * HOURS_IN_SECS) - .ymd(2010, 2, 19) - .and_hms_milli(14, 54, 23, 31) - ) + ExtXDateRange::new("id", { + #[cfg(feature = "chrono")] + { + FixedOffset::east(8 * HOURS_IN_SECS) + .ymd(2010, 2, 19) + .and_hms_milli(14, 54, 23, 31) + } + #[cfg(not(feature = "chrono"))] + { + "2010-02-19T14:54:23.031+08:00" + } + }) .required_version(), ProtocolVersion::V1 ); diff --git a/src/tags/media_segment/discontinuity.rs b/src/tags/media_segment/discontinuity.rs index 5a40451..924bcd5 100644 --- a/src/tags/media_segment/discontinuity.rs +++ b/src/tags/media_segment/discontinuity.rs @@ -5,31 +5,22 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.4.2.3. EXT-X-DISCONTINUITY] -/// The [`ExtXDiscontinuity`] tag indicates a discontinuity between the -/// [`Media Segment`] that follows it and the one that preceded it. -/// -/// Its format is: -/// ```text -/// #EXT-X-DISCONTINUITY -/// ``` -/// -/// [`Media Segment`]: crate::MediaSegment -/// [4.4.2.3. EXT-X-DISCONTINUITY]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-04#section-4.4.2.3 +/// The `ExtXDiscontinuity` tag indicates a discontinuity between the +/// `MediaSegment` that follows it and the one that preceded it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExtXDiscontinuity; +pub(crate) struct ExtXDiscontinuity; impl ExtXDiscontinuity { pub(crate) const PREFIX: &'static str = "#EXT-X-DISCONTINUITY"; } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXDiscontinuity { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXDiscontinuity { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { Self::PREFIX.fmt(f) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Self::PREFIX.fmt(f) } } impl FromStr for ExtXDiscontinuity { diff --git a/src/tags/media_segment/inf.rs b/src/tags/media_segment/inf.rs index fc1cb51..466497b 100644 --- a/src/tags/media_segment/inf.rs +++ b/src/tags/media_segment/inf.rs @@ -2,19 +2,18 @@ use std::fmt; use std::str::FromStr; use std::time::Duration; +use derive_more::AsRef; + use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.2.1. EXTINF] -/// -/// The [`ExtInf`] tag specifies the duration of a [`Media Segment`]. It applies -/// only to the next [`Media Segment`]. +/// Specifies the duration of a [`Media Segment`]. /// /// [`Media Segment`]: crate::media_segment::MediaSegment -/// [4.3.2.1. EXTINF]: https://tools.ietf.org/html/rfc8216#section-4.3.2.1 -#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(AsRef, Default, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ExtInf { + #[as_ref] duration: Duration, title: Option, } @@ -25,12 +24,14 @@ impl ExtInf { /// Makes a new [`ExtInf`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; /// /// let ext_inf = ExtInf::new(Duration::from_secs(5)); /// ``` + #[must_use] pub const fn new(duration: Duration) -> Self { Self { duration, @@ -41,22 +42,25 @@ impl ExtInf { /// Makes a new [`ExtInf`] tag with the given title. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; /// /// let ext_inf = ExtInf::with_title(Duration::from_secs(5), "title"); /// ``` - pub fn with_title(duration: Duration, title: T) -> Self { + #[must_use] + pub fn with_title>(duration: Duration, title: T) -> Self { Self { duration, - title: Some(title.to_string()), + title: Some(title.into()), } } /// Returns the duration of the associated media segment. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; @@ -65,11 +69,13 @@ impl ExtInf { /// /// assert_eq!(ext_inf.duration(), Duration::from_secs(5)); /// ``` + #[must_use] pub const fn duration(&self) -> Duration { self.duration } /// Sets the duration of the associated media segment. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; @@ -88,6 +94,7 @@ impl ExtInf { /// Returns the title of the associated media segment. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; @@ -96,11 +103,13 @@ impl ExtInf { /// /// assert_eq!(ext_inf.title(), &Some("title".to_string())); /// ``` + #[must_use] pub const fn title(&self) -> &Option { &self.title } /// Sets the title of the associated media segment. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtInf; /// use std::time::Duration; @@ -117,6 +126,8 @@ impl ExtInf { } } +/// This tag requires [`ProtocolVersion::V1`], if the duration does not have +/// nanoseconds, otherwise it requires [`ProtocolVersion::V3`]. impl RequiredVersion for ExtInf { fn required_version(&self) -> ProtocolVersion { if self.duration.subsec_nanos() == 0 { @@ -128,7 +139,7 @@ impl RequiredVersion for ExtInf { } impl fmt::Display for ExtInf { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "{},", self.duration.as_secs_f64())?; @@ -143,29 +154,20 @@ impl FromStr for ExtInf { type Err = Error; fn from_str(input: &str) -> Result { - let input = tag(input, Self::PREFIX)?; - let tokens = input.splitn(2, ',').collect::>(); + let mut input = tag(input, Self::PREFIX)?.splitn(2, ','); - if tokens.is_empty() { - return Err(Error::custom(format!( - "failed to parse #EXTINF tag, couldn't split input: {:?}", - input - ))); - } + let duration = input.next().unwrap(); + let duration = Duration::from_secs_f64( + duration + .parse() + .map_err(|e| Error::parse_float(duration, e))?, + ); - let duration = Duration::from_secs_f64(tokens[0].parse()?); - - let title = { - if tokens.len() >= 2 { - if tokens[1].trim().is_empty() { - None - } else { - Some(tokens[1].to_string()) - } - } else { - None - } - }; + let title = input + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); Ok(Self { duration, title }) } diff --git a/src/tags/media_segment/key.rs b/src/tags/media_segment/key.rs index 4ebe50b..e201072 100644 --- a/src/tags/media_segment/key.rs +++ b/src/tags/media_segment/key.rs @@ -1,89 +1,179 @@ use std::fmt; -use std::ops::{Deref, DerefMut}; use std::str::FromStr; -use crate::types::{DecryptionKey, EncryptionMethod, ProtocolVersion}; +use crate::types::{DecryptionKey, ProtocolVersion}; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.2.4. EXT-X-KEY] +/// Specifies how to decrypt encrypted data from the server. /// -/// [`Media Segment`]s may be encrypted. The [`ExtXKey`] tag specifies how to -/// decrypt them. It applies to every [`Media Segment`] and to every Media -/// Initialization Section declared by an [`ExtXMap`] tag, that appears -/// between it and the next [`ExtXKey`] tag in the Playlist file with the -/// same [`KeyFormat`] attribute (or the end of the Playlist file). -/// -/// # Note -/// In case of an empty key ([`EncryptionMethod::None`]), -/// all attributes will be ignored. -/// -/// [`KeyFormat`]: crate::types::KeyFormat -/// [`ExtXMap`]: crate::tags::ExtXMap -/// [`Media Segment`]: crate::MediaSegment -/// [4.3.2.4. EXT-X-KEY]: https://tools.ietf.org/html/rfc8216#section-4.3.2.4 -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExtXKey(DecryptionKey); +/// An unencrypted segment should be marked with [`ExtXKey::empty`]. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] +pub struct ExtXKey(pub Option); impl ExtXKey { pub(crate) const PREFIX: &'static str = "#EXT-X-KEY:"; - /// Makes a new [`ExtXKey`] tag. + /// Constructs an [`ExtXKey`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXKey; - /// use hls_m3u8::types::EncryptionMethod; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod, KeyFormat}; /// - /// let key = ExtXKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); + /// let key = ExtXKey::new( + /// DecryptionKey::builder() + /// .method(EncryptionMethod::Aes128) + /// .uri("https://www.example.com/") + /// .iv([ + /// 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, + /// ]) + /// .format(KeyFormat::Identity) + /// .versions(vec![1, 2, 3, 4, 5]) + /// .build()?, + /// ); + /// # Ok::<(), String>(()) + /// ``` + #[must_use] + #[inline] + pub const fn new(inner: DecryptionKey) -> Self { Self(Some(inner)) } + + /// Constructs an empty [`ExtXKey`], which signals that a segment is + /// unencrypted. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXKey; + /// assert_eq!(ExtXKey::empty(), ExtXKey(None)); + /// ``` + #[must_use] + #[inline] + pub const fn empty() -> Self { Self(None) } + + /// Returns `true` if the key is not empty. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXKey; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod}; + /// + /// let k = ExtXKey::new(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.url", + /// )); + /// assert_eq!(k.is_some(), true); + /// + /// let k = ExtXKey::empty(); + /// assert_eq!(k.is_some(), false); + /// ``` + #[must_use] + #[inline] + pub fn is_some(&self) -> bool { self.0.is_some() } + + /// Returns `true` if the key is empty. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXKey; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod}; + /// + /// let k = ExtXKey::new(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.url", + /// )); + /// assert_eq!(k.is_none(), false); + /// + /// let k = ExtXKey::empty(); + /// assert_eq!(k.is_none(), true); + /// ``` + #[must_use] + #[inline] + pub fn is_none(&self) -> bool { self.0.is_none() } + + /// Returns the underlying [`DecryptionKey`], if there is one. + /// + /// # Panics + /// + /// Panics if there is no underlying decryption key. + /// + /// # Examples + /// + /// ``` + /// # use hls_m3u8::tags::ExtXKey; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod}; + /// + /// let k = ExtXKey::new(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.url", + /// )); /// /// assert_eq!( - /// key.to_string(), - /// "#EXT-X-KEY:METHOD=AES-128,URI=\"https://www.example.com/\"" + /// k.unwrap(), + /// DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.url") /// ); /// ``` - pub fn new(method: EncryptionMethod, uri: T) -> Self { - Self(DecryptionKey::new(method, uri)) + /// + /// ```{.should_panic} + /// # use hls_m3u8::tags::ExtXKey; + /// use hls_m3u8::types::DecryptionKey; + /// + /// let decryption_key: DecryptionKey = ExtXKey::empty().unwrap(); // panics + /// ``` + #[must_use] + pub fn unwrap(self) -> DecryptionKey { + match self.0 { + Some(v) => v, + None => panic!("called `ExtXKey::unwrap()` on an empty key"), + } } - /// Makes a new [`ExtXKey`] tag without a decryption key. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXKey; - /// let key = ExtXKey::empty(); - /// - /// assert_eq!(key.to_string(), "#EXT-X-KEY:METHOD=NONE"); - /// ``` - pub const fn empty() -> Self { - Self(DecryptionKey { - method: EncryptionMethod::None, - uri: None, - iv: None, - key_format: None, - key_format_versions: None, - }) - } + /// Returns a reference to the underlying [`DecryptionKey`]. + #[must_use] + #[inline] + pub fn as_ref(&self) -> Option<&DecryptionKey> { self.0.as_ref() } - /// Returns whether the [`EncryptionMethod`] is - /// [`None`]. + /// Converts an [`ExtXKey`] into an `Option`. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXKey; - /// use hls_m3u8::types::EncryptionMethod; + /// use hls_m3u8::types::{DecryptionKey, EncryptionMethod}; /// - /// let key = ExtXKey::empty(); + /// assert_eq!(ExtXKey::empty().into_option(), None); /// - /// assert_eq!(key.method() == EncryptionMethod::None, key.is_empty()); + /// assert_eq!( + /// ExtXKey::new(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.url" + /// )) + /// .into_option(), + /// Some(DecryptionKey::new( + /// EncryptionMethod::Aes128, + /// "https://www.example.url" + /// )) + /// ); /// ``` - /// - /// [`None`]: EncryptionMethod::None - pub fn is_empty(&self) -> bool { self.0.method() == EncryptionMethod::None } + #[must_use] + #[inline] + pub fn into_option(self) -> Option { self.0 } } +/// This tag requires [`ProtocolVersion::V5`], if [`KeyFormat`] or +/// [`KeyFormatVersions`] is specified and [`ProtocolVersion::V2`] if an iv is +/// specified. +/// +/// Otherwise [`ProtocolVersion::V1`] is required. impl RequiredVersion for ExtXKey { - fn required_version(&self) -> ProtocolVersion { self.0.required_version() } + fn required_version(&self) -> ProtocolVersion { + self.0 + .as_ref() + .map_or(ProtocolVersion::V1, |i| i.required_version()) + } } impl FromStr for ExtXKey { @@ -91,22 +181,37 @@ impl FromStr for ExtXKey { fn from_str(input: &str) -> Result { let input = tag(input, Self::PREFIX)?; - Ok(Self(input.parse()?)) + + if input.trim() == "METHOD=NONE" { + Ok(Self(None)) + } else { + Ok(DecryptionKey::from_str(input)?.into()) + } } } +impl From> for ExtXKey { + fn from(value: Option) -> Self { Self(value) } +} + +impl From for ExtXKey { + fn from(value: DecryptionKey) -> Self { Self(Some(value)) } +} + +impl From for ExtXKey { + fn from(value: crate::tags::ExtXSessionKey) -> Self { Self(Some(value.0)) } +} + impl fmt::Display for ExtXKey { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}{}", Self::PREFIX, self.0) } -} + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", Self::PREFIX)?; -impl Deref for ExtXKey { - type Target = DecryptionKey; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for ExtXKey { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + if let Some(value) = &self.0 { + write!(f, "{}", value) + } else { + write!(f, "METHOD=NONE") + } + } } #[cfg(test)] @@ -115,45 +220,131 @@ mod test { use crate::types::{EncryptionMethod, KeyFormat}; use pretty_assertions::assert_eq; - #[test] - fn test_display() { - assert_eq!( - ExtXKey::empty().to_string(), - "#EXT-X-KEY:METHOD=NONE".to_string() - ); + macro_rules! generate_tests { + ( $( { $struct:expr, $str:expr } ),+ $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($struct.to_string(), $str.to_string()); + )+ + } - let mut key = ExtXKey::empty(); - // it is expected, that all attributes will be ignored for an empty key! - key.set_key_format(Some(KeyFormat::Identity)); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); - key.set_uri(Some("https://www.example.com")); - key.set_key_format_versions(Some(vec![1, 2, 3])); + #[test] + fn test_parser() { + $( + assert_eq!($struct, $str.parse().unwrap()); + )+ - assert_eq!(key.to_string(), "#EXT-X-KEY:METHOD=NONE".to_string()); + assert_eq!( + ExtXKey::new( + DecryptionKey::new( + EncryptionMethod::Aes128, + "http://www.example.com" + ) + ), + concat!( + "#EXT-X-KEY:", + "METHOD=AES-128,", + "URI=\"http://www.example.com\",", + "UNKNOWNTAG=abcd" + ).parse().unwrap(), + ); + assert!("#EXT-X-KEY:METHOD=AES-128,URI=".parse::().is_err()); + assert!("garbage".parse::().is_err()); + } + } } - #[test] - fn test_parser() { - assert_eq!( - "#EXT-X-KEY:\ - METHOD=AES-128,\ - URI=\"https://priv.example.com/key.php?r=52\"" - .parse::() - .unwrap(), - ExtXKey::new( + generate_tests! { + { + ExtXKey::empty(), + "#EXT-X-KEY:METHOD=NONE" + }, + { + ExtXKey::new(DecryptionKey::new( EncryptionMethod::Aes128, "https://priv.example.com/key.php?r=52" + )), + concat!( + "#EXT-X-KEY:", + "METHOD=AES-128,", + "URI=\"https://priv.example.com/key.php?r=52\"" ) + }, + { + ExtXKey::new( + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82]) + .build() + .unwrap() + ), + concat!( + "#EXT-X-KEY:", + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52" + ) + }, + { + ExtXKey::new( + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82]) + .format(KeyFormat::Identity) + .versions(vec![1, 2, 3]) + .build() + .unwrap() + ), + concat!( + "#EXT-X-KEY:", + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52,", + "KEYFORMAT=\"identity\",", + "KEYFORMATVERSIONS=\"1/2/3\"" + ) + }, + } + + #[test] + fn test_required_version() { + assert_eq!( + ExtXKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://www.example.com/" + )) + .required_version(), + ProtocolVersion::V1 ); - let mut key = ExtXKey::new( - EncryptionMethod::Aes128, - "https://www.example.com/hls-key/key.bin", + assert_eq!( + ExtXKey::new( + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/") + .format(KeyFormat::Identity) + .versions(vec![1, 2, 3]) + .build() + .unwrap() + ) + .required_version(), + ProtocolVersion::V5 + ); + + assert_eq!( + ExtXKey::new( + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/") + .iv([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7]) + .build() + .unwrap() + ) + .required_version(), + ProtocolVersion::V2 ); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); } } diff --git a/src/tags/media_segment/map.rs b/src/tags/media_segment/map.rs index f63f8d5..d1209b3 100644 --- a/src/tags/media_segment/map.rs +++ b/src/tags/media_segment/map.rs @@ -1,24 +1,47 @@ use std::fmt; use std::str::FromStr; +use shorthand::ShortHand; + use crate::attribute::AttributePairs; use crate::tags::ExtXKey; -use crate::types::{ByteRange, ProtocolVersion}; +use crate::types::{ByteRange, DecryptionKey, ProtocolVersion}; use crate::utils::{quote, tag, unquote}; -use crate::{Encrypted, Error, RequiredVersion}; +use crate::{Decryptable, Error, RequiredVersion}; -/// # [4.3.2.5. EXT-X-MAP] +/// The [`ExtXMap`] tag specifies how to obtain the [Media Initialization +/// Section], required to parse the applicable [`MediaSegment`]s. /// -/// The [`ExtXMap`] tag specifies how to obtain the Media Initialization -/// Section, required to parse the applicable [`MediaSegment`]s. +/// It applies to every [`MediaSegment`] that appears after it in the playlist +/// until the next [`ExtXMap`] tag or until the end of the playlist. /// +/// An [`ExtXMap`] tag should be supplied for [`MediaSegment`]s in playlists +/// with the [`ExtXIFramesOnly`] tag when the first [`MediaSegment`] (i.e., +/// I-frame) in the playlist (or the first segment following an +/// [`ExtXDiscontinuity`] tag) does not immediately follow the Media +/// Initialization Section at the beginning of its resource. +/// +/// If the Media Initialization Section declared by an [`ExtXMap`] tag is +/// encrypted with [`EncryptionMethod::Aes128`], the IV attribute of +/// the [`ExtXKey`] tag that applies to the [`ExtXMap`] is required. +/// +/// [Media Initialization Section]: https://tools.ietf.org/html/rfc8216#section-3 /// [`MediaSegment`]: crate::MediaSegment -/// [4.3.2.5. EXT-X-MAP]: https://tools.ietf.org/html/rfc8216#section-4.3.2.5 -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +/// [`ExtXIFramesOnly`]: crate::tags::ExtXIFramesOnly +/// [`ExtXDiscontinuity`]: crate::tags::ExtXDiscontinuity +/// [`EncryptionMethod::Aes128`]: crate::types::EncryptionMethod::Aes128 +/// [`MediaPlaylist`]: crate::MediaPlaylist +#[derive(ShortHand, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[shorthand(enable(must_use, into))] pub struct ExtXMap { + /// The `URI` that identifies a resource, that contains the media + /// initialization section. uri: String, + /// The range of the media initialization section. + #[shorthand(enable(copy))] range: Option, - keys: Vec, + #[shorthand(enable(skip))] + pub(crate) keys: Vec, } impl ExtXMap { @@ -27,13 +50,14 @@ impl ExtXMap { /// Makes a new [`ExtXMap`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXMap; /// let map = ExtXMap::new("https://prod.mediaspace.com/init.bin"); /// ``` - pub fn new(uri: T) -> Self { + pub fn new>(uri: T) -> Self { Self { - uri: uri.to_string(), + uri: uri.into(), range: None, keys: vec![], } @@ -42,101 +66,37 @@ impl ExtXMap { /// Makes a new [`ExtXMap`] tag with the given range. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXMap; /// use hls_m3u8::types::ByteRange; /// - /// let map = ExtXMap::with_range( - /// "https://prod.mediaspace.com/init.bin", - /// ByteRange::new(9, Some(2)), - /// ); + /// ExtXMap::with_range("https://prod.mediaspace.com/init.bin", 2..11); /// ``` - pub fn with_range(uri: T, range: ByteRange) -> Self { + pub fn with_range, B: Into>(uri: I, range: B) -> Self { Self { - uri: uri.to_string(), - range: Some(range), + uri: uri.into(), + range: Some(range.into()), keys: vec![], } } +} - /// Returns the `URI` that identifies a resource, that contains the media - /// initialization section. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMap; - /// let map = ExtXMap::new("https://prod.mediaspace.com/init.bin"); - /// - /// assert_eq!( - /// map.uri(), - /// &"https://prod.mediaspace.com/init.bin".to_string() - /// ); - /// ``` - pub const fn uri(&self) -> &String { &self.uri } - - /// Sets the `URI` that identifies a resource, that contains the media - /// initialization section. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMap; - /// let mut map = ExtXMap::new("https://prod.mediaspace.com/init.bin"); - /// - /// map.set_uri("https://dev.mediaspace.com/init.bin"); - /// assert_eq!( - /// map.uri(), - /// &"https://dev.mediaspace.com/init.bin".to_string() - /// ); - /// ``` - pub fn set_uri(&mut self, value: T) -> &mut Self { - self.uri = value.to_string(); - self - } - - /// Returns the range of the media initialization section. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMap; - /// use hls_m3u8::types::ByteRange; - /// - /// let map = ExtXMap::with_range( - /// "https://prod.mediaspace.com/init.bin", - /// ByteRange::new(9, Some(2)), - /// ); - /// - /// assert_eq!(map.range(), Some(ByteRange::new(9, Some(2)))); - /// ``` - pub const fn range(&self) -> Option { self.range } - - /// Sets the range of the media initialization section. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXMap; - /// use hls_m3u8::types::ByteRange; - /// - /// let mut map = ExtXMap::with_range( - /// "https://prod.mediaspace.com/init.bin", - /// ByteRange::new(9, Some(2)), - /// ); - /// - /// map.set_range(Some(ByteRange::new(1, None))); - /// assert_eq!(map.range(), Some(ByteRange::new(1, None))); - /// ``` - pub fn set_range(&mut self, value: Option) -> &mut Self { - self.range = value; - self +impl Decryptable for ExtXMap { + fn keys(&self) -> Vec<&DecryptionKey> { + // + self.keys.iter().filter_map(ExtXKey::as_ref).collect() } } -impl Encrypted for ExtXMap { - fn keys(&self) -> &Vec { &self.keys } - - fn keys_mut(&mut self) -> &mut Vec { &mut self.keys } -} - -/// This tag requires [`ProtocolVersion::V6`]. +/// Use of the [`ExtXMap`] tag in a [`MediaPlaylist`] that contains the +/// [`ExtXIFramesOnly`] tag requires [`ProtocolVersion::V5`] or +/// greater. Use of the [`ExtXMap`] tag in a [`MediaPlaylist`] that does not +/// contain the [`ExtXIFramesOnly`] tag requires [`ProtocolVersion::V6`] or +/// greater. +/// +/// [`ExtXIFramesOnly`]: crate::tags::ExtXIFramesOnly +/// [`MediaPlaylist`]: crate::MediaPlaylist impl RequiredVersion for ExtXMap { // this should return ProtocolVersion::V5, if it does not contain an // EXT-X-I-FRAMES-ONLY! @@ -147,7 +107,7 @@ impl RequiredVersion for ExtXMap { } impl fmt::Display for ExtXMap { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "URI={}", quote(&self.uri))?; @@ -168,8 +128,8 @@ impl FromStr for ExtXMap { let mut uri = None; let mut range = None; - for (key, value) in input.parse::()? { - match key.as_str() { + for (key, value) in AttributePairs::new(input) { + match key { "URI" => uri = Some(unquote(value)), "BYTERANGE" => { range = Some(unquote(value).parse()?); @@ -182,7 +142,8 @@ impl FromStr for ExtXMap { } } - let uri = uri.ok_or_else(|| Error::missing_value("EXT-X-URI"))?; + let uri = uri.ok_or_else(|| Error::missing_value("URI"))?; + Ok(Self { uri, range, @@ -204,7 +165,7 @@ mod test { ); assert_eq!( - ExtXMap::with_range("foo", ByteRange::new(9, Some(2))).to_string(), + ExtXMap::with_range("foo", ByteRange::from(2..11)).to_string(), "#EXT-X-MAP:URI=\"foo\",BYTERANGE=\"9@2\"".to_string(), ); } @@ -217,11 +178,11 @@ mod test { ); assert_eq!( - ExtXMap::with_range("foo", ByteRange::new(9, Some(2))), + ExtXMap::with_range("foo", ByteRange::from(2..11)), "#EXT-X-MAP:URI=\"foo\",BYTERANGE=\"9@2\"".parse().unwrap() ); assert_eq!( - ExtXMap::with_range("foo", ByteRange::new(9, Some(2))), + ExtXMap::with_range("foo", ByteRange::from(2..11)), "#EXT-X-MAP:URI=\"foo\",BYTERANGE=\"9@2\",UNKNOWN=IGNORED" .parse() .unwrap() @@ -232,14 +193,13 @@ mod test { fn test_required_version() { assert_eq!(ExtXMap::new("foo").required_version(), ProtocolVersion::V6); assert_eq!( - ExtXMap::with_range("foo", ByteRange::new(9, Some(2))).required_version(), + ExtXMap::with_range("foo", ByteRange::from(2..11)).required_version(), ProtocolVersion::V6 ); } #[test] - fn test_encrypted() { - assert_eq!(ExtXMap::new("foo").keys(), &vec![]); - assert_eq!(ExtXMap::new("foo").keys_mut(), &mut vec![]); + fn test_decryptable() { + assert_eq!(ExtXMap::new("foo").keys(), Vec::<&DecryptionKey>::new()); } } diff --git a/src/tags/media_segment/mod.rs b/src/tags/media_segment/mod.rs index 9d10bd5..500751f 100644 --- a/src/tags/media_segment/mod.rs +++ b/src/tags/media_segment/mod.rs @@ -1,15 +1,15 @@ -mod byte_range; -mod date_range; -mod discontinuity; -mod inf; -mod key; -mod map; -mod program_date_time; +pub(crate) mod byte_range; +pub(crate) mod date_range; +pub(crate) mod discontinuity; +pub(crate) mod inf; +pub(crate) mod key; +pub(crate) mod map; +pub(crate) mod program_date_time; pub use byte_range::*; -pub use date_range::*; -pub use discontinuity::*; +pub use date_range::ExtXDateRange; +pub(crate) use discontinuity::*; pub use inf::*; -pub use key::*; +pub use key::ExtXKey; pub use map::*; pub use program_date_time::*; diff --git a/src/tags/media_segment/program_date_time.rs b/src/tags/media_segment/program_date_time.rs index 5ea34ca..9e123d5 100644 --- a/src/tags/media_segment/program_date_time.rs +++ b/src/tags/media_segment/program_date_time.rs @@ -1,21 +1,41 @@ use std::fmt; -use std::ops::{Deref, DerefMut}; use std::str::FromStr; +#[cfg(feature = "chrono")] use chrono::{DateTime, FixedOffset, SecondsFormat}; +#[cfg(feature = "chrono")] +use derive_more::{Deref, DerefMut}; use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// # [4.3.2.6. EXT-X-PROGRAM-DATE-TIME] -/// The [`ExtXProgramDateTime`] tag associates the first sample of a -/// [`Media Segment`] with an absolute date and/or time. +/// Associates the first sample of a [`MediaSegment`] with an absolute date +/// and/or time. /// -/// [`Media Segment`]: crate::MediaSegment -/// [4.3.2.6. EXT-X-PROGRAM-DATE-TIME]: https://tools.ietf.org/html/rfc8216#section-4.3.2.6 -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExtXProgramDateTime(DateTime); +/// ## Features +/// +/// By enabling the `chrono` feature the `date_time`-field will change from +/// `String` to `DateTime` and the traits +/// - `Deref>`, +/// - `DerefMut>` +/// - and `Copy` +/// +/// will be derived. +/// +/// [`MediaSegment`]: crate::MediaSegment +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "chrono", derive(Deref, DerefMut, Copy))] +#[non_exhaustive] +pub struct ExtXProgramDateTime { + /// The date-time of the first sample of the associated media segment. + #[cfg(feature = "chrono")] + #[cfg_attr(feature = "chrono", deref_mut, deref)] + pub date_time: DateTime, + /// The date-time of the first sample of the associated media segment. + #[cfg(not(feature = "chrono"))] + pub date_time: String, +} impl ExtXProgramDateTime { pub(crate) const PREFIX: &'static str = "#EXT-X-PROGRAM-DATE-TIME:"; @@ -23,6 +43,7 @@ impl ExtXProgramDateTime { /// Makes a new [`ExtXProgramDateTime`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXProgramDateTime; /// use chrono::{FixedOffset, TimeZone}; @@ -35,64 +56,23 @@ impl ExtXProgramDateTime { /// .and_hms_milli(14, 54, 23, 31), /// ); /// ``` - pub const fn new(date_time: DateTime) -> Self { Self(date_time) } + #[must_use] + #[cfg(feature = "chrono")] + pub const fn new(date_time: DateTime) -> Self { Self { date_time } } - /// Returns the date-time of the first sample of the associated media - /// segment. + /// Makes a new [`ExtXProgramDateTime`] tag. /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXProgramDateTime; - /// use chrono::{FixedOffset, TimeZone}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let program_date_time = ExtXProgramDateTime::new( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// assert_eq!( - /// program_date_time.date_time(), - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31) - /// ); + /// let program_date_time = ExtXProgramDateTime::new("2010-02-19T14:54:23.031+08:00"); /// ``` - pub const fn date_time(&self) -> DateTime { self.0 } - - /// Sets the date-time of the first sample of the associated media segment. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXProgramDateTime; - /// use chrono::{FixedOffset, TimeZone}; - /// - /// const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds - /// - /// let mut program_date_time = ExtXProgramDateTime::new( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 2, 19) - /// .and_hms_milli(14, 54, 23, 31), - /// ); - /// - /// program_date_time.set_date_time( - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10), - /// ); - /// - /// assert_eq!( - /// program_date_time.date_time(), - /// FixedOffset::east(8 * HOURS_IN_SECS) - /// .ymd(2010, 10, 10) - /// .and_hms_milli(10, 10, 10, 10) - /// ); - /// ``` - pub fn set_date_time(&mut self, value: DateTime) -> &mut Self { - self.0 = value; - self + #[cfg(not(feature = "chrono"))] + pub fn new>(date_time: T) -> Self { + Self { + date_time: date_time.into(), + } } } @@ -102,8 +82,17 @@ impl RequiredVersion for ExtXProgramDateTime { } impl fmt::Display for ExtXProgramDateTime { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let date_time = self.0.to_rfc3339_opts(SecondsFormat::Millis, true); + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let date_time = { + #[cfg(feature = "chrono")] + { + self.date_time.to_rfc3339_opts(SecondsFormat::Millis, true) + } + #[cfg(not(feature = "chrono"))] + { + &self.date_time + } + }; write!(f, "{}{}", Self::PREFIX, date_time) } } @@ -114,37 +103,46 @@ impl FromStr for ExtXProgramDateTime { fn from_str(input: &str) -> Result { let input = tag(input, Self::PREFIX)?; - let date_time = DateTime::parse_from_rfc3339(input)?; - Ok(Self::new(date_time)) + Ok(Self::new({ + #[cfg(feature = "chrono")] + { + DateTime::parse_from_rfc3339(input).map_err(Error::chrono)? + } + #[cfg(not(feature = "chrono"))] + { + input + } + })) } } -impl Deref for ExtXProgramDateTime { - type Target = DateTime; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for ExtXProgramDateTime { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} - #[cfg(test)] mod test { use super::*; + #[cfg(feature = "chrono")] use chrono::{Datelike, TimeZone}; + #[cfg(feature = "chrono")] + use core::ops::DerefMut; use pretty_assertions::assert_eq; + #[cfg(feature = "chrono")] const HOURS_IN_SECS: i32 = 3600; // 1 hour = 3600 seconds #[test] fn test_display() { assert_eq!( - ExtXProgramDateTime::new( - FixedOffset::east(8 * HOURS_IN_SECS) - .ymd(2010, 2, 19) - .and_hms_milli(14, 54, 23, 31) - ) + ExtXProgramDateTime::new({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(8 * HOURS_IN_SECS) + .ymd(2010, 2, 19) + .and_hms_milli(14, 54, 23, 31) + } + #[cfg(not(feature = "chrono"))] + { + "2010-02-19T14:54:23.031+08:00" + } + }) .to_string(), "#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00".to_string() ); @@ -153,11 +151,18 @@ mod test { #[test] fn test_parser() { assert_eq!( - ExtXProgramDateTime::new( - FixedOffset::east(8 * HOURS_IN_SECS) - .ymd(2010, 2, 19) - .and_hms_milli(14, 54, 23, 31) - ), + ExtXProgramDateTime::new({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(8 * HOURS_IN_SECS) + .ymd(2010, 2, 19) + .and_hms_milli(14, 54, 23, 31) + } + #[cfg(not(feature = "chrono"))] + { + "2010-02-19T14:54:23.031+08:00" + } + }), "#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00" .parse::() .unwrap() @@ -167,17 +172,25 @@ mod test { #[test] fn test_required_version() { assert_eq!( - ExtXProgramDateTime::new( - FixedOffset::east(8 * HOURS_IN_SECS) - .ymd(2010, 2, 19) - .and_hms_milli(14, 54, 23, 31), - ) + ExtXProgramDateTime::new({ + #[cfg(feature = "chrono")] + { + FixedOffset::east(8 * HOURS_IN_SECS) + .ymd(2010, 2, 19) + .and_hms_milli(14, 54, 23, 31) + } + #[cfg(not(feature = "chrono"))] + { + "2010-02-19T14:54:23.031+08:00" + } + }) .required_version(), ProtocolVersion::V1 ); } #[test] + #[cfg(feature = "chrono")] fn test_deref() { assert_eq!( ExtXProgramDateTime::new( @@ -191,6 +204,7 @@ mod test { } #[test] + #[cfg(feature = "chrono")] fn test_deref_mut() { assert_eq!( ExtXProgramDateTime::new( diff --git a/src/tags/mod.rs b/src/tags/mod.rs index 35ae4c4..0dcda36 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -2,14 +2,14 @@ //! //! [4.3. Playlist Tags]: https://tools.ietf.org/html/rfc8216#section-4.3 -mod basic; -mod master_playlist; -mod media_playlist; -mod media_segment; -mod shared; +pub(crate) mod basic; +pub(crate) mod master_playlist; +pub(crate) mod media_playlist; +pub(crate) mod media_segment; +pub(crate) mod shared; pub use basic::*; pub use master_playlist::*; -pub use media_playlist::*; +pub(crate) use media_playlist::*; pub use media_segment::*; pub use shared::*; diff --git a/src/tags/shared/independent_segments.rs b/src/tags/shared/independent_segments.rs index 47b0f66..557d8f0 100644 --- a/src/tags/shared/independent_segments.rs +++ b/src/tags/shared/independent_segments.rs @@ -5,22 +5,24 @@ use crate::types::ProtocolVersion; use crate::utils::tag; use crate::{Error, RequiredVersion}; -/// [4.3.5.1. EXT-X-INDEPENDENT-SEGMENTS] +/// Signals that all media samples in a [`MediaSegment`] can be decoded without +/// information from other segments. /// -/// [4.3.5.1. EXT-X-INDEPENDENT-SEGMENTS]: https://tools.ietf.org/html/rfc8216#section-4.3.5.1 +/// [`MediaSegment`]: crate::MediaSegment #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] -pub struct ExtXIndependentSegments; +pub(crate) struct ExtXIndependentSegments; impl ExtXIndependentSegments { pub(crate) const PREFIX: &'static str = "#EXT-X-INDEPENDENT-SEGMENTS"; } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXIndependentSegments { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXIndependentSegments { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { Self::PREFIX.fmt(f) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Self::PREFIX.fmt(f) } } impl FromStr for ExtXIndependentSegments { diff --git a/src/tags/shared/mod.rs b/src/tags/shared/mod.rs index 956efe7..d2403fc 100644 --- a/src/tags/shared/mod.rs +++ b/src/tags/shared/mod.rs @@ -1,5 +1,5 @@ -mod independent_segments; -mod start; +pub(crate) mod independent_segments; +pub(crate) mod start; -pub use independent_segments::*; +pub(crate) use independent_segments::ExtXIndependentSegments; pub use start::*; diff --git a/src/tags/shared/start.rs b/src/tags/shared/start.rs index 22a3acb..c201fc7 100644 --- a/src/tags/shared/start.rs +++ b/src/tags/shared/start.rs @@ -1,18 +1,55 @@ use std::fmt; use std::str::FromStr; +use shorthand::ShortHand; + use crate::attribute::AttributePairs; -use crate::types::{ProtocolVersion, SignedDecimalFloatingPoint}; +use crate::types::{Float, ProtocolVersion}; use crate::utils::{parse_yes_or_no, tag}; use crate::{Error, RequiredVersion}; -/// [4.3.5.2. EXT-X-START] +/// This tag indicates a preferred point at which to start +/// playing a Playlist. /// -/// [4.3.5.2. EXT-X-START]: https://tools.ietf.org/html/rfc8216#section-4.3.5.2 -#[derive(PartialOrd, Debug, Clone, Copy, PartialEq)] +/// By default, clients should start playback at this point when beginning a +/// playback session. +#[derive(ShortHand, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Ord, Hash)] +#[shorthand(enable(must_use))] pub struct ExtXStart { - time_offset: SignedDecimalFloatingPoint, - precise: bool, + /// The time offset of the [`MediaSegment`]s in the playlist. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXStart; + /// use hls_m3u8::types::Float; + /// + /// let mut start = ExtXStart::new(Float::new(20.123456)); + /// # assert_eq!(start.time_offset(), Float::new(20.123456)); + /// + /// start.set_time_offset(Float::new(1.0)); + /// assert_eq!(start.time_offset(), Float::new(1.0)); + /// ``` + /// + /// [`MediaSegment`]: crate::MediaSegment + #[shorthand(enable(copy))] + time_offset: Float, + /// Whether clients should not render media stream whose presentation times + /// are prior to the specified time offset. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::tags::ExtXStart; + /// use hls_m3u8::types::Float; + /// + /// let mut start = ExtXStart::new(Float::new(20.123456)); + /// # assert_eq!(start.is_precise(), false); + /// start.set_is_precise(true); + /// + /// assert_eq!(start.is_precise(), true); + /// ``` + is_precise: bool, } impl ExtXStart { @@ -20,106 +57,56 @@ impl ExtXStart { /// Makes a new [`ExtXStart`] tag. /// - /// # Panic - /// Panics if the time_offset value is infinite. - /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXStart; - /// let start = ExtXStart::new(20.123456); + /// use hls_m3u8::types::Float; + /// + /// let start = ExtXStart::new(Float::new(20.123456)); /// ``` - pub fn new(time_offset: f64) -> Self { + #[must_use] + pub const fn new(time_offset: Float) -> Self { Self { - time_offset: SignedDecimalFloatingPoint::new(time_offset), - precise: false, + time_offset, + is_precise: false, } } /// Makes a new [`ExtXStart`] tag with the given `precise` flag. /// - /// # Panic - /// Panics if the time_offset value is infinite. - /// /// # Example + /// /// ``` /// # use hls_m3u8::tags::ExtXStart; - /// let start = ExtXStart::with_precise(20.123456, true); - /// assert_eq!(start.precise(), true); + /// use hls_m3u8::types::Float; + /// + /// let start = ExtXStart::with_precise(Float::new(20.123456), true); + /// assert_eq!(start.is_precise(), true); /// ``` - pub fn with_precise(time_offset: f64, precise: bool) -> Self { + #[must_use] + pub const fn with_precise(time_offset: Float, is_precise: bool) -> Self { Self { - time_offset: SignedDecimalFloatingPoint::new(time_offset), - precise, + time_offset, + is_precise, } } - - /// Returns the time offset of the media segments in the playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStart; - /// let start = ExtXStart::new(20.123456); - /// assert_eq!(start.time_offset(), 20.123456); - /// ``` - pub const fn time_offset(&self) -> f64 { self.time_offset.as_f64() } - - /// Sets the time offset of the media segments in the playlist. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStart; - /// let mut start = ExtXStart::new(20.123456); - /// # assert_eq!(start.time_offset(), 20.123456); - /// - /// start.set_time_offset(1.0); - /// - /// assert_eq!(start.time_offset(), 1.0); - /// ``` - pub fn set_time_offset(&mut self, value: f64) -> &mut Self { - self.time_offset = SignedDecimalFloatingPoint::new(value); - self - } - - /// Returns whether clients should not render media stream whose - /// presentation times are prior to the specified time offset. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStart; - /// let start = ExtXStart::with_precise(20.123456, true); - /// assert_eq!(start.precise(), true); - /// ``` - pub const fn precise(&self) -> bool { self.precise } - - /// Sets the `precise` flag. - /// - /// # Example - /// ``` - /// # use hls_m3u8::tags::ExtXStart; - /// let mut start = ExtXStart::new(20.123456); - /// # assert_eq!(start.precise(), false); - /// - /// start.set_precise(true); - /// - /// assert_eq!(start.precise(), true); - /// ``` - pub fn set_precise(&mut self, value: bool) -> &mut Self { - self.precise = value; - self - } } +/// This tag requires [`ProtocolVersion::V1`]. impl RequiredVersion for ExtXStart { fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } } impl fmt::Display for ExtXStart { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Self::PREFIX)?; write!(f, "TIME-OFFSET={}", self.time_offset)?; - if self.precise { + + if self.is_precise { write!(f, ",PRECISE=YES")?; } + Ok(()) } } @@ -131,12 +118,12 @@ impl FromStr for ExtXStart { let input = tag(input, Self::PREFIX)?; let mut time_offset = None; - let mut precise = false; + let mut is_precise = false; - for (key, value) in input.parse::()? { - match key.as_str() { - "TIME-OFFSET" => time_offset = Some((value.parse())?), - "PRECISE" => precise = (parse_yes_or_no(value))?, + for (key, value) in AttributePairs::new(input) { + match key { + "TIME-OFFSET" => time_offset = Some(value.parse()?), + "PRECISE" => is_precise = parse_yes_or_no(value)?, _ => { // [6.3.1. General Client Responsibilities] // > ignore any attribute/value pair with an unrecognized @@ -145,11 +132,11 @@ impl FromStr for ExtXStart { } } - let time_offset = time_offset.ok_or_else(|| Error::missing_value("EXT-X-TIME-OFFSET"))?; + let time_offset = time_offset.ok_or_else(|| Error::missing_value("TIME-OFFSET"))?; Ok(Self { time_offset, - precise, + is_precise, }) } } @@ -162,12 +149,12 @@ mod test { #[test] fn test_display() { assert_eq!( - ExtXStart::new(-1.23).to_string(), + ExtXStart::new(Float::new(-1.23)).to_string(), "#EXT-X-START:TIME-OFFSET=-1.23".to_string(), ); assert_eq!( - ExtXStart::with_precise(1.23, true).to_string(), + ExtXStart::with_precise(Float::new(1.23), true).to_string(), "#EXT-X-START:TIME-OFFSET=1.23,PRECISE=YES".to_string(), ); } @@ -175,12 +162,12 @@ mod test { #[test] fn test_required_version() { assert_eq!( - ExtXStart::new(-1.23).required_version(), + ExtXStart::new(Float::new(-1.23)).required_version(), ProtocolVersion::V1, ); assert_eq!( - ExtXStart::with_precise(1.23, true).required_version(), + ExtXStart::with_precise(Float::new(1.23), true).required_version(), ProtocolVersion::V1, ); } @@ -188,17 +175,17 @@ mod test { #[test] fn test_parser() { assert_eq!( - ExtXStart::new(-1.23), + ExtXStart::new(Float::new(-1.23)), "#EXT-X-START:TIME-OFFSET=-1.23".parse().unwrap(), ); assert_eq!( - ExtXStart::with_precise(1.23, true), + ExtXStart::with_precise(Float::new(1.23), true), "#EXT-X-START:TIME-OFFSET=1.23,PRECISE=YES".parse().unwrap(), ); assert_eq!( - ExtXStart::with_precise(1.23, true), + ExtXStart::with_precise(Float::new(1.23), true), "#EXT-X-START:TIME-OFFSET=1.23,PRECISE=YES,UNKNOWN=TAG" .parse() .unwrap(), diff --git a/src/traits.rs b/src/traits.rs index af8c5ca..dd459a3 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,150 +1,127 @@ -use crate::tags::ExtXKey; -use crate::types::{EncryptionMethod, ProtocolVersion}; +use std::collections::{BTreeMap, HashMap}; -/// A trait, that is implemented on all tags, that could be encrypted. -/// -/// # Example -/// ``` -/// use hls_m3u8::tags::ExtXKey; -/// use hls_m3u8::types::EncryptionMethod; -/// use hls_m3u8::Encrypted; -/// -/// struct ExampleTag { -/// keys: Vec, -/// } -/// -/// // Implementing the trait is very simple: -/// // Simply expose the internal buffer, that contains all the keys. -/// impl Encrypted for ExampleTag { -/// fn keys(&self) -> &Vec { &self.keys } -/// -/// fn keys_mut(&mut self) -> &mut Vec { &mut self.keys } -/// } -/// -/// let mut example_tag = ExampleTag { keys: vec![] }; -/// -/// // adding new keys: -/// example_tag.set_keys(vec![ExtXKey::empty()]); -/// example_tag.push_key(ExtXKey::new( -/// EncryptionMethod::Aes128, -/// "http://www.example.com/data.bin", -/// )); -/// -/// // getting the keys: -/// assert_eq!( -/// example_tag.keys(), -/// &vec![ -/// ExtXKey::empty(), -/// ExtXKey::new(EncryptionMethod::Aes128, "http://www.example.com/data.bin",) -/// ] -/// ); -/// -/// assert_eq!( -/// example_tag.keys_mut(), -/// &mut vec![ -/// ExtXKey::empty(), -/// ExtXKey::new(EncryptionMethod::Aes128, "http://www.example.com/data.bin",) -/// ] -/// ); -/// -/// assert!(example_tag.is_encrypted()); -/// assert!(!example_tag.is_not_encrypted()); -/// ``` -pub trait Encrypted { - /// Returns a shared reference to all keys, that can be used to decrypt this - /// tag. - fn keys(&self) -> &Vec; +use crate::types::{DecryptionKey, ProtocolVersion}; - /// Returns an exclusive reference to all keys, that can be used to decrypt - /// this tag. - fn keys_mut(&mut self) -> &mut Vec; - - /// Sets all keys, that can be used to decrypt this tag. - fn set_keys(&mut self, value: Vec) -> &mut Self { - let keys = self.keys_mut(); - *keys = value; - self - } - - /// Add a single key to the list of keys, that can be used to decrypt this - /// tag. - fn push_key(&mut self, value: ExtXKey) -> &mut Self { - self.keys_mut().push(value); - self - } - - /// Returns `true`, if the tag is encrypted. - /// - /// # Note - /// This will return `true`, if any of the keys satisfies - /// ```text - /// key.method() != EncryptionMethod::None - /// ``` - fn is_encrypted(&self) -> bool { - if self.keys().is_empty() { - return false; - } - self.keys() - .iter() - .any(|k| k.method() != EncryptionMethod::None) - } - - /// Returns `false`, if the tag is not encrypted. - /// - /// # Note - /// This is the inverse of [`is_encrypted`]. - /// - /// [`is_encrypted`]: #method.is_encrypted - fn is_not_encrypted(&self) -> bool { !self.is_encrypted() } +mod private { + pub trait Sealed {} + impl Sealed for crate::MediaSegment {} + impl Sealed for crate::tags::ExtXMap {} } -/// # Example -/// Implementing it: -/// ``` -/// # use hls_m3u8::RequiredVersion; -/// use hls_m3u8::types::ProtocolVersion; +/// Signals that a type or some of the asssociated data might need to be +/// decrypted. /// -/// struct ExampleTag(u64); +/// # Note /// -/// impl RequiredVersion for ExampleTag { -/// fn required_version(&self) -> ProtocolVersion { -/// if self.0 == 5 { -/// ProtocolVersion::V4 -/// } else { -/// ProtocolVersion::V1 -/// } -/// } -/// } -/// assert_eq!(ExampleTag(5).required_version(), ProtocolVersion::V4); -/// assert_eq!(ExampleTag(2).required_version(), ProtocolVersion::V1); -/// ``` +/// You are not supposed to implement this trait, therefore it is "sealed". +pub trait Decryptable: private::Sealed { + /// Returns all keys, associated with the type. + /// + /// # Example + /// + /// ``` + /// use hls_m3u8::tags::ExtXMap; + /// use hls_m3u8::types::{ByteRange, EncryptionMethod}; + /// use hls_m3u8::Decryptable; + /// + /// let map = ExtXMap::with_range("https://www.example.url/", ByteRange::from(2..11)); + /// + /// for key in map.keys() { + /// if key.method == EncryptionMethod::Aes128 { + /// // fetch content with the uri and decrypt the result + /// break; + /// } + /// } + /// ``` + #[must_use] + fn keys(&self) -> Vec<&DecryptionKey>; + + /// Most of the time only a single key is provided, so instead of iterating + /// through all keys, one might as well just get the first key. + #[must_use] + #[inline] + fn first_key(&self) -> Option<&DecryptionKey> { + ::keys(self).first().copied() + } + + /// Returns the number of keys. + #[must_use] + #[inline] + fn len(&self) -> usize { ::keys(self).len() } + + /// Returns `true`, if the number of keys is zero. + #[must_use] + #[inline] + fn is_empty(&self) -> bool { ::len(self) == 0 } +} + +#[doc(hidden)] pub trait RequiredVersion { /// Returns the protocol compatibility version that this tag requires. /// /// # Note + /// /// This is for the latest working [`ProtocolVersion`] and a client, that /// only supports an older version would break. + #[must_use] fn required_version(&self) -> ProtocolVersion; /// The protocol version, in which the tag has been introduced. + #[must_use] fn introduced_version(&self) -> ProtocolVersion { self.required_version() } } impl RequiredVersion for Vec { fn required_version(&self) -> ProtocolVersion { self.iter() - .map(|v| v.required_version()) + .map(RequiredVersion::required_version) .max() // return ProtocolVersion::V1, if the iterator is empty: .unwrap_or_default() } } +impl RequiredVersion for BTreeMap { + fn required_version(&self) -> ProtocolVersion { + self.values() + .map(RequiredVersion::required_version) + .max() + .unwrap_or_default() + } +} + impl RequiredVersion for Option { fn required_version(&self) -> ProtocolVersion { self.iter() - .map(|v| v.required_version()) + .map(RequiredVersion::required_version) .max() .unwrap_or_default() } } + +impl RequiredVersion for HashMap { + fn required_version(&self) -> ProtocolVersion { + self.values() + .map(RequiredVersion::required_version) + .max() + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_required_version_trait() { + struct Example; + + impl RequiredVersion for Example { + fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V3 } + } + + assert_eq!(Example.required_version(), ProtocolVersion::V3); + assert_eq!(Example.introduced_version(), ProtocolVersion::V3); + } +} diff --git a/src/types/byte_range.rs b/src/types/byte_range.rs index 795514d..ca1cce1 100644 --- a/src/types/byte_range.rs +++ b/src/types/byte_range.rs @@ -1,90 +1,409 @@ -use std::fmt; -use std::str::FromStr; +use core::convert::TryInto; +use core::fmt; +use core::ops::{ + Add, AddAssign, Bound, Range, RangeBounds, RangeInclusive, RangeTo, RangeToInclusive, Sub, + SubAssign, +}; +use core::str::FromStr; + +use shorthand::ShortHand; use crate::Error; -/// Byte range. +/// A range of bytes, which can be seen as either `..end` or `start..end`. /// -/// See: [4.3.2.2. EXT-X-BYTERANGE] +/// It can be constructed from `..end` and `start..end`: /// -/// [4.3.2.2. EXT-X-BYTERANGE]: https://tools.ietf.org/html/rfc8216#section-4.3.2.2 -#[derive(Copy, Hash, Eq, Ord, Debug, PartialEq, Clone, PartialOrd)] +/// ``` +/// use hls_m3u8::types::ByteRange; +/// +/// let range = ByteRange::from(10..20); +/// let range = ByteRange::from(..20); +/// ``` +#[derive(ShortHand, Copy, Hash, Eq, Ord, Debug, PartialEq, Clone, PartialOrd)] +#[shorthand(enable(must_use, copy), disable(option_as_ref, set))] pub struct ByteRange { - length: usize, + /// Returns the `start` of the [`ByteRange`], if there is one. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// assert_eq!(ByteRange::from(0..5).start(), Some(0)); + /// assert_eq!(ByteRange::from(..5).start(), None); + /// ``` start: Option, + /// Returns the `end` of the [`ByteRange`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// assert_eq!(ByteRange::from(0..5).end(), 5); + /// assert_eq!(ByteRange::from(..=5).end(), 6); + /// ``` + end: usize, } impl ByteRange { - /// Creates a new [`ByteRange`]. + /// Changes the length of the [`ByteRange`]. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::ByteRange; - /// ByteRange::new(22, Some(12)); + /// let mut range = ByteRange::from(0..5); + /// range.set_len(2); + /// + /// assert_eq!(range, ByteRange::from(0..2)); + /// + /// range.set_len(200); + /// assert_eq!(range, ByteRange::from(0..200)); /// ``` - pub const fn new(length: usize, start: Option) -> Self { Self { length, start } } + /// + /// # Note + /// + /// The `start` will not be changed. + pub fn set_len(&mut self, new_len: usize) { + // the new_len can be either greater or smaller than `self.len()`. + // if new_len is larger `checked_sub` will return `None` + if let Some(value) = self.len().checked_sub(new_len) { + self.end -= value; + } else { + self.end += new_len.saturating_sub(self.len()); + } + } - /// Returns the length of the range. + /// Sets the `start` of the [`ByteRange`]. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::ByteRange; - /// # - /// assert_eq!(ByteRange::new(20, Some(3)).length(), 20); + /// assert_eq!(ByteRange::from(0..5).set_start(Some(5)).start(), Some(5)); + /// assert_eq!(ByteRange::from(..5).set_start(Some(2)).start(), Some(2)); /// ``` - pub const fn length(&self) -> usize { self.length } + /// + /// # Panics + /// + /// This function will panic, if the `new_start` is larger, than the + /// [`end`](ByteRange::end). + pub fn set_start(&mut self, new_start: Option) -> &mut Self { + if new_start.map_or(false, |s| s > self.end) { + panic!( + "attempt to make the start ({}) larger than the end ({})", + new_start.unwrap(), + self.end + ); + } + + self.start = new_start; - /// Sets the length of the range. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::ByteRange; - /// # - /// let mut range = ByteRange::new(20, Some(3)); - /// - /// # assert_eq!(range.length(), 20); - /// range.set_length(10); - /// assert_eq!(range.length(), 10); - /// ``` - pub fn set_length(&mut self, value: usize) -> &mut Self { - self.length = value; self } - /// Returns the start of the range. + /// Adds `num` to the `start` and `end` of the range. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::ByteRange; - /// # - /// assert_eq!(ByteRange::new(20, Some(3)).start(), Some(3)); + /// let range = ByteRange::from(10..22); + /// let nrange = range.saturating_add(5); + /// + /// assert_eq!(nrange.len(), range.len()); + /// assert_eq!(nrange.start(), range.start().map(|c| c + 5)); /// ``` - pub const fn start(&self) -> Option { self.start } + /// + /// # Overflow + /// + /// If the range is saturated it will not overflow and instead stay + /// at it's current value. + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// let range = ByteRange::from(5..usize::max_value()); + /// + /// // this would cause the end to overflow + /// let nrange = range.saturating_add(1); + /// + /// // but the range remains unchanged + /// assert_eq!(range, nrange); + /// ``` + /// + /// # Note + /// + /// The length of the range will remain unchanged, + /// if the `start` is `Some`. + #[must_use] + pub fn saturating_add(mut self, num: usize) -> Self { + if let Some(start) = self.start { + // add the number to the start + if let (Some(start), Some(end)) = (start.checked_add(num), self.end.checked_add(num)) { + self.start = Some(start); + self.end = end; + } else { + // it is ensured at construction that the start will never be larger than the + // end. This clause can therefore be only reached if the end overflowed. + // -> It is only possible to add `usize::max_value() - end` to the start. + if let Some(start) = start.checked_add(usize::max_value() - self.end) { + self.start = Some(start); + self.end = usize::max_value(); + } else { + // both end + start overflowed -> do not change anything + } + } + } else { + self.end = self.end.saturating_add(num); + } - /// Sets the start of the range. + self + } + + /// Subtracts `num` from the `start` and `end` of the range. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::ByteRange; - /// # - /// let mut range = ByteRange::new(20, None); + /// let range = ByteRange::from(10..22); + /// let nrange = range.saturating_sub(5); /// - /// # assert_eq!(range.start(), None); - /// range.set_start(Some(3)); - /// assert_eq!(range.start(), Some(3)); + /// assert_eq!(nrange.len(), range.len()); + /// assert_eq!(nrange.start(), range.start().map(|c| c - 5)); /// ``` - pub fn set_start(&mut self, value: Option) -> &mut Self { - self.start = value; + /// + /// # Underflow + /// + /// If the range is saturated it will not underflow and instead stay + /// at it's current value. + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// let range = ByteRange::from(0..10); + /// + /// // this would cause the start to underflow + /// let nrange = range.saturating_sub(1); + /// + /// // but the range remains unchanged + /// assert_eq!(range, nrange); + /// ``` + /// + /// # Note + /// + /// The length of the range will remain unchanged, + /// if the `start` is `Some`. + #[must_use] + pub fn saturating_sub(mut self, num: usize) -> Self { + if let Some(start) = self.start { + // subtract the number from the start + if let (Some(start), Some(end)) = (start.checked_sub(num), self.end.checked_sub(num)) { + self.start = Some(start); + self.end = end; + } else { + // it is ensured at construction that the start will never be larger, than the + // end so this clause will only be reached, if the start underflowed. + // -> can at most subtract `start` from `end` + if let Some(end) = self.end.checked_sub(start) { + self.start = Some(0); + self.end = end; + } else { + // both end + start underflowed + // -> do not change anything + } + } + } else { + self.end = self.end.saturating_sub(num); + } + self } + + /// Returns the length, which is calculated by subtracting the `end` from + /// the `start`. If the `start` is `None` a 0 is assumed. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// let range = ByteRange::from(1..16); + /// + /// assert_eq!(range.len(), 15); + /// ``` + #[inline] + #[must_use] + pub fn len(&self) -> usize { self.end.saturating_sub(self.start.unwrap_or(0)) } + + /// Returns `true` if the length is zero. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::ByteRange; + /// let range = ByteRange::from(12..12); + /// + /// assert_eq!(range.is_empty(), true); + /// ``` + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { self.len() == 0 } +} + +impl Sub for ByteRange { + type Output = Self; + + #[must_use] + #[inline] + fn sub(self, rhs: usize) -> Self::Output { + Self { + start: self.start.map(|lhs| lhs - rhs), + end: self.end - rhs, + } + } +} + +impl SubAssign for ByteRange { + #[inline] + fn sub_assign(&mut self, other: usize) { *self = >::sub(*self, other); } +} + +impl Add for ByteRange { + type Output = Self; + + #[must_use] + #[inline] + fn add(self, rhs: usize) -> Self::Output { + Self { + start: self.start.map(|lhs| lhs + rhs), + end: self.end + rhs, + } + } +} + +impl AddAssign for ByteRange { + #[inline] + fn add_assign(&mut self, other: usize) { *self = >::add(*self, other); } +} + +macro_rules! impl_from_ranges { + ( $( $type:tt ),* ) => { + $( + #[allow(trivial_numeric_casts, clippy::fallible_impl_from)] + impl From> for ByteRange { + fn from(range: Range<$type>) -> Self { + if range.start > range.end { + panic!( + "the range start ({}) must be smaller than the end ({})", + range.start, range.end + ); + } + + Self { + start: Some(range.start as usize), + end: range.end as usize, + } + } + } + + #[allow(trivial_numeric_casts, clippy::fallible_impl_from)] + impl From> for ByteRange { + fn from(range: RangeInclusive<$type>) -> Self { + let (start, end) = range.into_inner(); + + if start > end { + panic!( + "the range start ({}) must be smaller than the end ({}+1)", + start, end + ); + } + + Self { + start: Some(start as usize), + end: (end as usize).saturating_add(1), + } + } + } + + #[allow(trivial_numeric_casts, clippy::fallible_impl_from)] + impl From> for ByteRange { + fn from(range: RangeTo<$type>) -> Self { + Self { + start: None, + end: range.end as usize, + } + } + } + + #[allow(trivial_numeric_casts, clippy::fallible_impl_from)] + impl From> for ByteRange { + fn from(range: RangeToInclusive<$type>) -> Self { + Self { + start: None, + end: (range.end as usize).saturating_add(1), + } + } + } + )* + } +} + +// TODO: replace with generics as soon as overlapping trait implementations are +// stable (`Into for usize` is reserved for upstream crates ._.) +impl_from_ranges![u64, u32, u16, u8, usize, i32]; + +#[must_use] +impl RangeBounds for ByteRange { + fn start_bound(&self) -> Bound<&usize> { + if let Some(start) = &self.start { + Bound::Included(start) + } else { + Bound::Unbounded + } + } + + #[inline] + fn end_bound(&self) -> Bound<&usize> { Bound::Excluded(&self.end) } +} + +/// This conversion will fail if the start of the [`ByteRange`] is `Some`. +impl TryInto> for ByteRange { + type Error = Error; + + fn try_into(self) -> Result, Self::Error> { + if self.start.is_some() { + return Err(Error::custom("a `RangeTo` (`..end`) does not have a start")); + } + + Ok(RangeTo { end: self.end }) + } +} + +/// This conversion will fail if the start of the [`ByteRange`] is `None`. +impl TryInto> for ByteRange { + type Error = Error; + + fn try_into(self) -> Result, Self::Error> { + if self.start.is_none() { + return Err(Error::custom( + "a `Range` (`start..end`) has to have a start.", + )); + } + + Ok(Range { + start: self.start.unwrap(), + end: self.end, + }) + } } impl fmt::Display for ByteRange { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.length)?; - if let Some(x) = self.start { - write!(f, "@{}", x)?; + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.len())?; + + if let Some(value) = self.start { + write!(f, "@{}", value)?; } + Ok(()) } } @@ -92,22 +411,23 @@ impl fmt::Display for ByteRange { impl FromStr for ByteRange { type Err = Error; - fn from_str(s: &str) -> Result { - let tokens = s.splitn(2, '@').collect::>(); - if tokens.is_empty() { - return Err(Error::invalid_input()); - } + fn from_str(input: &str) -> Result { + let mut input = input.splitn(2, '@'); - let length = tokens[0].parse()?; + let length = input.next().unwrap(); + let length = length + .parse::() + .map_err(|e| Error::parse_int(length, e))?; - let start = { - if tokens.len() == 2 { - Some(tokens[1].parse()?) - } else { - None - } - }; - Ok(Self::new(length, start)) + let start = input + .next() + .map(|v| v.parse::().map_err(|e| Error::parse_int(v, e))) + .transpose()?; + + Ok(Self { + start, + end: start.unwrap_or(0) + length, + }) } } @@ -116,51 +436,240 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + #[should_panic = "the range start (6) must be smaller than the end (0)"] + fn test_from_range_panic() { let _ = ByteRange::from(6..0); } + + #[test] + #[should_panic = "the range start (6) must be smaller than the end (0+1)"] + fn test_from_range_inclusive_panic() { let _ = ByteRange::from(6..=0); } + + #[test] + fn test_from_ranges() { + assert_eq!(ByteRange::from(1..10), ByteRange::from(1..=9)); + assert_eq!(ByteRange::from(..10), ByteRange::from(..=9)); + } + + #[test] + fn test_range_bounds() { + assert_eq!(ByteRange::from(0..10).start_bound(), Bound::Included(&0)); + assert_eq!(ByteRange::from(..10).start_bound(), Bound::Unbounded); + + assert_eq!(ByteRange::from(0..10).end_bound(), Bound::Excluded(&10)); + assert_eq!(ByteRange::from(..10).end_bound(), Bound::Excluded(&10)); + } + + #[test] + fn test_try_into() { + assert_eq!(ByteRange::from(1..4).try_into(), Ok(1..4)); + assert_eq!(ByteRange::from(..4).try_into(), Ok(..4)); + + assert!(TryInto::>::try_into(ByteRange::from(1..4)).is_err()); + assert!(TryInto::>::try_into(ByteRange::from(..4)).is_err()); + } + + #[test] + fn test_add_assign() { + let mut range = ByteRange::from(5..10); + range += 5; + + assert_eq!(range, ByteRange::from(10..15)); + } + + #[test] + #[should_panic = "attempt to add with overflow"] + fn test_add_assign_panic() { + let mut range = ByteRange::from(4..usize::max_value()); + range += 5; + + unreachable!(); + } + + #[test] + fn test_sub_assign() { + let mut range = ByteRange::from(10..20); + range -= 5; + + assert_eq!(range, ByteRange::from(5..15)); + } + + #[test] + #[should_panic = "attempt to subtract with overflow"] + fn test_sub_assign_panic() { + let mut range = ByteRange::from(4..10); + range -= 5; + + unreachable!(); + } + + #[test] + #[should_panic = "attempt to make the start (11) larger than the end (10)"] + fn test_set_start() { let _ = ByteRange::from(4..10).set_start(Some(11)); } + + #[test] + fn test_add() { + // normal addition + assert_eq!(ByteRange::from(5..10) + 5, ByteRange::from(10..15)); + assert_eq!(ByteRange::from(..10) + 5, ByteRange::from(..15)); + + // adding 0 + assert_eq!(ByteRange::from(5..10) + 0, ByteRange::from(5..10)); + assert_eq!(ByteRange::from(..10) + 0, ByteRange::from(..10)); + } + + #[test] + #[should_panic = "attempt to add with overflow"] + fn test_add_panic() { let _ = ByteRange::from(usize::max_value()..usize::max_value()) + 1; } + + #[test] + fn test_sub() { + // normal subtraction + assert_eq!(ByteRange::from(5..10) - 4, ByteRange::from(1..6)); + assert_eq!(ByteRange::from(..10) - 4, ByteRange::from(..6)); + + // subtracting 0 + assert_eq!(ByteRange::from(0..0) - 0, ByteRange::from(0..0)); + assert_eq!(ByteRange::from(2..3) - 0, ByteRange::from(2..3)); + + assert_eq!(ByteRange::from(..0) - 0, ByteRange::from(..0)); + assert_eq!(ByteRange::from(..3) - 0, ByteRange::from(..3)); + } + + #[test] + #[should_panic = "attempt to subtract with overflow"] + fn test_sub_panic() { let _ = ByteRange::from(0..0) - 1; } + + #[test] + fn test_saturating_add() { + // normal addition + assert_eq!( + ByteRange::from(5..10).saturating_add(5), + ByteRange::from(10..15) + ); + assert_eq!( + ByteRange::from(..10).saturating_add(5), + ByteRange::from(..15) + ); + + // adding 0 + assert_eq!( + ByteRange::from(6..11).saturating_add(0), + ByteRange::from(6..11) + ); + assert_eq!( + ByteRange::from(..11).saturating_add(0), + ByteRange::from(..11) + ); + + assert_eq!( + ByteRange::from(0..0).saturating_add(0), + ByteRange::from(0..0) + ); + assert_eq!(ByteRange::from(..0).saturating_add(0), ByteRange::from(..0)); + + // overflow + assert_eq!( + ByteRange::from(usize::max_value()..usize::max_value()).saturating_add(1), + ByteRange::from(usize::max_value()..usize::max_value()) + ); + assert_eq!( + ByteRange::from(..usize::max_value()).saturating_add(1), + ByteRange::from(..usize::max_value()) + ); + + assert_eq!( + ByteRange::from(usize::max_value() - 5..usize::max_value()).saturating_add(1), + ByteRange::from(usize::max_value() - 5..usize::max_value()) + ); + + // overflow, but something can be added to the range: + assert_eq!( + ByteRange::from(usize::max_value() - 5..usize::max_value() - 3).saturating_add(4), + ByteRange::from(usize::max_value() - 2..usize::max_value()) + ); + + assert_eq!( + ByteRange::from(..usize::max_value() - 3).saturating_add(4), + ByteRange::from(..usize::max_value()) + ); + } + + #[test] + fn test_saturating_sub() { + // normal subtraction + assert_eq!( + ByteRange::from(5..10).saturating_sub(4), + ByteRange::from(1..6) + ); + + // subtracting 0 + assert_eq!( + ByteRange::from(0..0).saturating_sub(0), + ByteRange::from(0..0) + ); + assert_eq!( + ByteRange::from(2..3).saturating_sub(0), + ByteRange::from(2..3) + ); + + // the start underflows + assert_eq!( + ByteRange::from(0..5).saturating_sub(4), + ByteRange::from(0..5) + ); + + // the start underflows, but one can still subtract something from it + assert_eq!( + ByteRange::from(1..5).saturating_sub(2), + ByteRange::from(0..4) + ); + + // both start and end underflow + assert_eq!( + ByteRange::from(1..3).saturating_sub(5), + ByteRange::from(0..2) + ); + + // both start + end are 0 + underflow + assert_eq!( + ByteRange::from(0..0).saturating_sub(1), + ByteRange::from(0..0) + ); + + // half open ranges: + assert_eq!(ByteRange::from(..6).saturating_sub(2), ByteRange::from(..4)); + assert_eq!(ByteRange::from(..5).saturating_sub(0), ByteRange::from(..5)); + assert_eq!(ByteRange::from(..0).saturating_sub(0), ByteRange::from(..0)); + + assert_eq!(ByteRange::from(..0).saturating_sub(1), ByteRange::from(..0)); + } + #[test] fn test_display() { - let byte_range = ByteRange { - length: 0, - start: Some(5), - }; - assert_eq!(byte_range.to_string(), "0@5".to_string()); + assert_eq!(ByteRange::from(0..5).to_string(), "5@0".to_string()); - let byte_range = ByteRange { - length: 99999, - start: Some(2), - }; - assert_eq!(byte_range.to_string(), "99999@2".to_string()); + assert_eq!( + ByteRange::from(2..100001).to_string(), + "99999@2".to_string() + ); - let byte_range = ByteRange { - length: 99999, - start: None, - }; - assert_eq!(byte_range.to_string(), "99999".to_string()); + assert_eq!(ByteRange::from(..99999).to_string(), "99999".to_string()); } #[test] fn test_parser() { + assert_eq!(ByteRange::from(2..22), "20@2".parse().unwrap()); + + assert_eq!(ByteRange::from(..300), "300".parse().unwrap()); + assert_eq!( - ByteRange { - length: 99999, - start: Some(2), - }, - "99999@2".parse::().unwrap() + ByteRange::from_str("a"), + Err(Error::parse_int("a", "a".parse::().unwrap_err())) ); assert_eq!( - ByteRange { - length: 99999, - start: Some(2), - }, - "99999@2".parse::().unwrap() - ); - - assert_eq!( - ByteRange { - length: 99999, - start: None, - }, - "99999".parse::().unwrap() + ByteRange::from_str("1@a"), + Err(Error::parse_int("a", "a".parse::().unwrap_err())) ); assert!("".parse::().is_err()); diff --git a/src/types/channels.rs b/src/types/channels.rs index effec06..c5cb994 100644 --- a/src/types/channels.rs +++ b/src/types/channels.rs @@ -1,100 +1,65 @@ use core::fmt; use core::str::FromStr; +use shorthand::ShortHand; + use crate::Error; -/// Specifies a list of parameters. +/// The maximum number of independent, simultaneous audio channels present in +/// any [`MediaSegment`] in the rendition. /// -/// # `MediaType::Audio` -/// The first parameter is a count of audio channels expressed as a [`u64`], -/// indicating the maximum number of independent, simultaneous audio channels -/// present in any [`MediaSegment`] in the rendition. For example, an -/// `AC-3 5.1` rendition would have a `CHANNELS="6"` attribute. -/// -/// # Example -/// Creating a `CHANNELS="6"` attribute -/// ``` -/// # use hls_m3u8::types::Channels; -/// let mut channels = Channels::new(6); -/// -/// assert_eq!( -/// format!("CHANNELS=\"{}\"", channels), -/// "CHANNELS=\"6\"".to_string() -/// ); -/// ``` +/// For example, an `AC-3 5.1` rendition would have a maximum channel number of +/// 6. /// /// [`MediaSegment`]: crate::MediaSegment -#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(ShortHand, Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[shorthand(enable(must_use))] pub struct Channels { - channel_number: u64, - unknown: Vec, + /// The maximum number of independent simultaneous audio channels. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Channels; + /// let mut channels = Channels::new(6); + /// # assert_eq!(channels.number(), 6); + /// + /// channels.set_number(5); + /// assert_eq!(channels.number(), 5); + /// ``` + number: u64, } impl Channels { /// Makes a new [`Channels`] struct. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::Channels; - /// let mut channels = Channels::new(6); - /// ``` - pub fn new(value: u64) -> Self { - Self { - channel_number: value, - unknown: vec![], - } - } - - /// Returns the channel number. + /// let channels = Channels::new(6); /// - /// # Example + /// println!("CHANNELS=\"{}\"", channels); + /// # assert_eq!(format!("CHANNELS=\"{}\"", channels), "CHANNELS=\"6\"".to_string()); /// ``` - /// # use hls_m3u8::types::Channels; - /// let mut channels = Channels::new(6); - /// - /// assert_eq!(channels.channel_number(), 6); - /// ``` - pub const fn channel_number(&self) -> u64 { self.channel_number } - - /// Sets the channel number. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::Channels; - /// let mut channels = Channels::new(3); - /// - /// channels.set_channel_number(6); - /// assert_eq!(channels.channel_number(), 6) - /// ``` - pub fn set_channel_number(&mut self, value: u64) -> &mut Self { - self.channel_number = value; - self - } + //#[inline] + #[must_use] + pub const fn new(number: u64) -> Self { Self { number } } } impl FromStr for Channels { type Err = Error; fn from_str(input: &str) -> Result { - let parameters = input.split('/').collect::>(); - let channel_number = parameters - .first() - .ok_or_else(|| Error::missing_attribute("First parameter of channels!"))? - .parse()?; - - Ok(Self { - channel_number, - unknown: parameters[1..].iter().map(|v| v.to_string()).collect(), - }) + Ok(Self::new( + input.parse().map_err(|e| Error::parse_int(input, e))?, + )) } } impl fmt::Display for Channels { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.channel_number)?; - if !self.unknown.is_empty() { - write!(f, "{}", self.unknown.join(","))?; - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.number)?; Ok(()) } @@ -107,17 +72,16 @@ mod tests { #[test] fn test_display() { - let mut channels = Channels::new(6); - assert_eq!(channels.to_string(), "6".to_string()); + assert_eq!(Channels::new(6).to_string(), "6".to_string()); - channels.set_channel_number(7); - assert_eq!(channels.to_string(), "7".to_string()); + assert_eq!(Channels::new(7).to_string(), "7".to_string()); } #[test] fn test_parser() { - assert_eq!("6".parse::().unwrap(), Channels::new(6)); - assert!("garbage".parse::().is_err()); - assert!("".parse::().is_err()); + assert_eq!(Channels::new(6), Channels::from_str("6").unwrap()); + + assert!(Channels::from_str("garbage").is_err()); + assert!(Channels::from_str("").is_err()); } } diff --git a/src/types/closed_captions.rs b/src/types/closed_captions.rs index 93bf543..049a665 100644 --- a/src/types/closed_captions.rs +++ b/src/types/closed_captions.rs @@ -5,19 +5,65 @@ use std::str::FromStr; use crate::utils::{quote, unquote}; /// The identifier of a closed captions group or its absence. -/// -/// See: [4.3.4.2. EXT-X-STREAM-INF] -/// -/// [4.3.4.2. EXT-X-STREAM-INF]: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 -#[allow(missing_docs)] +#[non_exhaustive] #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] pub enum ClosedCaptions { + /// It indicates the set of closed-caption renditions that can be used when + /// playing the presentation. + /// + /// The [`String`] must match [`ExtXMedia::group_id`] elsewhere in the + /// Playlist and it's [`ExtXMedia::media_type`] must be + /// [`MediaType::ClosedCaptions`]. + /// + /// [`ExtXMedia::group_id`]: crate::tags::ExtXMedia::group_id + /// [`ExtXMedia::media_type`]: crate::tags::ExtXMedia::media_type + /// [`MediaType::ClosedCaptions`]: crate::types::MediaType::ClosedCaptions GroupId(String), + /// This variant indicates that there are no closed captions in + /// any [`VariantStream`] in the [`MasterPlaylist`], therefore all + /// [`VariantStream::ExtXStreamInf`] tags must have this attribute with a + /// value of [`ClosedCaptions::None`]. + /// + /// Having [`ClosedCaptions`] in one [`VariantStream`] but not in another + /// can trigger playback inconsistencies. + /// + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`VariantStream`]: crate::tags::VariantStream + /// [`VariantStream::ExtXStreamInf`]: + /// crate::tags::VariantStream::ExtXStreamInf None, } +impl ClosedCaptions { + /// Creates a [`ClosedCaptions::GroupId`] with the provided [`String`]. + /// + /// # Example + /// + /// ``` + /// use hls_m3u8::types::ClosedCaptions; + /// + /// assert_eq!( + /// ClosedCaptions::group_id("vg1"), + /// ClosedCaptions::GroupId("vg1".into()) + /// ); + /// ``` + pub fn group_id>(value: I) -> Self { + // + Self::GroupId(value.into()) + } +} + +impl> PartialEq for ClosedCaptions { + fn eq(&self, other: &T) -> bool { + match &self { + Self::GroupId(value) => other.eq(value), + Self::None => other.eq("NONE"), + } + } +} + impl fmt::Display for ClosedCaptions { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { Self::GroupId(value) => write!(f, "{}", quote(value)), Self::None => write!(f, "NONE"), diff --git a/src/types/codecs.rs b/src/types/codecs.rs new file mode 100644 index 0000000..6b06ccf --- /dev/null +++ b/src/types/codecs.rs @@ -0,0 +1,101 @@ +use core::fmt; +use core::str::FromStr; + +use derive_more::{AsMut, AsRef, Deref, DerefMut}; + +use crate::Error; + +/// A list of formats, where each format specifies a media sample type that is +/// present in one or more renditions specified by the [`VariantStream`]. +/// +/// Valid format identifiers are those in the ISO Base Media File Format Name +/// Space defined by "The 'Codecs' and 'Profiles' Parameters for "Bucket" Media +/// Types" ([RFC6381]). +/// +/// For example, a stream containing AAC low complexity (AAC-LC) audio and H.264 +/// Main Profile Level 3.0 video would be +/// +/// ``` +/// # use hls_m3u8::types::Codecs; +/// let codecs = Codecs::from(&["mp4a.40.2", "avc1.4d401e"]); +/// ``` +/// +/// [RFC6381]: https://tools.ietf.org/html/rfc6381 +/// [`VariantStream`]: crate::tags::VariantStream +#[derive( + AsMut, AsRef, Deref, DerefMut, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, +)] +pub struct Codecs { + list: Vec, +} + +impl Codecs { + /// Makes a new (empty) [`Codecs`] struct. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Codecs; + /// let codecs = Codecs::new(); + /// ``` + #[inline] + #[must_use] + pub const fn new() -> Self { Self { list: Vec::new() } } +} + +impl fmt::Display for Codecs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(codec) = self.list.iter().next() { + write!(f, "{}", codec)?; + + for codec in self.list.iter().skip(1) { + write!(f, ",{}", codec)?; + } + } + + Ok(()) + } +} +impl FromStr for Codecs { + type Err = Error; + + fn from_str(input: &str) -> Result { + Ok(Self { + list: input.split(',').map(|s| s.into()).collect(), + }) + } +} + +impl, I: IntoIterator> From for Codecs { + fn from(value: I) -> Self { + Self { + list: value.into_iter().map(|s| s.as_ref().to_string()).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from() { + assert_eq!(Codecs::from(Vec::<&str>::new()), Codecs::new()); + } + + #[test] + fn test_display() { + assert_eq!( + Codecs::from(vec!["mp4a.40.2", "avc1.4d401e"]).to_string(), + "mp4a.40.2,avc1.4d401e".to_string() + ); + } + + #[test] + fn test_parser() { + assert_eq!( + Codecs::from_str("mp4a.40.2,avc1.4d401e").unwrap(), + Codecs::from(vec!["mp4a.40.2", "avc1.4d401e"]) + ); + } +} diff --git a/src/types/decimal_floating_point.rs b/src/types/decimal_floating_point.rs deleted file mode 100644 index 5e8c2de..0000000 --- a/src/types/decimal_floating_point.rs +++ /dev/null @@ -1,142 +0,0 @@ -use core::ops::Deref; -use core::str::FromStr; - -use derive_more::Display; - -use crate::Error; - -/// Non-negative decimal floating-point number. -/// -/// See: [4.2. Attribute Lists] -/// -/// [4.2. Attribute Lists]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-4.2 -#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd, Display)] -pub(crate) struct DecimalFloatingPoint(f64); - -impl DecimalFloatingPoint { - /// Makes a new [`DecimalFloatingPoint`] instance. - /// - /// # Errors - /// - /// The given value must have a positive sign and be finite, - /// otherwise this function will return an error that has the kind - /// `ErrorKind::InvalidInput`. - pub fn new(value: f64) -> crate::Result { - if value.is_sign_negative() || value.is_infinite() || value.is_nan() { - return Err(Error::invalid_input()); - } - Ok(Self(value)) - } - - pub(crate) const fn from_f64_unchecked(value: f64) -> Self { Self(value) } - - /// Converts [`DecimalFloatingPoint`] to [`f64`]. - pub const fn as_f64(self) -> f64 { self.0 } -} - -// this trait is implemented manually, so it doesn't construct a -// [`DecimalFloatingPoint`], with a negative value. -impl FromStr for DecimalFloatingPoint { - type Err = Error; - - fn from_str(input: &str) -> Result { Self::new(input.parse()?) } -} - -impl Deref for DecimalFloatingPoint { - type Target = f64; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl From for DecimalFloatingPoint { - fn from(value: f64) -> Self { - let mut result = value; - - // guard against the unlikely case of an infinite value... - if result.is_infinite() || result.is_nan() { - result = 0.0; - } - - Self(result.abs()) - } -} - -impl From for DecimalFloatingPoint { - fn from(value: f32) -> Self { f64::from(value).into() } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - macro_rules! test_from { - ( $($input:expr),* ) => { - use ::core::convert::From; - - #[test] - fn test_from() { - $( - assert_eq!( - DecimalFloatingPoint::from($input), - DecimalFloatingPoint::new(1.0).unwrap(), - ); - )* - } - } - } - - test_from![1_u8, 1_u16, 1_u32, 1.0_f32, -1.0_f32, 1.0_f64, -1.0_f64]; - - #[test] - pub fn test_display() { - let decimal_floating_point = DecimalFloatingPoint::new(22.0).unwrap(); - assert_eq!(decimal_floating_point.to_string(), "22".to_string()); - - let decimal_floating_point = DecimalFloatingPoint::new(4.1).unwrap(); - assert_eq!(decimal_floating_point.to_string(), "4.1".to_string()); - } - - #[test] - pub fn test_parser() { - let decimal_floating_point = DecimalFloatingPoint::new(22.0).unwrap(); - assert_eq!( - decimal_floating_point, - "22".parse::().unwrap() - ); - - let decimal_floating_point = DecimalFloatingPoint::new(4.1).unwrap(); - assert_eq!( - decimal_floating_point, - "4.1".parse::().unwrap() - ); - - assert!("1#".parse::().is_err()); - assert!("-1.0".parse::().is_err()); - } - - #[test] - fn test_new() { - assert!(DecimalFloatingPoint::new(::std::f64::INFINITY).is_err()); - assert!(DecimalFloatingPoint::new(-1.0).is_err()); - } - - #[test] - fn test_as_f64() { - assert_eq!(DecimalFloatingPoint::new(1.0).unwrap().as_f64(), 1.0); - } - - #[test] - fn test_from_inf() { - assert_eq!( - DecimalFloatingPoint::from(::std::f64::INFINITY), - DecimalFloatingPoint::new(0.0).unwrap() - ); - } - - #[test] - fn test_deref() { - assert_eq!(DecimalFloatingPoint::from(0.1).floor(), 0.0); - } -} diff --git a/src/types/decimal_resolution.rs b/src/types/decimal_resolution.rs deleted file mode 100644 index 2a4b4ae..0000000 --- a/src/types/decimal_resolution.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::str::FromStr; - -use derive_more::Display; - -use crate::Error; - -/// This is a simple wrapper type for the display resolution. (1920x1080, -/// 1280x720, ...). -/// -/// See: [4.2. Attribute Lists] -/// -/// [4.2. Attribute Lists]: https://tools.ietf.org/html/rfc8216#section-4.2 -#[derive(Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display)] -#[display(fmt = "{}x{}", width, height)] -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 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 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 - } -} - -/// [`DecimalResolution`] can be constructed from a tuple; `(width, height)`. -impl From<(usize, usize)> for DecimalResolution { - fn from(value: (usize, usize)) -> Self { Self::new(value.0, value.1) } -} - -impl FromStr for DecimalResolution { - type Err = Error; - - fn from_str(input: &str) -> Result { - let tokens = input.splitn(2, 'x').collect::>(); - - if tokens.len() != 2 { - return Err(Error::custom(format!( - "InvalidInput: Expected input format: [width]x[height] (ex. 1920x1080), got {:?}", - input, - ))); - } - - Ok(Self { - width: tokens[0].parse()?, - height: tokens[1].parse()?, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_display() { - assert_eq!( - DecimalResolution::new(1920, 1080).to_string(), - "1920x1080".to_string() - ); - - assert_eq!( - DecimalResolution::new(1280, 720).to_string(), - "1280x720".to_string() - ); - } - - #[test] - fn test_parser() { - assert_eq!( - DecimalResolution::new(1920, 1080), - "1920x1080".parse::().unwrap() - ); - - assert_eq!( - DecimalResolution::new(1280, 720), - "1280x720".parse::().unwrap() - ); - - assert!("1280".parse::().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 - ); - } - - #[test] - fn test_from() { - assert_eq!( - DecimalResolution::from((1920, 1080)), - DecimalResolution::new(1920, 1080) - ); - } -} diff --git a/src/types/decryption_key.rs b/src/types/decryption_key.rs index 77d85b6..c1cfbac 100644 --- a/src/types/decryption_key.rs +++ b/src/types/decryption_key.rs @@ -2,6 +2,7 @@ use std::fmt; use std::str::FromStr; use derive_builder::Builder; +use shorthand::ShortHand; use crate::attribute::AttributePairs; use crate::types::{ @@ -10,270 +11,131 @@ use crate::types::{ use crate::utils::{quote, unquote}; use crate::{Error, RequiredVersion}; -#[derive(Builder, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +/// Specifies how to decrypt encrypted data from the server. +#[derive(ShortHand, Builder, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] #[builder(setter(into), build_fn(validate = "Self::validate"))] -/// [`DecryptionKey`] contains data, that is shared between [`ExtXSessionKey`] -/// and [`ExtXKey`]. -/// -/// [`ExtXSessionKey`]: crate::tags::ExtXSessionKey -/// [`ExtXKey`]: crate::tags::ExtXKey +#[shorthand(enable(skip, must_use, into))] +#[non_exhaustive] pub struct DecryptionKey { - /// The [EncryptionMethod]. - pub(crate) method: EncryptionMethod, + /// The encryption method, which has been used to encrypt the data. + /// + /// An [`EncryptionMethod::Aes128`] signals that the data is encrypted using + /// the Advanced Encryption Standard (AES) with a 128-bit key, Cipher Block + /// Chaining (CBC), and Public-Key Cryptography Standards #7 (PKCS7) + /// padding. CBC is restarted on each segment boundary, using either the + /// [`DecryptionKey::iv`] field or the [`MediaSegment::number`] as the IV. + /// + /// An [`EncryptionMethod::SampleAes`] means that the [`MediaSegment`]s + /// contain media samples, such as audio or video, that are encrypted using + /// the Advanced Encryption Standard (Aes128). How these media streams are + /// encrypted and encapsulated in a segment depends on the media encoding + /// and the media format of the segment. + /// + /// ## Note + /// + /// This field is required. + /// + /// [`MediaSegment::number`]: crate::MediaSegment::number + /// [`MediaSegment`]: crate::MediaSegment + pub method: EncryptionMethod, + /// This uri points to a key file, which contains the cipher key. + /// + /// ## Note + /// + /// This field is required. #[builder(setter(into, strip_option), default)] - /// An `URI`, that specifies how to obtain the key. - pub(crate) uri: Option, + #[shorthand(disable(skip))] + pub(crate) uri: String, + /// An initialization vector (IV) is a fixed size input that can be used + /// along with a secret key for data encryption. + /// + /// ## Note + /// + /// This field is optional and an absent value indicates that + /// [`MediaSegment::number`] should be used instead. + /// + /// [`MediaSegment::number`]: crate::MediaSegment::number #[builder(setter(into, strip_option), default)] - /// The IV (Initialization Vector) attribute. - pub(crate) iv: Option, + pub iv: InitializationVector, + /// A server may offer multiple ways to retrieve a key by providing multiple + /// [`DecryptionKey`]s with different [`KeyFormat`] values. + /// + /// An [`EncryptionMethod::Aes128`] uses 16-octet (16 byte/128 bit) keys. If + /// the format is [`KeyFormat::Identity`], the key file is a single packed + /// array of 16 octets (16 byte/128 bit) in binary format. + /// + /// ## Note + /// + /// This field is optional. #[builder(setter(into, strip_option), default)] - /// A string that specifies how the key is - /// represented in the resource identified by the `URI`. - pub(crate) key_format: Option, + pub format: Option, + /// A list of numbers that can be used to indicate which version(s) + /// this instance complies with, if more than one version of a particular + /// [`KeyFormat`] is defined. + /// + /// ## Note + /// + /// This field is optional. #[builder(setter(into, strip_option), default)] - /// The [KeyFormatVersions] attribute. - pub(crate) key_format_versions: Option, -} - -impl DecryptionKeyBuilder { - fn validate(&self) -> Result<(), String> { - if self.method != Some(EncryptionMethod::None) && self.uri.is_none() { - return Err(Error::custom("Missing URL").to_string()); - } - Ok(()) - } + pub versions: Option, } impl DecryptionKey { - /// Makes a new [`DecryptionKey`]. + /// Creates a new `DecryptionKey` from an uri pointing to the key data and + /// an `EncryptionMethod`. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::DecryptionKey; /// use hls_m3u8::types::EncryptionMethod; /// - /// let key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); + /// let key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.uri/key"); /// ``` - pub fn new(method: EncryptionMethod, uri: T) -> Self { + #[must_use] + #[inline] + pub fn new>(method: EncryptionMethod, uri: I) -> Self { Self { method, - uri: Some(uri.to_string()), - iv: None, - key_format: None, - key_format_versions: None, + uri: uri.into(), + iv: InitializationVector::default(), + format: None, + versions: None, } } - /// Returns the [`EncryptionMethod`]. + /// Returns a builder for a `DecryptionKey`. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; + /// use hls_m3u8::types::{EncryptionMethod, KeyFormat}; /// - /// let key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// assert_eq!(key.method(), EncryptionMethod::Aes128); + /// let key = DecryptionKey::builder() + /// .method(EncryptionMethod::Aes128) + /// .uri("https://www.example.com/") + /// .iv([ + /// 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, + /// ]) + /// .format(KeyFormat::Identity) + /// .versions(&[1, 2, 3, 4, 5]) + /// .build()?; + /// # Ok::<(), String>(()) /// ``` - pub const fn method(&self) -> EncryptionMethod { self.method } - - /// Returns a Builder to build a [DecryptionKey]. + #[must_use] + #[inline] pub fn builder() -> DecryptionKeyBuilder { DecryptionKeyBuilder::default() } - - /// Sets the [`EncryptionMethod`]. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_method(EncryptionMethod::SampleAes); - /// - /// assert_eq!( - /// key.to_string(), - /// "METHOD=SAMPLE-AES,URI=\"https://www.example.com/\"".to_string() - /// ); - /// ``` - pub fn set_method(&mut self, value: EncryptionMethod) -> &mut Self { - self.method = value; - self - } - - /// Returns an `URI`, that specifies how to obtain the key. - /// - /// # Note - /// This attribute is required, if the [EncryptionMethod] is not `None`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// assert_eq!(key.uri(), &Some("https://www.example.com/".to_string())); - /// ``` - pub const fn uri(&self) -> &Option { &self.uri } - - /// Sets the `URI` attribute. - /// - /// # Note - /// This attribute is required, if the [`EncryptionMethod`] is not `None`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_uri(Some("http://www.google.com/")); - /// - /// assert_eq!( - /// key.to_string(), - /// "METHOD=AES-128,URI=\"http://www.google.com/\"".to_string() - /// ); - /// ``` - pub fn set_uri(&mut self, value: Option) -> &mut Self { - self.uri = value.map(|v| v.to_string()); - self - } - - /// Returns the IV (Initialization Vector) attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// # assert_eq!(key.iv(), None); - /// key.set_iv(Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7])); - /// - /// assert_eq!( - /// key.iv(), - /// Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7]) - /// ); - /// ``` - pub fn iv(&self) -> Option<[u8; 16]> { - if let Some(iv) = &self.iv { - Some(iv.to_slice()) - } else { - None - } - } - - /// Sets the `IV` attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_iv(Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7])); - /// - /// assert_eq!( - /// key.to_string(), - /// "METHOD=AES-128,URI=\"https://www.example.com/\",IV=0x01020304050607080901020304050607" - /// .to_string() - /// ); - /// ``` - pub fn set_iv(&mut self, value: Option) -> &mut Self - where - T: Into<[u8; 16]>, - { - self.iv = value.map(|v| InitializationVector(v.into())); - self - } - - /// Returns a string that specifies how the key is - /// represented in the resource identified by the `URI`. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::{EncryptionMethod, KeyFormat}; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_key_format(Some(KeyFormat::Identity)); - /// - /// assert_eq!(key.key_format(), Some(KeyFormat::Identity)); - /// ``` - pub const fn key_format(&self) -> Option { self.key_format } - - /// Sets the [`KeyFormat`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::{EncryptionMethod, KeyFormat}; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_key_format(Some(KeyFormat::Identity)); - /// - /// assert_eq!(key.key_format(), Some(KeyFormat::Identity)); - /// ``` - pub fn set_key_format>(&mut self, value: Option) -> &mut Self { - self.key_format = value.map(Into::into); - self - } - - /// Returns the [`KeyFormatVersions`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::{EncryptionMethod, KeyFormatVersions}; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_key_format_versions(Some(vec![1, 2, 3, 4, 5])); - /// - /// assert_eq!( - /// key.key_format_versions(), - /// &Some(KeyFormatVersions::from(vec![1, 2, 3, 4, 5])) - /// ); - /// ``` - pub const fn key_format_versions(&self) -> &Option { - &self.key_format_versions - } - - /// Sets the [`KeyFormatVersions`] attribute. - /// - /// # Example - /// ``` - /// # use hls_m3u8::types::DecryptionKey; - /// use hls_m3u8::types::EncryptionMethod; - /// - /// let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); - /// - /// key.set_key_format_versions(Some(vec![1, 2, 3, 4, 5])); - /// - /// assert_eq!( - /// key.to_string(), - /// "METHOD=AES-128,URI=\"https://www.example.com/\",KEYFORMATVERSIONS=\"1/2/3/4/5\"" - /// .to_string() - /// ); - /// ``` - pub fn set_key_format_versions>( - &mut self, - value: Option, - ) -> &mut Self { - self.key_format_versions = value.map(Into::into); - self - } } +/// This tag requires [`ProtocolVersion::V5`], if [`KeyFormat`] or +/// [`KeyFormatVersions`] is specified and [`ProtocolVersion::V2`] if an iv is +/// specified. +/// +/// Otherwise [`ProtocolVersion::V1`] is required. impl RequiredVersion for DecryptionKey { fn required_version(&self) -> ProtocolVersion { - if self.key_format.is_some() || self.key_format_versions.is_some() { + if self.format.is_some() || self.versions.is_some() { ProtocolVersion::V5 } else if self.iv.is_some() { ProtocolVersion::V2 @@ -290,16 +152,22 @@ impl FromStr for DecryptionKey { let mut method = None; let mut uri = None; let mut iv = None; - let mut key_format = None; - let mut key_format_versions = None; + let mut format = None; + let mut versions = None; - for (key, value) in input.parse::()? { - match key.as_str() { - "METHOD" => method = Some(value.parse()?), - "URI" => uri = Some(unquote(value)), + for (key, value) in AttributePairs::new(input) { + match key { + "METHOD" => method = Some(value.parse().map_err(Error::strum)?), + "URI" => { + let unquoted_uri = unquote(value); + + if !unquoted_uri.trim().is_empty() { + uri = Some(unquoted_uri); + } + } "IV" => iv = Some(value.parse()?), - "KEYFORMAT" => key_format = Some(value.parse()?), - "KEYFORMATVERSIONS" => key_format_versions = Some(value.parse()?), + "KEYFORMAT" => format = Some(value.parse()?), + "KEYFORMATVERSIONS" => versions = Some(value.parse()?), _ => { // [6.3.1. General Client Responsibilities] // > ignore any attribute/value pair with an unrecognized @@ -309,42 +177,50 @@ impl FromStr for DecryptionKey { } let method = method.ok_or_else(|| Error::missing_value("METHOD"))?; - if method != EncryptionMethod::None && uri.is_none() { - return Err(Error::missing_value("URI")); - } + let uri = uri.ok_or_else(|| Error::missing_value("URI"))?; + let iv = iv.unwrap_or_default(); Ok(Self { method, uri, iv, - key_format, - key_format_versions, + format, + versions, }) } } impl fmt::Display for DecryptionKey { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "METHOD={}", self.method)?; + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "METHOD={},URI={}", self.method, quote(&self.uri))?; - if self.method == EncryptionMethod::None { - return Ok(()); + if let InitializationVector::Aes128(_) = &self.iv { + write!(f, ",IV={}", &self.iv)?; } - if let Some(uri) = &self.uri { - write!(f, ",URI={}", quote(uri))?; - } - if let Some(value) = &self.iv { - write!(f, ",IV={}", value)?; - } - if let Some(value) = &self.key_format { + + if let Some(value) = &self.format { write!(f, ",KEYFORMAT={}", quote(value))?; } - if let Some(key_format_versions) = &self.key_format_versions { - if !key_format_versions.is_default() { - write!(f, ",KEYFORMATVERSIONS={}", key_format_versions)?; + if let Some(value) = &self.versions { + if !value.is_default() { + write!(f, ",KEYFORMATVERSIONS={}", value)?; } } + + Ok(()) + } +} + +impl DecryptionKeyBuilder { + fn validate(&self) -> Result<(), String> { + // a decryption key must contain a uri and a method + if self.method.is_none() { + return Err(Error::missing_field("DecryptionKey", "method").to_string()); + } else if self.uri.is_none() { + return Err(Error::missing_field("DecryptionKey", "uri").to_string()); + } + Ok(()) } } @@ -352,31 +228,58 @@ impl fmt::Display for DecryptionKey { #[cfg(test)] mod test { use super::*; - use crate::types::EncryptionMethod; + use crate::types::{EncryptionMethod, KeyFormat}; use pretty_assertions::assert_eq; + macro_rules! generate_tests { + ( $( { $struct:expr, $str:expr } ),+ $(,)* ) => { + #[test] + fn test_display() { + $( + assert_eq!($struct.to_string(), $str.to_string()); + )+ + } + + #[test] + fn test_parser() { + $( + assert_eq!($struct, $str.parse().unwrap()); + )+ + + assert_eq!( + DecryptionKey::new(EncryptionMethod::Aes128, "http://www.example.com"), + concat!( + "METHOD=AES-128,", + "URI=\"http://www.example.com\",", + "UNKNOWNTAG=abcd" + ).parse().unwrap(), + ); + assert!("METHOD=AES-128,URI=".parse::().is_err()); + assert!("garbage".parse::().is_err()); + } + } + } + #[test] fn test_builder() { - let key = DecryptionKey::builder() - .method(EncryptionMethod::Aes128) - .uri("https://www.example.com/") - .iv([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ]) - .key_format(KeyFormat::Identity) - .key_format_versions(vec![1, 2, 3, 4, 5]) - .build() - .unwrap(); + let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "https://www.example.com/"); + key.iv = [ + 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, + ] + .into(); + key.format = Some(KeyFormat::Identity); + key.versions = Some(vec![1, 2, 3, 4, 5].into()); assert_eq!( - key.to_string(), - "METHOD=AES-128,\ - URI=\"https://www.example.com/\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52,\ - KEYFORMAT=\"identity\",\ - KEYFORMATVERSIONS=\"1/2/3/4/5\"\ - " - .to_string() + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/") + .iv([16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82]) + .format(KeyFormat::Identity) + .versions(vec![1, 2, 3, 4, 5]) + .build() + .unwrap(), + key ); assert!(DecryptionKey::builder().build().is_err()); @@ -386,93 +289,47 @@ mod test { .is_err()); } - #[test] - fn test_display() { - let mut key = DecryptionKey::new( - EncryptionMethod::Aes128, - "https://www.example.com/hls-key/key.bin", - ); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); - - assert_eq!( - key.to_string(), - "METHOD=AES-128,\ - URI=\"https://www.example.com/hls-key/key.bin\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52" - .to_string() - ); - } - - #[test] - fn test_parser() { - assert_eq!( - "METHOD=AES-128,\ - URI=\"https://priv.example.com/key.php?r=52\"" - .parse::() - .unwrap(), + generate_tests! { + { DecryptionKey::new( EncryptionMethod::Aes128, "https://priv.example.com/key.php?r=52" + ), + concat!( + "METHOD=AES-128,", + "URI=\"https://priv.example.com/key.php?r=52\"" ) - ); - - let mut key = DecryptionKey::new( - EncryptionMethod::Aes128, - "https://www.example.com/hls-key/key.bin", - ); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); - - assert_eq!( - "METHOD=AES-128,\ - URI=\"https://www.example.com/hls-key/key.bin\",\ - IV=0X10ef8f758ca555115584bb5b3c687f52" - .parse::() + }, + { + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82]) + .build() .unwrap(), - key - ); - - let mut key = DecryptionKey::new(EncryptionMethod::Aes128, "http://www.example.com"); - key.set_iv(Some([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82, - ])); - key.set_key_format(Some(KeyFormat::Identity)); - - assert_eq!( - "METHOD=AES-128,\ - URI=\"http://www.example.com\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52,\ - KEYFORMAT=\"identity\"" - .parse::() + concat!( + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52" + ) + }, + { + DecryptionKey::builder() + .method(EncryptionMethod::Aes128) + .uri("https://www.example.com/hls-key/key.bin") + .iv([16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82]) + .format(KeyFormat::Identity) + .versions(vec![1, 2, 3]) + .build() .unwrap(), - key - ); - - key.set_key_format_versions(Some(vec![1, 2, 3])); - assert_eq!( - "METHOD=AES-128,\ - URI=\"http://www.example.com\",\ - IV=0x10ef8f758ca555115584bb5b3c687f52,\ - KEYFORMAT=\"identity\",\ - KEYFORMATVERSIONS=\"1/2/3\"" - .parse::() - .unwrap(), - key - ); - - assert_eq!( - "METHOD=AES-128,\ - URI=\"http://www.example.com\",\ - UNKNOWNTAG=abcd" - .parse::() - .unwrap(), - DecryptionKey::new(EncryptionMethod::Aes128, "http://www.example.com") - ); - assert!("METHOD=AES-128,URI=".parse::().is_err()); - assert!("garbage".parse::().is_err()); + concat!( + "METHOD=AES-128,", + "URI=\"https://www.example.com/hls-key/key.bin\",", + "IV=0x10ef8f758ca555115584bb5b3c687f52,", + "KEYFORMAT=\"identity\",", + "KEYFORMATVERSIONS=\"1/2/3\"" + ) + }, } #[test] @@ -487,8 +344,8 @@ mod test { DecryptionKey::builder() .method(EncryptionMethod::Aes128) .uri("https://www.example.com/") - .key_format(KeyFormat::Identity) - .key_format_versions(vec![1, 2, 3]) + .format(KeyFormat::Identity) + .versions(vec![1, 2, 3]) .build() .unwrap() .required_version(), diff --git a/src/types/encryption_method.rs b/src/types/encryption_method.rs index a555009..cedb5d1 100644 --- a/src/types/encryption_method.rs +++ b/src/types/encryption_method.rs @@ -1,49 +1,51 @@ use strum::{Display, EnumString}; -/// Encryption method. -/// -/// See: [4.3.2.4. EXT-X-KEY] -/// -/// [4.3.2.4. EXT-X-KEY]: https://tools.ietf.org/html/rfc8216#section-4.3.2.4 +/// The encryption method. +#[non_exhaustive] #[allow(missing_docs)] #[derive(Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] #[strum(serialize_all = "SCREAMING-KEBAB-CASE")] pub enum EncryptionMethod { - /// `None` means that [MediaSegment]s are not encrypted. + /// The [`MediaSegment`]s are completely encrypted using the Advanced + /// Encryption Standard ([AES-128]) with a 128-bit key, Cipher Block + /// Chaining (CBC), and [Public-Key Cryptography Standards #7 (PKCS7)] + /// padding. /// - /// [MediaSegment]: crate::MediaSegment - None, - /// `Aes128` signals that the [MediaSegment]s are completely encrypted - /// using the Advanced Encryption Standard ([AES_128]) with a 128-bit - /// key, Cipher Block Chaining (CBC), and - /// [Public-Key Cryptography Standards #7 (PKCS7)] padding. /// CBC is restarted on each segment boundary, using either the - /// Initialization Vector (IV) attribute value or the Media Sequence - /// Number as the IV. + /// Initialization Vector (IV) or the Media Sequence Number as the IV /// - /// [MediaSegment]: crate::MediaSegment - /// [AES_128]: http://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf + /// ``` + /// # let media_sequence_number = 5; + /// # assert_eq!( + /// format!("0x{:032x}", media_sequence_number) + /// # , "0x00000000000000000000000000000005".to_string()); + /// ``` + /// + /// [`MediaSegment`]: crate::MediaSegment + /// [AES-128]: http://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf /// [Public-Key Cryptography Standards #7 (PKCS7)]: https://tools.ietf.org/html/rfc5652 #[strum(serialize = "AES-128")] Aes128, - /// `SampleAes` means that the [MediaSegment]s - /// contain media samples, such as audio or video, that are encrypted - /// using the Advanced Encryption Standard ([AES_128]). How these media - /// streams are encrypted and encapsulated in a segment depends on the - /// media encoding and the media format of the segment. fMP4 Media - /// Segments are encrypted using the 'cbcs' scheme of - /// [Common Encryption]. Encryption of other Media Segment - /// formats containing [H.264], [AAC], [AC-3], - /// and Enhanced [AC-3] media streams is described in the HTTP - /// Live Streaming (HLS) [SampleEncryption specification]. + /// The [`MediaSegment`]s contain media samples, such as audio or video, + /// that are encrypted using the Advanced Encryption Standard ([`AES-128`]). /// - /// [MediaSegment]: crate::MediaSegment - /// [AES_128]: http://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf + /// How these media streams are encrypted and encapsulated in a segment + /// depends on the media encoding and the media format of the segment. + /// + /// `fMP4` [`MediaSegment`]s are encrypted using the `cbcs` scheme of + /// [Common Encryption]. + /// Encryption of other [`MediaSegment`] formats containing [H.264], [AAC], + /// [AC-3], and Enhanced [AC-3] media streams is described in the + /// [HTTP Live Streaming (HLS) SampleEncryption specification]. + /// + /// [`MediaSegment`]: crate::MediaSegment + /// [`AES-128`]: http://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf /// [Common Encryption]: https://tools.ietf.org/html/rfc8216#ref-COMMON_ENC /// [H.264]: https://tools.ietf.org/html/rfc8216#ref-H_264 /// [AAC]: https://tools.ietf.org/html/rfc8216#ref-ISO_14496 /// [AC-3]: https://tools.ietf.org/html/rfc8216#ref-AC_3 - /// [SampleEncryption specification]: https://tools.ietf.org/html/rfc8216#ref-SampleEnc + /// [HTTP Live Streaming (HLS) SampleEncryption specification]: + /// https://tools.ietf.org/html/rfc8216#ref-SampleEnc SampleAes, } @@ -59,7 +61,6 @@ mod tests { EncryptionMethod::SampleAes.to_string(), "SAMPLE-AES".to_string() ); - assert_eq!(EncryptionMethod::None.to_string(), "NONE".to_string()); } #[test] @@ -74,11 +75,6 @@ mod tests { "SAMPLE-AES".parse::().unwrap() ); - assert_eq!( - EncryptionMethod::None, - "NONE".parse::().unwrap() - ); - assert!("unknown".parse::().is_err()); } } diff --git a/src/types/float.rs b/src/types/float.rs new file mode 100644 index 0000000..7e2a8d9 --- /dev/null +++ b/src/types/float.rs @@ -0,0 +1,312 @@ +use core::cmp::Ordering; +use core::convert::TryFrom; +use core::str::FromStr; + +use derive_more::{AsRef, Deref, Display}; + +use crate::Error; + +/// A wrapper type around an [`f32`] that can not be constructed +/// with [`NaN`], [`INFINITY`] or [`NEG_INFINITY`]. +/// +/// [`NaN`]: core::f32::NAN +/// [`INFINITY`]: core::f32::INFINITY +/// [`NEG_INFINITY`]: core::f32::NEG_INFINITY +#[derive(AsRef, Deref, Default, Debug, Copy, Clone, Display, PartialOrd)] +pub struct Float(f32); + +impl Float { + /// Makes a new [`Float`] from an [`f32`]. + /// + /// # Panics + /// + /// If the given float is infinite or [`NaN`]. + /// + /// # Examples + /// + /// ``` + /// # use hls_m3u8::types::Float; + /// let float = Float::new(1.0); + /// ``` + /// + /// This would panic: + /// + /// ```should_panic + /// # use hls_m3u8::types::Float; + /// use core::f32::NAN; + /// + /// let float = Float::new(NAN); + /// ``` + /// + /// [`NaN`]: core::f32::NAN + #[must_use] + pub fn new(float: f32) -> Self { + if float.is_infinite() { + panic!("float must be finite: `{}`", float); + } + + if float.is_nan() { + panic!("float must not be `NaN`"); + } + + Self(float) + } + + /// Returns the underlying [`f32`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Float; + /// assert_eq!(Float::new(1.1_f32).as_f32(), 1.1_f32); + /// ``` + #[must_use] + pub const fn as_f32(self) -> f32 { self.0 } +} + +impl FromStr for Float { + type Err = Error; + + fn from_str(input: &str) -> Result { + let float = f32::from_str(input).map_err(|e| Error::parse_float(input, e))?; + Self::try_from(float) + } +} + +impl TryFrom for Float { + type Error = Error; + + fn try_from(float: f32) -> Result { + if float.is_infinite() { + return Err(Error::custom(format!("float must be finite: `{}`", float))); + } + + if float.is_nan() { + return Err(Error::custom("float must not be `NaN`")); + } + + Ok(Self(float)) + } +} + +macro_rules! implement_from { + ( $( $type:tt ),+ ) => { + $( + impl ::core::convert::From<$type> for Float { + fn from(value: $type) -> Self { + Self(value as f32) + } + } + )+ + } +} + +implement_from!(i16, u16, i8, u8); + +impl PartialEq for Float { + #[inline] + fn eq(&self, other: &Self) -> bool { self.0 == other.0 } +} + +// convenience implementation to compare f32 with a Float. +impl PartialEq for Float { + #[inline] + fn eq(&self, other: &f32) -> bool { &self.0 == other } +} + +// In order to implement `Eq` a struct has to satisfy +// the following requirements: +// - reflexive: a == a; +// - symmetric: a == b implies b == a; and +// - transitive: a == b and b == c implies a == c. +// +// The symmetric and transitive parts are already satisfied +// through `PartialEq`. The reflexive part is not satisfied for f32, +// because `f32::NAN` never equals `f32::NAN`. (`assert!(f32::NAN, f32::NAN)`) +// +// It is ensured, that this struct can not be constructed +// with NaN so all of the above requirements are satisfied and therefore Eq can +// be soundly implemented. +impl Eq for Float {} + +impl Ord for Float { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + if self.0 < other.0 { + Ordering::Less + } else if self == other { + Ordering::Equal + } else { + Ordering::Greater + } + } +} + +/// The output of Hash cannot be relied upon to be stable. The same version of +/// rust can return different values in different architectures. This is not a +/// property of the Hasher that you’re using but instead of the way Hash happens +/// to be implemented for the type you’re using (e.g., the current +/// implementation of Hash for slices of integers returns different values in +/// big and little-endian architectures). +/// +/// See +#[doc(hidden)] +impl ::core::hash::Hash for Float { + fn hash(&self, state: &mut H) + where + H: ::core::hash::Hasher, + { + // this implementation assumes, that the internal float is: + // - not NaN + // - neither negative nor positive infinity + + // to validate those assumptions debug_assertions are here + // (those will be removed in a release build) + debug_assert!(self.0.is_finite()); + debug_assert!(!self.0.is_nan()); + + // this implementation is based on + // https://internals.rust-lang.org/t/f32-f64-should-implement-hash/5436/33 + // + // The important points are: + // - NaN == NaN (Float does not allow NaN, so this should be satisfied) + // - +0 == -0 + + if self.0 == 0.0 || self.0 == -0.0 { + state.write(&0.0_f32.to_be_bytes()); + } else { + // I do not think it matters to differentiate between architectures, that use + // big endian by default and those, that use little endian. + state.write(&self.to_be_bytes()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::hash::{Hash, Hasher}; + use pretty_assertions::assert_eq; + + #[test] + fn test_ord() { + assert_eq!(Float::new(1.1).cmp(&Float::new(1.1)), Ordering::Equal); + assert_eq!(Float::new(1.1).cmp(&Float::new(2.1)), Ordering::Less); + assert_eq!(Float::new(1.1).cmp(&Float::new(0.1)), Ordering::Greater); + } + + #[test] + fn test_partial_ord() { + assert_eq!( + Float::new(1.1).partial_cmp(&Float::new(1.1)), + Some(Ordering::Equal) + ); + assert_eq!( + Float::new(1.1).partial_cmp(&Float::new(2.1)), + Some(Ordering::Less) + ); + assert_eq!( + Float::new(1.1).partial_cmp(&Float::new(0.1)), + Some(Ordering::Greater) + ); + } + + #[test] + fn test_hash() { + let mut hasher_left = std::collections::hash_map::DefaultHasher::new(); + let mut hasher_right = std::collections::hash_map::DefaultHasher::new(); + + assert_eq!( + Float::new(0.0).hash(&mut hasher_left), + Float::new(-0.0).hash(&mut hasher_right) + ); + + assert_eq!(hasher_left.finish(), hasher_right.finish()); + + let mut hasher_left = std::collections::hash_map::DefaultHasher::new(); + let mut hasher_right = std::collections::hash_map::DefaultHasher::new(); + + assert_eq!( + Float::new(1.0).hash(&mut hasher_left), + Float::new(1.0).hash(&mut hasher_right) + ); + + assert_eq!(hasher_left.finish(), hasher_right.finish()); + } + + #[test] + fn test_eq() { + struct _AssertEq + where + Float: Eq; + } + + #[test] + fn test_partial_eq() { + assert_eq!(Float::new(1.0).eq(&Float::new(1.0)), true); + assert_eq!(Float::new(1.0).eq(&Float::new(33.3)), false); + assert_eq!(Float::new(1.1), 1.1); + } + + #[test] + fn test_display() { + assert_eq!(Float::new(22.0).to_string(), "22".to_string()); + assert_eq!( + Float::new(3.14159265359).to_string(), + "3.1415927".to_string() + ); + assert_eq!( + Float::new(-3.14159265359).to_string(), + "-3.1415927".to_string() + ); + } + + #[test] + fn test_parser() { + assert_eq!(Float::new(22.0), Float::from_str("22").unwrap()); + assert_eq!(Float::new(-22.0), Float::from_str("-22").unwrap()); + assert_eq!( + Float::new(3.14159265359), + Float::from_str("3.14159265359").unwrap() + ); + assert!(Float::from_str("1#").is_err()); + assert!(Float::from_str("NaN").is_err()); + assert!(Float::from_str("inf").is_err()); + assert!(Float::from_str("-inf").is_err()); + } + + #[test] + #[should_panic = "float must be finite: `inf`"] + fn test_new_infinite() { let _ = Float::new(::core::f32::INFINITY); } + + #[test] + #[should_panic = "float must be finite: `-inf`"] + fn test_new_neg_infinite() { let _ = Float::new(::core::f32::NEG_INFINITY); } + + #[test] + #[should_panic = "float must not be `NaN`"] + fn test_new_nan() { let _ = Float::new(::core::f32::NAN); } + + #[test] + fn test_as_f32() { + assert_eq!(Float::new(1.1).as_f32(), 1.1_f32); + } + + #[test] + fn test_from() { + assert_eq!(Float::from(-1_i8), Float::new(-1.0)); + assert_eq!(Float::from(1_u8), Float::new(1.0)); + assert_eq!(Float::from(-1_i16), Float::new(-1.0)); + assert_eq!(Float::from(1_u16), Float::new(1.0)); + } + + #[test] + fn test_try_from() { + assert_eq!(Float::try_from(1.1_f32).unwrap(), Float::new(1.1)); + assert_eq!(Float::try_from(-1.1_f32).unwrap(), Float::new(-1.1)); + + assert!(Float::try_from(::core::f32::INFINITY).is_err()); + assert!(Float::try_from(::core::f32::NAN).is_err()); + assert!(Float::try_from(::core::f32::NEG_INFINITY).is_err()); + } +} diff --git a/src/types/hdcp_level.rs b/src/types/hdcp_level.rs index 1d03e2a..90ea014 100644 --- a/src/types/hdcp_level.rs +++ b/src/types/hdcp_level.rs @@ -1,16 +1,22 @@ use strum::{Display, EnumString}; -/// HDCP level. +/// HDCP ([`High-bandwidth Digital Content Protection`]) level. /// -/// See: [4.3.4.2. EXT-X-STREAM-INF] -/// -/// [4.3.4.2. EXT-X-STREAM-INF]: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 -#[allow(missing_docs)] +/// [`High-bandwidth Digital Content Protection`]: +/// https://www.digital-cp.com/sites/default/files/specifications/HDCP%20on%20HDMI%20Specification%20Rev2_2_Final1.pdf +#[non_exhaustive] #[derive(Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] #[strum(serialize_all = "SCREAMING-KEBAB-CASE")] pub enum HdcpLevel { + /// The associated [`VariantStream`] could fail to play unless the output is + /// protected by High-bandwidth Digital Content Protection ([`HDCP`]) Type 0 + /// or equivalent. + /// + /// [`VariantStream`]: crate::tags::VariantStream + /// [`HDCP`]: https://www.digital-cp.com/sites/default/files/specifications/HDCP%20on%20HDMI%20Specification%20Rev2_2_Final1.pdf #[strum(serialize = "TYPE-0")] Type0, + /// The content does not require output copy protection. None, } @@ -21,20 +27,14 @@ mod tests { #[test] fn test_display() { - let level = HdcpLevel::Type0; - assert_eq!(level.to_string(), "TYPE-0".to_string()); - - let level = HdcpLevel::None; - assert_eq!(level.to_string(), "NONE".to_string()); + assert_eq!(HdcpLevel::Type0.to_string(), "TYPE-0".to_string()); + assert_eq!(HdcpLevel::None.to_string(), "NONE".to_string()); } #[test] fn test_parser() { - let level = HdcpLevel::Type0; - assert_eq!(level, "TYPE-0".parse::().unwrap()); - - let level = HdcpLevel::None; - assert_eq!(level, "NONE".parse::().unwrap()); + assert_eq!(HdcpLevel::Type0, "TYPE-0".parse().unwrap()); + assert_eq!(HdcpLevel::None, "NONE".parse().unwrap()); assert!("unk".parse::().is_err()); } diff --git a/src/types/in_stream_id.rs b/src/types/in_stream_id.rs index b39ce5c..ee35989 100644 --- a/src/types/in_stream_id.rs +++ b/src/types/in_stream_id.rs @@ -1,13 +1,26 @@ use strum::{Display, EnumString}; -/// Identifier of a rendition within the segments in a media playlist. +use crate::traits::RequiredVersion; +use crate::types::ProtocolVersion; + +/// Identifier of a rendition within the [`MediaSegment`]s in a +/// [`MediaPlaylist`]. /// -/// See: [4.3.4.1. EXT-X-MEDIA] +/// The variants [`InStreamId::Cc1`], [`InStreamId::Cc2`], [`InStreamId::Cc3`], +/// and [`InStreamId::Cc4`] identify a Line 21 Data Services channel ([CEA608]). /// -/// [4.3.4.1. EXT-X-MEDIA]: https://tools.ietf.org/html/rfc8216#section-4.3.4.1 +/// The `Service` variants identify a Digital Television Closed Captioning +/// ([CEA708]) service block number. The `Service` variants range from +/// [`InStreamId::Service1`] to [`InStreamId::Service63`]. +/// +/// [CEA608]: https://tools.ietf.org/html/rfc8216#ref-CEA608 +/// [CEA708]: https://tools.ietf.org/html/rfc8216#ref-CEA708 +/// [`MediaSegment`]: crate::MediaSegment +/// [`MediaPlaylist`]: crate::MediaPlaylist +#[non_exhaustive] #[allow(missing_docs)] -#[derive(Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] #[strum(serialize_all = "UPPERCASE")] +#[derive(Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] pub enum InStreamId { Cc1, Cc2, @@ -78,6 +91,18 @@ pub enum InStreamId { Service63, } +/// The variants [`InStreamId::Cc1`], [`InStreamId::Cc2`], [`InStreamId::Cc3`] +/// and [`InStreamId::Cc4`] require [`ProtocolVersion::V1`], the other +/// [`ProtocolVersion::V7`]. +impl RequiredVersion for InStreamId { + fn required_version(&self) -> ProtocolVersion { + match &self { + Self::Cc1 | Self::Cc2 | Self::Cc3 | Self::Cc4 => ProtocolVersion::V1, + _ => ProtocolVersion::V7, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/types/initialization_vector.rs b/src/types/initialization_vector.rs index 971bcc9..779d3f1 100644 --- a/src/types/initialization_vector.rs +++ b/src/types/initialization_vector.rs @@ -1,42 +1,187 @@ -use std::fmt; -use std::ops::Deref; -use std::str::FromStr; +use core::fmt; +use core::str::FromStr; use crate::Error; -/// Initialization vector. +/// An initialization vector (IV) is a fixed size input that can be used along +/// with a secret key for data encryption. /// -/// See: [4.3.2.4. EXT-X-KEY] -/// -/// [4.3.2.4. EXT-X-KEY]: https://tools.ietf.org/html/rfc8216#section-4.3.2.4 +/// The use of an IV prevents repetition in encrypted data, making it more +/// difficult for a hacker using a dictionary attack to find patterns and break +/// a cipher. For example, a sequence might appear twice or more within the body +/// of a message. If there are repeated sequences in encrypted data, an attacker +/// could assume that the corresponding sequences in the message were also +/// identical. The IV prevents the appearance of corresponding duplicate +/// character sequences in the ciphertext. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct InitializationVector(pub [u8; 16]); +#[non_exhaustive] +pub enum InitializationVector { + /// An IV for use with Aes128. + Aes128([u8; 0x10]), + /// An [`ExtXKey`] tag with [`KeyFormat::Identity`] that does not have an IV + /// field indicates that the [`MediaSegment::number`] is to be used as the + /// IV when decrypting a `MediaSegment`. + /// + /// [`ExtXKey`]: crate::tags::ExtXKey + /// [`KeyFormat::Identity`]: crate::types::KeyFormat::Identity + /// [`MediaSegment::number`]: crate::MediaSegment::number + Number(u128), + /// Signals that an IV is missing. + Missing, +} impl InitializationVector { - /// Converts the [InitializationVector] to a slice. - pub const fn to_slice(&self) -> [u8; 16] { self.0 } + /// Returns the IV as an [`u128`]. `None` is returned for + /// [`InitializationVector::Missing`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::InitializationVector; + /// assert_eq!( + /// InitializationVector::Aes128([ + /// 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, + /// 0x90, 0x12 + /// ]) + /// .to_u128(), + /// Some(0x12345678901234567890123456789012) + /// ); + /// + /// assert_eq!(InitializationVector::Number(0x10).to_u128(), Some(0x10)); + /// + /// assert_eq!(InitializationVector::Missing.to_u128(), None); + /// ``` + #[must_use] + pub fn to_u128(&self) -> Option { + match *self { + Self::Aes128(v) => Some(u128::from_be_bytes(v)), + Self::Number(n) => Some(n), + Self::Missing => None, + } + } + + /// Returns the IV as a slice, which can be used to for example decrypt + /// a [`MediaSegment`]. `None` is returned for + /// [`InitializationVector::Missing`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::InitializationVector; + /// assert_eq!( + /// InitializationVector::Aes128([ + /// 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + /// 0x0F, 0x10, + /// ]) + /// .to_slice(), + /// Some([ + /// 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + /// 0x0F, 0x10, + /// ]) + /// ); + /// + /// assert_eq!( + /// InitializationVector::Number(0x12345678901234567890123456789012).to_slice(), + /// Some([ + /// 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, + /// 0x90, 0x12 + /// ]) + /// ); + /// + /// assert_eq!(InitializationVector::Missing.to_slice(), None); + /// ``` + /// + /// [`MediaSegment`]: crate::MediaSegment + #[must_use] + pub fn to_slice(&self) -> Option<[u8; 0x10]> { + match &self { + Self::Aes128(v) => Some(*v), + Self::Number(v) => Some(v.to_be_bytes()), + Self::Missing => None, + } + } + + /// Returns `true` if the initialization vector is not missing. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::InitializationVector; + /// assert_eq!( + /// InitializationVector::Aes128([ + /// 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + /// 0x0F, 0x10, + /// ]) + /// .is_some(), + /// true + /// ); + /// + /// assert_eq!(InitializationVector::Number(4).is_some(), true); + /// + /// assert_eq!(InitializationVector::Missing.is_some(), false); + /// ``` + #[must_use] + #[inline] + pub fn is_some(&self) -> bool { *self != Self::Missing } + + /// Returns `true` if the initialization vector is missing. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::InitializationVector; + /// assert_eq!( + /// InitializationVector::Aes128([ + /// 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + /// 0x0F, 0x10, + /// ]) + /// .is_none(), + /// false + /// ); + /// + /// assert_eq!(InitializationVector::Number(4).is_none(), false); + /// + /// assert_eq!(InitializationVector::Missing.is_none(), true); + /// ``` + #[must_use] + #[inline] + pub fn is_none(&self) -> bool { *self == Self::Missing } } -impl From<[u8; 16]> for InitializationVector { - fn from(value: [u8; 16]) -> Self { Self(value) } +impl Default for InitializationVector { + fn default() -> Self { Self::Missing } } -impl Deref for InitializationVector { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { &self.0 } +impl From<[u8; 0x10]> for InitializationVector { + fn from(value: [u8; 0x10]) -> Self { Self::Aes128(value) } } -impl AsRef<[u8]> for InitializationVector { - fn as_ref(&self) -> &[u8] { &self.0 } +impl From> for InitializationVector { + fn from(value: Option<[u8; 0x10]>) -> Self { + match value { + Some(v) => Self::Aes128(v), + None => Self::Missing, + } + } } impl fmt::Display for InitializationVector { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "0x")?; - for b in &self.0 { - write!(f, "{:02x}", b)?; + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::Aes128(buffer) => { + let mut result = [0; 0x10 * 2]; + ::hex::encode_to_slice(buffer, &mut result).unwrap(); + + write!(f, "0x{}", ::core::str::from_utf8(&result).unwrap())?; + } + Self::Number(num) => { + write!(f, "InitializationVector::Number({})", num)?; + } + Self::Missing => { + write!(f, "InitializationVector::Missing")?; + } } + Ok(()) } } @@ -46,20 +191,20 @@ impl FromStr for InitializationVector { fn from_str(input: &str) -> Result { if !(input.starts_with("0x") || input.starts_with("0X")) { - return Err(Error::invalid_input()); + return Err(Error::custom("An IV should either start with `0x` or `0X`")); } + if input.len() - 2 != 32 { - return Err(Error::invalid_input()); + return Err(Error::custom( + "An IV must be 32 bytes long + 2 bytes for 0x/0X", + )); } let mut result = [0; 16]; - for (i, c) in input.as_bytes().chunks(2).skip(1).enumerate() { - let d = std::str::from_utf8(c).map_err(Error::custom)?; - let b = u8::from_str_radix(d, 16).map_err(Error::custom)?; - result[i] = b; - } - Ok(Self(result)) + ::hex::decode_to_slice(&input.as_bytes()[2..], &mut result).map_err(Error::hex)?; + + Ok(Self::Aes128(result)) } } @@ -69,82 +214,92 @@ mod tests { use pretty_assertions::assert_eq; #[test] - fn test_display() { + fn test_default() { assert_eq!( - "0x10ef8f758ca555115584bb5b3c687f52".to_string(), - InitializationVector([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82 - ]) - .to_string() - ); - } - - #[test] - fn test_parser() { - assert_eq!( - "0x10ef8f758ca555115584bb5b3c687f52" - .parse::() - .unwrap(), - InitializationVector([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82 - ]) - ); - - assert_eq!( - "0X10ef8f758ca555115584bb5b3c687f52" - .parse::() - .unwrap(), - InitializationVector([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82 - ]) - ); - - assert_eq!( - "0X10EF8F758CA555115584BB5B3C687F52" - .parse::() - .unwrap(), - InitializationVector([ - 16, 239, 143, 117, 140, 165, 85, 17, 85, 132, 187, 91, 60, 104, 127, 82 - ]) - ); - - assert!("garbage".parse::().is_err()); - assert!("0xgarbage".parse::().is_err()); - assert!("0x12".parse::().is_err()); - assert!("0X10EF8F758CA555115584BB5B3C687F5Z" - .parse::() - .is_err()); - } - - #[test] - fn test_as_ref() { - assert_eq!( - InitializationVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).as_ref(), - &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - ); - } - - #[test] - fn test_deref() { - assert_eq!( - InitializationVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).deref(), - &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + InitializationVector::default(), + InitializationVector::Missing ); } #[test] fn test_from() { assert_eq!( - InitializationVector::from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - InitializationVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + InitializationVector::from([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]), + InitializationVector::Aes128([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]) + ); + + assert_eq!( + InitializationVector::from(None), + InitializationVector::Missing + ); + + assert_eq!( + InitializationVector::from(Some([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ])), + InitializationVector::Aes128([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]) + ) + } + + #[test] + fn test_display() { + assert_eq!( + InitializationVector::Aes128([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]) + .to_string(), + "0xffffffffffffffffffffffffffffffff".to_string() + ); + + assert_eq!( + InitializationVector::Number(5).to_string(), + "InitializationVector::Number(5)".to_string() + ); + + assert_eq!( + InitializationVector::Missing.to_string(), + "InitializationVector::Missing".to_string() ); } #[test] - fn test_to_slice() { + fn test_parser() { assert_eq!( - InitializationVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).to_slice(), - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + InitializationVector::Aes128([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]), + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".parse().unwrap() ); + + assert_eq!( + InitializationVector::Aes128([ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF + ]), + "0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".parse().unwrap() + ); + + // missing `0x` at the start: + assert!("0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + .parse::() + .is_err()); + // too small: + assert!("0xFF".parse::().is_err()); + // too large: + assert!("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + .parse::() + .is_err()); } } diff --git a/src/types/key_format.rs b/src/types/key_format.rs index 3ffac49..ac97478 100644 --- a/src/types/key_format.rs +++ b/src/types/key_format.rs @@ -5,11 +5,16 @@ use crate::types::ProtocolVersion; use crate::utils::{quote, tag, unquote}; use crate::{Error, RequiredVersion}; +/// Specifies how the key is represented in the resource identified by the +/// `URI`. +#[non_exhaustive] #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -/// [`KeyFormat`] specifies, how the key is represented in the -/// resource identified by the `URI`. pub enum KeyFormat { - /// The key is a single packed array of 16 octets in binary format. + /// An [`EncryptionMethod::Aes128`] uses 16-octet (16 byte/128 bit) keys. If + /// the format is [`KeyFormat::Identity`], the key file is a single packed + /// array of 16 octets (16 byte/128 bit) in binary format. + /// + /// [`EncryptionMethod::Aes128`]: crate::types::EncryptionMethod::Aes128 Identity, } @@ -28,7 +33,7 @@ impl FromStr for KeyFormat { } impl fmt::Display for KeyFormat { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", quote("identity")) } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", quote(&"identity")) } } /// This tag requires [`ProtocolVersion::V5`]. diff --git a/src/types/key_format_versions.rs b/src/types/key_format_versions.rs index 2b4fc95..518c7c5 100644 --- a/src/types/key_format_versions.rs +++ b/src/types/key_format_versions.rs @@ -1,50 +1,363 @@ -use std::convert::Infallible; +use std::cmp::Ordering; use std::fmt; -use std::ops::{Deref, DerefMut}; +use std::hash::{Hash, Hasher}; +use std::iter::{Extend, FromIterator}; +use std::ops::{Index, IndexMut}; +use std::slice::SliceIndex; use std::str::FromStr; use crate::types::ProtocolVersion; use crate::utils::{quote, unquote}; +use crate::Error; use crate::RequiredVersion; -/// A list of [`usize`], that can be used to indicate which version(s) +/// A list of numbers that can be used to indicate which version(s) /// this instance complies with, if more than one version of a particular /// [`KeyFormat`] is defined. /// +/// ## Note on maximum size +/// +/// To reduce the memory usage and to make this struct implement [`Copy`], a +/// fixed size array is used internally (`[u8; 9]`), which can store a maximum +/// number of 9 `u8` numbers. +/// +/// If you encounter any m3u8 file, which fails to parse, because the buffer is +/// too small, feel free to [make an issue](https://github.com/sile/hls_m3u8/issues). +/// +/// ## Example +/// +/// ``` +/// use hls_m3u8::types::KeyFormatVersions; +/// +/// assert_eq!( +/// KeyFormatVersions::from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).to_string(), +/// "\"255/255/255/255/255/255/255/255/255\"".to_string() +/// ); +/// ``` +/// /// [`KeyFormat`]: crate::types::KeyFormat -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] -pub struct KeyFormatVersions(Vec); +#[derive(Debug, Clone, Copy)] +pub struct KeyFormatVersions { + // NOTE(Luro02): if the current array is not big enough one can easily increase + // the number of elements or change the type to something bigger, + // but it would be kinda wasteful to use a `Vec` here, which requires + // allocations and has a size of at least 24 bytes + // (::std::mem::size_of::>() = 24). + buffer: [u8; 9], + // Indicates the number of used items in the array. + len: u8, +} impl KeyFormatVersions { - /// Makes a new [`KeyFormatVersions`]. + /// Constructs an empty [`KeyFormatVersions`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions, KeyFormatVersions::default()); + /// ``` + #[inline] + #[must_use] pub fn new() -> Self { Self::default() } - /// Add a value to the [`KeyFormatVersions`]. - pub fn push(&mut self, value: usize) { - if self.is_default() { - self.0 = vec![value]; + /// Add a value to the end of [`KeyFormatVersions`]. + /// + /// # Panics + /// + /// This function panics, if you try to push more elements, than + /// [`KeyFormatVersions::remaining`] returns. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// versions.push(1); + /// assert_eq!(versions, KeyFormatVersions::from([1])); + /// ``` + /// + /// This will panic, because it exceeded the maximum number of elements: + /// + /// ```{.should_panic} + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// for _ in 0..=versions.capacity() { + /// versions.push(1); // <- panics + /// } + /// ``` + pub fn push(&mut self, value: u8) { + if self.len as usize == self.buffer.len() { + panic!("reached maximum number of elements in KeyFormatVersions"); + } + + self.buffer[self.len()] = value; + self.len += 1; + } + + /// `KeyFormatVersions` has a limited capacity and this function returns how + /// many elements can be pushed, until it panics. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions.remaining(), versions.capacity()); + /// + /// versions.push(1); + /// versions.push(2); + /// versions.push(3); + /// assert_eq!(versions.remaining(), 6); + /// ``` + #[inline] + #[must_use] + pub fn remaining(&self) -> usize { self.capacity().saturating_sub(self.len()) } + + /// Returns the number of elements. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions.len(), 0); + /// + /// versions.push(2); + /// assert_eq!(versions.len(), 1); + /// ``` + #[inline] + #[must_use] + pub const fn len(&self) -> usize { self.len as usize } + + /// Returns the total number of elements that can be stored. + /// + /// # Note + /// + /// It should not be relied on that this function will always return 9. In + /// the future this number might increase. + #[inline] + #[must_use] + pub fn capacity(&self) -> usize { self.buffer.len() } + + /// Shortens the internal array to the provided length. + /// + /// # Note + /// + /// If `len` is greater than the current length, this has no effect. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::from([1, 2, 3, 4, 5, 6]); + /// versions.truncate(3); + /// + /// assert_eq!(versions, KeyFormatVersions::from([1, 2, 3])); + /// ``` + pub fn truncate(&mut self, len: usize) { + if len > self.len() { + return; + } + + self.len = len as u8; + } + + /// Returns `true` if there are no elements. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions.is_empty(), true); + /// + /// versions.push(2); + /// assert_eq!(versions.is_empty(), false); + /// ``` + #[inline] + #[must_use] + pub const fn is_empty(&self) -> bool { self.len() == 0 } + + /// Removes the last element and returns it, or `None` if it is empty. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions.pop(), None); + /// + /// versions.push(2); + /// assert_eq!(versions.pop(), Some(2)); + /// assert_eq!(versions.is_empty(), true); + /// ``` + pub fn pop(&mut self) -> Option { + if self.is_empty() { + None } else { - self.0.push(value); + self.len -= 1; + Some(self.buffer[self.len()]) } } - /// Returns `true`, if [`KeyFormatVersions`] has the default value of - /// `vec![1]`. - pub fn is_default(&self) -> bool { self.0 == vec![1] && self.0.len() == 1 || self.0.is_empty() } + /// Returns `true`, if it is either empty or has a length of 1 and the first + /// element is 1. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::KeyFormatVersions; + /// let mut versions = KeyFormatVersions::new(); + /// + /// assert_eq!(versions.is_default(), true); + /// + /// versions.push(1); + /// assert_eq!(versions.is_default(), true); + /// + /// assert_eq!(KeyFormatVersions::default().is_default(), true); + /// ``` + #[must_use] + pub fn is_default(&self) -> bool { + self.is_empty() || (self.buffer[self.len().saturating_sub(1)] == 1 && self.len() == 1) + } +} + +impl PartialEq for KeyFormatVersions { + fn eq(&self, other: &Self) -> bool { + if self.len() == other.len() { + // only compare the parts in the buffer, that are used: + self.as_ref() == self.as_ref() + } else { + false + } + } +} + +impl Eq for KeyFormatVersions {} + +impl PartialOrd for KeyFormatVersions { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + Some(::cmp(self, other)) + } +} + +impl Ord for KeyFormatVersions { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { self.as_ref().cmp(other.as_ref()) } +} + +impl Hash for KeyFormatVersions { + fn hash(&self, state: &mut H) { + state.write_usize(self.len()); + self.as_ref().hash(state); + } +} + +impl AsRef<[u8]> for KeyFormatVersions { + #[inline] + #[must_use] + fn as_ref(&self) -> &[u8] { &self.buffer[..self.len()] } +} + +impl AsMut<[u8]> for KeyFormatVersions { + #[inline] + #[must_use] + fn as_mut(&mut self) -> &mut [u8] { + // this temporary variable is required, because the compiler does not resolve + // the borrow to it's value immediately, so there is a shared borrow and + // therefore no exclusive borrow can be made. + let len = self.len(); + &mut self.buffer[..len] + } +} + +impl Extend for KeyFormatVersions { + fn extend>(&mut self, iter: I) { + for element in iter { + if self.remaining() == 0 { + break; + } + + self.push(element); + } + } +} + +impl<'a> Extend<&'a u8> for KeyFormatVersions { + fn extend>(&mut self, iter: I) { + >::extend(self, iter.into_iter().copied()) + } +} + +impl> Index for KeyFormatVersions { + type Output = I::Output; + + #[inline] + fn index(&self, index: I) -> &Self::Output { self.as_ref().index(index) } +} + +impl> IndexMut for KeyFormatVersions { + #[inline] + fn index_mut(&mut self, index: I) -> &mut Self::Output { self.as_mut().index_mut(index) } +} + +impl IntoIterator for KeyFormatVersions { + type IntoIter = IntoIter; + type Item = u8; + + fn into_iter(self) -> Self::IntoIter { self.into() } +} + +impl FromIterator for KeyFormatVersions { + fn from_iter>(iter: I) -> Self { + let mut result = Self::default(); + // an array like [0; 9] as empty + let mut is_empty = true; + + for item in iter { + if item != 0 { + is_empty = false; + } + + if result.remaining() == 0 { + break; + } + + result.push(item); + } + + if is_empty { + return Self::default(); + } + + result + } +} + +impl<'a> FromIterator<&'a u8> for KeyFormatVersions { + fn from_iter>(iter: I) -> Self { + >::from_iter(iter.into_iter().copied()) + } } impl Default for KeyFormatVersions { - fn default() -> Self { Self(vec![1]) } -} - -impl Deref for KeyFormatVersions { - type Target = Vec; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for KeyFormatVersions { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + #[inline] + fn default() -> Self { + Self { + buffer: [0; 9], + len: 0, + } + } } /// This tag requires [`ProtocolVersion::V5`]. @@ -53,45 +366,101 @@ impl RequiredVersion for KeyFormatVersions { } impl FromStr for KeyFormatVersions { - type Err = Infallible; + type Err = Error; fn from_str(input: &str) -> Result { - let mut result = unquote(input) + let mut result = Self::default(); + + for item in unquote(input) .split('/') - .filter_map(|v| v.parse().ok()) - .collect::>(); + .map(|v| v.parse().map_err(|e| Error::parse_int(v, e))) + { + let item = item?; + + if result.remaining() == 0 { + return Err(Error::custom( + "reached maximum number of elements in KeyFormatVersions", + )); + } + + result.push(item); + } if result.is_empty() { result.push(1); } - Ok(Self(result)) + Ok(result) } } impl fmt::Display for KeyFormatVersions { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if self.is_default() { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_default() || self.is_empty() { return write!(f, "{}", quote("1")); } - write!( - f, - "{}", - quote( - // vec![1, 2, 3] -> "1/2/3" - self.0 - .iter() - .map(|v| v.to_string()) - .collect::>() - .join("/") - ) - ) + write!(f, "\"{}", self.buffer[0])?; + + for item in &self.buffer[1..self.len()] { + write!(f, "/{}", item)?; + } + + write!(f, "\"")?; + + Ok(()) } } -impl>> From for KeyFormatVersions { - fn from(value: T) -> Self { Self(value.into()) } +impl> From for KeyFormatVersions { + fn from(value: T) -> Self { Self::from_iter(value.as_ref().iter().map(|i| *i as u8)) } +} + +/// `Iterator` for [`KeyFormatVersions`]. +#[derive(Debug, Clone, PartialEq)] +pub struct IntoIter { + buffer: [T; 9], + position: usize, + len: usize, +} + +impl From for IntoIter { + fn from(value: KeyFormatVersions) -> Self { + Self { + buffer: value.buffer, + position: 0, + len: value.len(), + } + } +} + +impl<'a> From<&'a KeyFormatVersions> for IntoIter { + fn from(value: &'a KeyFormatVersions) -> Self { + Self { + buffer: value.buffer, + position: 0, + len: value.len(), + } + } +} + +impl ExactSizeIterator for IntoIter { + fn len(&self) -> usize { self.len.saturating_sub(self.position) } +} + +impl ::core::iter::FusedIterator for IntoIter {} + +impl Iterator for IntoIter { + type Item = T; + + fn next(&mut self) -> Option { + if self.position == self.len { + return None; + } + + self.position += 1; + Some(self.buffer[self.position - 1]) + } } #[cfg(test)] @@ -99,28 +468,201 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_hash() { + let mut hasher_left = std::collections::hash_map::DefaultHasher::new(); + let mut hasher_right = std::collections::hash_map::DefaultHasher::new(); + + assert_eq!( + KeyFormatVersions::from([1, 2, 3]).hash(&mut hasher_left), + KeyFormatVersions::from([1, 2, 3]).hash(&mut hasher_right) + ); + + assert_eq!(hasher_left.finish(), hasher_right.finish()); + } + + #[test] + fn test_ord() { + assert_eq!( + KeyFormatVersions::from([1, 2]).cmp(&KeyFormatVersions::from([1, 2])), + Ordering::Equal + ); + + assert_eq!( + KeyFormatVersions::from([2]).cmp(&KeyFormatVersions::from([1, 2])), + Ordering::Greater + ); + + assert_eq!( + KeyFormatVersions::from([2, 3]).cmp(&KeyFormatVersions::from([1, 2])), + Ordering::Greater + ); + + assert_eq!( + KeyFormatVersions::from([]).cmp(&KeyFormatVersions::from([1, 2])), + Ordering::Less + ); + } + + #[test] + fn test_partial_eq() { + let mut versions = KeyFormatVersions::from([1, 2, 3, 4, 5, 6]); + versions.truncate(3); + + assert_eq!(versions, KeyFormatVersions::from([1, 2, 3])); + } + + #[test] + fn test_as_ref() { + assert_eq!(KeyFormatVersions::new().as_ref(), &[]); + assert_eq!(KeyFormatVersions::from([1, 2, 3]).as_ref(), &[1, 2, 3]); + assert_eq!(KeyFormatVersions::from([]).as_ref(), &[]); + } + + #[test] + fn test_as_mut() { + assert_eq!(KeyFormatVersions::new().as_mut(), &mut []); + assert_eq!(KeyFormatVersions::from([1, 2, 3]).as_mut(), &mut [1, 2, 3]); + assert_eq!(KeyFormatVersions::from([]).as_mut(), &mut []); + } + + #[test] + fn test_index() { + // test index + assert_eq!(&KeyFormatVersions::new()[..], &[]); + assert_eq!(&KeyFormatVersions::from([1, 2, 3])[..2], &[1, 2]); + assert_eq!(&KeyFormatVersions::from([1, 2, 3])[1..2], &[2]); + assert_eq!(&KeyFormatVersions::from([1, 2, 3])[..], &[1, 2, 3]); + + // test index_mut + assert_eq!(&mut KeyFormatVersions::new()[..], &mut []); + assert_eq!(&mut KeyFormatVersions::from([1, 2, 3])[..2], &mut [1, 2]); + assert_eq!(&mut KeyFormatVersions::from([1, 2, 3])[1..2], &mut [2]); + assert_eq!(&mut KeyFormatVersions::from([1, 2, 3])[..], &mut [1, 2, 3]); + } + + #[test] + fn test_extend() { + let mut versions = KeyFormatVersions::new(); + versions.extend(&[1, 2, 3]); + + assert_eq!(versions, KeyFormatVersions::from([1, 2, 3])); + + versions.extend(&[1, 2, 3]); + assert_eq!(versions, KeyFormatVersions::from([1, 2, 3, 1, 2, 3])); + + versions.extend(&[1, 2, 3, 4]); + assert_eq!( + versions, + KeyFormatVersions::from([1, 2, 3, 1, 2, 3, 1, 2, 3]) + ); + } + + #[test] + fn test_default() { + assert_eq!(KeyFormatVersions::default(), KeyFormatVersions::new()); + } + + #[test] + fn test_into_iter() { + assert_eq!(KeyFormatVersions::new().into_iter().next(), None); + assert_eq!(KeyFormatVersions::new().into_iter().len(), 0); + + let mut iterator = KeyFormatVersions::from([1, 2, 3, 4, 5]).into_iter(); + + assert_eq!(iterator.len(), 5); + assert_eq!(iterator.next(), Some(1)); + + assert_eq!(iterator.len(), 4); + assert_eq!(iterator.next(), Some(2)); + + assert_eq!(iterator.len(), 3); + assert_eq!(iterator.next(), Some(3)); + + assert_eq!(iterator.len(), 2); + assert_eq!(iterator.next(), Some(4)); + + assert_eq!(iterator.len(), 1); + assert_eq!(iterator.next(), Some(5)); + + assert_eq!(iterator.len(), 0); + assert_eq!(iterator.next(), None); + } + + #[test] + fn test_from_iter() { + assert_eq!( + { + let mut result = KeyFormatVersions::new(); + result.push(1); + result.push(2); + result.push(3); + result.push(4); + result + }, + KeyFormatVersions::from_iter(&[1, 2, 3, 4]) + ); + + assert_eq!( + { + let mut result = KeyFormatVersions::new(); + result.push(0); + result.push(1); + result.push(2); + result.push(3); + result.push(4); + result + }, + KeyFormatVersions::from_iter(&[0, 1, 2, 3, 4]) + ); + + assert_eq!(KeyFormatVersions::new(), KeyFormatVersions::from_iter(&[])); + + assert_eq!(KeyFormatVersions::new(), KeyFormatVersions::from_iter(&[0])); + assert_eq!( + KeyFormatVersions::new(), + KeyFormatVersions::from_iter(&[0, 0]) + ); + assert_eq!( + { + let mut result = KeyFormatVersions::new(); + result.push(0); + result.push(1); + result.push(2); + result.push(3); + result.push(4); + result.push(5); + result.push(6); + result.push(7); + result.push(8); + result + }, + KeyFormatVersions::from_iter(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + ); + } + #[test] fn test_display() { assert_eq!( - KeyFormatVersions::from(vec![1, 2, 3, 4, 5]).to_string(), + KeyFormatVersions::from([1, 2, 3, 4, 5]).to_string(), quote("1/2/3/4/5") ); - assert_eq!(KeyFormatVersions::from(vec![]).to_string(), quote("1")); - + assert_eq!(KeyFormatVersions::from([]).to_string(), quote("1")); assert_eq!(KeyFormatVersions::new().to_string(), quote("1")); } #[test] fn test_parser() { assert_eq!( - KeyFormatVersions::from(vec![1, 2, 3, 4, 5]), + KeyFormatVersions::from([1, 2, 3, 4, 5]), quote("1/2/3/4/5").parse().unwrap() ); - assert_eq!(KeyFormatVersions::from(vec![1]), "1".parse().unwrap()); + assert_eq!(KeyFormatVersions::from([1]), "1".parse().unwrap()); + assert_eq!(KeyFormatVersions::from([1, 2]), "1/2".parse().unwrap()); - assert_eq!(KeyFormatVersions::from(vec![1, 2]), "1/2".parse().unwrap()); + assert!("1/b".parse::().is_err()); } #[test] @@ -133,28 +675,20 @@ mod tests { #[test] fn test_is_default() { - assert!(KeyFormatVersions::new().is_default()); - assert!(KeyFormatVersions::from(vec![]).is_default()); - assert!(!KeyFormatVersions::from(vec![1, 2, 3]).is_default()); + assert_eq!(KeyFormatVersions::new().is_default(), true); + assert_eq!(KeyFormatVersions::default().is_default(), true); + + assert_eq!(KeyFormatVersions::from([]).is_default(), true); + assert_eq!(KeyFormatVersions::from([1]).is_default(), true); + + assert_eq!(KeyFormatVersions::from([1, 2, 3]).is_default(), false); } #[test] fn test_push() { - let mut key_format_versions = KeyFormatVersions::from(vec![]); - + let mut key_format_versions = KeyFormatVersions::new(); key_format_versions.push(2); - assert_eq!(KeyFormatVersions::from(vec![2]), key_format_versions); - } - #[test] - fn test_deref() { - assert!(!KeyFormatVersions::new().is_empty()); - } - - #[test] - fn test_deref_mut() { - let mut key_format_versions = KeyFormatVersions::from(vec![1, 2, 3]); - key_format_versions.pop(); - assert_eq!(key_format_versions, KeyFormatVersions::from(vec![1, 2])); + assert_eq!(KeyFormatVersions::from([2]), key_format_versions); } } diff --git a/src/types/media_type.rs b/src/types/media_type.rs index 19a999f..c18cae3 100644 --- a/src/types/media_type.rs +++ b/src/types/media_type.rs @@ -1,6 +1,7 @@ use strum::{Display, EnumString}; /// Specifies the media type. +#[non_exhaustive] #[allow(missing_docs)] #[derive(Ord, PartialOrd, Display, EnumString, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[strum(serialize_all = "SCREAMING-KEBAB-CASE")] diff --git a/src/types/mod.rs b/src/types/mod.rs index a1df2bd..87188d7 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,28 +1,30 @@ //! Miscellaneous types. -mod byte_range; -mod channels; -mod closed_captions; -mod decimal_floating_point; -mod decimal_resolution; -mod decryption_key; -mod encryption_method; -mod hdcp_level; -mod in_stream_id; -mod initialization_vector; -mod key_format; -mod key_format_versions; -mod media_type; -mod protocol_version; -mod signed_decimal_floating_point; -mod stream_inf; -mod value; +pub(crate) mod byte_range; +pub(crate) mod channels; +pub(crate) mod closed_captions; +pub(crate) mod codecs; +pub(crate) mod decryption_key; +pub(crate) mod encryption_method; +pub(crate) mod hdcp_level; +pub(crate) mod in_stream_id; +pub(crate) mod initialization_vector; +pub(crate) mod key_format; +pub(crate) mod key_format_versions; +pub(crate) mod media_type; +pub(crate) mod playlist_type; +pub(crate) mod protocol_version; +pub(crate) mod resolution; +pub(crate) mod stream_data; +pub(crate) mod value; + +pub(crate) mod float; +pub(crate) mod ufloat; pub use byte_range::*; pub use channels::*; pub use closed_captions::*; -pub(crate) use decimal_floating_point::*; -pub(crate) use decimal_resolution::*; -pub use decryption_key::*; +pub use codecs::*; +pub use decryption_key::DecryptionKey; pub use encryption_method::*; pub use hdcp_level::*; pub use in_stream_id::*; @@ -30,7 +32,11 @@ pub use initialization_vector::*; pub use key_format::*; pub use key_format_versions::*; pub use media_type::*; +pub use playlist_type::*; pub use protocol_version::*; -pub(crate) use signed_decimal_floating_point::*; -pub use stream_inf::*; +pub use resolution::*; +pub use stream_data::StreamData; pub use value::*; + +pub use float::Float; +pub use ufloat::UFloat; diff --git a/src/types/playlist_type.rs b/src/types/playlist_type.rs new file mode 100644 index 0000000..2ad1916 --- /dev/null +++ b/src/types/playlist_type.rs @@ -0,0 +1,101 @@ +use std::fmt; +use std::str::FromStr; + +use crate::types::ProtocolVersion; +use crate::utils::tag; +use crate::{Error, RequiredVersion}; + +/// Provides mutability information about the [`MediaPlaylist`]. +/// +/// It applies to the entire [`MediaPlaylist`]. +/// +/// [`MediaPlaylist`]: crate::MediaPlaylist +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PlaylistType { + /// If the [`PlaylistType`] is Event, [`MediaSegment`]s + /// can only be added to the end of the [`MediaPlaylist`]. + /// + /// [`MediaSegment`]: crate::MediaSegment + /// [`MediaPlaylist`]: crate::MediaPlaylist + Event, + /// If the [`PlaylistType`] is Video On Demand (Vod), + /// the [`MediaPlaylist`] cannot change. + /// + /// [`MediaPlaylist`]: crate::MediaPlaylist + Vod, +} + +impl PlaylistType { + pub(crate) const PREFIX: &'static str = "#EXT-X-PLAYLIST-TYPE:"; +} + +/// This tag requires [`ProtocolVersion::V1`]. +impl RequiredVersion for PlaylistType { + fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } +} + +impl fmt::Display for PlaylistType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::Event => write!(f, "{}EVENT", Self::PREFIX), + Self::Vod => write!(f, "{}VOD", Self::PREFIX), + } + } +} + +impl FromStr for PlaylistType { + type Err = Error; + + fn from_str(input: &str) -> Result { + let input = tag(input, Self::PREFIX)?; + match input { + "EVENT" => Ok(Self::Event), + "VOD" => Ok(Self::Vod), + _ => Err(Error::custom(format!("unknown playlist type: {:?}", input))), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_parser() { + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:VOD".parse::().unwrap(), + PlaylistType::Vod, + ); + + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:EVENT" + .parse::() + .unwrap(), + PlaylistType::Event, + ); + + assert!("#EXT-X-PLAYLIST-TYPE:H".parse::().is_err()); + + assert!("garbage".parse::().is_err()); + } + + #[test] + fn test_display() { + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:VOD".to_string(), + PlaylistType::Vod.to_string(), + ); + + assert_eq!( + "#EXT-X-PLAYLIST-TYPE:EVENT".to_string(), + PlaylistType::Event.to_string(), + ); + } + + #[test] + fn test_required_version() { + assert_eq!(PlaylistType::Vod.required_version(), ProtocolVersion::V1); + assert_eq!(PlaylistType::Event.required_version(), ProtocolVersion::V1); + } +} diff --git a/src/types/protocol_version.rs b/src/types/protocol_version.rs index 1ea2a0e..ebfac2c 100644 --- a/src/types/protocol_version.rs +++ b/src/types/protocol_version.rs @@ -3,12 +3,9 @@ use std::str::FromStr; use crate::Error; -/// # [7. Protocol Version Compatibility] -/// The [`ProtocolVersion`] specifies, which m3u8 revision is required, to parse -/// a certain tag correctly. -/// -/// [7. Protocol Version Compatibility]: -/// https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-7 +/// The [`ProtocolVersion`] specifies which `m3u8` revision is required, to +/// parse a certain tag correctly. +#[non_exhaustive] #[allow(missing_docs)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ProtocolVersion { @@ -22,19 +19,22 @@ pub enum ProtocolVersion { } impl ProtocolVersion { - /// Returns the newest [`ProtocolVersion`], that is supported by + /// Returns the latest [`ProtocolVersion`] that is supported by /// this library. /// /// # Example + /// /// ``` /// # use hls_m3u8::types::ProtocolVersion; /// assert_eq!(ProtocolVersion::latest(), ProtocolVersion::V7); /// ``` + #[must_use] + #[inline] pub const fn latest() -> Self { Self::V7 } } impl fmt::Display for ProtocolVersion { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { Self::V1 => write!(f, "1"), Self::V2 => write!(f, "2"), @@ -66,6 +66,7 @@ impl FromStr for ProtocolVersion { } } +/// The default is [`ProtocolVersion::V1`]. impl Default for ProtocolVersion { fn default() -> Self { Self::V1 } } diff --git a/src/types/resolution.rs b/src/types/resolution.rs new file mode 100644 index 0000000..84c554b --- /dev/null +++ b/src/types/resolution.rs @@ -0,0 +1,137 @@ +use std::str::FromStr; + +use derive_more::Display; +use shorthand::ShortHand; + +use crate::Error; + +/// The number of distinct pixels in each dimension that can be displayed (e.g. +/// 1920x1080). +/// +/// For example Full HD has a resolution of 1920x1080. +#[derive(ShortHand, Ord, PartialOrd, Debug, Clone, Copy, PartialEq, Eq, Hash, Display)] +#[display(fmt = "{}x{}", width, height)] +#[shorthand(enable(must_use))] +pub struct Resolution { + /// Horizontal pixel dimension. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Resolution; + /// let mut resolution = Resolution::new(1280, 720); + /// + /// resolution.set_width(1000); + /// assert_eq!(resolution.width(), 1000); + /// ``` + width: usize, + /// Vertical pixel dimension. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Resolution; + /// let mut resolution = Resolution::new(1280, 720); + /// + /// resolution.set_height(800); + /// assert_eq!(resolution.height(), 800); + /// ``` + height: usize, +} + +impl Resolution { + /// Constructs a new [`Resolution`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::Resolution; + /// let resolution = Resolution::new(1920, 1080); + /// ``` + #[must_use] + pub const fn new(width: usize, height: usize) -> Self { Self { width, height } } +} + +impl From<(usize, usize)> for Resolution { + fn from(value: (usize, usize)) -> Self { Self::new(value.0, value.1) } +} + +impl Into<(usize, usize)> for Resolution { + fn into(self) -> (usize, usize) { (self.width, self.height) } +} + +impl FromStr for Resolution { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut input = input.splitn(2, 'x'); + + let width = input + .next() + .ok_or_else(|| Error::custom("missing width for `Resolution` or an invalid input")) + .and_then(|v| v.parse().map_err(|e| Error::parse_int(v, e)))?; + + let height = input + .next() + .ok_or_else(|| Error::custom("missing height for `Resolution` or an invalid input")) + .and_then(|v| v.parse().map_err(|e| Error::parse_int(v, e)))?; + + Ok(Self { width, height }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_display() { + assert_eq!( + Resolution::new(1920, 1080).to_string(), + "1920x1080".to_string() + ); + + assert_eq!( + Resolution::new(1280, 720).to_string(), + "1280x720".to_string() + ); + } + + #[test] + fn test_parser() { + assert_eq!( + Resolution::new(1920, 1080), + "1920x1080".parse::().unwrap() + ); + + assert_eq!( + Resolution::new(1280, 720), + "1280x720".parse::().unwrap() + ); + + assert!("1280".parse::().is_err()); + } + + #[test] + fn test_width() { + assert_eq!(Resolution::new(1920, 1080).width(), 1920); + assert_eq!(Resolution::new(1920, 1080).set_width(12).width(), 12); + } + + #[test] + fn test_height() { + assert_eq!(Resolution::new(1920, 1080).height(), 1080); + assert_eq!(Resolution::new(1920, 1080).set_height(12).height(), 12); + } + + #[test] + fn test_from() { + assert_eq!(Resolution::from((1920, 1080)), Resolution::new(1920, 1080)); + } + + #[test] + fn test_into() { + assert_eq!((1920, 1080), Resolution::new(1920, 1080).into()); + } +} diff --git a/src/types/signed_decimal_floating_point.rs b/src/types/signed_decimal_floating_point.rs deleted file mode 100644 index 54a58e5..0000000 --- a/src/types/signed_decimal_floating_point.rs +++ /dev/null @@ -1,99 +0,0 @@ -use core::ops::Deref; -use derive_more::{Display, FromStr}; - -/// Signed decimal floating-point number. -/// -/// See: [4.2. Attribute Lists] -/// -/// [4.2. Attribute Lists]: https://tools.ietf.org/html/rfc8216#section-4.2 -#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd, Display, FromStr)] -pub(crate) struct SignedDecimalFloatingPoint(f64); - -impl SignedDecimalFloatingPoint { - /// Makes a new [`SignedDecimalFloatingPoint`] instance. - /// - /// # Panics - /// The given value must be finite, otherwise this function will panic! - pub fn new(value: f64) -> Self { - if value.is_infinite() || value.is_nan() { - panic!("Floating point value must be finite and not NaN!"); - } - Self(value) - } - - pub(crate) const fn from_f64_unchecked(value: f64) -> Self { Self(value) } - - /// Converts [`DecimalFloatingPoint`] to [`f64`]. - pub const fn as_f64(self) -> f64 { self.0 } -} - -impl Deref for SignedDecimalFloatingPoint { - type Target = f64; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - macro_rules! test_from { - ( $( $input:expr => $output:expr ),* ) => { - use ::core::convert::From; - - #[test] - fn test_from() { - $( - assert_eq!( - $input, - $output, - ); - )* - } - } - } - - test_from![ - SignedDecimalFloatingPoint::from(1_u8) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1_i8) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1_u16) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1_i16) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1_u32) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1_i32) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1.0_f32) => SignedDecimalFloatingPoint::new(1.0), - SignedDecimalFloatingPoint::from(1.0_f64) => SignedDecimalFloatingPoint::new(1.0) - ]; - - #[test] - fn test_display() { - assert_eq!( - SignedDecimalFloatingPoint::new(1.0).to_string(), - 1.0_f64.to_string() - ); - } - - #[test] - #[should_panic] - fn test_new_panic() { SignedDecimalFloatingPoint::new(::std::f64::INFINITY); } - - #[test] - fn test_parser() { - assert_eq!( - SignedDecimalFloatingPoint::new(1.0), - "1.0".parse::().unwrap() - ); - - assert!("garbage".parse::().is_err()); - } - - #[test] - fn test_as_f64() { - assert_eq!(SignedDecimalFloatingPoint::new(1.0).as_f64(), 1.0); - } - - #[test] - fn test_deref() { - assert_eq!(SignedDecimalFloatingPoint::from(0.1).floor(), 0.0); - } -} diff --git a/src/types/stream_data.rs b/src/types/stream_data.rs new file mode 100644 index 0000000..300aa25 --- /dev/null +++ b/src/types/stream_data.rs @@ -0,0 +1,402 @@ +use core::fmt; +use core::str::FromStr; + +use derive_builder::Builder; +use shorthand::ShortHand; + +use crate::attribute::AttributePairs; +use crate::types::{Codecs, HdcpLevel, ProtocolVersion, Resolution}; +use crate::utils::{quote, unquote}; +use crate::{Error, RequiredVersion}; + +/// The [`StreamData`] struct contains the data that is shared between both +/// variants of the [`VariantStream`]. +/// +/// [`VariantStream`]: crate::tags::VariantStream +#[derive(ShortHand, Builder, PartialOrd, Debug, Clone, PartialEq, Eq, Hash, Ord)] +#[builder(setter(strip_option))] +#[builder(derive(Debug, PartialEq, PartialOrd, Ord, Eq, Hash))] +#[shorthand(enable(must_use, into))] +pub struct StreamData { + /// The peak segment bitrate of the [`VariantStream`] in bits per second. + /// + /// If all the [`MediaSegment`]s in a [`VariantStream`] have already been + /// created, the bandwidth value must be the largest sum of peak segment + /// bitrates that is produced by any playable combination of renditions. + /// + /// (For a [`VariantStream`] with a single [`MediaPlaylist`], this is just + /// the peak segment bit rate of that [`MediaPlaylist`].) + /// + /// An inaccurate value can cause playback stalls or prevent clients from + /// playing the variant. If the [`MasterPlaylist`] is to be made available + /// before all [`MediaSegment`]s in the presentation have been encoded, the + /// bandwidth value should be the bandwidth value of a representative + /// period of similar content, encoded using the same settings. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// # + /// let mut stream = StreamData::new(20); + /// + /// stream.set_bandwidth(5); + /// assert_eq!(stream.bandwidth(), 5); + /// ``` + /// + /// # Note + /// + /// This field is required. + /// + /// [`VariantStream`]: crate::tags::VariantStream + /// [`MediaSegment`]: crate::MediaSegment + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`MediaPlaylist`]: crate::MediaPlaylist + #[shorthand(disable(into))] + bandwidth: u64, + /// The average bandwidth of the stream in bits per second. + /// + /// It represents the average segment bitrate of the [`VariantStream`]. If + /// all the [`MediaSegment`]s in a [`VariantStream`] have already been + /// created, the average bandwidth must be the largest sum of average + /// segment bitrates that is produced by any playable combination of + /// renditions. + /// + /// (For a [`VariantStream`] with a single [`MediaPlaylist`], this is just + /// the average segment bitrate of that [`MediaPlaylist`].) + /// + /// An inaccurate value can cause playback stalls or prevent clients from + /// playing the variant. If the [`MasterPlaylist`] is to be made available + /// before all [`MediaSegment`]s in the presentation have been encoded, the + /// average bandwidth should be the average bandwidth of a representative + /// period of similar content, encoded using the same settings. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// # + /// let mut stream = StreamData::new(20); + /// + /// stream.set_average_bandwidth(Some(300)); + /// assert_eq!(stream.average_bandwidth(), Some(300)); + /// ``` + /// + /// # Note + /// + /// This field is optional. + /// + /// [`MediaSegment`]: crate::MediaSegment + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`MediaPlaylist`]: crate::MediaPlaylist + /// [`VariantStream`]: crate::tags::VariantStream + #[builder(default)] + #[shorthand(enable(copy), disable(into, option_as_ref))] + average_bandwidth: Option, + /// A list of formats, where each format specifies a media sample type that + /// is present in one or more renditions specified by the [`VariantStream`]. + /// + /// Valid format identifiers are those in the ISO Base Media File Format + /// Name Space defined by "The 'Codecs' and 'Profiles' Parameters for + /// "Bucket" Media Types" ([RFC6381]). + /// + /// For example, a stream containing AAC low complexity (AAC-LC) audio and + /// H.264 Main Profile Level 3.0 video would be + /// + /// ``` + /// # use hls_m3u8::types::Codecs; + /// let codecs = Codecs::from(&["mp4a.40.2", "avc1.4d401e"]); + /// ``` + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// use hls_m3u8::types::Codecs; + /// + /// let mut stream = StreamData::new(20); + /// + /// stream.set_codecs(Some(&["mp4a.40.2", "avc1.4d401e"])); + /// assert_eq!( + /// stream.codecs(), + /// Some(&Codecs::from(&["mp4a.40.2", "avc1.4d401e"])) + /// ); + /// ``` + /// + /// # Note + /// + /// This field is optional, but every instance of + /// [`VariantStream::ExtXStreamInf`] should include a codecs attribute. + /// + /// [`VariantStream`]: crate::tags::VariantStream + /// [`VariantStream::ExtXStreamInf`]: + /// crate::tags::VariantStream::ExtXStreamInf + /// [RFC6381]: https://tools.ietf.org/html/rfc6381 + #[builder(default, setter(into))] + codecs: Option, + /// The resolution of the stream. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// use hls_m3u8::types::Resolution; + /// + /// let mut stream = StreamData::new(20); + /// + /// stream.set_resolution(Some((1920, 1080))); + /// assert_eq!(stream.resolution(), Some(Resolution::new(1920, 1080))); + /// # stream.set_resolution(Some((1280, 10))); + /// # assert_eq!(stream.resolution(), Some(Resolution::new(1280, 10))); + /// ``` + /// + /// # Note + /// + /// This field is optional, but it is recommended if the [`VariantStream`] + /// includes video. + /// + /// [`VariantStream`]: crate::tags::VariantStream + #[builder(default, setter(into))] + #[shorthand(enable(copy))] + resolution: Option, + /// High-bandwidth Digital Content Protection level of the + /// [`VariantStream`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// use hls_m3u8::types::HdcpLevel; + /// # + /// let mut stream = StreamData::new(20); + /// + /// stream.set_hdcp_level(Some(HdcpLevel::None)); + /// assert_eq!(stream.hdcp_level(), Some(HdcpLevel::None)); + /// ``` + /// + /// # Note + /// + /// This field is optional. + /// + /// [`VariantStream`]: crate::tags::VariantStream + #[builder(default)] + #[shorthand(enable(copy), disable(into))] + hdcp_level: Option, + /// It indicates the set of video renditions, that should be used when + /// playing the presentation. + /// + /// It must match the value of the [`ExtXMedia::group_id`] attribute + /// [`ExtXMedia`] tag elsewhere in the [`MasterPlaylist`] whose + /// [`ExtXMedia::media_type`] attribute is video. It indicates the set of + /// video renditions that should be used when playing the presentation. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// # + /// let mut stream = StreamData::new(20); + /// + /// stream.set_video(Some("video_01")); + /// assert_eq!(stream.video(), Some(&"video_01".to_string())); + /// ``` + /// + /// # Note + /// + /// This field is optional. + /// + /// [`ExtXMedia::group_id`]: crate::tags::ExtXMedia::group_id + /// [`ExtXMedia`]: crate::tags::ExtXMedia + /// [`MasterPlaylist`]: crate::MasterPlaylist + /// [`ExtXMedia::media_type`]: crate::tags::ExtXMedia::media_type + #[builder(default, setter(into))] + video: Option, +} + +impl StreamData { + /// Creates a new [`StreamData`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::StreamData; + /// # + /// let stream = StreamData::new(20); + /// ``` + #[must_use] + pub const fn new(bandwidth: u64) -> Self { + Self { + bandwidth, + average_bandwidth: None, + codecs: None, + resolution: None, + hdcp_level: None, + video: None, + } + } + + /// Returns a builder for [`StreamData`]. + /// + /// # Example + /// + /// ``` + /// use hls_m3u8::types::{HdcpLevel, StreamData}; + /// + /// StreamData::builder() + /// .bandwidth(200) + /// .average_bandwidth(15) + /// .codecs(&["mp4a.40.2", "avc1.4d401e"]) + /// .resolution((1920, 1080)) + /// .hdcp_level(HdcpLevel::Type0) + /// .video("video_01") + /// .build()?; + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] + pub fn builder() -> StreamDataBuilder { StreamDataBuilder::default() } +} + +impl fmt::Display for StreamData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "BANDWIDTH={}", self.bandwidth)?; + + if let Some(value) = &self.average_bandwidth { + write!(f, ",AVERAGE-BANDWIDTH={}", value)?; + } + if let Some(value) = &self.codecs { + write!(f, ",CODECS={}", quote(value))?; + } + if let Some(value) = &self.resolution { + write!(f, ",RESOLUTION={}", value)?; + } + if let Some(value) = &self.hdcp_level { + write!(f, ",HDCP-LEVEL={}", value)?; + } + if let Some(value) = &self.video { + write!(f, ",VIDEO={}", quote(value))?; + } + Ok(()) + } +} + +impl FromStr for StreamData { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut bandwidth = None; + let mut average_bandwidth = None; + let mut codecs = None; + let mut resolution = None; + let mut hdcp_level = None; + let mut video = None; + + for (key, value) in AttributePairs::new(input) { + match key { + "BANDWIDTH" => { + bandwidth = Some( + value + .parse::() + .map_err(|e| Error::parse_int(value, e))?, + ); + } + "AVERAGE-BANDWIDTH" => { + average_bandwidth = Some( + value + .parse::() + .map_err(|e| Error::parse_int(value, e))?, + ) + } + "CODECS" => codecs = Some(unquote(value).parse()?), + "RESOLUTION" => resolution = Some(value.parse()?), + "HDCP-LEVEL" => { + hdcp_level = Some(value.parse::().map_err(Error::strum)?) + } + "VIDEO" => video = Some(unquote(value)), + _ => { + // [6.3.1. General Client Responsibilities] + // > ignore any attribute/value pair with an unrecognized + // AttributeName. + } + } + } + + let bandwidth = bandwidth.ok_or_else(|| Error::missing_value("BANDWIDTH"))?; + + Ok(Self { + bandwidth, + average_bandwidth, + codecs, + resolution, + hdcp_level, + video, + }) + } +} + +/// This struct requires [`ProtocolVersion::V1`]. +impl RequiredVersion for StreamData { + fn required_version(&self) -> ProtocolVersion { ProtocolVersion::V1 } + + fn introduced_version(&self) -> ProtocolVersion { + if self.video.is_some() { + ProtocolVersion::V4 + } else { + ProtocolVersion::V1 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_display() { + let mut stream_data = StreamData::new(200); + stream_data.set_average_bandwidth(Some(15)); + stream_data.set_codecs(Some(&["mp4a.40.2", "avc1.4d401e"])); + stream_data.set_resolution(Some((1920, 1080))); + stream_data.set_hdcp_level(Some(HdcpLevel::Type0)); + stream_data.set_video(Some("video")); + + assert_eq!( + stream_data.to_string(), + concat!( + "BANDWIDTH=200,", + "AVERAGE-BANDWIDTH=15,", + "CODECS=\"mp4a.40.2,avc1.4d401e\",", + "RESOLUTION=1920x1080,", + "HDCP-LEVEL=TYPE-0,", + "VIDEO=\"video\"" + ) + .to_string() + ); + } + + #[test] + fn test_parser() { + let mut stream_data = StreamData::new(200); + stream_data.set_average_bandwidth(Some(15)); + stream_data.set_codecs(Some(&["mp4a.40.2", "avc1.4d401e"])); + stream_data.set_resolution(Some((1920, 1080))); + stream_data.set_hdcp_level(Some(HdcpLevel::Type0)); + stream_data.set_video(Some("video")); + + assert_eq!( + stream_data, + concat!( + "BANDWIDTH=200,", + "AVERAGE-BANDWIDTH=15,", + "CODECS=\"mp4a.40.2,avc1.4d401e\",", + "RESOLUTION=1920x1080,", + "HDCP-LEVEL=TYPE-0,", + "VIDEO=\"video\"" + ) + .parse() + .unwrap() + ); + + assert!("garbage".parse::().is_err()); + } +} diff --git a/src/types/stream_inf.rs b/src/types/stream_inf.rs deleted file mode 100644 index 3f5b4b1..0000000 --- a/src/types/stream_inf.rs +++ /dev/null @@ -1,362 +0,0 @@ -use std::fmt; -use std::str::FromStr; - -use derive_builder::Builder; - -use crate::attribute::AttributePairs; -use crate::types::{DecimalResolution, HdcpLevel}; -use crate::utils::{quote, unquote}; -use crate::Error; - -/// # [4.3.4.2. EXT-X-STREAM-INF] -/// -/// [4.3.4.2. EXT-X-STREAM-INF]: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 -#[derive(Builder, PartialOrd, Debug, Clone, PartialEq, Eq, Hash, Ord)] -#[builder(setter(into, strip_option))] -#[builder(derive(Debug, PartialEq))] -pub struct StreamInf { - /// The maximum bandwidth of the stream. - bandwidth: u64, - #[builder(default)] - /// The average bandwidth of the stream. - average_bandwidth: Option, - #[builder(default)] - /// Every media format in any of the renditions specified by the Variant - /// Stream. - codecs: Option, - #[builder(default)] - /// The resolution of the stream. - resolution: Option, - #[builder(default)] - /// High-bandwidth Digital Content Protection - hdcp_level: Option, - #[builder(default)] - /// It indicates the set of video renditions, that should be used when - /// playing the presentation. - video: Option, -} - -impl StreamInf { - /// Creates a new [`StreamInf`]. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// ``` - pub const fn new(bandwidth: u64) -> Self { - Self { - bandwidth, - average_bandwidth: None, - codecs: None, - resolution: None, - hdcp_level: None, - video: None, - } - } - - /// Returns the peak segment bit rate of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.bandwidth(), 20); - /// ``` - pub const fn bandwidth(&self) -> u64 { self.bandwidth } - - /// Sets the peak segment bit rate of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_bandwidth(5); - /// assert_eq!(stream.bandwidth(), 5); - /// ``` - pub fn set_bandwidth(&mut self, value: u64) -> &mut Self { - self.bandwidth = value; - self - } - - /// Returns the group identifier for the video in the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.video(), &None); - /// ``` - pub const fn video(&self) -> &Option { &self.video } - - /// Sets the group identifier for the video in the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_video(Some("video")); - /// assert_eq!(stream.video(), &Some("video".to_string())); - /// ``` - pub fn set_video(&mut self, value: Option) -> &mut Self { - self.video = value.map(|v| v.to_string()); - self - } - - /// Returns the average segment bit rate of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.average_bandwidth(), None); - /// ``` - pub const fn average_bandwidth(&self) -> Option { self.average_bandwidth } - - /// Sets the average segment bit rate of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_average_bandwidth(Some(300)); - /// assert_eq!(stream.average_bandwidth(), Some(300)); - /// ``` - pub fn set_average_bandwidth(&mut self, value: Option) -> &mut Self { - self.average_bandwidth = value; - self - } - - /// A string that represents the list of codec types contained the variant - /// stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.codecs(), &None); - /// ``` - pub const fn codecs(&self) -> &Option { &self.codecs } - - /// A string that represents the list of codec types contained the variant - /// stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_codecs(Some("mp4a.40.2,avc1.4d401e")); - /// assert_eq!(stream.codecs(), &Some("mp4a.40.2,avc1.4d401e".to_string())); - /// ``` - pub fn set_codecs(&mut self, value: Option) -> &mut Self { - self.codecs = value.map(|v| v.to_string()); - self - } - - /// Returns the resolution of the stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.resolution(), None); - /// ``` - pub fn resolution(&self) -> Option<(usize, usize)> { - if let Some(res) = &self.resolution { - Some((res.width(), res.height())) - } else { - None - } - } - - /// Sets the resolution of the stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_resolution(1920, 1080); - /// assert_eq!(stream.resolution(), Some((1920, 1080))); - /// # stream.set_resolution(1280, 10); - /// # assert_eq!(stream.resolution(), Some((1280, 10))); - /// ``` - pub fn set_resolution(&mut self, width: usize, height: usize) -> &mut Self { - if let Some(res) = &mut self.resolution { - res.set_width(width); - res.set_height(height); - } else { - self.resolution = Some(DecimalResolution::new(width, height)); - } - self - } - - /// The HDCP level of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::StreamInf; - /// # - /// let stream = StreamInf::new(20); - /// assert_eq!(stream.hdcp_level(), None); - /// ``` - pub const fn hdcp_level(&self) -> Option { self.hdcp_level } - - /// The HDCP level of the variant stream. - /// - /// # Examples - /// ``` - /// # use hls_m3u8::types::{HdcpLevel, StreamInf}; - /// # - /// let mut stream = StreamInf::new(20); - /// - /// stream.set_hdcp_level(Some(HdcpLevel::None)); - /// assert_eq!(stream.hdcp_level(), Some(HdcpLevel::None)); - /// ``` - pub fn set_hdcp_level>(&mut self, value: Option) -> &mut Self { - self.hdcp_level = value.map(Into::into); - self - } -} - -impl fmt::Display for StreamInf { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "BANDWIDTH={}", self.bandwidth)?; - - if let Some(value) = &self.average_bandwidth { - write!(f, ",AVERAGE-BANDWIDTH={}", value)?; - } - if let Some(value) = &self.codecs { - write!(f, ",CODECS={}", quote(value))?; - } - if let Some(value) = &self.resolution { - write!(f, ",RESOLUTION={}", value)?; - } - if let Some(value) = &self.hdcp_level { - write!(f, ",HDCP-LEVEL={}", value)?; - } - if let Some(value) = &self.video { - write!(f, ",VIDEO={}", quote(value))?; - } - Ok(()) - } -} - -impl FromStr for StreamInf { - type Err = Error; - - fn from_str(input: &str) -> Result { - let mut bandwidth = None; - let mut average_bandwidth = None; - let mut codecs = None; - let mut resolution = None; - let mut hdcp_level = None; - let mut video = None; - - for (key, value) in input.parse::()? { - match key.as_str() { - "BANDWIDTH" => bandwidth = Some(value.parse::()?), - "AVERAGE-BANDWIDTH" => average_bandwidth = Some(value.parse::()?), - "CODECS" => codecs = Some(unquote(value)), - "RESOLUTION" => resolution = Some(value.parse()?), - "HDCP-LEVEL" => hdcp_level = Some(value.parse()?), - "VIDEO" => video = Some(unquote(value)), - _ => { - // [6.3.1. General Client Responsibilities] - // > ignore any attribute/value pair with an unrecognized - // AttributeName. - } - } - } - - let bandwidth = bandwidth.ok_or_else(|| Error::missing_value("BANDWIDTH"))?; - - Ok(Self { - bandwidth, - average_bandwidth, - codecs, - resolution, - hdcp_level, - video, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_display() { - let mut stream_inf = StreamInf::new(200); - stream_inf.set_average_bandwidth(Some(15)); - stream_inf.set_codecs(Some("mp4a.40.2,avc1.4d401e")); - stream_inf.set_resolution(1920, 1080); - stream_inf.set_hdcp_level(Some(HdcpLevel::Type0)); - stream_inf.set_video(Some("video")); - - assert_eq!( - stream_inf.to_string(), - "BANDWIDTH=200,\ - AVERAGE-BANDWIDTH=15,\ - CODECS=\"mp4a.40.2,avc1.4d401e\",\ - RESOLUTION=1920x1080,\ - HDCP-LEVEL=TYPE-0,\ - VIDEO=\"video\"" - .to_string() - ); - } - - #[test] - fn test_parser() { - let mut stream_inf = StreamInf::new(200); - stream_inf.set_average_bandwidth(Some(15)); - stream_inf.set_codecs(Some("mp4a.40.2,avc1.4d401e")); - stream_inf.set_resolution(1920, 1080); - stream_inf.set_hdcp_level(Some(HdcpLevel::Type0)); - stream_inf.set_video(Some("video")); - - assert_eq!( - stream_inf, - "BANDWIDTH=200,\ - AVERAGE-BANDWIDTH=15,\ - CODECS=\"mp4a.40.2,avc1.4d401e\",\ - RESOLUTION=1920x1080,\ - HDCP-LEVEL=TYPE-0,\ - VIDEO=\"video\"" - .parse() - .unwrap() - ); - - assert_eq!( - stream_inf, - "BANDWIDTH=200,\ - AVERAGE-BANDWIDTH=15,\ - CODECS=\"mp4a.40.2,avc1.4d401e\",\ - RESOLUTION=1920x1080,\ - HDCP-LEVEL=TYPE-0,\ - VIDEO=\"video\",\ - UNKNOWN=\"value\"" - .parse() - .unwrap() - ); - - assert!("garbage".parse::().is_err()); - } -} diff --git a/src/types/ufloat.rs b/src/types/ufloat.rs new file mode 100644 index 0000000..25cb13f --- /dev/null +++ b/src/types/ufloat.rs @@ -0,0 +1,317 @@ +use core::cmp::Ordering; +use core::convert::TryFrom; +use core::str::FromStr; + +use derive_more::{AsRef, Deref, Display}; + +use crate::Error; + +/// A wrapper type around an [`f32`], that can not be constructed +/// with a negative float (e.g. `-1.1`), [`NaN`], [`INFINITY`] or +/// [`NEG_INFINITY`]. +/// +/// [`NaN`]: core::f32::NAN +/// [`INFINITY`]: core::f32::INFINITY +/// [`NEG_INFINITY`]: core::f32::NEG_INFINITY +#[derive(AsRef, Deref, Default, Debug, Copy, Clone, PartialOrd, Display)] +pub struct UFloat(f32); + +impl UFloat { + /// Makes a new [`UFloat`] from an [`f32`]. + /// + /// # Panics + /// + /// If the given float is negative, infinite or [`NaN`]. + /// + /// # Examples + /// + /// ``` + /// # use hls_m3u8::types::UFloat; + /// let float = UFloat::new(1.0); + /// ``` + /// + /// This would panic: + /// + /// ```should_panic + /// # use hls_m3u8::types::UFloat; + /// let float = UFloat::new(-1.0); + /// ``` + /// + /// [`NaN`]: core::f32::NAN + #[must_use] + pub fn new(float: f32) -> Self { + if float.is_infinite() { + panic!("float must be finite: `{}`", float); + } + + if float.is_nan() { + panic!("float must not be `NaN`"); + } + + if float.is_sign_negative() { + panic!("float must be positive: `{}`", float); + } + + Self(float) + } + + /// Returns the underlying [`f32`]. + /// + /// # Example + /// + /// ``` + /// # use hls_m3u8::types::UFloat; + /// assert_eq!(UFloat::new(1.1_f32).as_f32(), 1.1_f32); + /// ``` + #[must_use] + pub const fn as_f32(self) -> f32 { self.0 } +} + +impl FromStr for UFloat { + type Err = Error; + + fn from_str(input: &str) -> Result { + let float = f32::from_str(input).map_err(|e| Error::parse_float(input, e))?; + Self::try_from(float) + } +} + +impl TryFrom for UFloat { + type Error = Error; + + fn try_from(float: f32) -> Result { + if float.is_infinite() { + return Err(Error::custom(format!("float must be finite: `{}`", float))); + } + + if float.is_nan() { + return Err(Error::custom("float must not be `NaN`")); + } + + if float.is_sign_negative() { + return Err(Error::custom(format!( + "float must be positive: `{}`", + float + ))); + } + + Ok(Self(float)) + } +} + +macro_rules! implement_from { + ( $( $type:tt ),+ ) => { + $( + impl ::core::convert::From<$type> for UFloat { + fn from(value: $type) -> Self { + Self(value as f32) + } + } + )+ + } +} + +implement_from!(u16, u8); + +// This has to be implemented explicitly, because `Hash` is also implemented +// manually and both implementations have to agree according to clippy. +impl PartialEq for UFloat { + #[inline] + fn eq(&self, other: &Self) -> bool { self.0 == other.0 } +} + +// convenience implementation to compare f32 with a Float. +impl PartialEq for UFloat { + #[inline] + fn eq(&self, other: &f32) -> bool { &self.0 == other } +} + +// In order to implement `Eq` a struct has to satisfy +// the following requirements: +// - reflexive: a == a; +// - symmetric: a == b implies b == a; and +// - transitive: a == b and b == c implies a == c. +// +// The symmetric and transitive parts are already satisfied +// through `PartialEq`. The reflexive part is not satisfied for f32, +// because `f32::NAN` never equals `f32::NAN`. (`assert!(f32::NAN, f32::NAN)`) +// +// It is ensured, that this struct can not be constructed +// with NaN so all of the above requirements are satisfied and therefore Eq can +// be soundly implemented. +impl Eq for UFloat {} + +impl Ord for UFloat { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + if self.0 < other.0 { + Ordering::Less + } else if self == other { + Ordering::Equal + } else { + Ordering::Greater + } + } +} + +/// The output of Hash cannot be relied upon to be stable. The same version of +/// rust can return different values in different architectures. This is not a +/// property of the Hasher that you’re using but instead of the way Hash happens +/// to be implemented for the type you’re using (e.g., the current +/// implementation of Hash for slices of integers returns different values in +/// big and little-endian architectures). +/// +/// See +#[doc(hidden)] +impl ::core::hash::Hash for UFloat { + fn hash(&self, state: &mut H) + where + H: ::core::hash::Hasher, + { + // this implementation assumes, that the internal float is: + // - positive + // - not NaN + // - neither negative nor positive infinity + + // to validate those assumptions debug_assertions are here + // (those will be removed in a release build) + debug_assert!(self.0.is_sign_positive()); + debug_assert!(self.0.is_finite()); + debug_assert!(!self.0.is_nan()); + + // this implementation is based on + // https://internals.rust-lang.org/t/f32-f64-should-implement-hash/5436/33 + // + // The important points are: + // - NaN == NaN (UFloat does not allow NaN, so this should be satisfied) + // - +0 != -0 (UFloat does not allow negative numbers, so this is fine too) + + // I do not think it matters to differentiate between architectures, that use + // big endian by default and those, that use little endian. + state.write(&self.to_be_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::hash::{Hash, Hasher}; + use pretty_assertions::assert_eq; + + #[test] + fn test_display() { + assert_eq!(UFloat::new(22.0).to_string(), "22".to_string()); + assert_eq!( + UFloat::new(3.14159265359).to_string(), + "3.1415927".to_string() + ); + } + + #[test] + fn test_parser() { + assert_eq!(UFloat::new(22.0), UFloat::from_str("22").unwrap()); + assert_eq!( + UFloat::new(3.14159265359), + UFloat::from_str("3.14159265359").unwrap() + ); + assert!(UFloat::from_str("1#").is_err()); + assert!(UFloat::from_str("-1.0").is_err()); + assert!(UFloat::from_str("NaN").is_err()); + assert!(UFloat::from_str("inf").is_err()); + assert!(UFloat::from_str("-inf").is_err()); + } + + #[test] + fn test_hash() { + let mut hasher_left = std::collections::hash_map::DefaultHasher::new(); + let mut hasher_right = std::collections::hash_map::DefaultHasher::new(); + + assert_eq!( + UFloat::new(1.0).hash(&mut hasher_left), + UFloat::new(1.0).hash(&mut hasher_right) + ); + + assert_eq!(hasher_left.finish(), hasher_right.finish()); + } + + #[test] + fn test_ord() { + assert_eq!(UFloat::new(1.1).cmp(&UFloat::new(1.1)), Ordering::Equal); + assert_eq!(UFloat::new(1.1).cmp(&UFloat::new(2.1)), Ordering::Less); + assert_eq!(UFloat::new(1.1).cmp(&UFloat::new(0.1)), Ordering::Greater); + } + + #[test] + fn test_partial_ord() { + assert_eq!( + UFloat::new(1.1).partial_cmp(&UFloat::new(1.1)), + Some(Ordering::Equal) + ); + assert_eq!( + UFloat::new(1.1).partial_cmp(&UFloat::new(2.1)), + Some(Ordering::Less) + ); + assert_eq!( + UFloat::new(1.1).partial_cmp(&UFloat::new(0.1)), + Some(Ordering::Greater) + ); + } + + #[test] + fn test_partial_eq() { + assert_eq!(UFloat::new(1.0).eq(&UFloat::new(1.0)), true); + assert_eq!(UFloat::new(1.0).eq(&UFloat::new(33.3)), false); + assert_eq!(UFloat::new(1.1), 1.1); + } + + #[test] + #[should_panic = "float must be positive: `-1.1`"] + fn test_new_negative() { let _ = UFloat::new(-1.1); } + + #[test] + #[should_panic = "float must be positive: `0`"] + fn test_new_negative_zero() { let _ = UFloat::new(-0.0); } + + #[test] + #[should_panic = "float must be finite: `inf`"] + fn test_new_infinite() { let _ = UFloat::new(::core::f32::INFINITY); } + + #[test] + #[should_panic = "float must be finite: `-inf`"] + fn test_new_neg_infinite() { let _ = UFloat::new(::core::f32::NEG_INFINITY); } + + #[test] + #[should_panic = "float must not be `NaN`"] + fn test_new_nan() { let _ = UFloat::new(::core::f32::NAN); } + + #[test] + fn test_as_f32() { + assert_eq!(UFloat::new(1.1).as_f32(), 1.1_f32); + } + + #[test] + fn test_from() { + assert_eq!(UFloat::from(1_u8), UFloat::new(1.0)); + assert_eq!(UFloat::from(1_u16), UFloat::new(1.0)); + } + + #[test] + fn test_try_from() { + assert_eq!(UFloat::try_from(1.1_f32).unwrap(), UFloat::new(1.1)); + + assert_eq!( + UFloat::try_from(-1.1_f32), + Err(Error::custom("float must be positive: `-1.1`")) + ); + assert!(UFloat::try_from(::core::f32::INFINITY).is_err()); + assert!(UFloat::try_from(::core::f32::NAN).is_err()); + assert!(UFloat::try_from(::core::f32::NEG_INFINITY).is_err()); + } + + #[test] + fn test_eq() { + struct _AssertEq + where + UFloat: Eq; + } +} diff --git a/src/types/value.rs b/src/types/value.rs index 8c0d961..e7e9563 100644 --- a/src/types/value.rs +++ b/src/types/value.rs @@ -1,24 +1,24 @@ use std::fmt; use std::str::FromStr; -use hex; - +use crate::types::Float; use crate::utils::{quote, unquote}; use crate::Error; -#[derive(Debug, Clone, PartialEq, PartialOrd)] -/// A [`Value`]. +/// A `Value`. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] pub enum Value { - /// A [`String`]. + /// A `String`. String(String), /// A sequence of bytes. Hex(Vec), - /// A floating point number, that's neither NaN nor infinite! - Float(f64), + /// A floating point number, that's neither NaN nor infinite. + Float(Float), } impl fmt::Display for Value { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { Self::String(value) => write!(f, "{}", quote(value)), Self::Hex(value) => write!(f, "0x{}", hex::encode_upper(value)), @@ -32,9 +32,10 @@ impl FromStr for Value { fn from_str(input: &str) -> Result { if input.starts_with("0x") || input.starts_with("0X") { - Ok(Self::Hex(hex::decode( - input.trim_start_matches("0x").trim_start_matches("0X"), - )?)) + Ok(Self::Hex( + hex::decode(input.trim_start_matches("0x").trim_start_matches("0X")) + .map_err(Error::hex)?, + )) } else { match input.parse() { Ok(value) => Ok(Self::Float(value)), @@ -44,8 +45,8 @@ impl FromStr for Value { } } -impl From for Value { - fn from(value: f64) -> Self { Self::Float(value) } +impl> From for Value { + fn from(value: T) -> Self { Self::Float(value.into()) } } impl From> for Value { @@ -60,10 +61,6 @@ impl From<&str> for Value { fn from(value: &str) -> Self { Self::String(unquote(value)) } } -// impl> From for Value { -// fn from(value: T) -> Self { Self::Hex(value.as_ref().into()) } -// } - #[cfg(test)] mod tests { use super::*; @@ -71,7 +68,7 @@ mod tests { #[test] fn test_display() { - assert_eq!(Value::Float(1.1).to_string(), "1.1".to_string()); + assert_eq!(Value::Float(Float::new(1.1)).to_string(), "1.1".to_string()); assert_eq!( Value::String("&str".to_string()).to_string(), "\"&str\"".to_string() @@ -84,7 +81,7 @@ mod tests { #[test] fn test_parser() { - assert_eq!(Value::Float(1.1), "1.1".parse().unwrap()); + assert_eq!(Value::Float(Float::new(1.1)), "1.1".parse().unwrap()); assert_eq!( Value::String("&str".to_string()), "\"&str\"".parse().unwrap() @@ -96,7 +93,7 @@ mod tests { #[test] fn test_from() { - assert_eq!(Value::from(1.0_f64), Value::Float(1.0)); + assert_eq!(Value::from(1_u8), Value::Float(Float::new(1.0))); assert_eq!(Value::from("\"&str\""), Value::String("&str".to_string())); assert_eq!( Value::from("&str".to_string()), diff --git a/src/utils.rs b/src/utils.rs index d21d9e6..b7e628f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,44 @@ use crate::Error; +use core::iter; + +/// This is an extension trait that adds the below method to `bool`. +/// Those methods are already planned for the standard library, but are not +/// stable at the time of writing this comment. +/// +/// The current status can be seen here: +/// +/// +/// This trait exists to allow publishing a new version (requires stable +/// release) and the functions are prefixed with an `a` to prevent naming +/// conflicts with the coming std functions. +// TODO: replace this trait with std version as soon as it is stabilized +pub(crate) trait BoolExt { + #[must_use] + fn athen_some(self, t: T) -> Option; + + #[must_use] + fn athen T>(self, f: F) -> Option; +} + +impl BoolExt for bool { + #[inline] + fn athen_some(self, t: T) -> Option { + if self { + Some(t) + } else { + None + } + } + + #[inline] + fn athen T>(self, f: F) -> Option { + if self { + Some(f()) + } else { + None + } + } +} macro_rules! required_version { ( $( $tag:expr ),* ) => { @@ -11,27 +51,6 @@ macro_rules! required_version { } } -macro_rules! impl_from { - ( $($( $type:tt ),* => $target:path ),* ) => { - use ::core::convert::From; - - $( // repeat $target - $( // repeat $type - impl From<$type> for $target { - fn from(value: $type) -> Self { - Self::from_f64_unchecked(value.into()) - } - } - )* - )* - }; -} - -impl_from![ - u8, u16, u32 => crate::types::DecimalFloatingPoint, - u8, i8, u16, i16, u32, i32, f32, f64 => crate::types::SignedDecimalFloatingPoint -]; - pub(crate) fn parse_yes_or_no>(s: T) -> crate::Result { match s.as_ref() { "YES" => Ok(true), @@ -48,25 +67,30 @@ pub(crate) fn parse_yes_or_no>(s: T) -> crate::Result { /// /// Therefore it is safe to simply remove any occurence of those characters. /// [rfc8216#section-4.2](https://tools.ietf.org/html/rfc8216#section-4.2) -pub(crate) fn unquote(value: T) -> String { +pub(crate) fn unquote>(value: T) -> String { value - .to_string() - .replace("\"", "") - .replace("\n", "") - .replace("\r", "") + .as_ref() + .chars() + .filter(|c| *c != '"' && *c != '\n' && *c != '\r') + .collect() } /// Puts a string inside quotes. +#[allow(clippy::needless_pass_by_value)] pub(crate) fn quote(value: T) -> String { // the replace is for the case, that quote is called on an already quoted // string, which could cause problems! - format!("\"{}\"", value.to_string().replace("\"", "")) + iter::once('"') + .chain(value.to_string().chars().filter(|c| *c != '"')) + .chain(iter::once('"')) + .collect() } /// Checks, if the given tag is at the start of the input. If this is the case, /// it will remove it and return the rest of the input. /// /// # Error +/// /// This function will return `Error::MissingTag`, if the input doesn't start /// with the tag, that has been passed to this function. pub(crate) fn tag(input: &str, tag: T) -> crate::Result<&str> @@ -76,8 +100,8 @@ where if !input.trim().starts_with(tag.as_ref()) { return Err(Error::missing_tag(tag.as_ref(), input)); } - let result = input.split_at(tag.as_ref().len()).1; - Ok(result) + + Ok(input.trim().split_at(tag.as_ref().len()).1) } #[cfg(test)] @@ -122,5 +146,30 @@ mod tests { assert_eq!(input, "SampleString"); assert!(tag(input, "B").is_err()); + + assert_eq!( + tag( + concat!( + "\n #EXTM3U\n", + " #EXT-X-TARGETDURATION:5220\n", + " #EXTINF:0,\n", + " http://media.example.com/entire1.ts\n", + " #EXTINF:5220,\n", + " http://media.example.com/entire2.ts\n", + " #EXT-X-ENDLIST" + ), + "#EXTM3U" + ) + .unwrap(), + concat!( + "\n", + " #EXT-X-TARGETDURATION:5220\n", + " #EXTINF:0,\n", + " http://media.example.com/entire1.ts\n", + " #EXTINF:5220,\n", + " http://media.example.com/entire2.ts\n", + " #EXT-X-ENDLIST" + ) + ); } } diff --git a/tests/master_playlist.rs b/tests/master_playlist.rs index fb2ecfc..5a6cd4c 100644 --- a/tests/master_playlist.rs +++ b/tests/master_playlist.rs @@ -1,371 +1,129 @@ -use hls_m3u8::tags::{ExtXIFrameStreamInf, ExtXMedia, ExtXStreamInf}; -use hls_m3u8::types::MediaType; +use hls_m3u8::tags::{ExtXMedia, VariantStream}; +use hls_m3u8::types::{MediaType, StreamData}; use hls_m3u8::MasterPlaylist; use pretty_assertions::assert_eq; -#[test] -fn test_master_playlist() { - // https://tools.ietf.org/html/rfc8216#section-8.4 - let master_playlist = "#EXTM3U\n\ - #EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000\n\ - http://example.com/low.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000\n\ - http://example.com/mid.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000\n\ - http://example.com/hi.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n\ - http://example.com/audio-only.m3u8" - .parse::() - .unwrap(); +macro_rules! generate_tests { + ( $( $fnname:ident => { $struct:expr, $str:expr }),+ $(,)* ) => { + $( + #[test] + fn $fnname() { + assert_eq!($struct, $str.parse().unwrap()); - assert_eq!( - MasterPlaylist::builder() - .stream_inf_tags(vec![ - ExtXStreamInf::builder() - .bandwidth(1280000) - .average_bandwidth(1000000) - .uri("http://example.com/low.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(2560000) - .average_bandwidth(2000000) - .uri("http://example.com/mid.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(7680000) - .average_bandwidth(6000000) - .uri("http://example.com/hi.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(65000) - .codecs("mp4a.40.5") - .uri("http://example.com/audio-only.m3u8") - .build() - .unwrap(), - ]) - .build() - .unwrap(), - master_playlist - ); + assert_eq!($struct.to_string(), $str.to_string()); + } + )+ + } } -#[test] -fn test_master_playlist_with_i_frames() { - // https://tools.ietf.org/html/rfc8216#section-8.5 - let master_playlist = "#EXTM3U\n\ - #EXT-X-STREAM-INF:BANDWIDTH=1280000\n\ - low/audio-video.m3u8\n\ - #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI=\"low/iframe.m3u8\"\n\ - #EXT-X-STREAM-INF:BANDWIDTH=2560000\n\ - mid/audio-video.m3u8\n\ - #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI=\"mid/iframe.m3u8\"\n\ - #EXT-X-STREAM-INF:BANDWIDTH=7680000\n\ - hi/audio-video.m3u8\n\ - #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI=\"hi/iframe.m3u8\"\n\ - #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n\ - audio-only.m3u8" - .parse::() - .unwrap(); - - assert_eq!( +generate_tests! { + test_alternate_audio => { MasterPlaylist::builder() - .stream_inf_tags(vec![ - ExtXStreamInf::builder() - .bandwidth(1280000) - .uri("low/audio-video.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(2560000) - .uri("mid/audio-video.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(7680000) - .uri("hi/audio-video.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(65000) - .codecs("mp4a.40.5") - .uri("audio-only.m3u8") - .build() - .unwrap(), - ]) - .i_frame_stream_inf_tags(vec![ - ExtXIFrameStreamInf::builder() - .bandwidth(86000) - .uri("low/iframe.m3u8") - .build() - .unwrap(), - ExtXIFrameStreamInf::builder() - .bandwidth(150000) - .uri("mid/iframe.m3u8") - .build() - .unwrap(), - ExtXIFrameStreamInf::builder() - .bandwidth(550000) - .uri("hi/iframe.m3u8") - .build() - .unwrap(), - ]) - .build() - .unwrap(), - master_playlist - ); -} - -#[test] -fn test_master_playlist_with_alternative_audio() { - // https://tools.ietf.org/html/rfc8216#section-8.6 - // TODO: I think the CODECS=\"..." have to be replaced. - let master_playlist = "#EXTM3U\n\ - #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\", \ - DEFAULT=YES,AUTOSELECT=YES,LANGUAGE=\"en\", \ - URI=\"main/english-audio.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"Deutsch\", \ - DEFAULT=NO,AUTOSELECT=YES,LANGUAGE=\"de\", \ - URI=\"main/german-audio.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"Commentary\", \ - DEFAULT=NO,AUTOSELECT=NO,LANGUAGE=\"en\", \ - URI=\"commentary/audio-only.m3u8\"\n\ - - #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"...\",AUDIO=\"aac\"\n\ - low/video-only.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS=\"...\",AUDIO=\"aac\"\n\ - mid/video-only.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS=\"...\",AUDIO=\"aac\"\n\ - hi/video-only.m3u8\n\ - #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\",AUDIO=\"aac\"\n\ - main/english-audio.m3u8" - .parse::() - .unwrap(); - - assert_eq!( - MasterPlaylist::builder() - .media_tags(vec![ + .media(vec![ ExtXMedia::builder() .media_type(MediaType::Audio) - .group_id("aac") + .group_id("audio") + .language("eng") .name("English") - .is_default(true) .is_autoselect(true) - .language("en") - .uri("main/english-audio.m3u8") + .is_default(true) + .uri("eng/prog_index.m3u8") .build() .unwrap(), ExtXMedia::builder() .media_type(MediaType::Audio) - .group_id("aac") - .name("Deutsch") - .is_default(false) + .group_id("audio") + .language("fre") + .name("Français") .is_autoselect(true) - .language("de") - .uri("main/german-audio.m3u8") + .is_default(false) + .uri("fre/prog_index.m3u8") .build() .unwrap(), ExtXMedia::builder() .media_type(MediaType::Audio) - .group_id("aac") - .name("Commentary") + .group_id("audio") + .language("sp") + .name("Espanol") + .is_autoselect(true) .is_default(false) - .is_autoselect(false) - .language("en") - .uri("commentary/audio-only.m3u8") + .uri("sp/prog_index.m3u8") .build() .unwrap(), ]) - .stream_inf_tags(vec![ - ExtXStreamInf::builder() - .bandwidth(1280000) - .codecs("...") - .audio("aac") - .uri("low/video-only.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(2560000) - .codecs("...") - .audio("aac") - .uri("mid/video-only.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(7680000) - .codecs("...") - .audio("aac") - .uri("hi/video-only.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(65000) - .codecs("mp4a.40.5") - .audio("aac") - .uri("main/english-audio.m3u8") - .build() - .unwrap(), + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "lo/prog_index.m3u8".into(), + frame_rate: None, + audio: Some("audio".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(195023) + .codecs(&["avc1.42e00a", "mp4a.40.2"]) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "hi/prog_index.m3u8".into(), + frame_rate: None, + audio: Some("audio".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(591680) + .codecs(&["avc1.42e01e", "mp4a.40.2"]) + .build() + .unwrap() + } ]) .build() .unwrap(), - master_playlist - ); -} - -#[test] -fn test_master_playlist_with_alternative_video() { - // https://tools.ietf.org/html/rfc8216#section-8.7 - let master_playlist = "#EXTM3U\n\ - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"low\",NAME=\"Main\", \ - AUTOSELECT=YES,DEFAULT=YES,URI=\"low/main/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"low\",NAME=\"Centerfield\", \ - DEFAULT=NO,URI=\"low/centerfield/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"low\",NAME=\"Dugout\", \ - DEFAULT=NO,URI=\"low/dugout/audio-video.m3u8\"\n\ - - #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"...\",VIDEO=\"low\"\n\ - low/main/audio-video.m3u8\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"mid\",NAME=\"Main\", \ - AUTOSELECT=YES,DEFAULT=YES,URI=\"mid/main/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"mid\",NAME=\"Centerfield\", \ - DEFAULT=NO,URI=\"mid/centerfield/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"mid\",NAME=\"Dugout\", \ - DEFAULT=NO,URI=\"mid/dugout/audio-video.m3u8\"\n\ - - #EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS=\"...\",VIDEO=\"mid\"\n\ - mid/main/audio-video.m3u8\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"hi\",NAME=\"Main\", \ - AUTOSELECT=YES,DEFAULT=YES,URI=\"hi/main/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"hi\",NAME=\"Centerfield\", \ - DEFAULT=NO,URI=\"hi/centerfield/audio-video.m3u8\"\n\ - - #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"hi\",NAME=\"Dugout\", \ - DEFAULT=NO,URI=\"hi/dugout/audio-video.m3u8\"\n\ - - #EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS=\"...\",VIDEO=\"hi\" - hi/main/audio-video.m3u8" - .parse::() - .unwrap(); - - assert_eq!( - MasterPlaylist::builder() - .media_tags(vec![ - // low - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("low") - .name("Main") - .is_default(true) - .is_autoselect(true) - .uri("low/main/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("low") - .name("Centerfield") - .is_default(false) - .uri("low/centerfield/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("low") - .name("Dugout") - .is_default(false) - .uri("low/dugout/audio-video.m3u8") - .build() - .unwrap(), - // mid - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("mid") - .name("Main") - .is_default(true) - .is_autoselect(true) - .uri("mid/main/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("mid") - .name("Centerfield") - .is_default(false) - .uri("mid/centerfield/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("mid") - .name("Dugout") - .is_default(false) - .uri("mid/dugout/audio-video.m3u8") - .build() - .unwrap(), - // hi - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("hi") - .name("Main") - .is_default(true) - .is_autoselect(true) - .uri("hi/main/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("hi") - .name("Centerfield") - .is_default(false) - .uri("hi/centerfield/audio-video.m3u8") - .build() - .unwrap(), - ExtXMedia::builder() - .media_type(MediaType::Video) - .group_id("hi") - .name("Dugout") - .is_default(false) - .uri("hi/dugout/audio-video.m3u8") - .build() - .unwrap(), - ]) - .stream_inf_tags(vec![ - ExtXStreamInf::builder() - .bandwidth(1280000) - .codecs("...") - .video("low") - .uri("low/main/audio-video.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(2560000) - .codecs("...") - .video("mid") - .uri("mid/main/audio-video.m3u8") - .build() - .unwrap(), - ExtXStreamInf::builder() - .bandwidth(7680000) - .codecs("...") - .video("hi") - .uri("hi/main/audio-video.m3u8") - .build() - .unwrap(), - ]) - .build() - .unwrap(), - master_playlist - ); + concat!( + "#EXTM3U\n", + + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"eng/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"eng\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES", + "\n", + + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"fre/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"fre\",", + "NAME=\"Français\",", + "AUTOSELECT=YES", + "\n", + + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"sp/prog_index.m3u8\",", + "GROUP-ID=\"audio\",", + "LANGUAGE=\"sp\",", + "NAME=\"Espanol\",", + "AUTOSELECT=YES", + "\n", + + "#EXT-X-STREAM-INF:", + "BANDWIDTH=195023,", + "CODECS=\"avc1.42e00a,mp4a.40.2\",", + "AUDIO=\"audio\"", + "\n", + "lo/prog_index.m3u8\n", + + "#EXT-X-STREAM-INF:", + "BANDWIDTH=591680,", + "CODECS=\"avc1.42e01e,mp4a.40.2\",", + "AUDIO=\"audio\"", + "\n", + "hi/prog_index.m3u8\n" + ) + } } diff --git a/tests/media_playlist.rs b/tests/media_playlist.rs index 7f8a860..b1fb799 100644 --- a/tests/media_playlist.rs +++ b/tests/media_playlist.rs @@ -1,53 +1,317 @@ +//! Some tests of this file are from +//! +//! +//! TODO: the rest of the tests + use std::time::Duration; -use hls_m3u8::tags::{ExtInf, ExtXByteRange, ExtXMediaSequence, ExtXTargetDuration}; +use hls_m3u8::tags::{ExtInf, ExtXByteRange}; +use hls_m3u8::types::PlaylistType; use hls_m3u8::{MediaPlaylist, MediaSegment}; use pretty_assertions::assert_eq; -#[test] -fn test_media_playlist_with_byterange() { - let media_playlist = "#EXTM3U\n\ - #EXT-X-TARGETDURATION:10\n\ - #EXT-X-VERSION:4\n\ - #EXT-X-MEDIA-SEQUENCE:0\n\ - #EXTINF:10.0,\n\ - #EXT-X-BYTERANGE:75232@0\n\ - video.ts\n\ - #EXT-X-BYTERANGE:82112@752321\n\ - #EXTINF:10.0,\n\ - video.ts\n\ - #EXTINF:10.0,\n\ - #EXT-X-BYTERANGE:69864\n\ - video.ts" - .parse::() - .unwrap(); +macro_rules! generate_tests { + ( $( $fnname:ident => { $struct:expr, $str:expr }),+ $(,)* ) => { + $( + #[test] + fn $fnname() { + assert_eq!($struct, $str.parse().unwrap()); - assert_eq!( + assert_eq!($struct.to_string(), $str.to_string()); + } + )+ + } +} + +generate_tests! { + test_media_playlist_with_byterange => { MediaPlaylist::builder() - .target_duration_tag(ExtXTargetDuration::new(Duration::from_secs(10))) - .media_sequence_tag(ExtXMediaSequence::new(0)) + .media_sequence(1) + .target_duration(Duration::from_secs(10)) .segments(vec![ MediaSegment::builder() - .inf_tag(ExtInf::new(Duration::from_secs_f64(10.0))) - .byte_range_tag(ExtXByteRange::new(75232, Some(0))) + .duration(ExtInf::new(Duration::from_secs_f64(10.0))) + .byte_range(ExtXByteRange::from(0..75232)) .uri("video.ts") .build() .unwrap(), MediaSegment::builder() - .inf_tag(ExtInf::new(Duration::from_secs_f64(10.0))) - .byte_range_tag(ExtXByteRange::new(82112, Some(752321))) + .duration(ExtInf::new(Duration::from_secs_f64(10.0))) + .byte_range(ExtXByteRange::from(752321..82112 + 752321)) .uri("video.ts") .build() .unwrap(), MediaSegment::builder() - .inf_tag(ExtInf::new(Duration::from_secs_f64(10.0))) - .byte_range_tag(ExtXByteRange::new(69864, None)) + .duration(ExtInf::new(Duration::from_secs_f64(10.0))) + // 834433..904297 + .byte_range(ExtXByteRange::from(..69864)) .uri("video.ts") .build() .unwrap(), ]) .build() .unwrap(), - media_playlist - ) + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:4\n", + "#EXT-X-TARGETDURATION:10\n", + "#EXT-X-MEDIA-SEQUENCE:1\n", + + "#EXT-X-BYTERANGE:75232@0\n", + "#EXTINF:10,\n", + "video.ts\n", + + "#EXT-X-BYTERANGE:82112@752321\n", + "#EXTINF:10,\n", + "video.ts\n", + + "#EXT-X-BYTERANGE:69864@834433\n", + "#EXTINF:10,\n", + "video.ts\n" + ) + }, + test_absolute_uris => { + MediaPlaylist::builder() + .playlist_type(PlaylistType::Vod) + .target_duration(Duration::from_secs(10)) + .segments(vec![ + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .uri("http://example.com/00001.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .uri("https://example.com/00002.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .uri("//example.com/00003.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .uri("http://example.com/00004.ts") + .build() + .unwrap(), + ]) + // TODO: currently this is treated as a comment + // .unknown(vec![ + // "#ZEN-TOTAL-DURATION:57.9911".into() + // ]) + .has_end_list(true) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-TARGETDURATION:10\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + "#EXTINF:10,\n", + "http://example.com/00001.ts\n", + "#EXTINF:10,\n", + "https://example.com/00002.ts\n", + "#EXTINF:10,\n", + "//example.com/00003.ts\n", + "#EXTINF:10,\n", + "http://example.com/00004.ts\n", + //"#ZEN-TOTAL-DURATION:57.9911\n", + "#EXT-X-ENDLIST\n" + ) + }, + test_allow_cache => { + MediaPlaylist::builder() + .target_duration(Duration::from_secs(10)) + .media_sequence(1) + .playlist_type(PlaylistType::Vod) + .segments(vec![ + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .uri("hls_450k_video.ts") + .byte_range(0..522_828) + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(522_828..1_110_328) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(1_110_328..1_823_412) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(1_823_412..2_299_992) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(2_299_992..2_835_604) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(2_835_604..3_042_780) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(3_042_780..3_498_680) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(3_498_680..4_155_928) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(4_155_928..4_727_636) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(4_727_636..5_212_676) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(5_212_676..5_921_812) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(5_921_812..6_651_816) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(6_651_816..7_108_092) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(7_108_092..7_576_776) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(7_576_776..8_021_772) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs(10))) + .byte_range(8_021_772..8_353_216) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(1.4167))) + .byte_range(8_353_216..8_397_772) + .uri("hls_450k_video.ts") + .build() + .unwrap(), + ]) + .has_end_list(true) + .unknown(vec![ + // deprecated tag: + "#EXT-X-ALLOW-CACHE:YES".into() + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:4\n", + "#EXT-X-TARGETDURATION:10\n", + "#EXT-X-MEDIA-SEQUENCE:1\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + + "#EXT-X-BYTERANGE:522828@0\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:587500@522828\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:713084@1110328\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:476580@1823412\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:535612@2299992\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:207176@2835604\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:455900@3042780\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:657248@3498680\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:571708@4155928\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:485040@4727636\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:709136@5212676\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:730004@5921812\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:456276@6651816\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:468684@7108092\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:444996@7576776\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:331444@8021772\n", + "#EXTINF:10,\n", + "hls_450k_video.ts\n", + + "#EXT-X-BYTERANGE:44556@8353216\n", + "#EXTINF:1.4167,\n", + "hls_450k_video.ts\n", + + "#EXT-X-ALLOW-CACHE:YES\n", + "#EXT-X-ENDLIST\n" + ) + }, } diff --git a/tests/playlist.rs b/tests/playlist.rs deleted file mode 100644 index 25ccabd..0000000 --- a/tests/playlist.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Credits go to -//! - https://github.com/globocom/m3u8/blob/master/tests/playlists.py -use hls_m3u8::tags::*; -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::().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(), - &"http://media.example.com/entire1.ts".to_string() - ); - - assert_eq!( - media_playlist.segments()[1].uri(), - &"http://media.example.com/entire2.ts".to_string() - ); -} diff --git a/tests/rfc8216.rs b/tests/rfc8216.rs new file mode 100644 index 0000000..cec0e96 --- /dev/null +++ b/tests/rfc8216.rs @@ -0,0 +1,629 @@ +// https://tools.ietf.org/html/rfc8216#section-8 +use std::time::Duration; + +use hls_m3u8::tags::{ExtInf, ExtXKey, ExtXMedia, VariantStream}; +use hls_m3u8::types::{DecryptionKey, EncryptionMethod, MediaType, StreamData}; +use hls_m3u8::{MasterPlaylist, MediaPlaylist, MediaSegment}; +use pretty_assertions::assert_eq; + +macro_rules! generate_tests { + ( $( $fnname:ident => { $struct:expr, $str:expr }),+ $(,)* ) => { + $( + #[test] + fn $fnname() { + assert_eq!($struct, $str.parse().unwrap()); + + assert_eq!($struct.to_string(), $str.to_string()); + } + )+ + } +} + +generate_tests! { + test_simple_playlist => { + MediaPlaylist::builder() + .target_duration(Duration::from_secs(10)) + .segments(vec![ + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(9.009))) + .uri("http://media.example.com/first.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(9.009))) + .uri("http://media.example.com/second.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(3.003))) + .uri("http://media.example.com/third.ts") + .build() + .unwrap(), + ]) + .has_end_list(true) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:3\n", + "#EXT-X-TARGETDURATION:10\n", + "#EXTINF:9.009,\n", + "http://media.example.com/first.ts\n", + "#EXTINF:9.009,\n", + "http://media.example.com/second.ts\n", + "#EXTINF:3.003,\n", + "http://media.example.com/third.ts\n", + "#EXT-X-ENDLIST\n" + ) + }, + test_live_media_playlist_using_https => { + MediaPlaylist::builder() + .target_duration(Duration::from_secs(8)) + .media_sequence(2680) + .segments(vec![ + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(7.975))) + .uri("https://priv.example.com/fileSequence2680.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(7.941))) + .uri("https://priv.example.com/fileSequence2681.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(7.975))) + .uri("https://priv.example.com/fileSequence2682.ts") + .build() + .unwrap(), + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:3\n", + "#EXT-X-TARGETDURATION:8\n", + "#EXT-X-MEDIA-SEQUENCE:2680\n", + "#EXTINF:7.975,\n", + "https://priv.example.com/fileSequence2680.ts\n", + "#EXTINF:7.941,\n", + "https://priv.example.com/fileSequence2681.ts\n", + "#EXTINF:7.975,\n", + "https://priv.example.com/fileSequence2682.ts\n", + ) + }, + test_media_playlist_with_encrypted_segments => { + MediaPlaylist::builder() + .target_duration(Duration::from_secs(15)) + .media_sequence(7794) + .segments(vec![ + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(2.833))) + .keys(vec![ + ExtXKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://priv.example.com/key.php?r=52" + )) + ]) + .uri("http://media.example.com/fileSequence52-A.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(15.0))) + .keys(vec![ + ExtXKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://priv.example.com/key.php?r=52" + )) + ]) + .uri("http://media.example.com/fileSequence52-B.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(13.333))) + .keys(vec![ + ExtXKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://priv.example.com/key.php?r=52" + )) + ]) + .uri("http://media.example.com/fileSequence52-C.ts") + .build() + .unwrap(), + MediaSegment::builder() + .duration(ExtInf::new(Duration::from_secs_f64(15.0))) + .keys(vec![ + ExtXKey::new(DecryptionKey::new( + EncryptionMethod::Aes128, + "https://priv.example.com/key.php?r=53" + )) + ]) + .uri("http://media.example.com/fileSequence53-A.ts") + .build() + .unwrap(), + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:3\n", + "#EXT-X-TARGETDURATION:15\n", + "#EXT-X-MEDIA-SEQUENCE:7794\n", + + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://priv.example.com/key.php?r=52\"\n", + + "#EXTINF:2.833,\n", + "http://media.example.com/fileSequence52-A.ts\n", + "#EXTINF:15,\n", + "http://media.example.com/fileSequence52-B.ts\n", + "#EXTINF:13.333,\n", + "http://media.example.com/fileSequence52-C.ts\n", + + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://priv.example.com/key.php?r=53\"\n", + + "#EXTINF:15,\n", + "http://media.example.com/fileSequence53-A.ts\n" + ) + }, + test_master_playlist => { + MasterPlaylist::builder() + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "http://example.com/low.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(1280000) + .average_bandwidth(1000000) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/mid.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(2560000) + .average_bandwidth(2000000) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/hi.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(7680000) + .average_bandwidth(6000000) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "http://example.com/audio-only.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(65000) + .codecs(&["mp4a.40.5"]) + .build() + .unwrap() + }, + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1000000\n", + "http://example.com/low.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2000000\n", + "http://example.com/mid.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=7680000,AVERAGE-BANDWIDTH=6000000\n", + "http://example.com/hi.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n", + "http://example.com/audio-only.m3u8\n" + ) + }, + test_master_playlist_with_i_frames => { + MasterPlaylist::builder() + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "low/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::new(1280000) + }, + VariantStream::ExtXIFrame { + uri: "low/iframe.m3u8".into(), + stream_data: StreamData::new(86000), + }, + VariantStream::ExtXStreamInf { + uri: "mid/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::new(2560000) + }, + VariantStream::ExtXIFrame { + uri: "mid/iframe.m3u8".into(), + stream_data: StreamData::new(150000), + }, + VariantStream::ExtXStreamInf { + uri: "hi/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::new(7680000) + }, + VariantStream::ExtXIFrame { + uri: "hi/iframe.m3u8".into(), + stream_data: StreamData::new(550000), + }, + VariantStream::ExtXStreamInf { + uri: "audio-only.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(65000) + .codecs(&["mp4a.40.5"]) + .build() + .unwrap() + }, + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-STREAM-INF:BANDWIDTH=1280000\n", + "low/audio-video.m3u8\n", + "#EXT-X-I-FRAME-STREAM-INF:URI=\"low/iframe.m3u8\",BANDWIDTH=86000\n", + "#EXT-X-STREAM-INF:BANDWIDTH=2560000\n", + "mid/audio-video.m3u8\n", + "#EXT-X-I-FRAME-STREAM-INF:URI=\"mid/iframe.m3u8\",BANDWIDTH=150000\n", + "#EXT-X-STREAM-INF:BANDWIDTH=7680000\n", + "hi/audio-video.m3u8\n", + "#EXT-X-I-FRAME-STREAM-INF:URI=\"hi/iframe.m3u8\",BANDWIDTH=550000\n", + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\"\n", + "audio-only.m3u8\n" + ) + }, + test_master_playlist_with_alternative_audio => { + MasterPlaylist::builder() + .media(vec![ + ExtXMedia::builder() + .media_type(MediaType::Audio) + .group_id("aac") + .name("English") + .is_default(true) + .is_autoselect(true) + .language("en") + .uri("main/english-audio.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Audio) + .group_id("aac") + .name("Deutsch") + .is_default(false) + .is_autoselect(true) + .language("de") + .uri("main/german-audio.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Audio) + .group_id("aac") + .name("Commentary") + .is_default(false) + .is_autoselect(false) + .language("en") + .uri("commentary/audio-only.m3u8") + .build() + .unwrap(), + ]) + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "low/video-only.m3u8".into(), + frame_rate: None, + audio: Some("aac".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(1280000) + .codecs(&["..."]) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "mid/video-only.m3u8".into(), + frame_rate: None, + audio: Some("aac".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(2560000) + .codecs(&["..."]) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "hi/video-only.m3u8".into(), + frame_rate: None, + audio: Some("aac".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(7680000) + .codecs(&["..."]) + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "main/english-audio.m3u8".into(), + frame_rate: None, + audio: Some("aac".into()), + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(65000) + .codecs(&["mp4a.40.5"]) + .build() + .unwrap() + }, + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"main/english-audio.m3u8\",", + "GROUP-ID=\"aac\",", + "LANGUAGE=\"en\",", + "NAME=\"English\",", + "DEFAULT=YES,", + "AUTOSELECT=YES\n", + + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"main/german-audio.m3u8\",", + "GROUP-ID=\"aac\",", + "LANGUAGE=\"de\",", + "NAME=\"Deutsch\",", + "AUTOSELECT=YES\n", + + "#EXT-X-MEDIA:", + "TYPE=AUDIO,", + "URI=\"commentary/audio-only.m3u8\",", + "GROUP-ID=\"aac\",", + "LANGUAGE=\"en\",", + "NAME=\"Commentary\"\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"...\",AUDIO=\"aac\"\n", + "low/video-only.m3u8\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS=\"...\",AUDIO=\"aac\"\n", + "mid/video-only.m3u8\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS=\"...\",AUDIO=\"aac\"\n", + "hi/video-only.m3u8\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"mp4a.40.5\",AUDIO=\"aac\"\n", + "main/english-audio.m3u8\n" + ) + }, + test_master_playlist_with_alternative_video => { + MasterPlaylist::builder() + .media(vec![ + // low + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("low") + .name("Main") + .is_default(true) + .is_autoselect(true) + .uri("low/main/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("low") + .name("Centerfield") + .is_default(false) + .uri("low/centerfield/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("low") + .name("Dugout") + .is_default(false) + .uri("low/dugout/audio-video.m3u8") + .build() + .unwrap(), + // mid + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("mid") + .name("Main") + .is_default(true) + .is_autoselect(true) + .uri("mid/main/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("mid") + .name("Centerfield") + .is_default(false) + .uri("mid/centerfield/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("mid") + .name("Dugout") + .is_default(false) + .uri("mid/dugout/audio-video.m3u8") + .build() + .unwrap(), + // hi + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("hi") + .name("Main") + .is_default(true) + .is_autoselect(true) + .uri("hi/main/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("hi") + .name("Centerfield") + .is_default(false) + .uri("hi/centerfield/audio-video.m3u8") + .build() + .unwrap(), + ExtXMedia::builder() + .media_type(MediaType::Video) + .group_id("hi") + .name("Dugout") + .is_default(false) + .uri("hi/dugout/audio-video.m3u8") + .build() + .unwrap(), + ]) + .variant_streams(vec![ + VariantStream::ExtXStreamInf { + uri: "low/main/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(1280000) + .codecs(&["..."]) + .video("low") + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "mid/main/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(2560000) + .codecs(&["..."]) + .video("mid") + .build() + .unwrap() + }, + VariantStream::ExtXStreamInf { + uri: "hi/main/audio-video.m3u8".into(), + frame_rate: None, + audio: None, + subtitles: None, + closed_captions: None, + stream_data: StreamData::builder() + .bandwidth(7680000) + .codecs(&["..."]) + .video("hi") + .build() + .unwrap() + }, + ]) + .build() + .unwrap(), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"low/main/audio-video.m3u8\",", + "GROUP-ID=\"low\",", + "NAME=\"Main\",", + "DEFAULT=YES,", + "AUTOSELECT=YES", + "\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"low/centerfield/audio-video.m3u8\",", + "GROUP-ID=\"low\",", + "NAME=\"Centerfield\"", + "\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"low/dugout/audio-video.m3u8\",", + "GROUP-ID=\"low\",", + "NAME=\"Dugout\"", + "\n", + + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"mid/main/audio-video.m3u8\",", + "GROUP-ID=\"mid\",", + "NAME=\"Main\",", + "DEFAULT=YES,", + "AUTOSELECT=YES\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"mid/centerfield/audio-video.m3u8\",", + "GROUP-ID=\"mid\",", + "NAME=\"Centerfield\"\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"mid/dugout/audio-video.m3u8\",", + "GROUP-ID=\"mid\",", + "NAME=\"Dugout\"\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"hi/main/audio-video.m3u8\",", + "GROUP-ID=\"hi\",", + "NAME=\"Main\",", + "DEFAULT=YES,", + "AUTOSELECT=YES\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"hi/centerfield/audio-video.m3u8\",", + "GROUP-ID=\"hi\",", + "NAME=\"Centerfield\"\n", + + "#EXT-X-MEDIA:", + "TYPE=VIDEO,", + "URI=\"hi/dugout/audio-video.m3u8\",", + "GROUP-ID=\"hi\",", + "NAME=\"Dugout\"\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"...\",VIDEO=\"low\"\n", + "low/main/audio-video.m3u8\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=2560000,CODECS=\"...\",VIDEO=\"mid\"\n", + "mid/main/audio-video.m3u8\n", + + "#EXT-X-STREAM-INF:BANDWIDTH=7680000,CODECS=\"...\",VIDEO=\"hi\"\n", + "hi/main/audio-video.m3u8\n", + ) + } +} diff --git a/tests/version-number.rs b/tests/version-number.rs new file mode 100644 index 0000000..288592d --- /dev/null +++ b/tests/version-number.rs @@ -0,0 +1,9 @@ +#[test] +fn test_readme_deps() { + version_sync::assert_markdown_deps_updated!("README.md"); +} + +#[test] +fn test_html_root_url() { + version_sync::assert_html_root_url_updated!("src/lib.rs"); +}