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/// # date::DateTime,
234/// # tag::{KnownTag, UnknownTag, CustomTag, WritableCustomTag, WritableTag},
235/// # tag::hls::{self, Cue, Daterange, ExtensionAttributeValue},
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 calculate_start_date_based_on_inf_durations() -> DateTime { todo!() }
243/// # let output: Vec<u8> = Vec::new();
244/// # let upstream_playlist = b"";
245/// #[derive(Debug, PartialEq, Clone)]
246/// struct Scte35Tag<'a> {
247/// cue: &'a str,
248/// duration: Option<f64>,
249/// elapsed: Option<f64>,
250/// id: Option<&'a str>,
251/// time: Option<f64>,
252/// type_id: Option<u64>,
253/// upid: Option<&'a str>,
254/// blackout: Option<BlackoutValue>,
255/// cue_out: Option<CueOutValue>,
256/// cue_in: bool,
257/// segne: Option<(u64, u64)>,
258/// }
259/// #[derive(Debug, PartialEq, Clone)]
260/// enum BlackoutValue {
261/// Yes,
262/// No,
263/// Maybe,
264/// }
265/// #[derive(Debug, PartialEq, Clone)]
266/// enum CueOutValue {
267/// Yes,
268/// No,
269/// Cont,
270/// }
271/// impl<'a> TryFrom<UnknownTag<'a>> for Scte35Tag<'a> { // --snip--
272/// # type Error = ValidationError;
273/// # fn try_from(value: UnknownTag<'a>) -> Result<Self, Self::Error> {
274/// # todo!()
275/// # }
276/// }
277/// impl<'a> CustomTag<'a> for Scte35Tag<'a> {
278/// fn is_known_name(name: &str) -> bool {
279/// name == "-X-SCTE35"
280/// }
281/// }
282/// impl<'a> WritableCustomTag<'a> for Scte35Tag<'a> { // --snip--
283/// # fn into_writable_tag(self) -> WritableTag<'a> {
284/// # todo!()
285/// # }
286/// }
287/// #
288/// # let output: Vec<u8> = Vec::new();
289/// # let upstream_playlist = b"";
290///
291/// let mut reader = Reader::with_custom_from_bytes(
292/// upstream_playlist,
293/// ParsingOptionsBuilder::new().build(),
294/// PhantomData::<Scte35Tag>,
295/// );
296/// let mut writer = Writer::new(output);
297///
298/// loop {
299/// match reader.read_line() {
300/// Ok(Some(HlsLine::KnownTag(KnownTag::Custom(tag)))) => {
301/// if let Some(advert_id) = advert_id_from_scte35_out(tag.as_ref().cue) {
302/// let tag_ref = tag.as_ref();
303/// let id = format!("ADVERT:{}", tag_ref.id.unwrap_or(generate_uuid()));
304/// let start_date = calculate_start_date_based_on_inf_durations();
305/// let builder = Daterange::builder()
306/// .with_id(id)
307/// .with_start_date(start_date)
308/// .with_class("com.apple.hls.interstitial")
309/// .with_cue(Cue::Once)
310/// .with_extension_attribute(
311/// "X-ASSET-URI",
312/// ExtensionAttributeValue::QuotedString(Cow::Owned(
313/// advert_uri_from_id(&advert_id),
314/// )),
315/// )
316/// .with_extension_attribute(
317/// "X-RESTRICT",
318/// ExtensionAttributeValue::QuotedString(Cow::Borrowed("SKIP,JUMP")),
319/// );
320/// let interstitial_daterange = if tag_ref.duration == Some(0.0) {
321/// builder
322/// .with_extension_attribute(
323/// "X-RESUME-OFFSET",
324/// ExtensionAttributeValue::SignedDecimalFloatingPoint(0.0),
325/// )
326/// .finish()
327/// } else {
328/// builder.finish()
329/// };
330/// writer.write_line(HlsLine::from(interstitial_daterange))?;
331/// } else {
332/// writer.write_custom_line(HlsLine::from(tag))?;
333/// }
334/// }
335/// Ok(Some(line)) => {
336/// writer.write_custom_line(line)?;
337/// }
338/// Ok(None) => break, // End of playlist
339/// Err(e) => {
340/// writer.get_mut().write_all(e.errored_line)?;
341/// }
342/// };
343/// }
344///
345/// writer.into_inner().flush()?;
346///
347/// # Ok::<(), Box<dyn Error>>(())
348/// ```
349#[derive(Debug)]
350pub struct Reader<R, Custom> {
351 inner: R,
352 options: ParsingOptions,
353 _marker: PhantomData<Custom>,
354}
355
356macro_rules! impl_reader {
357 ($type:ty, $parse_fn:ident, $from_fn_ident:ident, $from_custom_fn_ident:ident, $error_type:ident) => {
358 impl<'a> Reader<&'a $type, NoCustomTag> {
359 /// Creates a reader without custom tag parsing support (in this case, the generic
360 /// `Custom` type is [`NoCustomTag`]).
361 pub fn $from_fn_ident(data: &'a $type, options: ParsingOptions) -> Self {
362 Self {
363 inner: data,
364 options,
365 _marker: PhantomData::<NoCustomTag>,
366 }
367 }
368 }
369 impl<'a, Custom> Reader<&'a $type, Custom>
370 where
371 Custom: CustomTag<'a>,
372 {
373 /// Creates a reader that supports custom tag parsing for the type specified by the
374 /// `PhatomData`.
375 pub fn $from_custom_fn_ident(
376 str: &'a $type,
377 options: ParsingOptions,
378 custom: PhantomData<Custom>,
379 ) -> Self {
380 Self {
381 inner: str,
382 options,
383 _marker: custom,
384 }
385 }
386
387 /// Returns the inner data of the reader.
388 pub fn into_inner(self) -> &'a $type {
389 self.inner
390 }
391
392 /// Reads a single HLS line from the reference data.
393 pub fn read_line(&mut self) -> Result<Option<HlsLine<'a, Custom>>, $error_type<'a>> {
394 if self.inner.is_empty() {
395 return Ok(None);
396 };
397 match $parse_fn(self.inner, &self.options) {
398 Ok(slice) => {
399 let parsed = slice.parsed;
400 let remaining = slice.remaining;
401 std::mem::swap(&mut self.inner, &mut remaining.unwrap_or_default());
402 Ok(Some(parsed))
403 }
404 Err(error) => {
405 let remaining = error.errored_line_slice.remaining;
406 std::mem::swap(&mut self.inner, &mut remaining.unwrap_or_default());
407 Err($error_type {
408 errored_line: error.errored_line_slice.parsed,
409 error: error.error,
410 })
411 }
412 }
413 }
414 }
415 };
416}
417
418impl_reader!(
419 str,
420 parse_with_custom,
421 from_str,
422 with_custom_from_str,
423 ReaderStrError
424);
425impl_reader!(
426 [u8],
427 parse_bytes_with_custom,
428 from_bytes,
429 with_custom_from_bytes,
430 ReaderBytesError
431);
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::{
437 config::ParsingOptionsBuilder,
438 error::{ParseTagValueError, SyntaxError, UnknownTagSyntaxError, ValidationError},
439 tag::{
440 CustomTagAccess, TagValue, UnknownTag,
441 hls::{Endlist, Inf, M3u, Targetduration, Version},
442 },
443 };
444 use pretty_assertions::assert_eq;
445
446 macro_rules! reader_test {
447 ($reader:tt, $method:tt, $expectation:expr $(, $buf:ident)?) => {
448 for i in 0..=11 {
449 let line = $reader.$method($(&mut $buf)?).unwrap();
450 match i {
451 0 => assert_eq!(Some(HlsLine::from(M3u)), line),
452 1 => assert_eq!(Some(HlsLine::from(Targetduration::new(10))), line),
453 2 => assert_eq!(Some(HlsLine::from(Version::new(3))), line),
454 3 => assert_eq!($expectation, line),
455 4 => assert_eq!(Some(HlsLine::from(Inf::new(9.009, String::new()))), line),
456 5 => assert_eq!(
457 Some(HlsLine::Uri("http://media.example.com/first.ts".into())),
458 line
459 ),
460 6 => assert_eq!(Some(HlsLine::from(Inf::new(9.009, String::new()))), line),
461 7 => assert_eq!(
462 Some(HlsLine::Uri("http://media.example.com/second.ts".into())),
463 line
464 ),
465 8 => assert_eq!(Some(HlsLine::from(Inf::new(3.003, String::new()))), line),
466 9 => assert_eq!(
467 Some(HlsLine::Uri("http://media.example.com/third.ts".into())),
468 line
469 ),
470 10 => assert_eq!(Some(HlsLine::from(Endlist)), line),
471 11 => assert_eq!(None, line),
472 _ => panic!(),
473 }
474 }
475 };
476 }
477
478 #[test]
479 fn reader_from_str_should_read_as_expected() {
480 let mut reader = Reader::from_str(
481 EXAMPLE_MANIFEST,
482 ParsingOptionsBuilder::new()
483 .with_parsing_for_all_tags()
484 .build(),
485 );
486 reader_test!(
487 reader,
488 read_line,
489 Some(HlsLine::from(UnknownTag {
490 name: "-X-EXAMPLE-TAG",
491 value: Some(TagValue(b"MEANING-OF-LIFE=42,QUESTION=\"UNKNOWN\"")),
492 original_input: &EXAMPLE_MANIFEST.as_bytes()[50..],
493 validation_error: None,
494 }))
495 );
496 }
497
498 #[test]
499 fn reader_from_buf_read_should_read_as_expected() {
500 let inner = EXAMPLE_MANIFEST.as_bytes();
501 let mut reader = Reader::from_bytes(
502 inner,
503 ParsingOptionsBuilder::new()
504 .with_parsing_for_all_tags()
505 .build(),
506 );
507 reader_test!(
508 reader,
509 read_line,
510 Some(HlsLine::from(UnknownTag {
511 name: "-X-EXAMPLE-TAG",
512 value: Some(TagValue(b"MEANING-OF-LIFE=42,QUESTION=\"UNKNOWN\"")),
513 original_input: &EXAMPLE_MANIFEST.as_bytes()[50..],
514 validation_error: None,
515 }))
516 );
517 }
518
519 #[test]
520 fn reader_from_str_with_custom_should_read_as_expected() {
521 let mut reader = Reader::with_custom_from_str(
522 EXAMPLE_MANIFEST,
523 ParsingOptionsBuilder::new()
524 .with_parsing_for_all_tags()
525 .build(),
526 PhantomData::<ExampleTag>,
527 );
528 reader_test!(
529 reader,
530 read_line,
531 Some(HlsLine::from(CustomTagAccess {
532 custom_tag: ExampleTag::new(42, "UNKNOWN"),
533 is_dirty: false,
534 original_input: EXAMPLE_MANIFEST[50..].as_bytes(),
535 }))
536 );
537 }
538
539 #[test]
540 fn reader_from_buf_with_custom_read_should_read_as_expected() {
541 let inner = EXAMPLE_MANIFEST.as_bytes();
542 let mut reader = Reader::with_custom_from_bytes(
543 inner,
544 ParsingOptionsBuilder::new()
545 .with_parsing_for_all_tags()
546 .build(),
547 PhantomData::<ExampleTag>,
548 );
549 reader_test!(
550 reader,
551 read_line,
552 Some(HlsLine::from(CustomTagAccess {
553 custom_tag: ExampleTag::new(42, "UNKNOWN"),
554 is_dirty: false,
555 original_input: EXAMPLE_MANIFEST[50..].as_bytes(),
556 }))
557 );
558 }
559
560 #[test]
561 fn when_reader_fails_it_moves_to_next_line() {
562 let input = concat!("#EXTM3U\n", "#EXT\n", "#Comment");
563 let mut reader = Reader::from_bytes(
564 input.as_bytes(),
565 ParsingOptionsBuilder::new()
566 .with_parsing_for_all_tags()
567 .build(),
568 );
569 assert_eq!(Ok(Some(HlsLine::from(M3u))), reader.read_line());
570 assert_eq!(
571 Err(ReaderBytesError {
572 errored_line: b"#EXT",
573 error: SyntaxError::from(UnknownTagSyntaxError::UnexpectedNoTagName)
574 }),
575 reader.read_line()
576 );
577 assert_eq!(
578 Ok(Some(HlsLine::Comment("Comment".into()))),
579 reader.read_line()
580 );
581 }
582
583 // Example custom tag implementation for the tests above.
584 #[derive(Debug, PartialEq, Clone)]
585 struct ExampleTag<'a> {
586 answer: u64,
587 question: &'a str,
588 }
589 impl ExampleTag<'static> {
590 fn new(answer: u64, question: &'static str) -> Self {
591 Self { answer, question }
592 }
593 }
594 impl<'a> TryFrom<UnknownTag<'a>> for ExampleTag<'a> {
595 type Error = ValidationError;
596 fn try_from(tag: UnknownTag<'a>) -> Result<Self, Self::Error> {
597 let mut attribute_list = tag
598 .value()
599 .ok_or(ParseTagValueError::UnexpectedEmpty)?
600 .try_as_attribute_list()?;
601 let Some(answer) = attribute_list
602 .remove("MEANING-OF-LIFE")
603 .and_then(|v| v.unquoted())
604 .and_then(|v| v.try_as_decimal_integer().ok())
605 else {
606 return Err(ValidationError::MissingRequiredAttribute("MEANING-OF-LIFE"));
607 };
608 let Some(question) = attribute_list.remove("QUESTION").and_then(|v| v.quoted()) else {
609 return Err(ValidationError::MissingRequiredAttribute("QUESTION"));
610 };
611 Ok(Self { answer, question })
612 }
613 }
614 impl<'a> CustomTag<'a> for ExampleTag<'a> {
615 fn is_known_name(name: &str) -> bool {
616 name == "-X-EXAMPLE-TAG"
617 }
618 }
619}
620
621#[cfg(test)]
622// Example taken from HLS specification with one custom tag added.
623// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-9.1
624const EXAMPLE_MANIFEST: &str = r#"#EXTM3U
625#EXT-X-TARGETDURATION:10
626#EXT-X-VERSION:3
627#EXT-X-EXAMPLE-TAG:MEANING-OF-LIFE=42,QUESTION="UNKNOWN"
628#EXTINF:9.009,
629http://media.example.com/first.ts
630#EXTINF:9.009,
631http://media.example.com/second.ts
632#EXTINF:3.003,
633http://media.example.com/third.ts
634#EXT-X-ENDLIST
635"#;