Skip to main content

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