Skip to main content

quick_m3u8/
reader.rs

1use crate::{
2    config::ParsingOptions,
3    error::{ReaderBytesError, ReaderStrError},
4    line::{HlsLine, parse_bytes_with_custom, parse_with_custom},
5    tag::{CustomTag, NoCustomTag},
6};
7use std::marker::PhantomData;
8
9/// A reader that parses lines of input HLS playlist data.
10///
11/// The `Reader` is the primary intended structure provided by the library for parsing HLS playlist
12/// data. The user has the flexibility to define which of the library provided HLS tags should be
13/// parsed as well as define a custom tag type to be extracted during parsing.
14///
15/// ## Basic usage
16///
17/// A reader can take an input `&str` (or `&[u8]`) and sequentially parse information about HLS
18/// lines. For example, you could use the `Reader` to build up a media playlist:
19/// ```
20/// # use quick_m3u8::{HlsLine, Reader};
21/// # use quick_m3u8::config::ParsingOptions;
22/// # use quick_m3u8::tag::{
23/// #     hls::{self, DiscontinuitySequence, MediaSequence, Targetduration, Version, M3u},
24/// #     KnownTag,
25/// # };
26/// # let playlist = r#"#EXTM3U
27/// # #EXT-X-TARGETDURATION:4
28/// # #EXT-X-MEDIA-SEQUENCE:541647
29/// # #EXT-X-VERSION:6
30/// # "#;
31/// #[derive(Debug, PartialEq)]
32/// struct MediaPlaylist<'a> {
33///     version: u64,
34///     targetduration: u64,
35///     media_sequence: u64,
36///     discontinuity_sequence: u64,
37///     // etc.
38///     lines: Vec<HlsLine<'a>>,
39/// }
40/// let mut reader = Reader::from_str(playlist, ParsingOptions::default());
41///
42/// let mut version = None;
43/// let mut targetduration = None;
44/// let mut media_sequence = 0;
45/// let mut discontinuity_sequence = 0;
46/// // etc.
47/// let mut lines = Vec::new();
48///
49/// // Validate playlist header
50/// match reader.read_line() {
51///     Ok(Some(HlsLine::KnownTag(KnownTag::Hls(hls::Tag::M3u(tag))))) => {
52///         lines.push(HlsLine::from(tag))
53///     }
54///     _ => return Err(format!("missing playlist header").into()),
55/// }
56///
57/// loop {
58///     match reader.read_line() {
59///         Ok(Some(line)) => match line {
60///             HlsLine::KnownTag(KnownTag::Hls(hls::Tag::Version(tag))) => {
61///                 version = Some(tag.version());
62///                 lines.push(HlsLine::from(tag));
63///             }
64///             HlsLine::KnownTag(KnownTag::Hls(hls::Tag::Targetduration(tag))) => {
65///                 targetduration = Some(tag.target_duration());
66///                 lines.push(HlsLine::from(tag));
67///             }
68///             HlsLine::KnownTag(KnownTag::Hls(hls::Tag::MediaSequence(tag))) => {
69///                 media_sequence = tag.media_sequence();
70///                 lines.push(HlsLine::from(tag));
71///             }
72///             HlsLine::KnownTag(KnownTag::Hls(hls::Tag::DiscontinuitySequence(tag))) => {
73///                 discontinuity_sequence = tag.discontinuity_sequence();
74///                 lines.push(HlsLine::from(tag));
75///             }
76///             // etc.
77///             _ => lines.push(line),
78///         },
79///         Ok(None) => break, // End of playlist
80///         Err(e) => return Err(format!("problem reading line: {e}").into()),
81///     }
82/// }
83///
84/// let version = version.unwrap_or(1);
85/// let Some(targetduration) = targetduration else {
86///     return Err("missing required EXT-X-TARGETDURATION".into());
87/// };
88/// let media_playlist = MediaPlaylist {
89///     version,
90///     targetduration,
91///     media_sequence,
92///     discontinuity_sequence,
93///     lines,
94/// };
95///
96/// assert_eq!(
97///     media_playlist,
98///     MediaPlaylist {
99///         version: 6,
100///         targetduration: 4,
101///         media_sequence: 541647,
102///         discontinuity_sequence: 0,
103///         lines: vec![
104///             // --snip--
105/// #            HlsLine::from(M3u),
106/// #            HlsLine::from(Targetduration::new(4)),
107/// #            HlsLine::from(MediaSequence::new(541647)),
108/// #            HlsLine::from(Version::new(6)),
109///         ],
110///     }
111/// );
112///
113/// # Ok::<(), Box<dyn std::error::Error>>(())
114/// ```
115///
116/// ## Configuring known tags
117///
118/// It is quite common that a user does not need to support parsing of all HLS tags for their use-
119/// case. To support this better the `Reader` allows for configuration of what HLS tags are
120/// considered "known" by the library. While it may sound strange to configure for less information
121/// to be parsed, doing so can have significant performance benefits, and at no loss if the
122/// information is not needed anyway. Unknown tags make no attempt to parse or validate the value
123/// portion of the tag (the part after `:`) and just provide the name of the tag along with the line
124/// up to (and not including) the new line characters. To provide some indication of the performance
125/// difference, running locally (as of commit `6fcc38a67bf0eee0769b7e85f82599d1da6eb56d`), the
126/// benchmarks show that on a very large media playlist parsing with all tags can be around 2x
127/// slower than parsing with no tags (`2.3842 ms` vs `1.1364 ms`):
128/// ```sh
129/// Large playlist, all tags, using Reader::from_str, no writing
130///                         time:   [2.3793 ms 2.3842 ms 2.3891 ms]
131/// Large playlist, no tags, using Reader::from_str, no writing
132///                         time:   [1.1357 ms 1.1364 ms 1.1372 ms]
133/// ```
134///
135/// For example, let's say that we are updating a playlist to add in HLS interstitial daterange,
136/// based on SCTE35-OUT information in an upstream playlist. The only tag we need to know about for
137/// this is EXT-X-DATERANGE, so we can configure our reader to only consider this tag during parsing
138/// which provides a benefit in terms of processing time.
139/// ```
140/// # use quick_m3u8::{
141/// # Reader, HlsLine, Writer,
142/// # config::ParsingOptionsBuilder,
143/// # tag::KnownTag,
144/// # tag::hls::{self, Cue, Daterange, ExtensionAttributeValue},
145/// # };
146/// # use std::{borrow::Cow, error::Error, io::Write};
147/// # fn advert_id_from_scte35_out(_: &str) -> Option<String> { None }
148/// # fn advert_uri_from_id(_: &str) -> String { String::new() }
149/// # fn duration_from_daterange(_: &Daterange) -> f64 { 0.0 }
150/// # let output = Vec::new();
151/// # let upstream_playlist = b"";
152/// let mut reader = Reader::from_bytes(
153///     upstream_playlist,
154///     ParsingOptionsBuilder::new()
155///         .with_parsing_for_daterange()
156///         .build(),
157/// );
158/// let mut writer = Writer::new(output);
159///
160/// loop {
161///     match reader.read_line() {
162///         Ok(Some(HlsLine::KnownTag(KnownTag::Hls(hls::Tag::Daterange(tag))))) => {
163///             if let Some(advert_id) = tag.scte35_out().and_then(advert_id_from_scte35_out) {
164///                 let id = format!("ADVERT:{}", tag.id());
165///                 let builder = Daterange::builder()
166///                     .with_id(id)
167///                     .with_class("com.apple.hls.interstitial")
168///                     .with_cue(Cue::Once)
169///                     .with_extension_attribute(
170///                         "X-ASSET-URI",
171///                         ExtensionAttributeValue::QuotedString(Cow::Owned(
172///                             advert_uri_from_id(&advert_id),
173///                         )),
174///                     )
175///                     .with_extension_attribute(
176///                         "X-RESTRICT",
177///                         ExtensionAttributeValue::QuotedString(Cow::Borrowed("SKIP,JUMP")),
178///                     );
179///                 // START-DATE has been clarified to be optional as of draft 18, so we need to
180///                 // check for existence. In reality, I should store the start dates of all found
181///                 // dateranges, to properly set the correct START-DATE on this interstitial tag;
182///                 // however, this is just a basic example and that's not the point I'm trying to
183///                 // illustrate, so leaving that out for now.
184///                 let builder = if let Some(start_date) = tag.start_date() {
185///                     builder.with_start_date(start_date)
186///                 } else {
187///                     builder
188///                 };
189///                 let interstitial_daterange = if duration_from_daterange(&tag) == 0.0 {
190///                     builder
191///                         .with_extension_attribute(
192///                             "X-RESUME-OFFSET",
193///                             ExtensionAttributeValue::SignedDecimalFloatingPoint(0.0),
194///                         )
195///                         .finish()
196///                 } else {
197///                     builder.finish()
198///                 };
199///                 writer.write_line(HlsLine::from(interstitial_daterange))?;
200///             } else {
201///                 writer.write_line(HlsLine::from(tag))?;
202///             }
203///         }
204///         Ok(Some(line)) => {
205///             writer.write_line(line)?;
206///         }
207///         Ok(None) => break, // End of playlist
208///         Err(e) => {
209///             writer.get_mut().write_all(e.errored_line)?;
210///         }
211///     };
212/// }
213///
214/// writer.into_inner().flush()?;
215/// # Ok::<(), Box<dyn Error>>(())
216/// ```
217///
218/// ## Custom tag reading
219///
220/// We can also configure the `Reader` to accept parsing of custom defined tags. Using the same idea
221/// as above, we can imagine that instead of EXT-X-DATERANGE in the upstream playlist, we want to
222/// depend on the EXT-X-SCTE35 tag that is defined within the SCTE35 specification. This tag is not
223/// defined in the HLS specification; however, we can define it here, and use it when it comes to
224/// parsing and utilizing that data. Below is a modified version of the above HLS interstitials
225/// example that instead relies on a custom defined `Scte35Tag` (though I leave the details of
226/// `TryFrom<ParsedTag>` unfilled for sake of simplicity in this example). Note, when defining a
227/// that the reader should use a custom tag, utilize `std::marker::PhantomData` to specify what the
228/// type of the custom tag is.
229/// ```
230/// # use quick_m3u8::{
231/// # Reader, HlsLine, Writer,
232/// # config::ParsingOptionsBuilder,
233/// # tag::{KnownTag, UnknownTag, CustomTag, WritableCustomTag, WritableTag},
234/// # tag::hls::{self, Cue, Daterange, ExtensionAttributeValue},
235/// # tag::hls::{DaterangeIdHasBeenSet, DaterangeBuilder},
236/// # error::ValidationError,
237/// # };
238/// # use std::{borrow::Cow, error::Error, io::Write, marker::PhantomData};
239/// # fn advert_id_from_scte35_out(_: &str) -> Option<String> { None }
240/// # fn advert_uri_from_id(_: &str) -> String { String::new() }
241/// # fn generate_uuid() -> &'static str { "" }
242/// # fn with_start_date_based_on_inf_durations(
243/// #     builder: DaterangeBuilder<'_, DaterangeIdHasBeenSet>
244/// # ) -> DaterangeBuilder<'_, DaterangeIdHasBeenSet> {
245/// #     todo!();
246/// # }
247/// # let output: Vec<u8> = Vec::new();
248/// # let upstream_playlist = b"";
249/// #[derive(Debug, PartialEq, Clone)]
250/// struct Scte35Tag<'a> {
251///     cue: &'a str,
252///     duration: Option<f64>,
253///     elapsed: Option<f64>,
254///     id: Option<&'a str>,
255///     time: Option<f64>,
256///     type_id: Option<u64>,
257///     upid: Option<&'a str>,
258///     blackout: Option<BlackoutValue>,
259///     cue_out: Option<CueOutValue>,
260///     cue_in: bool,
261///     segne: Option<(u64, u64)>,
262/// }
263/// #[derive(Debug, PartialEq, Clone)]
264/// enum BlackoutValue {
265///     Yes,
266///     No,
267///     Maybe,
268/// }
269/// #[derive(Debug, PartialEq, Clone)]
270/// enum CueOutValue {
271///     Yes,
272///     No,
273///     Cont,
274/// }
275/// impl<'a> TryFrom<UnknownTag<'a>> for Scte35Tag<'a> { // --snip--
276/// #    type Error = ValidationError;
277/// #    fn try_from(value: UnknownTag<'a>) -> Result<Self, Self::Error> {
278/// #        todo!()
279/// #    }
280/// }
281/// impl<'a> CustomTag<'a> for Scte35Tag<'a> {
282///     fn is_known_name(name: &str) -> bool {
283///         name == "-X-SCTE35"
284///     }
285/// }
286/// impl<'a> WritableCustomTag<'a> for Scte35Tag<'a> { // --snip--
287/// #    fn into_writable_tag(self) -> WritableTag<'a> {
288/// #        todo!()
289/// #    }
290/// }
291/// #
292/// # let output: Vec<u8> = Vec::new();
293/// # let upstream_playlist = b"";
294///
295/// let mut reader = Reader::with_custom_from_bytes(
296///     upstream_playlist,
297///     ParsingOptionsBuilder::new().build(),
298///     PhantomData::<Scte35Tag>,
299/// );
300/// let mut writer = Writer::new(output);
301///
302/// loop {
303///     match reader.read_line() {
304///         Ok(Some(HlsLine::KnownTag(KnownTag::Custom(tag)))) => {
305///             if let Some(advert_id) = advert_id_from_scte35_out(tag.as_ref().cue) {
306///                 let tag_ref = tag.as_ref();
307///                 let id = format!("ADVERT:{}", tag_ref.id.unwrap_or(generate_uuid()));
308///                 let builder = Daterange::builder()
309///                     .with_id(id)
310///                     .with_class("com.apple.hls.interstitial")
311///                     .with_cue(Cue::Once)
312///                     .with_extension_attribute(
313///                         "X-ASSET-URI",
314///                         ExtensionAttributeValue::QuotedString(Cow::Owned(
315///                             advert_uri_from_id(&advert_id),
316///                         )),
317///                     )
318///                     .with_extension_attribute(
319///                         "X-RESTRICT",
320///                         ExtensionAttributeValue::QuotedString(Cow::Borrowed("SKIP,JUMP")),
321///                     );
322///                 let builder = with_start_date_based_on_inf_durations(builder);
323///                 let interstitial_daterange = if tag_ref.duration == Some(0.0) {
324///                     builder
325///                         .with_extension_attribute(
326///                             "X-RESUME-OFFSET",
327///                             ExtensionAttributeValue::SignedDecimalFloatingPoint(0.0),
328///                         )
329///                         .finish()
330///                 } else {
331///                     builder.finish()
332///                 };
333///                 writer.write_line(HlsLine::from(interstitial_daterange))?;
334///             } else {
335///                 writer.write_custom_line(HlsLine::from(tag))?;
336///             }
337///         }
338///         Ok(Some(line)) => {
339///             writer.write_custom_line(line)?;
340///         }
341///         Ok(None) => break, // End of playlist
342///         Err(e) => {
343///             writer.get_mut().write_all(e.errored_line)?;
344///         }
345///     };
346/// }
347///
348/// writer.into_inner().flush()?;
349///
350/// # Ok::<(), Box<dyn Error>>(())
351/// ```
352#[derive(Debug)]
353pub struct Reader<R, Custom> {
354    inner: R,
355    options: ParsingOptions,
356    _marker: PhantomData<Custom>,
357}
358
359macro_rules! impl_reader {
360    ($type:ty, $parse_fn:ident, $from_fn_ident:ident, $from_custom_fn_ident:ident, $error_type:ident) => {
361        impl<'a> Reader<&'a $type, NoCustomTag> {
362            /// Creates a reader without custom tag parsing support (in this case, the generic
363            /// `Custom` type is [`NoCustomTag`]).
364            pub fn $from_fn_ident(data: &'a $type, options: ParsingOptions) -> Self {
365                Self {
366                    inner: data,
367                    options,
368                    _marker: PhantomData::<NoCustomTag>,
369                }
370            }
371        }
372        impl<'a, Custom> Reader<&'a $type, Custom>
373        where
374            Custom: CustomTag<'a>,
375        {
376            /// Creates a reader that supports custom tag parsing for the type specified by the
377            /// `PhatomData`.
378            pub fn $from_custom_fn_ident(
379                str: &'a $type,
380                options: ParsingOptions,
381                custom: PhantomData<Custom>,
382            ) -> Self {
383                Self {
384                    inner: str,
385                    options,
386                    _marker: custom,
387                }
388            }
389
390            /// Returns the inner data of the reader.
391            pub fn into_inner(self) -> &'a $type {
392                self.inner
393            }
394
395            /// Reads a single HLS line from the reference data.
396            pub fn read_line(&mut self) -> Result<Option<HlsLine<'a, Custom>>, $error_type<'a>> {
397                if self.inner.is_empty() {
398                    return Ok(None);
399                };
400                match $parse_fn(self.inner, &self.options) {
401                    Ok(slice) => {
402                        let parsed = slice.parsed;
403                        let remaining = slice.remaining;
404                        std::mem::swap(&mut self.inner, &mut remaining.unwrap_or_default());
405                        Ok(Some(parsed))
406                    }
407                    Err(error) => {
408                        let remaining = error.errored_line_slice.remaining;
409                        std::mem::swap(&mut self.inner, &mut remaining.unwrap_or_default());
410                        Err($error_type {
411                            errored_line: error.errored_line_slice.parsed,
412                            error: error.error,
413                        })
414                    }
415                }
416            }
417        }
418    };
419}
420
421impl_reader!(
422    str,
423    parse_with_custom,
424    from_str,
425    with_custom_from_str,
426    ReaderStrError
427);
428impl_reader!(
429    [u8],
430    parse_bytes_with_custom,
431    from_bytes,
432    with_custom_from_bytes,
433    ReaderBytesError
434);
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::{
440        config::ParsingOptionsBuilder,
441        error::{ParseTagValueError, SyntaxError, UnknownTagSyntaxError, ValidationError},
442        tag::{
443            CustomTagAccess, TagValue, UnknownTag,
444            hls::{Endlist, Inf, M3u, Targetduration, Version},
445        },
446    };
447    use pretty_assertions::assert_eq;
448
449    macro_rules! reader_test {
450        ($reader:tt, $method:tt, $expectation:expr $(, $buf:ident)?) => {
451            for i in 0..=11 {
452                let line = $reader.$method($(&mut $buf)?).unwrap();
453                match i {
454                    0 => assert_eq!(Some(HlsLine::from(M3u)), line),
455                    1 => assert_eq!(Some(HlsLine::from(Targetduration::new(10))), line),
456                    2 => assert_eq!(Some(HlsLine::from(Version::new(3))), line),
457                    3 => assert_eq!($expectation, line),
458                    4 => assert_eq!(Some(HlsLine::from(Inf::new(9.009, String::new()))), line),
459                    5 => assert_eq!(
460                        Some(HlsLine::Uri("http://media.example.com/first.ts".into())),
461                        line
462                    ),
463                    6 => assert_eq!(Some(HlsLine::from(Inf::new(9.009, String::new()))), line),
464                    7 => assert_eq!(
465                        Some(HlsLine::Uri("http://media.example.com/second.ts".into())),
466                        line
467                    ),
468                    8 => assert_eq!(Some(HlsLine::from(Inf::new(3.003, String::new()))), line),
469                    9 => assert_eq!(
470                        Some(HlsLine::Uri("http://media.example.com/third.ts".into())),
471                        line
472                    ),
473                    10 => assert_eq!(Some(HlsLine::from(Endlist)), line),
474                    11 => assert_eq!(None, line),
475                    _ => panic!(),
476                }
477            }
478        };
479    }
480
481    #[test]
482    fn reader_from_str_should_read_as_expected() {
483        let mut reader = Reader::from_str(
484            EXAMPLE_MANIFEST,
485            ParsingOptionsBuilder::new()
486                .with_parsing_for_all_tags()
487                .build(),
488        );
489        reader_test!(
490            reader,
491            read_line,
492            Some(HlsLine::from(UnknownTag {
493                name: "-X-EXAMPLE-TAG",
494                value: Some(TagValue(b"MEANING-OF-LIFE=42,QUESTION=\"UNKNOWN\"")),
495                original_input: &EXAMPLE_MANIFEST.as_bytes()[50..],
496                validation_error: None,
497            }))
498        );
499    }
500
501    #[test]
502    fn reader_from_buf_read_should_read_as_expected() {
503        let inner = EXAMPLE_MANIFEST.as_bytes();
504        let mut reader = Reader::from_bytes(
505            inner,
506            ParsingOptionsBuilder::new()
507                .with_parsing_for_all_tags()
508                .build(),
509        );
510        reader_test!(
511            reader,
512            read_line,
513            Some(HlsLine::from(UnknownTag {
514                name: "-X-EXAMPLE-TAG",
515                value: Some(TagValue(b"MEANING-OF-LIFE=42,QUESTION=\"UNKNOWN\"")),
516                original_input: &EXAMPLE_MANIFEST.as_bytes()[50..],
517                validation_error: None,
518            }))
519        );
520    }
521
522    #[test]
523    fn reader_from_str_with_custom_should_read_as_expected() {
524        let mut reader = Reader::with_custom_from_str(
525            EXAMPLE_MANIFEST,
526            ParsingOptionsBuilder::new()
527                .with_parsing_for_all_tags()
528                .build(),
529            PhantomData::<ExampleTag>,
530        );
531        reader_test!(
532            reader,
533            read_line,
534            Some(HlsLine::from(CustomTagAccess {
535                custom_tag: ExampleTag::new(42, "UNKNOWN"),
536                is_dirty: false,
537                original_input: EXAMPLE_MANIFEST[50..].as_bytes(),
538            }))
539        );
540    }
541
542    #[test]
543    fn reader_from_buf_with_custom_read_should_read_as_expected() {
544        let inner = EXAMPLE_MANIFEST.as_bytes();
545        let mut reader = Reader::with_custom_from_bytes(
546            inner,
547            ParsingOptionsBuilder::new()
548                .with_parsing_for_all_tags()
549                .build(),
550            PhantomData::<ExampleTag>,
551        );
552        reader_test!(
553            reader,
554            read_line,
555            Some(HlsLine::from(CustomTagAccess {
556                custom_tag: ExampleTag::new(42, "UNKNOWN"),
557                is_dirty: false,
558                original_input: EXAMPLE_MANIFEST[50..].as_bytes(),
559            }))
560        );
561    }
562
563    #[test]
564    fn when_reader_fails_it_moves_to_next_line() {
565        let input = concat!("#EXTM3U\n", "#EXT\n", "#Comment");
566        let mut reader = Reader::from_bytes(
567            input.as_bytes(),
568            ParsingOptionsBuilder::new()
569                .with_parsing_for_all_tags()
570                .build(),
571        );
572        assert_eq!(Ok(Some(HlsLine::from(M3u))), reader.read_line());
573        assert_eq!(
574            Err(ReaderBytesError {
575                errored_line: b"#EXT",
576                error: SyntaxError::from(UnknownTagSyntaxError::UnexpectedNoTagName)
577            }),
578            reader.read_line()
579        );
580        assert_eq!(
581            Ok(Some(HlsLine::Comment("Comment".into()))),
582            reader.read_line()
583        );
584    }
585
586    // Example custom tag implementation for the tests above.
587    #[derive(Debug, PartialEq, Clone)]
588    struct ExampleTag<'a> {
589        answer: u64,
590        question: &'a str,
591    }
592    impl ExampleTag<'static> {
593        fn new(answer: u64, question: &'static str) -> Self {
594            Self { answer, question }
595        }
596    }
597    impl<'a> TryFrom<UnknownTag<'a>> for ExampleTag<'a> {
598        type Error = ValidationError;
599        fn try_from(tag: UnknownTag<'a>) -> Result<Self, Self::Error> {
600            let mut attribute_list = tag
601                .value()
602                .ok_or(ParseTagValueError::UnexpectedEmpty)?
603                .try_as_attribute_list()?;
604            let Some(answer) = attribute_list
605                .remove("MEANING-OF-LIFE")
606                .and_then(|v| v.unquoted())
607                .and_then(|v| v.try_as_decimal_integer().ok())
608            else {
609                return Err(ValidationError::MissingRequiredAttribute("MEANING-OF-LIFE"));
610            };
611            let Some(question) = attribute_list.remove("QUESTION").and_then(|v| v.quoted()) else {
612                return Err(ValidationError::MissingRequiredAttribute("QUESTION"));
613            };
614            Ok(Self { answer, question })
615        }
616    }
617    impl<'a> CustomTag<'a> for ExampleTag<'a> {
618        fn is_known_name(name: &str) -> bool {
619            name == "-X-EXAMPLE-TAG"
620        }
621    }
622}
623
624#[cfg(test)]
625// Example taken from HLS specification with one custom tag added.
626// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-9.1
627const EXAMPLE_MANIFEST: &str = r#"#EXTM3U
628#EXT-X-TARGETDURATION:10
629#EXT-X-VERSION:3
630#EXT-X-EXAMPLE-TAG:MEANING-OF-LIFE=42,QUESTION="UNKNOWN"
631#EXTINF:9.009,
632http://media.example.com/first.ts
633#EXTINF:9.009,
634http://media.example.com/second.ts
635#EXTINF:3.003,
636http://media.example.com/third.ts
637#EXT-X-ENDLIST
638"#;