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