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}