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