quick_m3u8/tag_internal/
value.rs

1//! Collection of methods and types used to extract meaning from the value component of a tag line.
2//!
3//! The value of a tag (when not empty) is everything after the `:` and before the new line break.
4//! This module provides types of values and methods for parsing into these types from input data.
5
6use crate::{
7    date::{self, DateTime},
8    error::{
9        AttributeListParsingError, DateTimeSyntaxError, DecimalResolutionParseError,
10        ParseDecimalFloatingPointWithTitleError, ParseDecimalIntegerRangeError, ParseFloatError,
11        ParseNumberError, ParsePlaylistTypeError,
12    },
13    utils::parse_u64,
14};
15use memchr::{memchr, memchr_iter, memchr3_iter};
16use std::{borrow::Cow, collections::HashMap, fmt::Display};
17
18/// A wrapper struct that provides many convenience methods for converting a tag value into a more
19/// specialized type.
20///
21/// The `TagValue` is intended to wrap the bytes following the `:` and before the end of line (not
22/// including the `\r` or `\n` characters). The constructor remains public (for convenience, as
23/// described below) so bear this in mind if trying to use this struct directly. It is unlikely that
24/// a user will need to construct this directly, and instead, should access this via
25/// [`crate::tag::UnknownTag::value`] (`Tag` is via [`crate::custom_parsing::tag::parse`]). There
26/// may be exceptions and so the library provides this flexibility.
27///
28/// For example, a (perhaps interesting) use case for using this struct directly can be to parse
29/// information out of comment tags. For example, it has been noticed that the Unified Streaming
30/// Packager seems to output a custom timestamp comment with its live playlists, that looks like a
31/// tag; however, the library will not parse this as a tag because the syntax is
32/// `#USP-X-TIMESTAMP-MAP:<attribute-list>`, so the lack of `#EXT` prefix means it is seen as a
33/// comment only. Despite this, if we split on the `:`, we can use this struct to extract
34/// information about the value.
35/// ```
36/// # use quick_m3u8::{
37/// #     HlsLine, Reader,
38/// #     config::ParsingOptions,
39/// #     date, date_time,
40/// #     custom_parsing::ParsedByteSlice,
41/// #     tag::{TagValue, AttributeValue},
42/// #     error::ValidationError,
43/// # };
44/// let pseudo_tag = "#USP-X-TIMESTAMP-MAP:MPEGTS=900000,LOCAL=1970-01-01T00:00:00Z";
45/// let mut reader = Reader::from_str(pseudo_tag, ParsingOptions::default());
46/// match reader.read_line() {
47///     Ok(Some(HlsLine::Comment(tag))) => {
48///         let mut tag_split = tag.splitn(2, ':');
49///         if tag_split.next() != Some("USP-X-TIMESTAMP-MAP") {
50///             return Err(format!("unexpected tag name").into());
51///         }
52///         let Some(value) = tag_split.next() else {
53///             return Err(format!("unexpected no tag value").into());
54///         };
55///         let tag_value = TagValue(value.trim().as_bytes());
56///         let list = tag_value.try_as_attribute_list()?;
57///
58///         // Prove that we can extract the value of MPEGTS
59///         let mpegts = list
60///             .get("MPEGTS")
61///             .and_then(AttributeValue::unquoted)
62///             .ok_or(ValidationError::MissingRequiredAttribute("MPEGTS"))?
63///             .try_as_decimal_integer()?;
64///         assert_eq!(900000, mpegts);
65///
66///         // Prove that we can extract the value of LOCAL
67///         let local = list
68///             .get("LOCAL")
69///             .and_then(AttributeValue::unquoted)
70///             .and_then(|v| date::parse_bytes(v.0).ok())
71///             .ok_or(ValidationError::MissingRequiredAttribute("LOCAL"))?;
72///         assert_eq!(date_time!(1970-01-01 T 00:00:00.000), local);
73///     }
74///     r => return Err(format!("unexpected result {r:?}").into()),
75/// }
76/// # Ok::<(), Box<dyn std::error::Error>>(())
77/// ```
78#[derive(Debug, PartialEq, Clone, Copy)]
79pub struct TagValue<'a>(pub &'a [u8]);
80impl<'a> TagValue<'a> {
81    /// Indicates whether the value is empty or not.
82    ///
83    /// This is only the case if the tag contained a `:` value separator but had no value content
84    /// afterwards (before the new line). Under all known circumstances this is an error. If a tag
85    /// value is empty then this is indicated via [`crate::tag::UnknownTag::value`] providing
86    /// `None`.
87    pub fn is_empty(&self) -> bool {
88        self.0.is_empty()
89    }
90
91    /// Attempt to convert the tag value bytes into a decimal integer.
92    ///
93    /// For example:
94    /// ```
95    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:100")?.parsed;
96    /// if let Some(value) = tag.value() {
97    ///     assert_eq!(100, value.try_as_decimal_integer()?);
98    /// }
99    /// # else { panic!("unexpected empty value" ); }
100    /// # Ok::<(), Box<dyn std::error::Error>>(())
101    /// ```
102    pub fn try_as_decimal_integer(&self) -> Result<u64, ParseNumberError> {
103        parse_u64(self.0)
104    }
105
106    /// Attempt to convert the tag value bytes into a decimal integer range (`<n>[@<o>]`).
107    ///
108    /// For example:
109    /// ```
110    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:1024@512")?.parsed;
111    /// if let Some(value) = tag.value() {
112    ///     assert_eq!((1024, Some(512)), value.try_as_decimal_integer_range()?);
113    /// }
114    /// # else { panic!("unexpected empty value" ); }
115    /// # Ok::<(), Box<dyn std::error::Error>>(())
116    /// ```
117    pub fn try_as_decimal_integer_range(
118        &self,
119    ) -> Result<(u64, Option<u64>), ParseDecimalIntegerRangeError> {
120        match memchr(b'@', self.0) {
121            Some(n) => {
122                let length = parse_u64(&self.0[..n])
123                    .map_err(ParseDecimalIntegerRangeError::InvalidLength)?;
124                let offset = parse_u64(&self.0[(n + 1)..])
125                    .map_err(ParseDecimalIntegerRangeError::InvalidOffset)?;
126                Ok((length, Some(offset)))
127            }
128            None => parse_u64(self.0)
129                .map(|length| (length, None))
130                .map_err(ParseDecimalIntegerRangeError::InvalidLength),
131        }
132    }
133
134    /// Attempt to convert the tag value bytes into a playlist type.
135    ///
136    /// For example:
137    /// ```
138    /// # use quick_m3u8::tag::HlsPlaylistType;
139    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:VOD")?.parsed;
140    /// if let Some(value) = tag.value() {
141    ///     assert_eq!(HlsPlaylistType::Vod, value.try_as_playlist_type()?);
142    /// }
143    /// # else { panic!("unexpected empty value" ); }
144    /// # Ok::<(), Box<dyn std::error::Error>>(())
145    /// ```
146    pub fn try_as_playlist_type(&self) -> Result<HlsPlaylistType, ParsePlaylistTypeError> {
147        if self.0 == b"VOD" {
148            Ok(HlsPlaylistType::Vod)
149        } else if self.0 == b"EVENT" {
150            Ok(HlsPlaylistType::Event)
151        } else {
152            Err(ParsePlaylistTypeError::InvalidValue)
153        }
154    }
155
156    /// Attempt to convert the tag value bytes into a decimal floating point.
157    ///
158    /// For example:
159    /// ```
160    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:3.14")?.parsed;
161    /// if let Some(value) = tag.value() {
162    ///     assert_eq!(3.14, value.try_as_decimal_floating_point()?);
163    /// }
164    /// # else { panic!("unexpected empty value" ); }
165    /// # Ok::<(), Box<dyn std::error::Error>>(())
166    /// ```
167    pub fn try_as_decimal_floating_point(&self) -> Result<f64, ParseFloatError> {
168        fast_float2::parse(self.0).map_err(|_| ParseFloatError)
169    }
170
171    /// Attempt to convert the tag value bytes into a decimal floating point with title.
172    ///
173    /// For example:
174    /// ```
175    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:3.14,pi")?.parsed;
176    /// if let Some(value) = tag.value() {
177    ///     assert_eq!((3.14, "pi"), value.try_as_decimal_floating_point_with_title()?);
178    /// }
179    /// # else { panic!("unexpected empty value" ); }
180    /// # Ok::<(), Box<dyn std::error::Error>>(())
181    /// ```
182    pub fn try_as_decimal_floating_point_with_title(
183        &self,
184    ) -> Result<(f64, &'a str), ParseDecimalFloatingPointWithTitleError> {
185        match memchr(b',', self.0) {
186            Some(n) => {
187                let duration = fast_float2::parse(&self.0[..n])?;
188                let title = std::str::from_utf8(&self.0[(n + 1)..])?;
189                Ok((duration, title))
190            }
191            None => {
192                let duration = fast_float2::parse(self.0)?;
193                Ok((duration, ""))
194            }
195        }
196    }
197
198    /// Attempt to convert the tag value bytes into a date time.
199    ///
200    /// For example:
201    /// ```
202    /// # use quick_m3u8::date_time;
203    /// let tag = quick_m3u8::custom_parsing::tag::parse(
204    ///     "#EXT-X-EXAMPLE:2025-08-10T17:27:42.213-05:00"
205    /// )?.parsed;
206    /// if let Some(value) = tag.value() {
207    ///     assert_eq!(date_time!(2025-08-10 T 17:27:42.213 -05:00), value.try_as_date_time()?);
208    /// }
209    /// # else { panic!("unexpected empty value"); }
210    /// # Ok::<(), Box<dyn std::error::Error>>(())
211    /// ```
212    pub fn try_as_date_time(&self) -> Result<DateTime, DateTimeSyntaxError> {
213        date::parse_bytes(self.0)
214    }
215
216    /// Attempt to convert the tag value bytes into an attribute list.
217    ///
218    /// For example:
219    /// ```
220    /// # use std::collections::HashMap;
221    /// # use quick_m3u8::tag::{AttributeValue, UnquotedAttributeValue};
222    /// let tag = quick_m3u8::custom_parsing::tag::parse(
223    ///     "#EXT-X-EXAMPLE:TYPE=LIST,VALUE=\"example\""
224    /// )?.parsed;
225    /// if let Some(value) = tag.value() {
226    ///     assert_eq!(
227    ///         HashMap::from([
228    ///             ("TYPE", AttributeValue::Unquoted(UnquotedAttributeValue(b"LIST"))),
229    ///             ("VALUE", AttributeValue::Quoted("example"))
230    ///         ]),
231    ///         value.try_as_attribute_list()?
232    ///     );
233    /// }
234    /// # else { panic!("unexpected empty value"); }
235    /// # Ok::<(), Box<dyn std::error::Error>>(())
236    /// ```
237    pub fn try_as_attribute_list(
238        &self,
239    ) -> Result<HashMap<&'a str, AttributeValue<'a>>, AttributeListParsingError> {
240        let mut attribute_list = HashMap::new();
241        let mut list_iter = memchr3_iter(b'=', b',', b'"', self.0);
242        // Name in first position is special because we want to capture the whole value from the
243        // previous_match_index (== 0), rather than in the rest of cases, where we want to capture
244        // the value at the index after the previous match (which should be b','). Therefore, we use
245        // the `next` method to step through the first match and handle it specially, then proceed
246        // to loop through the iterator for all others.
247        let Some(first_match_index) = list_iter.next() else {
248            return Err(AttributeListParsingError::EndOfLineWhileReadingAttributeName);
249        };
250        if self.0[first_match_index] != b'=' {
251            return Err(AttributeListParsingError::UnexpectedCharacterInAttributeName);
252        }
253        let mut previous_match_index = first_match_index;
254        let mut state = AttributeListParsingState::ReadingValue {
255            name: std::str::from_utf8(&self.0[..first_match_index])?,
256        };
257        for i in list_iter {
258            let byte = self.0[i];
259            match state {
260                AttributeListParsingState::ReadingName => {
261                    if byte == b'=' {
262                        // end of name section
263                        let name = std::str::from_utf8(&self.0[(previous_match_index + 1)..i])?;
264                        if name.is_empty() {
265                            return Err(AttributeListParsingError::EmptyAttributeName);
266                        }
267                        state = AttributeListParsingState::ReadingValue { name };
268                    } else {
269                        // b',' and b'"' are both unexpected
270                        return Err(AttributeListParsingError::UnexpectedCharacterInAttributeName);
271                    }
272                    previous_match_index = i;
273                }
274                AttributeListParsingState::ReadingQuotedValue { name } => {
275                    if byte == b'"' {
276                        // only byte that ends the quoted value is b'"'
277                        let value = std::str::from_utf8(&self.0[(previous_match_index + 1)..i])?;
278                        state =
279                            AttributeListParsingState::FinishedReadingQuotedValue { name, value };
280                        previous_match_index = i;
281                    }
282                }
283                AttributeListParsingState::ReadingValue { name } => {
284                    if byte == b'"' {
285                        // must check that this is the first character of the value
286                        if previous_match_index != (i - 1) {
287                            // finding b'"' mid-value is unexpected
288                            return Err(
289                                AttributeListParsingError::UnexpectedCharacterInAttributeValue,
290                            );
291                        }
292                        state = AttributeListParsingState::ReadingQuotedValue { name };
293                    } else if byte == b',' {
294                        let value = UnquotedAttributeValue(&self.0[(previous_match_index + 1)..i]);
295                        if value.0.is_empty() {
296                            // an empty unquoted value is unexpected (only quoted may be empty)
297                            return Err(AttributeListParsingError::EmptyUnquotedValue);
298                        }
299                        attribute_list.insert(name, AttributeValue::Unquoted(value));
300                        state = AttributeListParsingState::ReadingName;
301                    } else {
302                        // b'=' is unexpected while reading value (only b',' or b'"' are expected)
303                        return Err(AttributeListParsingError::UnexpectedCharacterInAttributeValue);
304                    }
305                    previous_match_index = i;
306                }
307                AttributeListParsingState::FinishedReadingQuotedValue { name, value } => {
308                    if byte == b',' {
309                        attribute_list.insert(name, AttributeValue::Quoted(value));
310                        state = AttributeListParsingState::ReadingName;
311                    } else {
312                        // b',' (or end of line) must come after end of quote - all else is invalid
313                        return Err(AttributeListParsingError::UnexpectedCharacterAfterQuoteEnd);
314                    }
315                    previous_match_index = i;
316                }
317            }
318        }
319        // Need to check state at end of line as this will likely not be a match in the above
320        // iteration.
321        match state {
322            AttributeListParsingState::ReadingName => {
323                return Err(AttributeListParsingError::EndOfLineWhileReadingAttributeName);
324            }
325            AttributeListParsingState::ReadingValue { name } => {
326                let value = UnquotedAttributeValue(&self.0[(previous_match_index + 1)..]);
327                if value.0.is_empty() {
328                    // an empty unquoted value is unexpected (only quoted may be empty)
329                    return Err(AttributeListParsingError::EmptyUnquotedValue);
330                }
331                attribute_list.insert(name, AttributeValue::Unquoted(value));
332            }
333            AttributeListParsingState::ReadingQuotedValue { name: _ } => {
334                return Err(AttributeListParsingError::EndOfLineWhileReadingQuotedValue);
335            }
336            AttributeListParsingState::FinishedReadingQuotedValue { name, value } => {
337                attribute_list.insert(name, AttributeValue::Quoted(value));
338            }
339        }
340        Ok(attribute_list)
341    }
342}
343
344enum AttributeListParsingState<'a> {
345    ReadingName,
346    ReadingValue { name: &'a str },
347    ReadingQuotedValue { name: &'a str },
348    FinishedReadingQuotedValue { name: &'a str, value: &'a str },
349}
350
351/// An attribute value within an attribute list.
352///
353/// Values may be quoted or unquoted. In the case that they are unquoted they may be converted into
354/// several other data types. This is done via use of convenience methods on
355/// [`UnquotedAttributeValue`].
356#[derive(Debug, PartialEq, Clone, Copy)]
357pub enum AttributeValue<'a> {
358    /// An unquoted value (e.g. `TYPE=AUDIO`, `BANDWIDTH=10000000`, `SCORE=1.5`,
359    /// `RESOLUTION=1920x1080`, `SCTE35-OUT=0xABCD`, etc.).
360    Unquoted(UnquotedAttributeValue<'a>),
361    /// A quoted value (e.g. `CODECS="avc1.64002a,mp4a.40.2"`).
362    Quoted(&'a str),
363}
364impl<'a> AttributeValue<'a> {
365    /// A convenience method to get the value of the `Unquoted` case.
366    ///
367    /// This can be useful when chaining on optional values. For example:
368    /// ```
369    /// # use std::collections::HashMap;
370    /// # use quick_m3u8::tag::AttributeValue;
371    /// fn get_bandwidth(list: &HashMap<&str, AttributeValue>) -> Option<u64> {
372    ///     list
373    ///         .get("BANDWIDTH")
374    ///         .and_then(AttributeValue::unquoted)
375    ///         .and_then(|v| v.try_as_decimal_integer().ok())
376    /// }
377    /// ```
378    pub fn unquoted(&self) -> Option<UnquotedAttributeValue<'a>> {
379        match self {
380            AttributeValue::Unquoted(v) => Some(*v),
381            AttributeValue::Quoted(_) => None,
382        }
383    }
384    /// A convenience method to get the value of the `Quoted` case.
385    ///
386    /// This can be useful when chaining on optional values. For example:
387    /// ```
388    /// # use std::collections::HashMap;
389    /// # use quick_m3u8::tag::AttributeValue;
390    /// fn get_codecs<'a>(list: &HashMap<&'a str, AttributeValue<'a>>) -> Option<&'a str> {
391    ///     list
392    ///         .get("CODECS")
393    ///         .and_then(AttributeValue::quoted)
394    /// }
395    /// ```
396    pub fn quoted(&self) -> Option<&'a str> {
397        match self {
398            AttributeValue::Unquoted(_) => None,
399            AttributeValue::Quoted(s) => Some(*s),
400        }
401    }
402}
403
404/// A wrapper struct that provides many convenience methods for converting an unquoted attribute
405/// value into a specialized type.
406///
407/// It is very unlikely that this struct will need to be constructed directly. This is more normally
408/// found when taking an attribute list tag value and accessing some of the internal attributes. For
409/// example:
410/// ```
411/// # use std::collections::HashMap;
412/// # use quick_m3u8::tag::{AttributeValue, UnquotedAttributeValue};
413/// # use quick_m3u8::error::{ParseTagValueError, ValidationError};
414/// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-EXAMPLE:TYPE=PI,NUMBER=3.14")?.parsed;
415/// let list = tag
416///     .value()
417///     .ok_or(ParseTagValueError::UnexpectedEmpty)?
418///     .try_as_attribute_list()?;
419///
420/// let type_value = list
421///     .get("TYPE")
422///     .and_then(AttributeValue::unquoted)
423///     .ok_or(ValidationError::MissingRequiredAttribute("TYPE"))?;
424/// assert_eq!(UnquotedAttributeValue(b"PI"), type_value);
425/// assert_eq!(Ok("PI"), type_value.try_as_utf_8());
426///
427/// let number_value = list
428///     .get("NUMBER")
429///     .and_then(AttributeValue::unquoted)
430///     .ok_or(ValidationError::MissingRequiredAttribute("NUMBER"))?;
431/// assert_eq!(UnquotedAttributeValue(b"3.14"), number_value);
432/// assert_eq!(Ok(3.14), number_value.try_as_decimal_floating_point());
433/// # Ok::<(), Box<dyn std::error::Error>>(())
434/// ```
435#[derive(Debug, PartialEq, Clone, Copy)]
436pub struct UnquotedAttributeValue<'a>(pub &'a [u8]);
437impl<'a> UnquotedAttributeValue<'a> {
438    /// Attempt to convert the attribute value bytes into a decimal integer.
439    ///
440    /// For example:
441    /// ```
442    /// # use quick_m3u8::tag::AttributeValue;
443    /// # use quick_m3u8::error::ParseTagValueError;
444    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-TEST:EXAMPLE=42")?.parsed;
445    /// let list = tag
446    ///     .value()
447    ///     .ok_or(ParseTagValueError::UnexpectedEmpty)?
448    ///     .try_as_attribute_list()?;
449    /// assert_eq!(
450    ///     Some(42),
451    ///     list
452    ///         .get("EXAMPLE")
453    ///         .and_then(AttributeValue::unquoted)
454    ///         .and_then(|v| v.try_as_decimal_integer().ok())
455    /// );
456    /// # Ok::<(), Box<dyn std::error::Error>>(())
457    /// ```
458    pub fn try_as_decimal_integer(&self) -> Result<u64, ParseNumberError> {
459        parse_u64(self.0)
460    }
461
462    /// Attempt to convert the attribute value bytes into a decimal floating point.
463    ///
464    /// For example:
465    /// ```
466    /// # use quick_m3u8::tag::AttributeValue;
467    /// # use quick_m3u8::error::ParseTagValueError;
468    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-TEST:EXAMPLE=3.14")?.parsed;
469    /// let list = tag
470    ///     .value()
471    ///     .ok_or(ParseTagValueError::UnexpectedEmpty)?
472    ///     .try_as_attribute_list()?;
473    /// assert_eq!(
474    ///     Some(3.14),
475    ///     list
476    ///         .get("EXAMPLE")
477    ///         .and_then(AttributeValue::unquoted)
478    ///         .and_then(|v| v.try_as_decimal_floating_point().ok())
479    /// );
480    /// # Ok::<(), Box<dyn std::error::Error>>(())
481    /// ```
482    pub fn try_as_decimal_floating_point(&self) -> Result<f64, ParseFloatError> {
483        fast_float2::parse(self.0).map_err(|_| ParseFloatError)
484    }
485
486    /// Attempt to convert the attribute value bytes into a decimal resolution.
487    ///
488    /// For example:
489    /// ```
490    /// # use quick_m3u8::tag::{AttributeValue, DecimalResolution};
491    /// # use quick_m3u8::error::ParseTagValueError;
492    /// let tag = quick_m3u8::custom_parsing::tag::parse("#EXT-X-TEST:EXAMPLE=1920x1080")?.parsed;
493    /// let list = tag
494    ///     .value()
495    ///     .ok_or(ParseTagValueError::UnexpectedEmpty)?
496    ///     .try_as_attribute_list()?;
497    /// assert_eq!(
498    ///     Some(DecimalResolution { width: 1920, height: 1080 }),
499    ///     list
500    ///         .get("EXAMPLE")
501    ///         .and_then(AttributeValue::unquoted)
502    ///         .and_then(|v| v.try_as_decimal_resolution().ok())
503    /// );
504    /// # Ok::<(), Box<dyn std::error::Error>>(())
505    /// ```
506    pub fn try_as_decimal_resolution(
507        &self,
508    ) -> Result<DecimalResolution, DecimalResolutionParseError> {
509        let mut x_iter = memchr_iter(b'x', self.0);
510        let Some(i) = x_iter.next() else {
511            return Err(DecimalResolutionParseError::MissingHeight);
512        };
513        let width =
514            parse_u64(&self.0[..i]).map_err(|_| DecimalResolutionParseError::InvalidWidth)?;
515        let height = parse_u64(&self.0[(i + 1)..])
516            .map_err(|_| DecimalResolutionParseError::InvalidHeight)?;
517        Ok(DecimalResolution { width, height })
518    }
519
520    /// Attempt to convert the attribute value bytes into a UTF-8 string.
521    ///
522    /// For example:
523    /// ```
524    /// # use quick_m3u8::tag::AttributeValue;
525    /// # use quick_m3u8::error::ParseTagValueError;
526    /// let tag = quick_m3u8::custom_parsing::tag::parse(
527    ///     "#EXT-X-TEST:EXAMPLE=ENUMERATED-VALUE"
528    /// )?.parsed;
529    /// let list = tag
530    ///     .value()
531    ///     .ok_or(ParseTagValueError::UnexpectedEmpty)?
532    ///     .try_as_attribute_list()?;
533    /// assert_eq!(
534    ///     Some("ENUMERATED-VALUE"),
535    ///     list
536    ///         .get("EXAMPLE")
537    ///         .and_then(AttributeValue::unquoted)
538    ///         .and_then(|v| v.try_as_utf_8().ok())
539    /// );
540    /// # Ok::<(), Box<dyn std::error::Error>>(())
541    /// ```
542    pub fn try_as_utf_8(&self) -> Result<&'a str, std::str::Utf8Error> {
543        std::str::from_utf8(self.0)
544    }
545}
546
547/// The HLS playlist type, as defined in [`#EXT-X-PLAYLIST-TYPE`].
548///
549/// [`#EXT-X-PLAYLIST-TYPE`]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.4.3.5
550#[derive(Debug, PartialEq, Clone, Copy)]
551pub enum HlsPlaylistType {
552    /// If the `EXT-X-PLAYLIST-TYPE` value is EVENT, Media Segments can only be added to the end of
553    /// the Media Playlist.
554    Event,
555    /// If the `EXT-X-PLAYLIST-TYPE` value is Video On Demand (VOD), the Media Playlist cannot
556    /// change.
557    Vod,
558}
559
560/// Provides a writable version of [`TagValue`].
561///
562/// This is provided so that custom tag implementations may provide an output that does not depend
563/// on having parsed data to derive the write output from. This helps with mutability as well as
564/// allowing for custom tags to be constructed from scratch (without being parsed from source data).
565///
566/// While [`TagValue`] is just a wrapper around a borrowed slice of bytes, `WritableTagValue` is an
567/// enumeration of different value types, as this helps keep converting from a custom tag more easy
568/// (otherwise all users of the library would need to manage re-constructing the playlist line
569/// directly).
570#[derive(Debug, PartialEq)]
571pub enum WritableTagValue<'a> {
572    /// The value is empty.
573    ///
574    /// For example, the `#EXTM3U` tag has an `Empty` value.
575    Empty,
576    /// The value is a decimal integer.
577    ///
578    /// For example, the `#EXT-X-VERSION:<n>` tag has a `DecimalInteger` value (e.g.
579    /// `#EXT-X-VERSION:9`).
580    DecimalInteger(u64),
581    /// The value is a decimal integer range.
582    ///
583    /// For example, the `#EXT-X-BYTERANGE:<n>[@<o>]` tag has a `DecimalIntegerRange` value (e.g.
584    /// `#EXT-X-BYTERANGE:4545045@720`).
585    DecimalIntegerRange(u64, Option<u64>),
586    /// The value is a float with a string title.
587    ///
588    /// For example, the `#EXTINF:<duration>,[<title>]` tag has a
589    /// `DecimalFloatingPointWithOptionalTitle` value (e.g. `#EXTINF:3.003,free-form text`).
590    ///
591    /// If the title provided is empty (`""`) then the comma will not be written.
592    DecimalFloatingPointWithOptionalTitle(f64, Cow<'a, str>),
593    /// The value is a date time.
594    ///
595    /// For example, the `#EXT-X-PROGRAM-DATE-TIME:<date-time-msec>` tag has a `DateTime` value
596    /// (e.g. `#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23.031+08:00`).
597    DateTime(DateTime),
598    /// The value is an attribute list.
599    ///
600    /// For example, the `#EXT-X-MAP:<attribute-list>` tag has an `AttributeList` value (e.g.
601    /// `#EXT-X-MAP:URI="init.mp4"`).
602    AttributeList(HashMap<Cow<'a, str>, WritableAttributeValue<'a>>),
603    /// The value is a UTF-8 string.
604    ///
605    /// For example, the `#EXT-X-PLAYLIST-TYPE:<type-enum>` tag has a `Utf8` value (e.g.
606    /// `#EXT-X-PLAYLIST-TYPE:VOD`).
607    ///
608    /// Note, this effectively provides the user of the library an "escape hatch" to write any value
609    /// that they want.
610    ///
611    /// Also note, the library does not validate for correctness of the input value, so take care to
612    /// not introduce new lines or invalid characters (e.g. whitespace) as this will lead to an
613    /// invalid HLS playlist.
614    Utf8(Cow<'a, str>),
615}
616impl From<u64> for WritableTagValue<'_> {
617    fn from(value: u64) -> Self {
618        Self::DecimalInteger(value)
619    }
620}
621impl From<(u64, Option<u64>)> for WritableTagValue<'_> {
622    fn from(value: (u64, Option<u64>)) -> Self {
623        Self::DecimalIntegerRange(value.0, value.1)
624    }
625}
626impl<'a, T> From<(f64, T)> for WritableTagValue<'a>
627where
628    T: Into<Cow<'a, str>>,
629{
630    fn from(value: (f64, T)) -> Self {
631        Self::DecimalFloatingPointWithOptionalTitle(value.0, value.1.into())
632    }
633}
634impl From<DateTime> for WritableTagValue<'_> {
635    fn from(value: DateTime) -> Self {
636        Self::DateTime(value)
637    }
638}
639impl<'a, K, V> From<HashMap<K, V>> for WritableTagValue<'a>
640where
641    K: Into<Cow<'a, str>>,
642    V: Into<WritableAttributeValue<'a>>,
643{
644    fn from(mut value: HashMap<K, V>) -> Self {
645        let mut map = HashMap::new();
646        for (key, value) in value.drain() {
647            map.insert(key.into(), value.into());
648        }
649        Self::AttributeList(map)
650    }
651}
652impl<'a, K, V, const N: usize> From<[(K, V); N]> for WritableTagValue<'a>
653where
654    K: Into<Cow<'a, str>>,
655    V: Into<WritableAttributeValue<'a>>,
656{
657    fn from(value: [(K, V); N]) -> Self {
658        let mut map = HashMap::new();
659        for (key, value) in value {
660            map.insert(key.into(), value.into());
661        }
662        Self::AttributeList(map)
663    }
664}
665impl<'a> From<Cow<'a, str>> for WritableTagValue<'a> {
666    fn from(value: Cow<'a, str>) -> Self {
667        Self::Utf8(value)
668    }
669}
670impl<'a> From<&'a str> for WritableTagValue<'a> {
671    fn from(value: &'a str) -> Self {
672        Self::Utf8(Cow::Borrowed(value))
673    }
674}
675impl<'a> From<String> for WritableTagValue<'a> {
676    fn from(value: String) -> Self {
677        Self::Utf8(Cow::Owned(value))
678    }
679}
680
681/// Provides a writable version of [`AttributeValue`].
682///
683/// This is provided so that custom tag implementations may provide an output that does not depend
684/// on having parsed data to derive the write output from. This helps with mutability as well as
685/// allowing for custom tags to be constructed from scratch (without being parsed from source data).
686///
687/// While [`AttributeValue`] is mostly just a wrapper around a borrowed slice of bytes,
688/// `WritableAttributeValue` is an enumeration of more value types, as this helps keep converting
689/// from a custom tag more easy (otherwise all users of the library would need to manage
690/// re-constructing the playlist line directly).
691#[derive(Debug, PartialEq, Clone)]
692pub enum WritableAttributeValue<'a> {
693    /// A decimal integer.
694    ///
695    /// From [Section 4.2], this represents:
696    /// * decimal-integer
697    ///
698    /// [Section 4.2]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.2
699    DecimalInteger(u64),
700    /// A signed float.
701    ///
702    /// From [Section 4.2], this represents:
703    /// * decimal-floating-point
704    /// * signed-decimal-floating-point
705    ///
706    /// [Section 4.2]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.2
707    SignedDecimalFloatingPoint(f64),
708    /// A decimal resolution.
709    ///
710    /// From [Section 4.2], this represents:
711    /// * decimal-resolution
712    ///
713    /// [Section 4.2]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.2
714    DecimalResolution(DecimalResolution),
715    /// A quoted string.
716    ///
717    /// From [Section 4.2], this represents:
718    /// * quoted-string
719    /// * enumerated-string-list
720    ///
721    /// [Section 4.2]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.2
722    QuotedString(Cow<'a, str>),
723    /// An unquoted string.
724    ///
725    /// From [Section 4.2], this represents:
726    /// * hexadecimal-sequence
727    /// * enumerated-string
728    ///
729    /// [Section 4.2]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-17#section-4.2
730    ///
731    /// Note, this case can be used as an "escape hatch" to write any of the other cases that
732    /// resolve from unquoted, but those are provided as convenience.
733    ///
734    /// Also note, the library does not validate for correctness of the input value, so take care to
735    /// not introduce new lines or invalid characters (e.g. whitespace) as this will lead to an
736    /// invalid HLS playlist.
737    UnquotedString(Cow<'a, str>),
738}
739impl From<u64> for WritableAttributeValue<'_> {
740    fn from(value: u64) -> Self {
741        Self::DecimalInteger(value)
742    }
743}
744impl From<f64> for WritableAttributeValue<'_> {
745    fn from(value: f64) -> Self {
746        Self::SignedDecimalFloatingPoint(value)
747    }
748}
749impl From<DecimalResolution> for WritableAttributeValue<'_> {
750    fn from(value: DecimalResolution) -> Self {
751        Self::DecimalResolution(value)
752    }
753}
754
755/// A decimal resolution (`<width>x<height>`).
756#[derive(Debug, PartialEq, Clone, Copy)]
757pub struct DecimalResolution {
758    /// A horizontal pixel dimension (width).
759    pub width: u64,
760    /// A vertical pixel dimension (height).
761    pub height: u64,
762}
763impl Display for DecimalResolution {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        write!(f, "{}x{}", self.width, self.height)
766    }
767}
768impl TryFrom<&str> for DecimalResolution {
769    type Error = DecimalResolutionParseError;
770
771    fn try_from(s: &str) -> Result<Self, Self::Error> {
772        let mut split = s.splitn(2, 'x');
773        let Some(width_str) = split.next() else {
774            return Err(DecimalResolutionParseError::MissingWidth);
775        };
776        let width = width_str
777            .parse()
778            .map_err(|_| DecimalResolutionParseError::InvalidWidth)?;
779        let Some(height_str) = split.next() else {
780            return Err(DecimalResolutionParseError::MissingHeight);
781        };
782        let height = height_str
783            .parse()
784            .map_err(|_| DecimalResolutionParseError::InvalidHeight)?;
785        Ok(DecimalResolution { width, height })
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use crate::date_time;
792
793    use super::*;
794    use pretty_assertions::assert_eq;
795
796    #[test]
797    fn type_enum() {
798        let value = TagValue(b"EVENT");
799        assert_eq!(Ok(HlsPlaylistType::Event), value.try_as_playlist_type());
800
801        let value = TagValue(b"VOD");
802        assert_eq!(Ok(HlsPlaylistType::Vod), value.try_as_playlist_type());
803    }
804
805    #[test]
806    fn decimal_integer() {
807        let value = TagValue(b"42");
808        assert_eq!(Ok(42), value.try_as_decimal_integer());
809    }
810
811    #[test]
812    fn decimal_integer_range() {
813        let value = TagValue(b"42@42");
814        assert_eq!(Ok((42, Some(42))), value.try_as_decimal_integer_range());
815    }
816
817    #[test]
818    fn decimal_floating_point_with_optional_title() {
819        // Positive tests
820        let value = TagValue(b"42.0");
821        assert_eq!(
822            Ok((42.0, "")),
823            value.try_as_decimal_floating_point_with_title()
824        );
825        let value = TagValue(b"42.42");
826        assert_eq!(
827            Ok((42.42, "")),
828            value.try_as_decimal_floating_point_with_title()
829        );
830        let value = TagValue(b"42,");
831        assert_eq!(
832            Ok((42.0, "")),
833            value.try_as_decimal_floating_point_with_title()
834        );
835        let value = TagValue(b"42,=ATTRIBUTE-VALUE");
836        assert_eq!(
837            Ok((42.0, "=ATTRIBUTE-VALUE")),
838            value.try_as_decimal_floating_point_with_title()
839        );
840        // Negative tests
841        let value = TagValue(b"-42.0");
842        assert_eq!(
843            Ok((-42.0, "")),
844            value.try_as_decimal_floating_point_with_title()
845        );
846        let value = TagValue(b"-42.42");
847        assert_eq!(
848            Ok((-42.42, "")),
849            value.try_as_decimal_floating_point_with_title()
850        );
851        let value = TagValue(b"-42,");
852        assert_eq!(
853            Ok((-42.0, "")),
854            value.try_as_decimal_floating_point_with_title()
855        );
856        let value = TagValue(b"-42,=ATTRIBUTE-VALUE");
857        assert_eq!(
858            Ok((-42.0, "=ATTRIBUTE-VALUE")),
859            value.try_as_decimal_floating_point_with_title()
860        );
861    }
862
863    #[test]
864    fn date_time_msec() {
865        let value = TagValue(b"2025-06-03T17:56:42.123Z");
866        assert_eq!(
867            Ok(date_time!(2025-06-03 T 17:56:42.123)),
868            value.try_as_date_time(),
869        );
870        let value = TagValue(b"2025-06-03T17:56:42.123+01:00");
871        assert_eq!(
872            Ok(date_time!(2025-06-03 T 17:56:42.123 01:00)),
873            value.try_as_date_time(),
874        );
875        let value = TagValue(b"2025-06-03T17:56:42.123-05:00");
876        assert_eq!(
877            Ok(date_time!(2025-06-03 T 17:56:42.123 -05:00)),
878            value.try_as_date_time(),
879        );
880    }
881
882    mod attribute_list {
883        use super::*;
884
885        macro_rules! unquoted_value_test {
886            (TagValue is $tag_value:literal $($name_lit:literal=$val:literal expects $exp:literal from $method:ident)+) => {
887                let value = TagValue($tag_value);
888                let list = value.try_as_attribute_list().expect("should be valid list");
889                assert_eq!(
890                    list,
891                    HashMap::from([
892                        $(
893                            ($name_lit, AttributeValue::Unquoted(UnquotedAttributeValue($val))),
894                        )+
895                    ])
896                );
897                $(
898                    assert_eq!(Ok($exp), UnquotedAttributeValue($val).$method());
899                )+
900            };
901        }
902
903        macro_rules! quoted_value_test {
904            (TagValue is $tag_value:literal $($name_lit:literal expects $exp:literal)+) => {
905                let value = TagValue($tag_value);
906                let list = value.try_as_attribute_list().expect("should be valid list");
907                assert_eq!(
908                    list,
909                    HashMap::from([
910                        $(
911                            ($name_lit, AttributeValue::Quoted($exp)),
912                        )+
913                    ])
914                );
915            };
916        }
917
918        mod decimal_integer {
919            use super::*;
920            use pretty_assertions::assert_eq;
921
922            #[test]
923            fn single_attribute() {
924                unquoted_value_test!(
925                    TagValue is b"NAME=123"
926                    "NAME"=b"123" expects 123 from try_as_decimal_integer
927                );
928            }
929
930            #[test]
931            fn multi_attributes() {
932                unquoted_value_test!(
933                    TagValue is b"NAME=123,NEXT-NAME=456"
934                    "NAME"=b"123" expects 123 from try_as_decimal_integer
935                    "NEXT-NAME"=b"456" expects 456 from try_as_decimal_integer
936                );
937            }
938        }
939
940        mod signed_decimal_floating_point {
941            use super::*;
942            use pretty_assertions::assert_eq;
943
944            #[test]
945            fn positive_float_single_attribute() {
946                unquoted_value_test!(
947                    TagValue is b"NAME=42.42"
948                    "NAME"=b"42.42" expects 42.42 from try_as_decimal_floating_point
949                );
950            }
951
952            #[test]
953            fn negative_integer_single_attribute() {
954                unquoted_value_test!(
955                    TagValue is b"NAME=-42"
956                    "NAME"=b"-42" expects -42.0 from try_as_decimal_floating_point
957                );
958            }
959
960            #[test]
961            fn negative_float_single_attribute() {
962                unquoted_value_test!(
963                    TagValue is b"NAME=-42.42"
964                    "NAME"=b"-42.42" expects -42.42 from try_as_decimal_floating_point
965                );
966            }
967
968            #[test]
969            fn positive_float_multi_attributes() {
970                unquoted_value_test!(
971                    TagValue is b"NAME=42.42,NEXT-NAME=84.84"
972                    "NAME"=b"42.42" expects 42.42 from try_as_decimal_floating_point
973                    "NEXT-NAME"=b"84.84" expects 84.84 from try_as_decimal_floating_point
974                );
975            }
976
977            #[test]
978            fn negative_integer_multi_attributes() {
979                unquoted_value_test!(
980                    TagValue is b"NAME=-42,NEXT-NAME=-84"
981                    "NAME"=b"-42" expects -42.0 from try_as_decimal_floating_point
982                    "NEXT-NAME"=b"-84" expects -84.0 from try_as_decimal_floating_point
983                );
984            }
985
986            #[test]
987            fn negative_float_multi_attributes() {
988                unquoted_value_test!(
989                    TagValue is b"NAME=-42.42,NEXT-NAME=-84.84"
990                    "NAME"=b"-42.42" expects -42.42 from try_as_decimal_floating_point
991                    "NEXT-NAME"=b"-84.84" expects -84.84 from try_as_decimal_floating_point
992                );
993            }
994        }
995
996        mod quoted_string {
997            use super::*;
998            use pretty_assertions::assert_eq;
999
1000            #[test]
1001            fn single_attribute() {
1002                quoted_value_test!(
1003                    TagValue is b"NAME=\"Hello, World!\""
1004                    "NAME" expects "Hello, World!"
1005                );
1006            }
1007
1008            #[test]
1009            fn multi_attributes() {
1010                quoted_value_test!(
1011                    TagValue is b"NAME=\"Hello,\",NEXT-NAME=\"World!\""
1012                    "NAME" expects "Hello,"
1013                    "NEXT-NAME" expects "World!"
1014                );
1015            }
1016        }
1017
1018        mod unquoted_string {
1019            use super::*;
1020            use pretty_assertions::assert_eq;
1021
1022            #[test]
1023            fn single_attribute() {
1024                unquoted_value_test!(
1025                    TagValue is b"NAME=PQ"
1026                    "NAME"=b"PQ" expects "PQ" from try_as_utf_8
1027                );
1028            }
1029
1030            #[test]
1031            fn multi_attributes() {
1032                unquoted_value_test!(
1033                    TagValue is b"NAME=PQ,NEXT-NAME=HLG"
1034                    "NAME"=b"PQ" expects "PQ" from try_as_utf_8
1035                    "NEXT-NAME"=b"HLG" expects "HLG" from try_as_utf_8
1036                );
1037            }
1038        }
1039    }
1040}