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