Skip to main content

quick_m3u8/
line.rs

1//! Types and operations for working with lines of a HLS playlist.
2//!
3//! This module includes various types and functions for working with lines of a HLS playlist. The
4//! main informational type of the library, [`HlsLine`], exists in this module (and re-exported at
5//! the top level), along with parsing functions to extract `HlsLine` from input data.
6
7use crate::{
8    config::ParsingOptions,
9    error::{ParseLineBytesError, ParseLineStrError, SyntaxError},
10    tag::{CustomTag, CustomTagAccess, KnownTag, NoCustomTag, UnknownTag, hls},
11    tag_internal::unknown::parse_assuming_ext_taken,
12    utils::{split_on_new_line, str_from},
13};
14use std::{borrow::Cow, cmp::PartialEq, fmt::Debug};
15
16/// A parsed line from a HLS playlist.
17///
18/// The HLS specification, in [Section 4.1. Definition of a Playlist], defines lines in a playlist
19/// as such:
20/// > Each line is a URI, is blank, or starts with the character '#'. Lines that start with the
21/// > character '#' are either comments or tags. Tags begin with #EXT.
22///
23/// This data structure follows that guidance but also adds [`HlsLine::UnknownTag`] and
24/// [`KnownTag::Custom`]. These cases are described in more detail within their own documentation,
25/// but in short, the first allows us to capture tags that are not yet known to the library
26/// (providing at least a split between name and value), while the second allows a user of the
27/// library to define their own custom tag specification that can be then parsed into a strongly
28/// typed structure within a `HlsLine::KnownTag` by the library.
29///
30/// [Section 4.1. Definition of a Playlist]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-4.1
31#[derive(Debug, PartialEq, Clone)]
32#[allow(clippy::large_enum_variant)] // See comment on crate::tag::known::Tag.
33pub enum HlsLine<'a, Custom = NoCustomTag>
34where
35    Custom: CustomTag<'a>,
36{
37    /// A tag known to the library, either via the included definitions of HLS tags as specified in
38    /// the `draft-pantos-hls` Internet-Draft, or via a custom tag registration provided by the user
39    /// of the library.
40    ///
41    /// See [`KnownTag`] for more information.
42    KnownTag(KnownTag<'a, Custom>),
43    /// A tag, as defined by the `#EXT` prefix, but not one that is known to the library, or that is
44    /// deliberately ignored via [`ParsingOptions`].
45    ///
46    /// See [`UnknownTag`] for more information.
47    UnknownTag(UnknownTag<'a>),
48    /// A comment line. These are lines that begin with `#` and are followed by a string of UTF-8
49    /// characters (though not BOM or UTF-8 control characters). The line is terminated by either a
50    /// line feed (`\n`) or a carriage return followed by a line feed (`\r\n`).
51    ///
52    /// The associated value is a [`std::borrow::Cow`] to allow for both a user constructed value
53    /// and also a copy-free reference to the original parsed data. It includes all characters after
54    /// the `#` (including any whitespace) and does not include the line break characters. Below
55    /// demonstrates this:
56    /// ```
57    /// # use quick_m3u8::{config::ParsingOptions, HlsLine, custom_parsing::line::parse,
58    /// # error::ParseLineStrError};
59    /// # use std::borrow::Cow;
60    /// # let options = ParsingOptions::default();
61    /// let original = "# Comment line. Note the leading space.\r\n";
62    /// let line = parse(original, &options)?.parsed;
63    /// assert_eq!(
64    ///     HlsLine::Comment(Cow::Borrowed(" Comment line. Note the leading space.")),
65    ///     line,
66    /// );
67    /// # Ok::<(), ParseLineStrError>(())
68    /// ```
69    Comment(Cow<'a, str>),
70    /// A URI line. These are lines that do not begin with `#` and are not empty. It is important to
71    /// note that the library does not do any validation on the line being a valid URI. The only
72    /// validation that happens is that line can be represented as a UTF-8 string (internally we use
73    /// [`std::str::from_utf8`]). This means that the line may contain characters that are invalid
74    /// in a URI, or may otherwise not make sense in the context of the parsed playlist. It is up to
75    /// the user of the library to validate the URI, perhaps using a URL parsing library (such as
76    /// [url]).
77    ///
78    /// The associated value is a [`std::borrow::Cow`] to allow for both a user constructed value
79    /// and also a copy-free reference to the original parsed data. It includes all characters up
80    /// until, but not including, the line break characters. The following demonstrates this:
81    /// ```
82    /// # use quick_m3u8::{config::ParsingOptions, HlsLine, error::ParseLineStrError};
83    /// # use quick_m3u8::custom_parsing::line::parse;
84    /// # use std::borrow::Cow;
85    /// # let options = ParsingOptions::default();
86    /// let expected = "hi.m3u8";
87    /// // Demonstrating that new line characters are not included:
88    /// assert_eq!(
89    ///     HlsLine::Uri(Cow::Borrowed(expected)),
90    ///     parse("hi.m3u8\n", &options)?.parsed,
91    /// );
92    /// assert_eq!(
93    ///     HlsLine::Uri(Cow::Borrowed(expected)),
94    ///     parse("hi.m3u8\r\n", &options)?.parsed,
95    /// );
96    /// assert_eq!(
97    ///     HlsLine::Uri(Cow::Borrowed(expected)),
98    ///     parse("hi.m3u8", &options)?.parsed,
99    /// );
100    /// # Ok::<(), ParseLineStrError>(())
101    /// ```
102    ///
103    /// [url]: https://crates.io/crates/url
104    Uri(Cow<'a, str>),
105    /// A blank line. This line contained no characters other than a new line. Note that since the
106    /// library does not validate characters in a URI line, a line comprised entirely of whitespace
107    /// will still be parsed as a URI line, rather than a blank line. As mentioned, it is up to the
108    /// user of the library to properly validate URI lines.
109    /// ```
110    /// # use quick_m3u8::{config::ParsingOptions, HlsLine, error::ParseLineStrError};
111    /// # use quick_m3u8::custom_parsing::line::parse;
112    /// # use std::borrow::Cow;
113    /// # let options = ParsingOptions::default();
114    /// // Demonstrating what is considered a blank line:
115    /// assert_eq!(
116    ///     HlsLine::Blank,
117    ///     parse("", &options)?.parsed,
118    /// );
119    /// assert_eq!(
120    ///     HlsLine::Blank,
121    ///     parse("\n", &options)?.parsed,
122    /// );
123    /// assert_eq!(
124    ///     HlsLine::Blank,
125    ///     parse("\r\n", &options)?.parsed,
126    /// );
127    /// // Demonstrating that a whitespace only line is still parsed as a URI:
128    /// assert_eq!(
129    ///     HlsLine::Uri(Cow::Borrowed("    ")),
130    ///     parse("    \n", &options)?.parsed,
131    /// );
132    /// # Ok::<(), ParseLineStrError>(())
133    /// ```
134    Blank,
135}
136
137impl<'a, Custom> From<hls::Tag<'a>> for HlsLine<'a, Custom>
138where
139    Custom: CustomTag<'a>,
140{
141    fn from(tag: hls::Tag<'a>) -> Self {
142        Self::KnownTag(KnownTag::Hls(tag))
143    }
144}
145
146impl<'a, Custom> From<CustomTagAccess<'a, Custom>> for HlsLine<'a, Custom>
147where
148    Custom: CustomTag<'a>,
149{
150    fn from(tag: CustomTagAccess<'a, Custom>) -> Self {
151        Self::KnownTag(KnownTag::Custom(tag))
152    }
153}
154
155impl<'a, Custom> From<UnknownTag<'a>> for HlsLine<'a, Custom>
156where
157    Custom: CustomTag<'a>,
158{
159    fn from(tag: UnknownTag<'a>) -> Self {
160        Self::UnknownTag(tag)
161    }
162}
163
164impl<'a> HlsLine<'a> {
165    /// Convenience constructor for [`HlsLine::Comment`]. This will construct the line with the
166    /// generic `Custom` in [`HlsLine::KnownTag`] being [`NoCustomTag`].
167    pub fn comment(comment: impl Into<Cow<'a, str>>) -> Self {
168        Self::Comment(comment.into())
169    }
170
171    /// Convenience constructor for [`HlsLine::Uri`]. This will construct the line with the generic
172    /// `Custom` in [`HlsLine::KnownTag`] being [`NoCustomTag`].
173    pub fn uri(uri: impl Into<Cow<'a, str>>) -> Self {
174        Self::Uri(uri.into())
175    }
176
177    /// Convenience constructor for [`HlsLine::Blank`]. This will construct the line with the
178    /// generic `Custom` in [`HlsLine::KnownTag`] being [`NoCustomTag`].
179    pub fn blank() -> Self {
180        Self::Blank
181    }
182}
183
184macro_rules! impl_line_from_tag {
185    ($tag_mod_path:path, $tag_name:ident) => {
186        impl<'a, Custom> From<$tag_mod_path> for HlsLine<'a, Custom>
187        where
188            Custom: CustomTag<'a>,
189        {
190            fn from(tag: $tag_mod_path) -> Self {
191                Self::KnownTag($crate::tag::KnownTag::Hls(
192                    $crate::tag::hls::Tag::$tag_name(tag),
193                ))
194            }
195        }
196    };
197}
198
199impl_line_from_tag!(hls::M3u, M3u);
200impl_line_from_tag!(hls::Version<'a>, Version);
201impl_line_from_tag!(hls::IndependentSegments, IndependentSegments);
202impl_line_from_tag!(hls::Start<'a>, Start);
203impl_line_from_tag!(hls::Define<'a>, Define);
204impl_line_from_tag!(hls::Targetduration<'a>, Targetduration);
205impl_line_from_tag!(hls::MediaSequence<'a>, MediaSequence);
206impl_line_from_tag!(hls::DiscontinuitySequence<'a>, DiscontinuitySequence);
207impl_line_from_tag!(hls::Endlist, Endlist);
208impl_line_from_tag!(hls::PlaylistType, PlaylistType);
209impl_line_from_tag!(hls::IFramesOnly, IFramesOnly);
210impl_line_from_tag!(hls::PartInf<'a>, PartInf);
211impl_line_from_tag!(hls::ServerControl<'a>, ServerControl);
212impl_line_from_tag!(hls::Inf<'a>, Inf);
213impl_line_from_tag!(hls::Byterange<'a>, Byterange);
214impl_line_from_tag!(hls::Discontinuity, Discontinuity);
215impl_line_from_tag!(hls::Key<'a>, Key);
216impl_line_from_tag!(hls::Map<'a>, Map);
217impl_line_from_tag!(hls::ProgramDateTime<'a>, ProgramDateTime);
218impl_line_from_tag!(hls::Gap, Gap);
219impl_line_from_tag!(hls::Bitrate<'a>, Bitrate);
220impl_line_from_tag!(hls::Part<'a>, Part);
221impl_line_from_tag!(hls::Daterange<'a>, Daterange);
222impl_line_from_tag!(hls::Skip<'a>, Skip);
223impl_line_from_tag!(hls::PreloadHint<'a>, PreloadHint);
224impl_line_from_tag!(hls::RenditionReport<'a>, RenditionReport);
225impl_line_from_tag!(hls::Media<'a>, Media);
226impl_line_from_tag!(hls::StreamInf<'a>, StreamInf);
227impl_line_from_tag!(hls::IFrameStreamInf<'a>, IFrameStreamInf);
228impl_line_from_tag!(hls::SessionData<'a>, SessionData);
229impl_line_from_tag!(hls::SessionKey<'a>, SessionKey);
230impl_line_from_tag!(hls::ContentSteering<'a>, ContentSteering);
231
232/// A slice of parsed line data from a HLS playlist.
233///
234/// This struct allows us to parse some way into a playlist, breaking on the new line, and providing
235/// the remaining characters after the new line in the [`Self::remaining`] field. This is a building
236/// block type that is used by the [`crate::Reader`] to work through an input playlist with each
237/// call to [`crate::Reader::read_line`].
238#[derive(Debug, PartialEq, Clone)]
239pub struct ParsedLineSlice<'a, T>
240where
241    T: Debug + PartialEq,
242{
243    /// The parsed data from the slice of line data from the playlist.
244    pub parsed: T,
245    /// The remaining string slice (after new line characters) from the playlist after parsing. If
246    /// the parsed line was the last in the input data then the `remaining` is `None`.
247    pub remaining: Option<&'a str>,
248}
249/// A slice of parsed line data from a HLS playlist.
250///
251/// This struct allows us to parse some way into a playlist, breaking on the new line, and providing
252/// the remaining characters after the new line in the [`Self::remaining`] field. This is a building
253/// block type that is used by the [`crate::Reader`] to work through an input playlist with each
254/// call to [`crate::Reader::read_line`].
255#[derive(Debug, PartialEq, Clone)]
256pub struct ParsedByteSlice<'a, T>
257where
258    T: Debug + PartialEq,
259{
260    /// The parsed data from the slice of line data from the playlist.
261    pub parsed: T,
262    /// The remaining byte slice (after new line characters) from the playlist after parsing. If
263    /// the parsed line was the last in the input data then the `remaining` is `None`.
264    pub remaining: Option<&'a [u8]>,
265}
266
267/// Parse an input string slice with the provided options.
268///
269/// This method is a lower level method than using [`crate::Reader`] directly. The `Reader` uses
270/// this method internally. It allows the user to parse a single line of HLS data and provides the
271/// remaining data after the new line. Custom reader implementations can be built on top of this
272/// method.
273///
274/// ## Example
275/// ```
276/// # use quick_m3u8::{
277/// # config::ParsingOptions,
278/// # HlsLine, custom_parsing::{ParsedLineSlice, line::parse},
279/// # error::ParseLineStrError,
280/// # tag::hls::{M3u, Targetduration, Version},
281/// # };
282/// const PLAYLIST: &str = r#"#EXTM3U
283/// #EXT-X-TARGETDURATION:10
284/// #EXT-X-VERSION:3
285/// "#;
286/// let options = ParsingOptions::default();
287///
288/// let ParsedLineSlice { parsed, remaining } = parse(PLAYLIST, &options)?;
289/// assert_eq!(parsed, HlsLine::from(M3u));
290///
291/// let Some(remaining) = remaining else { return Ok(()) };
292/// let ParsedLineSlice { parsed, remaining } = parse(remaining, &options)?;
293/// assert_eq!(parsed, HlsLine::from(Targetduration::new(10)));
294///
295/// let Some(remaining) = remaining else { return Ok(()) };
296/// let ParsedLineSlice { parsed, remaining } = parse(remaining, &options)?;
297/// assert_eq!(parsed, HlsLine::from(Version::new(3)));
298///
299/// let Some(remaining) = remaining else { return Ok(()) };
300/// let ParsedLineSlice { parsed, remaining } = parse(remaining, &options)?;
301/// assert_eq!(parsed, HlsLine::Blank);
302/// assert_eq!(remaining, None);
303/// # Ok::<(), ParseLineStrError>(())
304/// ```
305pub fn parse<'a>(
306    input: &'a str,
307    options: &ParsingOptions,
308) -> Result<ParsedLineSlice<'a, HlsLine<'a>>, ParseLineStrError<'a>> {
309    parse_with_custom::<NoCustomTag>(input, options)
310}
311
312/// Parse an input string slice with the provided options with support for the provided custom tag.
313///
314/// This method is a lower level method than using [`crate::Reader`] directly. The `Reader` uses
315/// this method internally. It allows the user to parse a single line of HLS data and provides the
316/// remaining data after the new line. Custom reader implementations can be built on top of this
317/// method. This method differs from [`parse`] as it allows the user to provide their own custom tag
318/// implementation for parsing.
319///
320/// ## Example
321/// ```
322/// # use quick_m3u8::{
323/// # HlsLine,
324/// # config::ParsingOptions,
325/// # custom_parsing::{ParsedLineSlice, line::parse_with_custom},
326/// # error::{ParseLineStrError, ValidationError, ParseTagValueError},
327/// # tag::{KnownTag, CustomTag, UnknownTag},
328/// # tag::hls::{M3u, Targetduration, Version},
329/// # };
330/// #[derive(Debug, Clone, PartialEq)]
331/// struct UserDefinedTag<'a> {
332///     message: &'a str,
333/// }
334/// impl<'a> TryFrom<UnknownTag<'a>> for UserDefinedTag<'a> { // --snip--
335/// #    type Error = ValidationError;
336/// #    fn try_from(tag: UnknownTag<'a>) -> Result<Self, Self::Error> {
337/// #        let mut list = tag
338/// #            .value()
339/// #            .ok_or(ParseTagValueError::UnexpectedEmpty)?
340/// #            .try_as_attribute_list()?;
341/// #        let Some(message) = list.remove("MESSAGE").and_then(|v| v.quoted()) else {
342/// #            return Err(ValidationError::MissingRequiredAttribute("MESSAGE"));
343/// #        };
344/// #        Ok(Self { message })
345/// #    }
346/// }
347/// impl<'a> CustomTag<'a> for UserDefinedTag<'a> { // --snip--
348/// #    fn is_known_name(name: &str) -> bool {
349/// #        name == "-X-USER-DEFINED-TAG"
350/// #    }
351/// }
352///
353/// const PLAYLIST: &str = r#"#EXTM3U
354/// #EXT-X-USER-DEFINED-TAG:MESSAGE="Hello, World!"
355/// "#;
356/// let options = ParsingOptions::default();
357///
358/// let ParsedLineSlice {
359///     parsed,
360///     remaining
361/// } = parse_with_custom::<UserDefinedTag>(PLAYLIST, &options)?;
362/// assert_eq!(parsed, HlsLine::from(M3u));
363///
364/// let Some(remaining) = remaining else { return Ok(()) };
365/// let ParsedLineSlice {
366///     parsed,
367///     remaining
368/// } = parse_with_custom::<UserDefinedTag>(remaining, &options)?;
369/// let HlsLine::KnownTag(KnownTag::Custom(tag)) = parsed else { return Ok(()) };
370/// assert_eq!(tag.as_ref(), &UserDefinedTag { message: "Hello, World!" });
371///
372/// let Some(remaining) = remaining else { return Ok(()) };
373/// let ParsedLineSlice {
374///     parsed,
375///     remaining
376/// } = parse_with_custom::<UserDefinedTag>(remaining, &options)?;
377/// assert_eq!(parsed, HlsLine::Blank);
378/// assert_eq!(remaining, None);
379/// # Ok::<(), ParseLineStrError>(())
380/// ```
381pub fn parse_with_custom<'a, 'b, Custom>(
382    input: &'a str,
383    options: &'b ParsingOptions,
384) -> Result<ParsedLineSlice<'a, HlsLine<'a, Custom>>, ParseLineStrError<'a>>
385where
386    Custom: CustomTag<'a>,
387{
388    parse_bytes_with_custom(input.as_bytes(), options)
389        // These conversions from ParsedByteSlice to ParsedLineSlice are only safe here because we
390        // know that these must represent valid UTF-8.
391        .map(|r| ParsedLineSlice {
392            parsed: r.parsed,
393            remaining: r.remaining.map(str_from),
394        })
395        .map_err(|error| ParseLineStrError {
396            errored_line_slice: ParsedLineSlice {
397                parsed: str_from(error.errored_line_slice.parsed),
398                remaining: error.errored_line_slice.remaining.map(str_from),
399            },
400            error: error.error,
401        })
402}
403
404/// Parse an input byte slice with the provided options.
405///
406/// This method is equivalent to [`parse`] but using `&[u8]` instead of `&str`. Refer to
407/// documentation of [`parse`] for more information.
408pub fn parse_bytes<'a>(
409    input: &'a [u8],
410    options: &ParsingOptions,
411) -> Result<ParsedByteSlice<'a, HlsLine<'a>>, ParseLineBytesError<'a>> {
412    parse_bytes_with_custom::<NoCustomTag>(input, options)
413}
414
415/// Parse an input byte slice with the provided options with support for the provided custom tag.
416///
417/// This method is equivalent to [`parse_with_custom`] but using `&[u8]` instead of `&str`. Refer to
418/// documentation of [`parse_with_custom`] for more information.
419pub fn parse_bytes_with_custom<'a, 'b, Custom>(
420    input: &'a [u8],
421    options: &'b ParsingOptions,
422) -> Result<ParsedByteSlice<'a, HlsLine<'a, Custom>>, ParseLineBytesError<'a>>
423where
424    Custom: CustomTag<'a>,
425{
426    if input.is_empty() {
427        Ok(ParsedByteSlice {
428            parsed: HlsLine::Blank,
429            remaining: None,
430        })
431    } else if input[0] == b'#' {
432        if input.get(3) == Some(&b'T') && &input[..3] == b"#EX" {
433            let tag_rest = &input[4..];
434            let mut tag = parse_assuming_ext_taken(tag_rest, input)
435                .map_err(|error| map_err_bytes(error, input))?;
436            if options.is_known_name(tag.parsed.name) || Custom::is_known_name(tag.parsed.name) {
437                match KnownTag::try_from(tag.parsed) {
438                    Ok(known_tag) => Ok(ParsedByteSlice {
439                        parsed: HlsLine::KnownTag(known_tag),
440                        remaining: tag.remaining,
441                    }),
442                    Err(e) => {
443                        tag.parsed.validation_error = Some(e);
444                        Ok(ParsedByteSlice {
445                            parsed: HlsLine::UnknownTag(tag.parsed),
446                            remaining: tag.remaining,
447                        })
448                    }
449                }
450            } else {
451                Ok(ParsedByteSlice {
452                    parsed: HlsLine::UnknownTag(tag.parsed),
453                    remaining: tag.remaining,
454                })
455            }
456        } else {
457            let ParsedByteSlice { parsed, remaining } = split_on_new_line(&input[1..]);
458            let comment =
459                std::str::from_utf8(parsed).map_err(|error| map_err_bytes(error, input))?;
460            Ok(ParsedByteSlice {
461                parsed: HlsLine::Comment(Cow::Borrowed(comment)),
462                remaining,
463            })
464        }
465    } else {
466        let ParsedByteSlice { parsed, remaining } = split_on_new_line(input);
467        let uri = std::str::from_utf8(parsed).map_err(|error| map_err_bytes(error, input))?;
468        if uri.is_empty() {
469            Ok(ParsedByteSlice {
470                parsed: HlsLine::Blank,
471                remaining,
472            })
473        } else {
474            Ok(ParsedByteSlice {
475                parsed: HlsLine::Uri(Cow::Borrowed(uri)),
476                remaining,
477            })
478        }
479    }
480}
481
482fn map_err_bytes<E: Into<SyntaxError>>(error: E, input: &[u8]) -> ParseLineBytesError<'_> {
483    let errored_line_slice = split_on_new_line(input);
484    ParseLineBytesError {
485        errored_line_slice,
486        error: error.into(),
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::{
494        config::ParsingOptionsBuilder,
495        error::{ParseTagValueError, ValidationError},
496        tag::{
497            AttributeValue, TagValue,
498            hls::{self, M3u, Start},
499        },
500    };
501    use pretty_assertions::assert_eq;
502
503    #[test]
504    fn uri_line() {
505        assert_eq!(
506            Ok(HlsLine::Uri("hello/world.m3u8".into())),
507            parse("hello/world.m3u8", &ParsingOptions::default()).map(|p| p.parsed)
508        )
509    }
510
511    #[test]
512    fn blank_line() {
513        assert_eq!(
514            Ok(HlsLine::Blank),
515            parse("", &ParsingOptions::default()).map(|p| p.parsed)
516        );
517    }
518
519    #[test]
520    fn comment() {
521        assert_eq!(
522            Ok(HlsLine::Comment("Comment".into())),
523            parse("#Comment", &ParsingOptions::default()).map(|p| p.parsed)
524        );
525    }
526
527    #[test]
528    fn basic_tag() {
529        assert_eq!(
530            Ok(HlsLine::from(hls::Tag::M3u(M3u))),
531            parse("#EXTM3U", &ParsingOptions::default()).map(|p| p.parsed)
532        );
533    }
534
535    #[test]
536    fn custom_tag() {
537        // Set up custom tag
538        #[derive(Debug, PartialEq, Clone)]
539        struct TestTag<'a> {
540            greeting_type: &'a str,
541            message: &'a str,
542            times: u64,
543            score: Option<f64>,
544        }
545        impl<'a> TryFrom<UnknownTag<'a>> for TestTag<'a> {
546            type Error = ValidationError;
547
548            fn try_from(tag: UnknownTag<'a>) -> Result<Self, Self::Error> {
549                let value = tag.value().ok_or(ParseTagValueError::UnexpectedEmpty)?;
550                let list = value.try_as_attribute_list()?;
551                let Some(greeting_type) = list
552                    .get("TYPE")
553                    .and_then(AttributeValue::unquoted)
554                    .and_then(|v| v.try_as_utf_8().ok())
555                else {
556                    return Err(ValidationError::MissingRequiredAttribute("TYPE"));
557                };
558                let Some(message) = list.get("MESSAGE").and_then(AttributeValue::quoted) else {
559                    return Err(ValidationError::MissingRequiredAttribute("MESSAGE"));
560                };
561                let Some(times) = list
562                    .get("TIMES")
563                    .and_then(AttributeValue::unquoted)
564                    .and_then(|v| v.try_as_decimal_integer().ok())
565                else {
566                    return Err(ValidationError::MissingRequiredAttribute("TIMES"));
567                };
568                let score = list
569                    .get("SCORE")
570                    .and_then(AttributeValue::unquoted)
571                    .and_then(|v| v.try_as_decimal_floating_point().ok());
572                Ok(Self {
573                    greeting_type,
574                    message,
575                    times,
576                    score,
577                })
578            }
579        }
580        impl CustomTag<'static> for TestTag<'static> {
581            fn is_known_name(name: &str) -> bool {
582                name == "-X-TEST-TAG"
583            }
584        }
585        // Test
586        assert_eq!(
587            Ok(HlsLine::from(CustomTagAccess {
588                custom_tag: TestTag {
589                    greeting_type: "GREETING".into(),
590                    message: "Hello, World!".into(),
591                    times: 42,
592                    score: None,
593                },
594                is_dirty: false,
595                original_input: b"#EXT-X-TEST-TAG:TYPE=GREETING,MESSAGE=\"Hello, World!\",TIMES=42"
596            })),
597            parse_with_custom::<TestTag>(
598                "#EXT-X-TEST-TAG:TYPE=GREETING,MESSAGE=\"Hello, World!\",TIMES=42",
599                &ParsingOptions::default()
600            )
601            .map(|p| p.parsed)
602        );
603    }
604
605    #[test]
606    fn avoiding_parsing_known_tag_when_configured_to_avoid_via_parsing_options() {
607        assert_eq!(
608            Ok(HlsLine::from(hls::Tag::Start(
609                Start::builder().with_time_offset(-18.0).finish()
610            ))),
611            parse("#EXT-X-START:TIME-OFFSET=-18", &ParsingOptions::default()).map(|p| p.parsed)
612        );
613        assert_eq!(
614            Ok(HlsLine::UnknownTag(UnknownTag {
615                name: "-X-START",
616                value: Some(TagValue(b"TIME-OFFSET=-18")),
617                original_input: b"#EXT-X-START:TIME-OFFSET=-18",
618                validation_error: None,
619            })),
620            parse(
621                "#EXT-X-START:TIME-OFFSET=-18",
622                &ParsingOptionsBuilder::new()
623                    .with_parsing_for_all_tags()
624                    .without_parsing_for_start()
625                    .build()
626            )
627            .map(|p| p.parsed)
628        );
629    }
630
631    #[test]
632    fn empty_line_before_new_line_break_should_be_parsed_as_blank() {
633        let input = "\n#something else";
634        assert_eq!(
635            ParsedLineSlice {
636                parsed: HlsLine::Blank,
637                remaining: Some("#something else")
638            },
639            parse(input, &ParsingOptions::default()).unwrap()
640        );
641    }
642}