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