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}