2019-09-13 14:06:52 +00:00
|
|
|
use std::collections::HashSet;
|
|
|
|
use std::fmt;
|
|
|
|
use std::str::FromStr;
|
|
|
|
|
2019-09-14 11:26:16 +00:00
|
|
|
use derive_builder::Builder;
|
|
|
|
|
2019-03-31 09:58:11 +00:00
|
|
|
use crate::line::{Line, Lines, Tag};
|
|
|
|
use crate::tags::{
|
2020-02-10 12:20:39 +00:00
|
|
|
ExtM3u, ExtXIndependentSegments, ExtXMedia, ExtXSessionData, ExtXSessionKey, ExtXStart,
|
|
|
|
ExtXVersion, VariantStream,
|
2019-03-31 09:58:11 +00:00
|
|
|
};
|
2019-10-04 09:02:21 +00:00
|
|
|
use crate::types::{ClosedCaptions, MediaType, ProtocolVersion};
|
2020-03-28 09:46:07 +00:00
|
|
|
use crate::utils::{tag, BoolExt};
|
2019-10-04 09:02:21 +00:00
|
|
|
use crate::{Error, RequiredVersion};
|
2018-02-14 01:31:24 +00:00
|
|
|
|
2020-02-10 12:20:39 +00:00
|
|
|
/// The master playlist describes all of the available variants for your
|
2020-02-14 12:05:18 +00:00
|
|
|
/// content.
|
2020-03-17 14:58:43 +00:00
|
|
|
///
|
2020-02-14 12:05:18 +00:00
|
|
|
/// Each variant is a version of the stream at a particular bitrate and is
|
2020-03-17 14:58:43 +00:00
|
|
|
/// 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::<MasterPlaylist>()?;
|
|
|
|
///
|
|
|
|
/// 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<dyn ::std::error::Error>>(())
|
|
|
|
/// ```
|
|
|
|
///
|
|
|
|
/// [`MediaPlaylist`]: crate::MediaPlaylist
|
2020-03-25 15:13:40 +00:00
|
|
|
#[derive(Builder, Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
2019-09-14 11:26:16 +00:00
|
|
|
#[builder(build_fn(validate = "Self::validate"))]
|
2019-09-14 19:08:35 +00:00
|
|
|
#[builder(setter(into, strip_option))]
|
2020-03-28 09:46:07 +00:00
|
|
|
#[non_exhaustive]
|
2018-02-14 01:31:24 +00:00
|
|
|
pub struct MasterPlaylist {
|
2020-03-17 14:58:43 +00:00
|
|
|
/// Indicates that all media samples in a [`MediaSegment`] can be
|
|
|
|
/// decoded without information from other segments.
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional and by default `false`. If the field is `true`,
|
|
|
|
/// it applies to every [`MediaSegment`] in every [`MediaPlaylist`] of this
|
|
|
|
/// [`MasterPlaylist`].
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-02-10 12:20:39 +00:00
|
|
|
/// [`MediaSegment`]: crate::MediaSegment
|
|
|
|
/// [`MediaPlaylist`]: crate::MediaPlaylist
|
2019-10-05 07:44:23 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub has_independent_segments: bool,
|
|
|
|
/// A preferred point at which to start playing a playlist.
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional and by default the playlist should be played from
|
|
|
|
/// the start.
|
2019-10-05 07:44:23 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub start: Option<ExtXStart>,
|
|
|
|
/// A list of all [`ExtXMedia`] tags, which describe an alternative
|
|
|
|
/// rendition.
|
2020-02-10 12:20:39 +00:00
|
|
|
///
|
|
|
|
/// 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.
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional.
|
2020-02-10 12:20:39 +00:00
|
|
|
///
|
|
|
|
/// [`MediaPlaylist`]: crate::MediaPlaylist
|
2019-10-05 07:44:23 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub media: Vec<ExtXMedia>,
|
2020-02-10 12:20:39 +00:00
|
|
|
/// A list of all streams of this [`MasterPlaylist`].
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional.
|
2019-10-05 07:44:23 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub variant_streams: Vec<VariantStream>,
|
2020-02-14 12:05:18 +00:00
|
|
|
/// The [`ExtXSessionData`] tag allows arbitrary session data to be
|
|
|
|
/// carried in a [`MasterPlaylist`].
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional.
|
2019-10-05 07:44:23 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub session_data: Vec<ExtXSessionData>,
|
|
|
|
/// A list of [`ExtXSessionKey`]s, that allows the client to preload
|
2020-02-14 12:05:18 +00:00
|
|
|
/// these keys without having to read the [`MediaPlaylist`]s first.
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:38:11 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional.
|
2020-02-21 19:44:09 +00:00
|
|
|
///
|
|
|
|
/// [`MediaPlaylist`]: crate::MediaPlaylist
|
2020-02-02 12:38:11 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub session_keys: Vec<ExtXSessionKey>,
|
|
|
|
/// A list of all tags that could not be identified while parsing the input.
|
2020-02-02 12:50:56 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// ### Note
|
2020-02-02 12:50:56 +00:00
|
|
|
///
|
2020-03-17 14:58:43 +00:00
|
|
|
/// This field is optional.
|
2020-02-02 12:50:56 +00:00
|
|
|
#[builder(default)]
|
2020-03-17 14:58:43 +00:00
|
|
|
pub unknown_tags: Vec<String>,
|
2018-02-14 17:52:56 +00:00
|
|
|
}
|
2019-09-08 09:30:52 +00:00
|
|
|
|
2018-02-14 17:52:56 +00:00
|
|
|
impl MasterPlaylist {
|
2020-02-02 12:38:11 +00:00
|
|
|
/// Returns a builder for a [`MasterPlaylist`].
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
|
|
|
/// # Example
|
2020-01-23 18:13:26 +00:00
|
|
|
///
|
2019-10-05 12:45:40 +00:00
|
|
|
/// ```
|
2020-02-24 15:44:02 +00:00
|
|
|
/// # use hls_m3u8::MasterPlaylist;
|
2020-03-17 14:58:43 +00:00
|
|
|
/// use hls_m3u8::tags::{ExtXStart, VariantStream};
|
|
|
|
/// use hls_m3u8::types::{Float, StreamData};
|
2019-10-05 12:45:40 +00:00
|
|
|
///
|
|
|
|
/// MasterPlaylist::builder()
|
2020-03-17 14:58:43 +00:00
|
|
|
/// .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)))
|
2019-10-05 12:45:40 +00:00
|
|
|
/// .build()?;
|
2020-01-23 18:13:26 +00:00
|
|
|
/// # Ok::<(), Box<dyn ::std::error::Error>>(())
|
2019-10-05 12:45:40 +00:00
|
|
|
/// ```
|
2020-02-24 15:30:43 +00:00
|
|
|
#[must_use]
|
|
|
|
#[inline]
|
2019-10-03 15:01:15 +00:00
|
|
|
pub fn builder() -> MasterPlaylistBuilder { MasterPlaylistBuilder::default() }
|
2020-03-17 14:58:43 +00:00
|
|
|
|
|
|
|
/// Returns all streams, which have an audio group id.
|
|
|
|
pub fn audio_streams(&self) -> impl Iterator<Item = &VariantStream> {
|
|
|
|
self.variant_streams.iter().filter(|stream| {
|
|
|
|
if let VariantStream::ExtXStreamInf { audio: Some(_), .. } = stream {
|
|
|
|
true
|
|
|
|
} else {
|
|
|
|
false
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Returns all streams, which have a video group id.
|
|
|
|
pub fn video_streams(&self) -> impl Iterator<Item = &VariantStream> {
|
|
|
|
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 all streams, which have no group id.
|
|
|
|
pub fn unassociated_streams(&self) -> impl Iterator<Item = &VariantStream> {
|
|
|
|
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 all `ExtXMedia` tags, associated with the provided stream.
|
|
|
|
pub fn associated_with<'a>(
|
|
|
|
&'a self,
|
|
|
|
stream: &'a VariantStream,
|
|
|
|
) -> impl Iterator<Item = &ExtXMedia> + 'a {
|
|
|
|
self.media
|
|
|
|
.iter()
|
|
|
|
.filter(move |media| stream.is_associated(media))
|
|
|
|
}
|
2019-10-05 10:49:08 +00:00
|
|
|
}
|
|
|
|
|
2019-09-22 08:57:28 +00:00
|
|
|
impl RequiredVersion for MasterPlaylist {
|
2019-10-05 10:49:08 +00:00
|
|
|
fn required_version(&self) -> ProtocolVersion {
|
|
|
|
required_version![
|
2020-03-28 09:46:07 +00:00
|
|
|
self.has_independent_segments
|
|
|
|
.athen_some(ExtXIndependentSegments),
|
2020-02-10 12:20:39 +00:00
|
|
|
self.start,
|
|
|
|
self.media,
|
2020-03-17 14:58:43 +00:00
|
|
|
self.variant_streams,
|
2020-02-10 12:20:39 +00:00
|
|
|
self.session_data,
|
|
|
|
self.session_keys
|
2019-10-05 10:49:08 +00:00
|
|
|
]
|
|
|
|
}
|
2019-09-22 08:57:28 +00:00
|
|
|
}
|
|
|
|
|
2019-09-14 11:26:16 +00:00
|
|
|
impl MasterPlaylistBuilder {
|
2019-09-14 19:08:35 +00:00
|
|
|
fn validate(&self) -> Result<(), String> {
|
2020-03-17 14:58:43 +00:00
|
|
|
if let Some(variant_streams) = &self.variant_streams {
|
|
|
|
self.validate_variants(variant_streams)
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
}
|
|
|
|
|
2019-09-14 19:08:35 +00:00
|
|
|
self.validate_session_data_tags()
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
fn validate_variants(&self, variant_streams: &[VariantStream]) -> crate::Result<()> {
|
2020-02-14 12:05:18 +00:00
|
|
|
let mut closed_captions_none = false;
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
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));
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
2020-03-17 14:58:43 +00:00
|
|
|
|
|
|
|
if let Some(group_id) = &stream_data.video() {
|
|
|
|
if !self.check_media_group(MediaType::Video, group_id) {
|
|
|
|
return Err(Error::unmatched_group(group_id));
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
2020-03-17 14:58:43 +00:00
|
|
|
|
|
|
|
if let Some(group_id) = &subtitles {
|
|
|
|
if !self.check_media_group(MediaType::Subtitles, group_id) {
|
|
|
|
return Err(Error::unmatched_group(group_id));
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
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`"));
|
|
|
|
}
|
2020-02-14 12:05:18 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
if !self.check_media_group(MediaType::ClosedCaptions, group_id) {
|
|
|
|
return Err(Error::unmatched_group(group_id));
|
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
}
|
2020-03-17 14:58:43 +00:00
|
|
|
_ => {
|
|
|
|
if !closed_captions_none {
|
|
|
|
closed_captions_none = true;
|
|
|
|
}
|
2020-02-14 12:05:18 +00:00
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
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));
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2019-09-14 19:08:35 +00:00
|
|
|
Ok(())
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2019-09-14 19:08:35 +00:00
|
|
|
fn validate_session_data_tags(&self) -> crate::Result<()> {
|
|
|
|
let mut set = HashSet::new();
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
if let Some(values) = &self.session_data {
|
|
|
|
set.reserve(values.len());
|
2020-02-14 12:05:18 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
for tag in values {
|
|
|
|
if !set.insert((tag.data_id(), tag.language())) {
|
|
|
|
return Err(Error::custom(format!("conflict: {}", tag)));
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2019-09-14 19:08:35 +00:00
|
|
|
Ok(())
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2020-02-10 12:20:39 +00:00
|
|
|
fn check_media_group<T: AsRef<str>>(&self, media_type: MediaType, group_id: T) -> bool {
|
|
|
|
if let Some(value) = &self.media {
|
2020-03-17 14:39:07 +00:00
|
|
|
value.iter().any(|media| {
|
|
|
|
media.media_type == media_type && media.group_id().as_str() == group_id.as_ref()
|
|
|
|
})
|
2019-09-14 11:26:16 +00:00
|
|
|
} else {
|
|
|
|
false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-10-05 10:49:08 +00:00
|
|
|
impl RequiredVersion for MasterPlaylistBuilder {
|
|
|
|
fn required_version(&self) -> ProtocolVersion {
|
|
|
|
// TODO: the .flatten() can be removed as soon as `recursive traits` are
|
|
|
|
// supported. (RequiredVersion is implemented for Option<T>, but
|
|
|
|
// not for Option<Option<T>>)
|
|
|
|
// https://github.com/rust-lang/chalk/issues/12
|
|
|
|
required_version![
|
2020-03-28 09:46:07 +00:00
|
|
|
self.has_independent_segments
|
|
|
|
.unwrap_or(false)
|
|
|
|
.athen_some(ExtXIndependentSegments),
|
2020-02-10 12:20:39 +00:00
|
|
|
self.start.flatten(),
|
|
|
|
self.media,
|
2020-03-17 14:58:43 +00:00
|
|
|
self.variant_streams,
|
2020-02-10 12:20:39 +00:00
|
|
|
self.session_data,
|
|
|
|
self.session_keys
|
2019-10-05 10:49:08 +00:00
|
|
|
]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-02-14 01:31:24 +00:00
|
|
|
impl fmt::Display for MasterPlaylist {
|
2020-04-09 06:43:13 +00:00
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
2018-02-14 01:31:24 +00:00
|
|
|
writeln!(f, "{}", ExtM3u)?;
|
2020-02-10 12:20:39 +00:00
|
|
|
|
2019-10-05 10:49:08 +00:00
|
|
|
if self.required_version() != ProtocolVersion::V1 {
|
|
|
|
writeln!(f, "{}", ExtXVersion::new(self.required_version()))?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-02-14 12:05:18 +00:00
|
|
|
for value in &self.media {
|
|
|
|
writeln!(f, "{}", value)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
for value in &self.variant_streams {
|
2020-02-14 12:05:18 +00:00
|
|
|
writeln!(f, "{}", value)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-02-14 12:05:18 +00:00
|
|
|
for value in &self.session_data {
|
|
|
|
writeln!(f, "{}", value)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-02-14 12:05:18 +00:00
|
|
|
for value in &self.session_keys {
|
|
|
|
writeln!(f, "{}", value)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
if self.has_independent_segments {
|
|
|
|
writeln!(f, "{}", ExtXIndependentSegments)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-02-10 12:20:39 +00:00
|
|
|
if let Some(value) = &self.start {
|
2019-09-14 19:42:06 +00:00
|
|
|
writeln!(f, "{}", value)?;
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-02 12:38:11 +00:00
|
|
|
|
2020-02-14 12:05:18 +00:00
|
|
|
for value in &self.unknown_tags {
|
|
|
|
writeln!(f, "{}", value)?;
|
2020-02-02 12:50:56 +00:00
|
|
|
}
|
|
|
|
|
2018-02-14 01:31:24 +00:00
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
}
|
2019-09-13 14:06:52 +00:00
|
|
|
|
2018-02-14 01:31:24 +00:00
|
|
|
impl FromStr for MasterPlaylist {
|
|
|
|
type Err = Error;
|
2019-09-14 09:57:56 +00:00
|
|
|
|
|
|
|
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
2020-02-06 11:27:48 +00:00
|
|
|
let input = tag(input, ExtM3u::PREFIX)?;
|
2019-10-05 14:08:03 +00:00
|
|
|
let mut builder = Self::builder();
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2020-02-10 12:20:39 +00:00
|
|
|
let mut media = vec![];
|
2020-03-17 14:58:43 +00:00
|
|
|
let mut variant_streams = vec![];
|
2020-02-10 12:20:39 +00:00
|
|
|
let mut session_data = vec![];
|
|
|
|
let mut session_keys = vec![];
|
2020-02-02 12:50:56 +00:00
|
|
|
let mut unknown_tags = vec![];
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2020-02-06 11:27:48 +00:00
|
|
|
for line in Lines::from(input) {
|
2020-02-02 13:33:57 +00:00
|
|
|
match line? {
|
2018-02-14 01:31:24 +00:00
|
|
|
Line::Tag(tag) => {
|
|
|
|
match tag {
|
2019-10-05 10:49:08 +00:00
|
|
|
Tag::ExtXVersion(_) => {
|
|
|
|
// This tag can be ignored, because the
|
|
|
|
// MasterPlaylist will automatically set the
|
2020-02-06 11:27:48 +00:00
|
|
|
// ExtXVersion tag to the minimum required version
|
|
|
|
// TODO: this might be verified?
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
Tag::ExtInf(_)
|
|
|
|
| Tag::ExtXByteRange(_)
|
|
|
|
| Tag::ExtXDiscontinuity(_)
|
|
|
|
| Tag::ExtXKey(_)
|
|
|
|
| Tag::ExtXMap(_)
|
|
|
|
| Tag::ExtXProgramDateTime(_)
|
|
|
|
| Tag::ExtXDateRange(_)
|
|
|
|
| Tag::ExtXTargetDuration(_)
|
|
|
|
| Tag::ExtXMediaSequence(_)
|
|
|
|
| Tag::ExtXDiscontinuitySequence(_)
|
|
|
|
| Tag::ExtXEndList(_)
|
2020-03-25 11:17:03 +00:00
|
|
|
| Tag::PlaylistType(_)
|
2018-02-14 01:31:24 +00:00
|
|
|
| Tag::ExtXIFramesOnly(_) => {
|
2020-03-17 14:58:43 +00:00
|
|
|
return Err(Error::unexpected_tag(tag));
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
Tag::ExtXMedia(t) => {
|
2020-02-10 12:20:39 +00:00
|
|
|
media.push(t);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
Tag::VariantStream(t) => {
|
2020-03-17 14:58:43 +00:00
|
|
|
variant_streams.push(t);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
Tag::ExtXSessionData(t) => {
|
2020-02-10 12:20:39 +00:00
|
|
|
session_data.push(t);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
Tag::ExtXSessionKey(t) => {
|
2020-02-10 12:20:39 +00:00
|
|
|
session_keys.push(t);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-03-17 14:58:43 +00:00
|
|
|
Tag::ExtXIndependentSegments(_) => {
|
|
|
|
builder.has_independent_segments(true);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
Tag::ExtXStart(t) => {
|
2020-02-10 12:20:39 +00:00
|
|
|
builder.start(t);
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2019-09-14 19:08:35 +00:00
|
|
|
_ => {
|
2018-02-14 15:50:57 +00:00
|
|
|
// [6.3.1. General Client Responsibilities]
|
|
|
|
// > ignore any unrecognized tags.
|
2020-02-02 12:50:56 +00:00
|
|
|
unknown_tags.push(tag.to_string());
|
2018-02-14 15:50:57 +00:00
|
|
|
}
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
Line::Uri(uri) => {
|
2020-03-17 14:58:43 +00:00
|
|
|
return Err(Error::custom(format!("unexpected uri: {:?}", uri)));
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
2020-02-10 12:20:39 +00:00
|
|
|
_ => {}
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2020-02-10 12:20:39 +00:00
|
|
|
builder.media(media);
|
2020-03-17 14:58:43 +00:00
|
|
|
builder.variant_streams(variant_streams);
|
2020-02-10 12:20:39 +00:00
|
|
|
builder.session_data(session_data);
|
|
|
|
builder.session_keys(session_keys);
|
2020-02-02 12:50:56 +00:00
|
|
|
builder.unknown_tags(unknown_tags);
|
2019-09-14 11:26:16 +00:00
|
|
|
|
2020-01-23 18:13:26 +00:00
|
|
|
builder.build().map_err(Error::builder)
|
2018-02-14 01:31:24 +00:00
|
|
|
}
|
|
|
|
}
|
2019-09-14 09:57:56 +00:00
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use super::*;
|
2020-02-14 12:05:18 +00:00
|
|
|
use crate::types::StreamData;
|
2019-10-08 13:42:33 +00:00
|
|
|
use pretty_assertions::assert_eq;
|
2019-09-14 09:57:56 +00:00
|
|
|
|
2020-03-17 14:58:43 +00:00
|
|
|
#[test]
|
|
|
|
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::<Vec<_>>()
|
|
|
|
);
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2019-09-14 09:57:56 +00:00
|
|
|
#[test]
|
2019-09-14 10:34:34 +00:00
|
|
|
fn test_parser() {
|
2020-02-14 12:05:18 +00:00
|
|
|
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"
|
|
|
|
)
|
2019-10-05 10:49:08 +00:00
|
|
|
.parse::<MasterPlaylist>()
|
2020-02-14 12:05:18 +00:00
|
|
|
.unwrap(),
|
|
|
|
MasterPlaylist::builder()
|
2020-03-17 14:58:43 +00:00
|
|
|
.variant_streams(vec![
|
2020-02-14 12:05:18 +00:00
|
|
|
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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["mp4a.40.5"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.build()
|
|
|
|
.unwrap()
|
|
|
|
},
|
|
|
|
])
|
|
|
|
.build()
|
|
|
|
.unwrap()
|
|
|
|
);
|
2019-09-14 10:34:34 +00:00
|
|
|
}
|
2019-09-14 11:26:16 +00:00
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_display() {
|
2020-02-14 12:05:18 +00:00
|
|
|
assert_eq!(
|
|
|
|
MasterPlaylist::builder()
|
2020-03-17 14:58:43 +00:00
|
|
|
.variant_streams(vec![
|
2020-02-14 12:05:18 +00:00
|
|
|
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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["avc1.42e00a", "mp4a.40.2"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.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)
|
2020-02-24 13:09:26 +00:00
|
|
|
.codecs(&["mp4a.40.5"])
|
2020-02-14 12:05:18 +00:00
|
|
|
.build()
|
|
|
|
.unwrap()
|
|
|
|
},
|
|
|
|
])
|
|
|
|
.build()
|
|
|
|
.unwrap()
|
2020-02-16 16:14:28 +00:00
|
|
|
.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()
|
2020-02-14 12:05:18 +00:00
|
|
|
);
|
2019-09-14 11:26:16 +00:00
|
|
|
}
|
2019-09-14 09:57:56 +00:00
|
|
|
}
|