1#![doc = include_str!("../README.md")]
2#![cfg_attr(not(feature = "std"), no_std)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4#![warn(missing_docs)]
5#![doc = "\n## Runnable examples\n"]
8#![doc = "Run with `cargo run -p broadcast-hls --example <name>`.\n"]
9#![doc = "\n### `build_playlist`\n\n```rust,ignore"]
10#![doc = include_str!("../examples/build_playlist.rs")]
11#![doc = "```\n\n### `parse_playlist`\n\n```rust,ignore"]
12#![doc = include_str!("../examples/parse_playlist.rs")]
13#![doc = "```"]
14
15extern crate alloc;
16
17mod error;
18
19pub use error::{Error, Result};
20
21use alloc::collections::BTreeMap;
22use alloc::collections::BTreeSet;
23use alloc::format;
24use alloc::string::{String, ToString};
25use alloc::vec::Vec;
26
27use broadcast_common::hex::hex_encode;
28
29pub use broadcast_common::CencScheme;
37
38pub const CENC_KEYFORMAT: &str = "urn:mpeg:dash:mp4protection:2011";
46
47pub const CENC_KEYFORMATVERSIONS: &str = "1";
49
50pub fn cenc_ext_x_key(scheme: CencScheme, kid: &[u8; 16], key_uri: &str) -> Result<Option<String>> {
71 let method = match scheme {
75 CencScheme::Cbcs => "SAMPLE-AES",
76 CencScheme::Cenc => "SAMPLE-AES-CTR",
77 _ => return Ok(None),
80 };
81 let mut s = format!("#EXT-X-KEY:METHOD={method}");
82 let attrs = [
83 (String::from("URI"), AttrValue::quoted(key_uri)?),
84 (
85 String::from("KEYFORMAT"),
86 AttrValue::quoted(CENC_KEYFORMAT)?,
87 ),
88 (
89 String::from("KEYFORMATVERSIONS"),
90 AttrValue::quoted(CENC_KEYFORMATVERSIONS)?,
91 ),
92 (
93 String::from("KEYID"),
94 AttrValue::bare(format!("0x{}", hex_encode(kid)))?,
95 ),
96 ];
97 render_attribute_list(&mut s, &attrs);
98 Ok(Some(s))
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ByteRange {
113 pub length: u64,
115 pub offset: Option<u64>,
121}
122
123impl ByteRange {
124 fn render(&self) -> String {
127 match self.offset {
128 Some(o) => format!("{}@{o}", self.length),
129 None => format!("{}", self.length),
130 }
131 }
132
133 fn parse(s: &str, line_no: usize, line: &str) -> Result<Self> {
135 let mut split = s.splitn(2, '@');
136 let n = split.next().unwrap_or("");
137 let length = parse_decimal_int::<u64>(n, line_no, line, "BYTERANGE length")?;
138 let offset = match split.next() {
139 Some(o) => Some(parse_decimal_int::<u64>(
140 o,
141 line_no,
142 line,
143 "BYTERANGE offset",
144 )?),
145 None => None,
146 };
147 if let Some(o) = offset.filter(|&o| o.checked_add(length).is_none()) {
148 return Err(Error::HlsParse {
149 line_no,
150 line: line.to_string(),
151 reason: format!("BYTERANGE offset ({o}) + length ({length}) overflows u64"),
152 });
153 }
154 Ok(ByteRange { length, offset })
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Default)]
161pub struct MapTag {
162 pub uri: String,
165 pub byte_range: Option<ByteRange>,
170 pub extra_attrs: Vec<(String, AttrValue)>,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177#[non_exhaustive]
178pub enum PreloadHintType {
179 #[default]
181 Part,
182 Map,
184}
185
186impl PreloadHintType {
187 pub fn name(&self) -> &'static str {
189 match self {
190 PreloadHintType::Part => "PART",
191 PreloadHintType::Map => "MAP",
192 }
193 }
194}
195
196broadcast_common::impl_spec_display!(PreloadHintType);
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200#[non_exhaustive]
201pub enum ParseDiagnosticKind {
202 InvalidProgramDateTime,
206}
207
208impl ParseDiagnosticKind {
209 pub fn name(&self) -> &'static str {
211 match self {
212 Self::InvalidProgramDateTime => "invalid_program_date_time",
213 }
214 }
215}
216
217broadcast_common::impl_spec_display!(ParseDiagnosticKind);
218
219#[derive(Debug, Clone, PartialEq, Eq)]
222#[non_exhaustive]
223pub struct ParseDiagnostic {
224 pub kind: ParseDiagnosticKind,
226 pub line_no: usize,
228 pub line: String,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Default)]
237pub struct RenditionReport {
238 pub uri: String,
241 pub last_msn: u64,
244 pub last_part: Option<u64>,
247 pub extra_attrs: Vec<(String, AttrValue)>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Default)]
255pub struct SkipInfo {
256 pub skipped_segments: u64,
258 pub recently_removed_daterange_ids: Vec<String>,
262 pub extra_attrs: Vec<(String, AttrValue)>,
265}
266
267#[derive(Debug, Clone, PartialEq, Default)]
275pub struct PartSpec {
276 pub uri: String,
278 pub duration: DecimalSeconds,
280 pub independent: bool,
283 pub byte_range: Option<ByteRange>,
288 pub gap: bool,
291 pub extra_attrs: Vec<(String, AttrValue)>,
294}
295
296#[derive(Debug, Clone, PartialEq)]
301#[non_exhaustive]
302pub struct OpenSegment {
303 pub parts: Vec<PartSpec>,
305 pub map: Option<MapTag>,
312 pub pre_tags: Vec<String>,
319 pub program_date_time: Option<ProgramDateTime>,
322 pub discontinuous: bool,
326}
327
328impl OpenSegment {
329 pub fn new(parts: Vec<PartSpec>) -> Self {
332 Self {
333 parts,
334 map: None,
335 pre_tags: Vec::new(),
336 program_date_time: None,
337 discontinuous: false,
338 }
339 }
340
341 pub fn with_map(mut self, map: MapTag) -> Self {
343 self.map = Some(map);
344 self
345 }
346}
347
348#[derive(Debug, Clone, PartialEq)]
356#[non_exhaustive]
357pub struct StartPoint {
358 pub time_offset: SignedDecimalSeconds,
362 pub precise: bool,
366 pub extra_attrs: Vec<(String, AttrValue)>,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
376#[non_exhaustive]
377pub enum Define {
378 Name {
380 name: String,
382 value: String,
384 extra_attrs: Vec<(String, AttrValue)>,
387 },
388 Import {
396 name: String,
398 extra_attrs: Vec<(String, AttrValue)>,
401 },
402 QueryParam {
405 name: String,
407 extra_attrs: Vec<(String, AttrValue)>,
410 },
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420#[non_exhaustive]
421pub enum PlaylistType {
422 Event,
424 Vod,
426}
427
428impl PlaylistType {
429 pub fn name(&self) -> &'static str {
431 match self {
432 PlaylistType::Event => "EVENT",
433 PlaylistType::Vod => "VOD",
434 }
435 }
436}
437
438broadcast_common::impl_spec_display!(PlaylistType);
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
447#[non_exhaustive]
448pub enum SessionDataFormat {
449 #[default]
452 Json,
453 Raw,
455}
456
457impl SessionDataFormat {
458 pub fn name(&self) -> &'static str {
460 match self {
461 SessionDataFormat::Json => "JSON",
462 SessionDataFormat::Raw => "RAW",
463 }
464 }
465}
466
467broadcast_common::impl_spec_display!(SessionDataFormat);
468
469#[derive(Debug, Clone, PartialEq, Eq)]
473#[non_exhaustive]
474pub enum SessionDataContent {
475 Value(String),
477 Uri {
479 uri: String,
481 format: SessionDataFormat,
484 },
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
492#[non_exhaustive]
493pub struct SessionData {
494 pub data_id: String,
496 pub content: SessionDataContent,
498 pub language: Option<String>,
501 pub extra_attrs: Vec<(String, AttrValue)>,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509#[non_exhaustive]
510pub enum EncryptionMethod {
511 None,
514 Aes128,
516 SampleAes,
518 SampleAesCtr,
520 Aes256Gcm,
522}
523
524impl EncryptionMethod {
525 pub fn name(&self) -> &'static str {
527 match self {
528 EncryptionMethod::None => "NONE",
529 EncryptionMethod::Aes128 => "AES-128",
530 EncryptionMethod::SampleAes => "SAMPLE-AES",
531 EncryptionMethod::SampleAesCtr => "SAMPLE-AES-CTR",
532 EncryptionMethod::Aes256Gcm => "AES-256-GCM",
533 }
534 }
535}
536
537broadcast_common::impl_spec_display!(EncryptionMethod);
538
539#[derive(Debug, Clone, PartialEq, Eq)]
544#[non_exhaustive]
545pub struct SessionKey {
546 pub method: EncryptionMethod,
548 pub uri: Option<String>,
550 pub iv: Option<[u8; 16]>,
552 pub keyformat: Option<String>,
554 pub keyformatversions: Option<String>,
556 pub extra_attrs: Vec<(String, AttrValue)>,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
564#[non_exhaustive]
565pub struct ContentSteering {
566 pub server_uri: String,
568 pub pathway_id: Option<String>,
571 pub extra_attrs: Vec<(String, AttrValue)>,
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583#[non_exhaustive]
584pub struct ProgramDateTime(jiff::Timestamp);
585
586impl ProgramDateTime {
587 pub fn parse(s: &str) -> crate::Result<Self> {
594 let trimmed = s.trim();
595 let ts = if let Ok(ts) = trimmed.parse::<jiff::Timestamp>() {
596 ts
597 } else if trimmed.contains(':')
598 && let Ok(dt) = trimmed.parse::<jiff::civil::DateTime>()
599 {
600 dt.to_zoned(jiff::tz::TimeZone::UTC)
601 .map_err(|_| crate::Error::HlsParse {
602 line_no: 0,
603 line: trimmed.to_string(),
604 reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid date/time".to_string(),
605 })?
606 .timestamp()
607 } else {
608 return Err(crate::Error::HlsParse {
609 line_no: 0,
610 line: trimmed.to_string(),
611 reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
612 .to_string(),
613 });
614 };
615 let ms = ts.as_millisecond();
616 let ts = jiff::Timestamp::from_millisecond(ms).map_err(|_| crate::Error::HlsParse {
617 line_no: 0,
618 line: trimmed.to_string(),
619 reason: "EXT-X-PROGRAM-DATE-TIME value is out of range".to_string(),
620 })?;
621 Ok(Self(ts))
622 }
623
624 pub fn from_timestamp(ts: jiff::Timestamp) -> Self {
627 Self(jiff::Timestamp::from_millisecond(ts.as_millisecond()).unwrap_or(ts))
629 }
630
631 pub fn timestamp(&self) -> jiff::Timestamp {
633 self.0
634 }
635
636 pub fn render(&self) -> alloc::string::String {
638 self.0.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
639 }
640}
641
642impl core::str::FromStr for ProgramDateTime {
643 type Err = crate::Error;
644 fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
645 Self::parse(s)
646 }
647}
648
649impl core::fmt::Display for ProgramDateTime {
650 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
651 f.write_str(&self.render())
652 }
653}
654
655#[derive(Debug, Clone, PartialEq, Default)]
657pub struct MediaSegment {
658 pub uri: String,
660 pub duration: DecimalSeconds,
662 pub discontinuous: bool,
665 pub parts: Vec<PartSpec>,
671 pub byte_range: Option<ByteRange>,
676 pub map: Option<MapTag>,
684 pub gap: bool,
689 pub bitrate: Option<u64>,
698 pub title: Option<String>,
707 pub pre_tags: Vec<String>,
723 pub program_date_time: Option<ProgramDateTime>,
726}
727
728#[derive(Debug, Clone, PartialEq, Default)]
730pub struct MediaPlaylist {
731 pub version: u8,
738 pub target_duration: u32,
740 pub media_sequence: u64,
742 pub diagnostics: Vec<ParseDiagnostic>,
745 pub discontinuity_sequence: u64,
752 pub always_emit_discontinuity_sequence: bool,
757 pub segments: Vec<MediaSegment>,
759 pub open_segment: Option<OpenSegment>,
762 pub endlist: bool,
764 pub extra_tags: Vec<String>,
767 pub low_latency: Option<LowLatencyConfig>,
773 pub iframes_only: bool,
778 pub rendition_reports: Vec<RenditionReport>,
783 pub skip: Option<SkipInfo>,
788 pub independent_segments: bool,
792 pub start: Option<StartPoint>,
795 pub defines: Vec<Define>,
798 pub playlist_type: Option<PlaylistType>,
802}
803
804#[derive(Debug, Clone, PartialEq)]
810pub struct LowLatencyConfig {
811 pub part_target: Option<DecimalSeconds>,
819 pub part_hold_back: Option<DecimalSeconds>,
826 pub preload_hint_part: Option<String>,
830 pub preload_hint_type: PreloadHintType,
836 pub preload_hint_byte_range_start: Option<u64>,
839 pub preload_hint_byte_range_length: Option<u64>,
843 pub can_skip_until: Option<DecimalSeconds>,
847 pub can_block_reload: bool,
857 pub sc_extra_attrs: Vec<(String, AttrValue)>,
867 pub pi_extra_attrs: Vec<(String, AttrValue)>,
869 pub ph_extra_attrs: Vec<(String, AttrValue)>,
871 pub hold_back: Option<DecimalSeconds>,
881 pub can_skip_dateranges: bool,
888}
889
890impl LowLatencyConfig {
891 pub fn effective_part_hold_back(&self) -> Option<DecimalSeconds> {
899 let Some(part_target) = self.part_target else {
900 return self.part_hold_back;
901 };
902 let floor = 3.0 * part_target.get();
903 Some(DecimalSeconds::new_unchecked(
906 self.part_hold_back
907 .map_or(floor, |phb| phb.get().max(floor)),
908 ))
909 }
910}
911
912impl Default for LowLatencyConfig {
913 fn default() -> Self {
922 Self {
923 part_target: None,
924 part_hold_back: None,
925 preload_hint_part: None,
926 preload_hint_type: PreloadHintType::default(),
927 preload_hint_byte_range_start: None,
928 preload_hint_byte_range_length: None,
929 can_skip_until: None,
930 can_block_reload: true,
931 sc_extra_attrs: Vec::new(),
932 pi_extra_attrs: Vec::new(),
933 ph_extra_attrs: Vec::new(),
934 hold_back: None,
935 can_skip_dateranges: false,
936 }
937 }
938}
939
940const VERSION_KEY_IV: u8 = 2;
955const VERSION_FLOAT_EXTINF: u8 = 3;
957const VERSION_BYTERANGE_OR_IFRAMES_ONLY: u8 = 4;
959const VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY: u8 = 5;
965const VERSION_MAP_WITHOUT_IFRAMES_ONLY: u8 = 6;
968const VERSION_MEDIA_SERVICE_INSTREAM_ID: u8 = 7;
971const VERSION_VARIABLE_SUBSTITUTION: u8 = 8;
973const VERSION_SKIP: u8 = 9;
975const VERSION_SKIP_REPLACES_DATERANGE: u8 = 10;
979const VERSION_DEFINE_QUERYPARAM: u8 = 11;
981const VERSION_REQ_ATTRIBUTE: u8 = 12;
983const VERSION_MEDIA_INSTREAM_ID_NON_CC: u8 = 13;
986
987const VARIABLE_SUBSTITUTION_MARKER: &str = "{$";
995
996fn bump_version(v: &mut Option<u8>, n: u8) {
1000 *v = Some(match *v {
1001 Some(m) => m.max(n),
1002 None => n,
1003 });
1004}
1005
1006fn is_fractional_duration(duration: DecimalSeconds) -> bool {
1032 format_extinf(duration).contains('.')
1033}
1034
1035fn contains_variable_substitution(s: &str) -> bool {
1037 s.contains(VARIABLE_SUBSTITUTION_MARKER)
1038}
1039
1040fn scan_tag_lines_for_version(tags: &[String]) -> Option<u8> {
1066 let mut v: Option<u8> = None;
1067 for tag in tags {
1068 if let Some(rest) = tag.strip_prefix("#EXT-X-KEY:") {
1069 let (attrs, _quoted) = parse_attribute_list(rest);
1070 if attrs.contains_key("IV") {
1071 bump_version(&mut v, VERSION_KEY_IV);
1072 }
1073 if attrs.get("METHOD").map(String::as_str) == Some("SAMPLE-AES")
1074 || attrs.contains_key("KEYFORMAT")
1075 || attrs.contains_key("KEYFORMATVERSIONS")
1076 {
1077 bump_version(
1078 &mut v,
1079 VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY,
1080 );
1081 }
1082 } else if let Some(rest) = tag.strip_prefix("#EXT-X-DEFINE:") {
1083 if parse_attribute_list(rest).0.contains_key("QUERYPARAM") {
1091 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1092 }
1093 } else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
1094 let (attrs, _quoted) = parse_attribute_list(rest);
1095 if let Some(instream_id) = attrs.get("INSTREAM-ID") {
1096 if instream_id.starts_with("SERVICE") {
1097 bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
1098 }
1099 let is_closed_captions =
1100 attrs.get("TYPE").map(String::as_str) == Some("CLOSED-CAPTIONS");
1101 if !is_closed_captions {
1102 bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
1103 }
1104 }
1105 }
1106 if let Some(colon) = tag.find(':')
1109 && parse_attribute_list(&tag[colon + 1..])
1110 .0
1111 .keys()
1112 .any(|k| k.starts_with("REQ-"))
1113 {
1114 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1115 }
1116 }
1117 v
1118}
1119
1120fn any_typed_req_attr(
1129 start: Option<&StartPoint>,
1130 defines: &[Define],
1131 sd: &[SessionData],
1132 sk: &[SessionKey],
1133 cs: Option<&ContentSteering>,
1134 media: &[MediaRendition],
1135 variants: &[Variant],
1136 iframes: &[IFrameVariant],
1137) -> bool {
1138 let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1139 attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1140 };
1141 if start.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1142 return true;
1143 }
1144 if defines.iter().any(|d| {
1145 let attrs = match d {
1146 Define::Name { extra_attrs, .. } => extra_attrs,
1147 Define::Import { extra_attrs, .. } => extra_attrs,
1148 Define::QueryParam { extra_attrs, .. } => extra_attrs,
1149 };
1150 extra_attr_is_req(attrs)
1151 }) {
1152 return true;
1153 }
1154 if sd.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1155 return true;
1156 }
1157 if sk.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1158 return true;
1159 }
1160 if cs.is_some_and(|c| extra_attr_is_req(&c.extra_attrs)) {
1161 return true;
1162 }
1163 if media.iter().any(|m| extra_attr_is_req(&m.extra_attrs)) {
1164 return true;
1165 }
1166 if variants.iter().any(|v| extra_attr_is_req(&v.extra_attrs)) {
1167 return true;
1168 }
1169 if iframes.iter().any(|i| extra_attr_is_req(&i.extra_attrs)) {
1170 return true;
1171 }
1172 false
1173}
1174
1175fn any_media_typed_req_attr(
1179 segments: &[MediaSegment],
1180 open_segment: Option<&OpenSegment>,
1181 rendition_reports: &[RenditionReport],
1182 skip: Option<&SkipInfo>,
1183 low_latency: Option<&LowLatencyConfig>,
1184) -> bool {
1185 let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1186 attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1187 };
1188 if segments.iter().any(|s| {
1189 s.map
1190 .as_ref()
1191 .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1192 || s.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs))
1193 }) {
1194 return true;
1195 }
1196 if let Some(open) = open_segment
1197 && (open
1198 .map
1199 .as_ref()
1200 .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1201 || open.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs)))
1202 {
1203 return true;
1204 }
1205 if rendition_reports
1206 .iter()
1207 .any(|r| extra_attr_is_req(&r.extra_attrs))
1208 {
1209 return true;
1210 }
1211 if skip.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1212 return true;
1213 }
1214 if low_latency.is_some_and(|ll| {
1215 extra_attr_is_req(&ll.sc_extra_attrs)
1216 || extra_attr_is_req(&ll.pi_extra_attrs)
1217 || extra_attr_is_req(&ll.ph_extra_attrs)
1218 }) {
1219 return true;
1220 }
1221 false
1222}
1223
1224fn effective_version(explicit: u8, computed: Option<u8>) -> Option<u8> {
1229 match (explicit, computed) {
1230 (0, None) => None,
1231 (0, Some(m)) => Some(m),
1232 (e, None) => Some(e),
1233 (e, Some(m)) => Some(e.max(m)),
1234 }
1235}
1236
1237impl MediaPlaylist {
1238 pub fn computed_version(&self) -> Option<u8> {
1249 let mut v: Option<u8> = None;
1250
1251 let has_map = self.segments.iter().any(|s| s.map.is_some())
1255 || (self.low_latency.is_some()
1258 && self.open_segment.as_ref().is_some_and(|o| o.map.is_some()))
1259 || self.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MAP:"));
1260 if has_map {
1261 bump_version(
1262 &mut v,
1263 if self.iframes_only {
1264 VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY
1265 } else {
1266 VERSION_MAP_WITHOUT_IFRAMES_ONLY
1267 },
1268 );
1269 }
1270
1271 if self.iframes_only || self.segments.iter().any(|s| s.byte_range.is_some()) {
1273 bump_version(&mut v, VERSION_BYTERANGE_OR_IFRAMES_ONLY);
1274 }
1275
1276 if self
1278 .segments
1279 .iter()
1280 .any(|s| is_fractional_duration(s.duration))
1281 {
1282 bump_version(&mut v, VERSION_FLOAT_EXTINF);
1283 }
1284
1285 if let Some(skip) = &self.skip {
1287 bump_version(&mut v, VERSION_SKIP);
1288 if !skip.recently_removed_daterange_ids.is_empty() {
1289 bump_version(&mut v, VERSION_SKIP_REPLACES_DATERANGE);
1290 }
1291 }
1292
1293 if self
1298 .defines
1299 .iter()
1300 .any(|d| matches!(d, Define::QueryParam { .. }))
1301 {
1302 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1303 }
1304
1305 if self
1311 .extra_tags
1312 .iter()
1313 .any(|t| contains_variable_substitution(t))
1314 || self
1315 .segments
1316 .iter()
1317 .any(|s| contains_variable_substitution(&s.uri))
1318 || self.media_playlist_typed_strings_use_substitution()
1319 {
1320 bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
1321 }
1322
1323 {
1334 let mut opaque_tags: Vec<&String> = self.extra_tags.iter().collect();
1335 opaque_tags.extend(self.segments.iter().flat_map(|s| &s.pre_tags));
1336 if let Some(open) = &self.open_segment {
1337 opaque_tags.extend(open.pre_tags.iter());
1338 }
1339 let owned: Vec<String> = opaque_tags.into_iter().cloned().collect();
1340 if let Some(m) = scan_tag_lines_for_version(&owned) {
1341 bump_version(&mut v, m);
1342 }
1343 }
1344
1345 if any_media_typed_req_attr(
1348 &self.segments,
1349 self.open_segment
1351 .as_ref()
1352 .filter(|_| self.low_latency.is_some()),
1353 &self.rendition_reports,
1354 self.skip.as_ref(),
1355 self.low_latency.as_ref(),
1356 ) || any_typed_req_attr(
1357 self.start.as_ref(),
1358 &self.defines,
1359 &[],
1360 &[],
1361 None,
1362 &[],
1363 &[],
1364 &[],
1365 ) {
1366 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1367 }
1368
1369 v
1370 }
1371
1372 fn media_playlist_typed_strings_use_substitution(&self) -> bool {
1382 let seg_strings = self.segments.iter().flat_map(|s| {
1383 s.map
1384 .iter()
1385 .map(|m| &m.uri)
1386 .chain(s.parts.iter().map(|p| &p.uri))
1387 });
1388 let open_strings = self.open_segment.iter().flat_map(|o| {
1389 o.map
1390 .iter()
1391 .map(|m| &m.uri)
1392 .chain(o.parts.iter().map(|p| &p.uri))
1393 });
1394 let define_values = self.defines.iter().filter_map(|d| match d {
1395 Define::Name { value, .. } => Some(value),
1396 _ => None,
1397 });
1398 let report_uris = self.rendition_reports.iter().map(|r| &r.uri);
1399 let preload = self
1400 .low_latency
1401 .iter()
1402 .filter_map(|ll| ll.preload_hint_part.as_ref());
1403
1404 seg_strings
1405 .chain(open_strings)
1406 .chain(define_values)
1407 .chain(report_uris)
1408 .chain(preload)
1409 .any(|s| contains_variable_substitution(s))
1410 }
1411
1412 fn effective_version(&self) -> Option<u8> {
1417 effective_version(self.version, self.computed_version())
1418 }
1419}
1420
1421pub const KEYFORMAT_IDENTITY: &str = "identity";
1424
1425pub type MediaKey = SessionKey;
1428
1429const EXT_X_KEY_PREFIX: &str = "#EXT-X-KEY:";
1430
1431fn key_format_of(key: &MediaKey) -> &str {
1432 key.keyformat.as_deref().unwrap_or(KEYFORMAT_IDENTITY)
1433}
1434
1435#[derive(Default)]
1436struct KeyState {
1437 identity: Option<MediaKey>,
1438 others: BTreeMap<String, MediaKey>,
1439}
1440
1441impl KeyState {
1442 fn apply_tags(&mut self, tags: &[String]) -> Result<()> {
1443 for tag in tags {
1444 let Some(rest) = tag.strip_prefix(EXT_X_KEY_PREFIX) else {
1445 continue;
1446 };
1447 let key = parse_media_key(rest, 0, tag)?;
1448 if key.method != EncryptionMethod::None && key.uri.is_none() {
1449 return Err(Error::KeyWithoutUri { method: key.method });
1450 }
1451 if key.method == EncryptionMethod::None {
1452 self.identity = None;
1453 self.others.clear();
1454 } else if key_format_of(&key) == KEYFORMAT_IDENTITY {
1455 self.identity = Some(key);
1456 } else {
1457 self.others.insert(key_format_of(&key).to_string(), key);
1458 }
1459 }
1460 Ok(())
1461 }
1462
1463 fn effective(&self) -> Result<Option<MediaKey>> {
1464 if let Some(key) = &self.identity {
1465 return Ok(Some(key.clone()));
1466 }
1467 match self.others.keys().next() {
1468 Some(keyformat) => Err(Error::UnsupportedKeyFormat {
1469 keyformat: keyformat.clone(),
1470 }),
1471 None => Ok(None),
1472 }
1473 }
1474}
1475
1476impl MediaPlaylist {
1477 pub fn segment_keys(&self) -> Result<Vec<Option<MediaKey>>> {
1490 self.segment_key_results()?.into_iter().collect()
1491 }
1492
1493 pub fn segment_key_results(&self) -> Result<Vec<Result<Option<MediaKey>>>> {
1502 let mut state = KeyState::default();
1503 let mut keys = Vec::with_capacity(self.segments.len());
1504 for seg in &self.segments {
1505 state.apply_tags(&seg.pre_tags)?;
1506 keys.push(state.effective());
1507 }
1508 Ok(keys)
1509 }
1510
1511 pub fn open_segment_key(&self) -> Result<Option<MediaKey>> {
1517 let mut state = KeyState::default();
1518 for seg in &self.segments {
1519 state.apply_tags(&seg.pre_tags)?;
1520 }
1521 if let Some(open) = &self.open_segment {
1522 state.apply_tags(&open.pre_tags)?;
1523 }
1524 state.effective()
1525 }
1526}
1527
1528impl MediaPlaylist {
1529 pub fn to_m3u8(&self) -> Result<String> {
1557 self.validate_for_render()?;
1558 let mut s = String::new();
1559 s.push_str("#EXTM3U\n");
1560 if let Some(version) = self.effective_version() {
1561 s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
1562 }
1563 if self.iframes_only {
1564 s.push_str("#EXT-X-I-FRAMES-ONLY\n");
1565 }
1566 if self.independent_segments {
1569 s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
1570 }
1571 for def in &self.defines {
1572 push_define_line(&mut s, def)?;
1573 }
1574 if let Some(start) = &self.start {
1575 push_start_line(&mut s, start);
1576 }
1577 s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
1578 s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
1579 if self.discontinuity_sequence > 0 || self.always_emit_discontinuity_sequence {
1580 s.push_str(&format!(
1581 "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
1582 self.discontinuity_sequence
1583 ));
1584 }
1585 if let Some(pt) = self.playlist_type {
1587 s.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", pt.name()));
1588 }
1589
1590 if let Some(ll) = &self.low_latency {
1593 s.push_str(&format!(
1602 "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD={}",
1603 if ll.can_block_reload { "YES" } else { "NO" },
1604 ));
1605 if let Some(phb) = ll.effective_part_hold_back() {
1606 s.push_str(&format!(",PART-HOLD-BACK={}", format_secs(phb)));
1607 }
1608 if let Some(hb) = ll.hold_back {
1609 s.push_str(&format!(",HOLD-BACK={}", format_secs(hb)));
1610 }
1611 if let Some(csu) = ll.can_skip_until {
1612 s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
1613 if ll.can_skip_dateranges {
1614 s.push_str(",CAN-SKIP-DATERANGES=YES");
1615 }
1616 }
1617 render_attribute_list(&mut s, &ll.sc_extra_attrs);
1618 s.push('\n');
1619 if let Some(part_target) = ll.part_target {
1623 s.push_str(&format!(
1624 "#EXT-X-PART-INF:PART-TARGET={}",
1625 format_secs(part_target),
1626 ));
1627 render_attribute_list(&mut s, &ll.pi_extra_attrs);
1628 s.push('\n');
1629 }
1630 }
1631
1632 if let Some(skip) = &self.skip {
1635 s.push_str(&format!(
1636 "#EXT-X-SKIP:SKIPPED-SEGMENTS={}",
1637 skip.skipped_segments
1638 ));
1639 if !skip.recently_removed_daterange_ids.is_empty() {
1640 s.push_str(&format!(
1641 ",RECENTLY-REMOVED-DATERANGES=\"{}\"",
1642 skip.recently_removed_daterange_ids.join("\t")
1643 ));
1644 }
1645 render_attribute_list(&mut s, &skip.extra_attrs);
1646 s.push('\n');
1647 }
1648
1649 for tag in &self.extra_tags {
1650 s.push_str(tag);
1651 s.push('\n');
1652 }
1653
1654 for (i, seg) in self.segments.iter().enumerate() {
1655 for tag in &seg.pre_tags {
1658 s.push_str(tag);
1659 s.push('\n');
1660 }
1661 if seg.discontinuous {
1662 s.push_str("#EXT-X-DISCONTINUITY\n");
1663 }
1664 let prev_map = if i == 0 {
1668 None
1669 } else {
1670 self.segments[i - 1].map.as_ref()
1671 };
1672 if seg.map.as_ref() != prev_map
1673 && let Some(map) = &seg.map
1674 {
1675 push_map_line(&mut s, map)?;
1676 }
1677 let prev_bitrate = if i == 0 {
1680 None
1681 } else {
1682 self.segments[i - 1].bitrate
1683 };
1684 if seg.bitrate != prev_bitrate
1685 && let Some(kbps) = seg.bitrate
1686 {
1687 s.push_str(&format!("#EXT-X-BITRATE:{kbps}\n"));
1688 }
1689 if let Some(pdt) = &seg.program_date_time {
1693 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1694 }
1695 if self.low_latency.is_some() {
1698 for part in &seg.parts {
1699 push_part_line(&mut s, part)?;
1700 }
1701 }
1702 if seg.gap {
1705 s.push_str("#EXT-X-GAP\n");
1706 }
1707 match &seg.title {
1709 Some(title) => {
1710 validate_quoted_string(title, "EXTINF title")?;
1711 s.push_str(&format!(
1712 "#EXTINF:{},{title}\n",
1713 format_extinf(seg.duration)
1714 ));
1715 }
1716 None => {
1717 s.push_str(&format!("#EXTINF:{},\n", format_extinf(seg.duration)));
1718 }
1719 }
1720 if let Some(br) = &seg.byte_range {
1723 s.push_str(&format!("#EXT-X-BYTERANGE:{}\n", br.render()));
1724 }
1725 s.push_str(&seg.uri);
1726 s.push('\n');
1727 }
1728
1729 if let Some(open) = &self.open_segment {
1738 if open.discontinuous {
1739 s.push_str("#EXT-X-DISCONTINUITY\n");
1740 }
1741 if self.low_latency.is_some() {
1742 let prev_map = self.segments.last().and_then(|s| s.map.as_ref());
1747 if open.map.as_ref() != prev_map
1748 && let Some(map) = &open.map
1749 {
1750 push_map_line(&mut s, map)?;
1751 }
1752 if let Some(pdt) = &open.program_date_time {
1754 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1755 }
1756 for part in &open.parts {
1757 push_part_line(&mut s, part)?;
1758 }
1759 } else if let Some(pdt) = &open.program_date_time {
1760 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1761 }
1762 for tag in &open.pre_tags {
1763 s.push_str(tag);
1764 s.push('\n');
1765 }
1766 }
1767
1768 if let Some(ll) = &self.low_latency
1771 && let Some(uri) = &ll.preload_hint_part
1772 {
1773 s.push_str(&format!(
1774 "#EXT-X-PRELOAD-HINT:TYPE={},URI=\"{uri}\"",
1775 ll.preload_hint_type.name(),
1776 ));
1777 if let Some(start) = ll.preload_hint_byte_range_start {
1778 s.push_str(&format!(",BYTERANGE-START={start}"));
1779 }
1780 if let Some(len) = ll.preload_hint_byte_range_length {
1781 s.push_str(&format!(",BYTERANGE-LENGTH={len}"));
1782 }
1783 render_attribute_list(&mut s, &ll.ph_extra_attrs);
1784 s.push('\n');
1785 }
1786
1787 for rr in &self.rendition_reports {
1789 s.push_str(&format!(
1790 "#EXT-X-RENDITION-REPORT:URI=\"{}\",LAST-MSN={}",
1791 rr.uri, rr.last_msn
1792 ));
1793 if let Some(lp) = rr.last_part {
1794 s.push_str(&format!(",LAST-PART={lp}"));
1795 }
1796 render_attribute_list(&mut s, &rr.extra_attrs);
1797 s.push('\n');
1798 }
1799
1800 if self.endlist {
1801 s.push_str("#EXT-X-ENDLIST\n");
1802 }
1803
1804 Ok(s)
1805 }
1806
1807 fn validate_for_render(&self) -> Result<()> {
1814 validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
1817 for seg in &self.segments {
1818 validate_uri(&seg.uri, "segment URI")?;
1819 if let Some(title) = &seg.title {
1820 validate_quoted_string(title, "EXTINF title")?;
1821 }
1822 validate_tag_body("segment pre-tag", &seg.pre_tags)?;
1823 for part in &seg.parts {
1824 validate_uri(&part.uri, "EXT-X-PART URI")?;
1825 }
1826 if let Some(map) = &seg.map {
1827 validate_uri(&map.uri, "EXT-X-MAP URI")?;
1828 }
1829 }
1830 if let Some(open) = &self.open_segment {
1831 validate_tag_body("open-segment pre-tag", &open.pre_tags)?;
1832 for part in &open.parts {
1833 validate_uri(&part.uri, "EXT-X-PART URI")?;
1834 }
1835 if let Some(map) = &open.map {
1836 validate_uri(&map.uri, "EXT-X-MAP URI")?;
1837 }
1838 }
1839 if let Some(ll) = &self.low_latency
1840 && let Some(uri) = &ll.preload_hint_part
1841 {
1842 validate_uri(uri, "EXT-X-PRELOAD-HINT URI")?;
1843 }
1844 for report in &self.rendition_reports {
1845 validate_uri(&report.uri, "EXT-X-RENDITION-REPORT URI")?;
1846 }
1847 if let Some(skip) = &self.skip {
1848 for id in &skip.recently_removed_daterange_ids {
1851 validate_quoted_string(id, "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id")?;
1852 if id.contains('\t') {
1853 return Err(Error::InvalidQuotedString {
1854 what: "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id".to_string(),
1855 bad_char: '\t',
1856 value: id.clone(),
1857 });
1858 }
1859 }
1860 }
1861 Ok(())
1862 }
1863
1864 pub fn parse(input: &str) -> Result<Self> {
1874 let mut version: u8 = 0;
1879 let mut target_duration: Option<u32> = None;
1880 let mut media_sequence: u64 = 0;
1881 let mut discontinuity_sequence: u64 = 0;
1882 let mut saw_discontinuity_sequence = false;
1883 let mut iframes_only = false;
1884 let mut endlist = false;
1885 let mut pending_title: Option<String> = None;
1886 let mut extra_tags: Vec<String> = Vec::new();
1887 let mut segments: Vec<MediaSegment> = Vec::new();
1888 let mut rendition_reports: Vec<RenditionReport> = Vec::new();
1889 let mut skip: Option<SkipInfo> = None;
1890 let mut saw_extm3u = false;
1891 let mut independent_segments = false;
1893 let mut start: Option<StartPoint> = None;
1894 let mut defines: Vec<Define> = Vec::new();
1895 let mut playlist_type: Option<PlaylistType> = None;
1896
1897 let mut part_target: Option<DecimalSeconds> = None;
1899 let mut part_hold_back: Option<DecimalSeconds> = None;
1900 let mut can_skip_until: Option<DecimalSeconds> = None;
1901 let mut can_skip_dateranges = false;
1902 let mut hold_back: Option<DecimalSeconds> = None;
1903 let mut can_block_reload = false;
1908 let mut preload_hint_part: Option<String> = None;
1909 let mut preload_hint_type = PreloadHintType::Part;
1910 let mut preload_hint_byte_range_start: Option<u64> = None;
1911 let mut preload_hint_byte_range_length: Option<u64> = None;
1912 let mut saw_ll_tag = false;
1913 let mut sc_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1914 let mut pi_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1915 let mut ph_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1916
1917 let mut current_map: Option<MapTag> = None;
1920 let mut pending_discontinuous = false;
1921 let mut pending_byte_range: Option<ByteRange> = None;
1922 let mut pending_parts: Vec<PartSpec> = Vec::new();
1923 let mut pending_duration: Option<DecimalSeconds> = None;
1924 let mut pending_gap = false;
1927 let mut current_bitrate: Option<u64> = None;
1928 let mut pending_pre_tags: Vec<String> = Vec::new();
1931 let mut pending_program_date_time: Option<ProgramDateTime> = None;
1933 let mut diagnostics: Vec<ParseDiagnostic> = Vec::new();
1934
1935 for (idx, raw_line) in input.lines().enumerate() {
1936 let line_no = idx + 1;
1937 let mut line = raw_line.trim_end_matches('\r');
1938 if line_no == 1 {
1939 line = line.strip_prefix('\u{feff}').unwrap_or(line);
1940 }
1941 let line = line.trim();
1942 if line.is_empty() {
1943 continue;
1944 }
1945
1946 if line == "#EXTM3U" {
1947 saw_extm3u = true;
1948 } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
1949 version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
1950 } else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
1951 target_duration = Some(parse_decimal_int(
1952 rest,
1953 line_no,
1954 line,
1955 "EXT-X-TARGETDURATION",
1956 )?);
1957 } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
1958 media_sequence = parse_decimal_int(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
1959 } else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
1960 discontinuity_sequence =
1961 parse_decimal_int(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
1962 saw_discontinuity_sequence = true;
1963 } else if line == "#EXT-X-I-FRAMES-ONLY" {
1964 iframes_only = true;
1965 } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
1966 independent_segments = true;
1967 } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
1968 start = Some(parse_start(rest, line_no, line)?);
1969 } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
1970 defines.push(parse_define(rest, line_no, line)?);
1971 } else if let Some(rest) = line.strip_prefix("#EXT-X-PLAYLIST-TYPE:") {
1972 playlist_type = Some(match rest.trim() {
1973 "EVENT" => PlaylistType::Event,
1974 "VOD" => PlaylistType::Vod,
1975 other => {
1976 return Err(Error::HlsParse {
1977 line_no,
1978 line: line.to_string(),
1979 reason: format!(
1980 "EXT-X-PLAYLIST-TYPE value {other:?} is neither EVENT nor VOD"
1981 ),
1982 });
1983 }
1984 });
1985 } else if line == "#EXT-X-ENDLIST" {
1986 endlist = true;
1987 } else if line == "#EXT-X-DISCONTINUITY" {
1988 pending_discontinuous = true;
1989 } else if line == "#EXT-X-GAP" {
1990 pending_gap = true;
1991 } else if let Some(rest) = line.strip_prefix("#EXT-X-BITRATE:") {
1992 current_bitrate = Some(parse_decimal_int(rest, line_no, line, "EXT-X-BITRATE")?);
1993 } else if let Some(rest) = line.strip_prefix("#EXT-X-BYTERANGE:") {
1994 pending_byte_range = Some(ByteRange::parse(rest, line_no, line)?);
1995 } else if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
1996 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1997 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-MAP")?;
1998 let byte_range = match attrs.get("BYTERANGE") {
1999 Some(v) => Some(ByteRange::parse(v, line_no, line)?),
2000 None => None,
2001 };
2002 let extra_attrs = filter_extra_attrs(&attrs, &["URI", "BYTERANGE"], "ed)?;
2003 current_map = Some(MapTag {
2004 uri,
2005 byte_range,
2006 extra_attrs,
2007 });
2008 } else if let Some(rest) = line.strip_prefix("#EXTINF:") {
2009 let (dur_str, title) = match rest.split_once(',') {
2010 Some((dur, title)) => (dur, title),
2011 None => (rest, ""),
2012 };
2013 pending_duration = Some(parse_decimal_seconds(
2014 dur_str,
2015 line_no,
2016 line,
2017 "EXTINF duration",
2018 )?);
2019 pending_title = (!title.is_empty()).then(|| title.to_string());
2023 } else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
2024 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2025 if let Some(v) = attrs.get("PART-TARGET") {
2026 part_target = Some(parse_decimal_seconds(v, line_no, line, "PART-TARGET")?);
2027 }
2028 pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"], "ed)?);
2029 saw_ll_tag = true;
2030 } else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
2031 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2032 if let Some(v) = attrs.get("PART-HOLD-BACK") {
2033 part_hold_back =
2034 Some(parse_decimal_seconds(v, line_no, line, "PART-HOLD-BACK")?);
2035 }
2036 if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
2037 can_skip_until =
2038 Some(parse_decimal_seconds(v, line_no, line, "CAN-SKIP-UNTIL")?);
2039 }
2040 can_skip_dateranges =
2041 attrs.get("CAN-SKIP-DATERANGES").map(String::as_str) == Some("YES");
2042 can_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
2043 if let Some(v) = attrs.get("HOLD-BACK") {
2044 hold_back = Some(parse_decimal_seconds(v, line_no, line, "HOLD-BACK")?);
2045 }
2046 sc_extra_attrs.extend(filter_extra_attrs(
2047 &attrs,
2048 &[
2049 "PART-HOLD-BACK",
2050 "CAN-SKIP-UNTIL",
2051 "CAN-BLOCK-RELOAD",
2052 "HOLD-BACK",
2053 "CAN-SKIP-DATERANGES",
2054 ],
2055 "ed,
2056 )?);
2057 saw_ll_tag = true;
2058 } else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
2059 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2060 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-PART")?;
2061 let duration_str = attrs.get("DURATION").ok_or_else(|| Error::HlsParse {
2062 line_no,
2063 line: line.to_string(),
2064 reason: "EXT-X-PART missing required DURATION attribute".to_string(),
2065 })?;
2066 let duration =
2067 parse_decimal_seconds(duration_str, line_no, line, "EXT-X-PART DURATION")?;
2068 let independent = attrs.get("INDEPENDENT").map(String::as_str) == Some("YES");
2069 let gap = attrs.get("GAP").map(String::as_str) == Some("YES");
2070 let byte_range = match attrs.get("BYTERANGE") {
2071 Some(v) => Some(ByteRange::parse(v, line_no, line)?),
2072 None => None,
2073 };
2074 let extra_attrs = filter_extra_attrs(
2075 &attrs,
2076 &["URI", "DURATION", "INDEPENDENT", "GAP", "BYTERANGE"],
2077 "ed,
2078 )?;
2079 pending_parts.push(PartSpec {
2080 uri,
2081 duration,
2082 independent,
2083 byte_range,
2084 gap,
2085 extra_attrs,
2086 });
2087 saw_ll_tag = true;
2088 } else if let Some(rest) = line.strip_prefix("#EXT-X-PRELOAD-HINT:") {
2089 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2090 preload_hint_type = match attrs.get("TYPE").map(String::as_str) {
2091 Some("MAP") => PreloadHintType::Map,
2092 _ => PreloadHintType::Part,
2093 };
2094 preload_hint_part = Some(require_attr(
2095 &attrs,
2096 "URI",
2097 line_no,
2098 line,
2099 "EXT-X-PRELOAD-HINT",
2100 )?);
2101 if let Some(v) = attrs.get("BYTERANGE-START") {
2102 preload_hint_byte_range_start =
2103 Some(parse_decimal_int(v, line_no, line, "BYTERANGE-START")?);
2104 }
2105 if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
2106 preload_hint_byte_range_length =
2107 Some(parse_decimal_int(v, line_no, line, "BYTERANGE-LENGTH")?);
2108 }
2109 ph_extra_attrs.extend(filter_extra_attrs(
2110 &attrs,
2111 &["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
2112 "ed,
2113 )?);
2114 saw_ll_tag = true;
2115 } else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
2116 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2117 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
2118 let last_msn_str =
2122 require_attr(&attrs, "LAST-MSN", line_no, line, "EXT-X-RENDITION-REPORT")?;
2123 let last_msn = parse_decimal_int(&last_msn_str, line_no, line, "LAST-MSN")?;
2124 let last_part = match attrs.get("LAST-PART") {
2125 Some(v) => Some(parse_decimal_int(v, line_no, line, "LAST-PART")?),
2126 None => None,
2127 };
2128 let extra_attrs =
2129 filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"], "ed)?;
2130 rendition_reports.push(RenditionReport {
2131 uri,
2132 last_msn,
2133 last_part,
2134 extra_attrs,
2135 });
2136 } else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
2137 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2138 let skipped_segments_str =
2139 require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
2140 let skipped_segments =
2141 parse_decimal_int(&skipped_segments_str, line_no, line, "SKIPPED-SEGMENTS")?;
2142 let recently_removed_daterange_ids = attrs
2143 .get("RECENTLY-REMOVED-DATERANGES")
2144 .map(|v| {
2145 v.split('\t')
2146 .filter(|s| !s.is_empty())
2147 .map(ToString::to_string)
2148 .collect()
2149 })
2150 .unwrap_or_default();
2151 let extra_attrs = filter_extra_attrs(
2152 &attrs,
2153 &["SKIPPED-SEGMENTS", "RECENTLY-REMOVED-DATERANGES"],
2154 "ed,
2155 )?;
2156 skip = Some(SkipInfo {
2157 skipped_segments,
2158 recently_removed_daterange_ids,
2159 extra_attrs,
2160 });
2161 } else if let Some(rest) = line.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
2162 match ProgramDateTime::parse(rest.trim()) {
2165 Ok(pdt) => pending_program_date_time = Some(pdt),
2166 Err(_) => diagnostics.push(ParseDiagnostic {
2167 kind: ParseDiagnosticKind::InvalidProgramDateTime,
2168 line_no,
2169 line: line.to_string(),
2170 }),
2171 }
2172 } else if let Some(rest) = line.strip_prefix("#EXT") {
2173 let _ = rest;
2174 if is_segment_defining_tag(line) {
2182 pending_pre_tags.push(line.to_string());
2183 } else {
2184 extra_tags.push(line.to_string());
2185 }
2186 } else if line.starts_with('#') {
2187 } else {
2189 let duration = pending_duration.take().ok_or_else(|| Error::HlsParse {
2192 line_no,
2193 line: line.to_string(),
2194 reason: "media segment URI with no preceding #EXTINF".to_string(),
2195 })?;
2196 segments.push(MediaSegment {
2197 uri: line.to_string(),
2198 duration,
2199 discontinuous: core::mem::take(&mut pending_discontinuous),
2200 parts: core::mem::take(&mut pending_parts),
2201 byte_range: pending_byte_range.take(),
2202 map: current_map.clone(),
2203 gap: core::mem::take(&mut pending_gap),
2204 bitrate: current_bitrate,
2205 title: pending_title.take(),
2206 pre_tags: core::mem::take(&mut pending_pre_tags),
2207 program_date_time: pending_program_date_time.take(),
2208 });
2209 }
2210 }
2211
2212 if !saw_extm3u {
2213 return Err(Error::HlsParse {
2214 line_no: 1,
2215 line: String::new(),
2216 reason: "missing #EXTM3U header".to_string(),
2217 });
2218 }
2219 let target_duration = target_duration.ok_or_else(|| Error::HlsParse {
2220 line_no: 0,
2221 line: String::new(),
2222 reason: "missing required #EXT-X-TARGETDURATION".to_string(),
2223 })?;
2224
2225 let open_segment = if pending_parts.is_empty()
2229 && pending_pre_tags.is_empty()
2230 && pending_program_date_time.is_none()
2231 && !pending_discontinuous
2232 {
2233 None
2234 } else {
2235 let open = OpenSegment::new(core::mem::take(&mut pending_parts));
2236 let open = match ¤t_map {
2237 Some(map) => open.with_map(map.clone()),
2238 None => open,
2239 };
2240 Some(OpenSegment {
2241 pre_tags: core::mem::take(&mut pending_pre_tags),
2242 program_date_time: pending_program_date_time.take(),
2243 discontinuous: core::mem::take(&mut pending_discontinuous),
2244 ..open
2245 })
2246 };
2247
2248 if let (Some(start), Some(len)) = (
2249 preload_hint_byte_range_start,
2250 preload_hint_byte_range_length,
2251 ) && start.checked_add(len).is_none()
2252 {
2253 return Err(Error::HlsParse {
2254 line_no: 0,
2255 line: String::new(),
2256 reason: format!(
2257 "PRELOAD-HINT BYTERANGE-START ({start}) + BYTERANGE-LENGTH ({len}) overflows u64"
2258 ),
2259 });
2260 }
2261
2262 let low_latency = if saw_ll_tag {
2263 Some(LowLatencyConfig {
2264 part_target,
2265 part_hold_back,
2266 preload_hint_part,
2267 preload_hint_type,
2268 preload_hint_byte_range_start,
2269 preload_hint_byte_range_length,
2270 can_skip_until,
2271 can_block_reload,
2272 sc_extra_attrs,
2273 pi_extra_attrs,
2274 ph_extra_attrs,
2275 hold_back,
2276 can_skip_dateranges,
2277 })
2278 } else {
2279 None
2280 };
2281
2282 Ok(MediaPlaylist {
2283 version,
2284 target_duration,
2285 media_sequence,
2286 diagnostics,
2287 discontinuity_sequence,
2288 always_emit_discontinuity_sequence: saw_discontinuity_sequence,
2289 segments,
2290 open_segment,
2291 endlist,
2292 extra_tags,
2293 low_latency,
2294 iframes_only,
2295 rendition_reports,
2296 skip,
2297 independent_segments,
2298 start,
2299 defines,
2300 playlist_type,
2301 })
2302 }
2303}
2304
2305fn push_part_line(s: &mut String, part: &PartSpec) -> Result<()> {
2310 validate_uri(&part.uri, "EXT-X-PART URI")?;
2311 s.push_str(&format!(
2312 "#EXT-X-PART:DURATION={},URI=\"{}\"",
2313 format_secs(part.duration),
2314 part.uri,
2315 ));
2316 if let Some(br) = &part.byte_range {
2317 s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2318 }
2319 if part.independent {
2320 s.push_str(",INDEPENDENT=YES");
2321 }
2322 if part.gap {
2323 s.push_str(",GAP=YES");
2324 }
2325 render_attribute_list(s, &part.extra_attrs);
2326 s.push('\n');
2327 Ok(())
2328}
2329
2330fn push_map_line(s: &mut String, map: &MapTag) -> Result<()> {
2333 validate_uri(&map.uri, "EXT-X-MAP URI")?;
2334 s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
2335 if let Some(br) = &map.byte_range {
2336 s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2337 }
2338 render_attribute_list(s, &map.extra_attrs);
2339 s.push('\n');
2340 Ok(())
2341}
2342
2343fn push_define_line(s: &mut String, def: &Define) -> Result<()> {
2345 match def {
2346 Define::Name {
2347 name,
2348 value,
2349 extra_attrs,
2350 } => {
2351 validate_quoted_string(name, "EXT-X-DEFINE NAME")?;
2352 validate_quoted_string(value, "EXT-X-DEFINE VALUE")?;
2353 s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
2354 render_attribute_list(s, extra_attrs);
2355 s.push('\n');
2356 }
2357 Define::Import { name, extra_attrs } => {
2358 validate_quoted_string(name, "EXT-X-DEFINE IMPORT")?;
2359 s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
2360 render_attribute_list(s, extra_attrs);
2361 s.push('\n');
2362 }
2363 Define::QueryParam { name, extra_attrs } => {
2364 validate_quoted_string(name, "EXT-X-DEFINE QUERYPARAM")?;
2365 s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
2366 render_attribute_list(s, extra_attrs);
2367 s.push('\n');
2368 }
2369 }
2370 Ok(())
2371}
2372
2373fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
2376 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2377 let present = [
2378 attrs.contains_key("NAME"),
2379 attrs.contains_key("IMPORT"),
2380 attrs.contains_key("QUERYPARAM"),
2381 ]
2382 .iter()
2383 .filter(|&&b| b)
2384 .count();
2385 if present != 1 {
2386 return Err(Error::HlsParse {
2387 line_no,
2388 line: line.to_string(),
2389 reason: "EXT-X-DEFINE must contain exactly one of NAME, IMPORT, QUERYPARAM".to_string(),
2390 });
2391 }
2392 if let Some(name) = attrs.get("NAME") {
2393 let value = require_attr(&attrs, "VALUE", line_no, line, "EXT-X-DEFINE")?;
2394 let extra_attrs = filter_extra_attrs(&attrs, &["NAME", "VALUE"], "ed)?;
2395 Ok(Define::Name {
2396 name: name.clone(),
2397 value,
2398 extra_attrs,
2399 })
2400 } else if let Some(name) = attrs.get("IMPORT") {
2401 let extra_attrs = filter_extra_attrs(&attrs, &["IMPORT"], "ed)?;
2402 Ok(Define::Import {
2403 name: name.clone(),
2404 extra_attrs,
2405 })
2406 } else {
2407 let name = attrs
2408 .get("QUERYPARAM")
2409 .expect("exactly one of the three checked above")
2410 .clone();
2411 let extra_attrs = filter_extra_attrs(&attrs, &["QUERYPARAM"], "ed)?;
2412 Ok(Define::QueryParam { name, extra_attrs })
2413 }
2414}
2415
2416#[derive(Debug, Clone, PartialEq, Eq)]
2441pub struct AttrValue {
2442 value: String,
2443 quoted: bool,
2444}
2445
2446impl AttrValue {
2447 pub fn quoted(value: impl Into<String>) -> Result<Self> {
2451 let value = value.into();
2452 if value.contains('"') || value.contains('\r') || value.contains('\n') {
2453 return Err(Error::InvalidAttrValue {
2454 value,
2455 kind: "a quoted-string",
2456 });
2457 }
2458 Ok(Self {
2459 value,
2460 quoted: true,
2461 })
2462 }
2463
2464 pub fn bare(value: impl Into<String>) -> Result<Self> {
2468 let value = value.into();
2469 if value.contains(',')
2470 || value.contains('"')
2471 || value.contains('\r')
2472 || value.contains('\n')
2473 || value.contains(char::is_whitespace)
2474 {
2475 return Err(Error::InvalidAttrValue {
2476 value,
2477 kind: "a bare value",
2478 });
2479 }
2480 Ok(Self {
2481 value,
2482 quoted: false,
2483 })
2484 }
2485
2486 #[must_use]
2488 pub fn as_str(&self) -> &str {
2489 &self.value
2490 }
2491
2492 #[must_use]
2494 pub fn is_quoted(&self) -> bool {
2495 self.quoted
2496 }
2497
2498 pub fn for_attr(name: &str, value: impl Into<String>) -> Result<Self> {
2513 let value = value.into();
2514 match known_attr_value_kind(name) {
2515 Some(AttrValueKind::Quoted) => Self::quoted(value),
2516 Some(AttrValueKind::QuotedUnless(bare)) => {
2517 if value == bare {
2518 Self::bare(value)
2519 } else {
2520 Self::quoted(value)
2521 }
2522 }
2523 None => {
2524 if value.is_empty()
2525 || value.contains(',')
2526 || value.contains('"')
2527 || value.contains(char::is_whitespace)
2528 {
2529 Self::quoted(value)
2530 } else {
2531 Self::bare(value)
2532 }
2533 }
2534 }
2535 }
2536}
2537
2538#[cfg(feature = "serde")]
2546impl serde::Serialize for AttrValue {
2547 fn serialize<S: serde::Serializer>(
2548 &self,
2549 serializer: S,
2550 ) -> core::result::Result<S::Ok, S::Error> {
2551 use serde::ser::SerializeStruct;
2552 let mut s = serializer.serialize_struct("AttrValue", 2)?;
2553 s.serialize_field("value", &self.value)?;
2554 s.serialize_field("quoted", &self.quoted)?;
2555 s.end()
2556 }
2557}
2558
2559#[cfg(feature = "serde")]
2560impl<'de> serde::Deserialize<'de> for AttrValue {
2561 fn deserialize<D: serde::Deserializer<'de>>(
2562 deserializer: D,
2563 ) -> core::result::Result<Self, D::Error> {
2564 #[derive(serde::Deserialize)]
2565 struct Raw {
2566 value: String,
2567 quoted: bool,
2568 }
2569 let raw = Raw::deserialize(deserializer)?;
2570 if raw.quoted {
2571 AttrValue::quoted(raw.value)
2572 } else {
2573 AttrValue::bare(raw.value)
2574 }
2575 .map_err(serde::de::Error::custom)
2576 }
2577}
2578
2579enum AttrValueKind {
2585 Quoted,
2587 QuotedUnless(&'static str),
2590}
2591
2592fn known_attr_value_kind(name: &str) -> Option<AttrValueKind> {
2598 match name {
2599 "AUDIO"
2600 | "VIDEO"
2601 | "SUBTITLES"
2602 | "PATHWAY-ID"
2603 | "STABLE-VARIANT-ID"
2604 | "STABLE-RENDITION-ID"
2605 | "SUPPLEMENTAL-CODECS"
2606 | "REQ-VIDEO-LAYOUT"
2607 | "ALLOWED-CPC" => Some(AttrValueKind::Quoted),
2608 "CLOSED-CAPTIONS" => Some(AttrValueKind::QuotedUnless("NONE")),
2609 _ => None,
2610 }
2611}
2612
2613pub fn render_attribute_list(s: &mut String, attrs: &[(String, AttrValue)]) {
2635 for (name, value) in attrs {
2636 s.push(',');
2637 s.push_str(name);
2638 s.push('=');
2639 if value.is_quoted() {
2640 s.push('"');
2641 s.push_str(value.as_str());
2642 s.push('"');
2643 } else {
2644 s.push_str(value.as_str());
2645 }
2646 }
2647}
2648
2649const QUOTED_STRING_FORBIDDEN: &[char] = &['"', '\r', '\n'];
2657
2658fn validate_quoted_string(value: &str, what: &str) -> Result<()> {
2661 if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2662 return Err(Error::InvalidQuotedString {
2663 what: what.to_string(),
2664 bad_char: bad,
2665 value: value.to_string(),
2666 });
2667 }
2668 Ok(())
2669}
2670
2671fn validate_tag_body(what: &str, tags: &[String]) -> Result<()> {
2676 for tag in tags {
2677 if let Some(bad) = tag.chars().find(|c| *c == '\r' || *c == '\n') {
2678 return Err(Error::InvalidQuotedString {
2679 what: what.to_string(),
2680 bad_char: bad,
2681 value: tag.clone(),
2682 });
2683 }
2684 }
2685 Ok(())
2686}
2687
2688fn validate_uri(value: &str, what: &str) -> Result<()> {
2691 if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2692 return Err(Error::InvalidUri {
2693 what: what.to_string(),
2694 bad_char: bad,
2695 value: value.to_string(),
2696 });
2697 }
2698 Ok(())
2699}
2700
2701fn push_start_line(s: &mut String, start: &StartPoint) {
2703 s.push_str(&format!(
2704 "#EXT-X-START:TIME-OFFSET={}",
2705 format_signed_secs(start.time_offset)
2706 ));
2707 if start.precise {
2708 s.push_str(",PRECISE=YES");
2709 }
2710 render_attribute_list(s, &start.extra_attrs);
2711 s.push('\n');
2712}
2713
2714fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
2716 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2717 let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
2718 let time_offset = parse_signed_decimal_seconds(&time_offset_str, line_no, line, "TIME-OFFSET")?;
2719 let precise = attrs.get("PRECISE").map(String::as_str) == Some("YES");
2720 let extra_attrs = filter_extra_attrs(&attrs, &["TIME-OFFSET", "PRECISE"], "ed)?;
2721 Ok(StartPoint {
2722 time_offset,
2723 precise,
2724 extra_attrs,
2725 })
2726}
2727
2728fn push_session_data_line(s: &mut String, sd: &SessionData) -> Result<()> {
2730 validate_quoted_string(&sd.data_id, "EXT-X-SESSION-DATA DATA-ID")?;
2731 s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
2732 match &sd.content {
2733 SessionDataContent::Value(v) => {
2734 validate_quoted_string(v, "EXT-X-SESSION-DATA VALUE")?;
2735 s.push_str(&format!(",VALUE=\"{v}\""));
2736 }
2737 SessionDataContent::Uri { uri, format } => {
2738 validate_uri(uri, "EXT-X-SESSION-DATA URI")?;
2739 s.push_str(&format!(",URI=\"{uri}\""));
2740 if *format == SessionDataFormat::Raw {
2741 s.push_str(",FORMAT=RAW");
2742 }
2743 }
2744 }
2745 if let Some(lang) = &sd.language {
2746 validate_quoted_string(lang, "EXT-X-SESSION-DATA LANGUAGE")?;
2747 s.push_str(&format!(",LANGUAGE=\"{lang}\""));
2748 }
2749 render_attribute_list(s, &sd.extra_attrs);
2750 s.push('\n');
2751 Ok(())
2752}
2753
2754fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
2756 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2757 let data_id = require_attr(&attrs, "DATA-ID", line_no, line, "EXT-X-SESSION-DATA")?;
2758 let value = attrs.get("VALUE");
2759 let uri = attrs.get("URI");
2760 let content = match (value, uri) {
2761 (Some(v), None) => SessionDataContent::Value(v.clone()),
2762 (None, Some(u)) => {
2763 let format = match attrs.get("FORMAT").map(String::as_str) {
2764 Some("RAW") => SessionDataFormat::Raw,
2765 _ => SessionDataFormat::Json,
2766 };
2767 SessionDataContent::Uri {
2768 uri: u.clone(),
2769 format,
2770 }
2771 }
2772 (Some(_), Some(_)) => {
2773 return Err(Error::HlsParse {
2774 line_no,
2775 line: line.to_string(),
2776 reason: "EXT-X-SESSION-DATA must not contain both VALUE and URI".to_string(),
2777 });
2778 }
2779 (None, None) => {
2780 return Err(Error::HlsParse {
2781 line_no,
2782 line: line.to_string(),
2783 reason: "EXT-X-SESSION-DATA must contain either VALUE or URI".to_string(),
2784 });
2785 }
2786 };
2787 let language = attrs.get("LANGUAGE").cloned();
2788 let known: &[&str] = match &content {
2789 SessionDataContent::Value(_) => &["DATA-ID", "VALUE", "LANGUAGE"],
2790 SessionDataContent::Uri { .. } => &["DATA-ID", "URI", "FORMAT", "LANGUAGE"],
2791 };
2792 let extra_attrs = filter_extra_attrs(&attrs, known, "ed)?;
2793 Ok(SessionData {
2794 data_id,
2795 content,
2796 language,
2797 extra_attrs,
2798 })
2799}
2800
2801fn push_session_key_line(s: &mut String, sk: &SessionKey) -> Result<()> {
2803 s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
2804 if let Some(uri) = &sk.uri {
2805 validate_uri(uri, "EXT-X-SESSION-KEY URI")?;
2806 s.push_str(&format!(",URI=\"{uri}\""));
2807 }
2808 if let Some(iv) = &sk.iv {
2809 s.push_str(&format!(",IV=0x{}", hex_encode(iv)));
2810 }
2811 if let Some(kf) = &sk.keyformat {
2812 validate_quoted_string(kf, "EXT-X-SESSION-KEY KEYFORMAT")?;
2813 s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
2814 }
2815 if let Some(kfv) = &sk.keyformatversions {
2816 validate_quoted_string(kfv, "EXT-X-SESSION-KEY KEYFORMATVERSIONS")?;
2817 s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
2818 }
2819 render_attribute_list(s, &sk.extra_attrs);
2820 s.push('\n');
2821 Ok(())
2822}
2823
2824fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2827 parse_key_attributes(rest, line_no, line, "EXT-X-SESSION-KEY", false)
2828}
2829
2830fn parse_media_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2832 parse_key_attributes(rest, line_no, line, "EXT-X-KEY", true)
2833}
2834
2835fn parse_key_attributes(
2836 rest: &str,
2837 line_no: usize,
2838 line: &str,
2839 tag: &str,
2840 allow_none: bool,
2841) -> Result<SessionKey> {
2842 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2843 let method_str = require_attr(&attrs, "METHOD", line_no, line, tag)?;
2844 let method = match method_str.as_str() {
2845 "NONE" if allow_none => EncryptionMethod::None,
2846 "NONE" => {
2847 return Err(Error::HlsParse {
2848 line_no,
2849 line: line.to_string(),
2850 reason: "EXT-X-SESSION-KEY METHOD MUST NOT be NONE (RFC 8216bis §4.4.6.5)"
2851 .to_string(),
2852 });
2853 }
2854 "AES-128" => EncryptionMethod::Aes128,
2855 "SAMPLE-AES" => EncryptionMethod::SampleAes,
2856 "SAMPLE-AES-CTR" => EncryptionMethod::SampleAesCtr,
2857 "AES-256-GCM" => EncryptionMethod::Aes256Gcm,
2858 other => {
2859 return Err(Error::HlsParse {
2860 line_no,
2861 line: line.to_string(),
2862 reason: format!("{tag} METHOD value {other:?} is not recognized"),
2863 });
2864 }
2865 };
2866 if method == EncryptionMethod::None && attrs.len() > 1 {
2867 return Err(Error::HlsParse {
2868 line_no,
2869 line: line.to_string(),
2870 reason: format!(
2871 "{tag} METHOD=NONE MUST NOT carry other attributes (RFC 8216bis §4.4.4.4)"
2872 ),
2873 });
2874 }
2875 let uri = attrs.get("URI").cloned();
2876 let iv = match attrs.get("IV") {
2877 Some(v) => Some(parse_iv(v)?),
2878 None => None,
2879 };
2880 let keyformat = attrs.get("KEYFORMAT").cloned();
2881 let keyformatversions = attrs.get("KEYFORMATVERSIONS").cloned();
2882 let extra_attrs = filter_extra_attrs(
2883 &attrs,
2884 &["METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS"],
2885 "ed,
2886 )?;
2887 Ok(SessionKey {
2888 method,
2889 uri,
2890 iv,
2891 keyformat,
2892 keyformatversions,
2893 extra_attrs,
2894 })
2895}
2896
2897const IV_HEX_DIGITS: usize = 32;
2903
2904fn parse_iv(s: &str) -> Result<[u8; 16]> {
2905 let hex = s
2906 .strip_prefix("0x")
2907 .or_else(|| s.strip_prefix("0X"))
2908 .filter(|h| h.len() == IV_HEX_DIGITS && h.bytes().all(|b| b.is_ascii_hexdigit()))
2909 .ok_or_else(|| Error::InvalidIv {
2910 value: s.to_string(),
2911 })?;
2912 let mut out = [0u8; 16];
2913 for i in 0..16 {
2914 out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex digits");
2916 }
2917 Ok(out)
2918}
2919
2920fn format_signed_secs(v: SignedDecimalSeconds) -> String {
2926 let v = v.get();
2927 if v < 0.0 {
2928 format!("-{}", format_secs(DecimalSeconds::new_unchecked(-v)))
2930 } else {
2931 format_secs(DecimalSeconds::new_unchecked(v))
2932 }
2933}
2934
2935fn format_secs(v: DecimalSeconds) -> String {
2951 let v = v.get();
2955 let millis = (v * 1000.0 + 0.5) as u64;
2956 let whole = millis / 1000;
2957 let frac = millis % 1000;
2958 let compact = if frac == 0 {
2959 format!("{whole}")
2960 } else {
2961 let mut f = format!("{frac:03}");
2962 while f.ends_with('0') {
2963 f.pop();
2964 }
2965 format!("{whole}.{f}")
2966 };
2967 if compact.parse::<f64>() == Ok(v) {
2968 return compact;
2969 }
2970 format!("{v}")
2971}
2972
2973fn format_extinf(v: DecimalSeconds) -> String {
3002 let v = v.get();
3004 if let Some(whole) = whole_seconds(v) {
3005 return format!("{whole}");
3006 }
3007 let three = format!("{v:.3}");
3008 if three.parse::<f64>() == Ok(v) {
3009 return three;
3010 }
3011 format!("{v}")
3012}
3013
3014fn whole_seconds(v: f64) -> Option<u64> {
3022 if !v.is_finite() || !(0.0..WHOLE_SECONDS_CAST_LIMIT).contains(&v) {
3023 return None;
3024 }
3025 let whole = v as u64;
3026 (whole as f64 == v).then_some(whole)
3027}
3028
3029const WHOLE_SECONDS_CAST_LIMIT: f64 = 9_007_199_254_740_992.0; fn parse_decimal_float(
3043 s: &str,
3044 line_no: usize,
3045 line: &str,
3046 what: &str,
3047 signed: bool,
3048) -> Result<f64> {
3049 let t = s.trim();
3050 if !is_valid_decimal_token(t, signed) {
3051 return Err(Error::HlsParse {
3052 line_no,
3053 line: line.to_string(),
3054 reason: format!("{what} value {s:?} is not a valid decimal number"),
3055 });
3056 }
3057 t.parse::<f64>().map_err(|_| Error::HlsParse {
3060 line_no,
3061 line: line.to_string(),
3062 reason: format!("{what} value {s:?} is not a valid number"),
3063 })
3064}
3065
3066fn parse_decimal_int<T: core::str::FromStr>(
3073 s: &str,
3074 line_no: usize,
3075 line: &str,
3076 what: &str,
3077) -> Result<T> {
3078 let t = s.trim();
3079 if !is_valid_decimal_token(t, false) {
3080 return Err(Error::HlsParse {
3081 line_no,
3082 line: line.to_string(),
3083 reason: format!("{what} value {s:?} is not a valid decimal integer"),
3084 });
3085 }
3086 t.parse::<T>().map_err(|_| Error::HlsParse {
3087 line_no,
3088 line: line.to_string(),
3089 reason: format!("{what} value {s:?} is not a valid number"),
3090 })
3091}
3092
3093fn is_valid_decimal_token(s: &str, signed: bool) -> bool {
3104 let s = s.strip_prefix('-').filter(|_| signed).unwrap_or(s);
3105 let (int_part, frac_part) = match s.split_once('.') {
3106 Some((i, f)) => (i, Some(f)),
3107 None => (s, None),
3108 };
3109 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3110 return false;
3111 }
3112 match frac_part {
3113 Some(f) => !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()),
3114 None => true,
3115 }
3116}
3117
3118#[derive(Debug, Clone, Copy, PartialEq)]
3128pub struct DecimalSeconds(f64);
3129
3130impl DecimalSeconds {
3131 pub const ZERO: Self = Self(0.0);
3133
3134 pub fn new(value: f64) -> Result<Self> {
3137 if value.is_finite() && value >= 0.0 {
3138 Ok(Self(value))
3139 } else {
3140 Err(Error::InvalidDecimalSeconds(value))
3141 }
3142 }
3143
3144 #[must_use]
3146 pub fn get(self) -> f64 {
3147 self.0
3148 }
3149
3150 fn new_unchecked(value: f64) -> Self {
3153 debug_assert!(value.is_finite() && value >= 0.0, "{value} must be valid");
3154 Self(value)
3155 }
3156}
3157
3158impl Default for DecimalSeconds {
3159 fn default() -> Self {
3160 Self::ZERO
3161 }
3162}
3163
3164impl core::fmt::Display for DecimalSeconds {
3165 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3169 core::fmt::Display::fmt(&self.0, f)
3170 }
3171}
3172
3173#[derive(Debug, Clone, Copy, PartialEq)]
3177pub struct SignedDecimalSeconds(f64);
3178
3179impl SignedDecimalSeconds {
3180 pub const ZERO: Self = Self(0.0);
3182
3183 pub fn new(value: f64) -> Result<Self> {
3185 if value.is_finite() {
3186 Ok(Self(value))
3187 } else {
3188 Err(Error::InvalidSignedDecimalSeconds(value))
3189 }
3190 }
3191
3192 #[must_use]
3194 pub fn get(self) -> f64 {
3195 self.0
3196 }
3197
3198 fn new_unchecked(value: f64) -> Self {
3201 debug_assert!(value.is_finite(), "{value} must be finite");
3202 Self(value)
3203 }
3204}
3205
3206impl core::fmt::Display for SignedDecimalSeconds {
3207 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3208 core::fmt::Display::fmt(&self.0, f)
3209 }
3210}
3211
3212fn parse_decimal_seconds(
3218 s: &str,
3219 line_no: usize,
3220 line: &str,
3221 what: &str,
3222) -> Result<DecimalSeconds> {
3223 parse_decimal_float(s, line_no, line, what, false).map(DecimalSeconds::new_unchecked)
3226}
3227
3228fn parse_signed_decimal_seconds(
3232 s: &str,
3233 line_no: usize,
3234 line: &str,
3235 what: &str,
3236) -> Result<SignedDecimalSeconds> {
3237 parse_decimal_float(s, line_no, line, what, true).map(SignedDecimalSeconds::new_unchecked)
3239}
3240
3241pub fn parse_attribute_list(s: &str) -> (BTreeMap<String, String>, BTreeSet<String>) {
3256 scan_attribute_list(s, false).unwrap_or_default()
3257}
3258
3259pub fn parse_attribute_list_strict(
3265 s: &str,
3266) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3267 scan_attribute_list(s, true)
3268}
3269
3270fn scan_attribute_list(
3271 s: &str,
3272 strict: bool,
3273) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3274 let mut map = BTreeMap::new();
3275 let mut quoted_keys = BTreeSet::new();
3276 let bytes = s.as_bytes();
3277 let len = bytes.len();
3278 let mut i = 0;
3279 while i < len {
3280 while i < len && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) {
3281 i += 1;
3282 }
3283 if i >= len {
3284 break;
3285 }
3286 let key_start = i;
3287 while i < len && bytes[i] != b'=' {
3288 i += 1;
3289 }
3290 if i >= len {
3291 break;
3292 }
3293 let key = &s[key_start..i];
3294 i += 1;
3295 let quoted = i < len && bytes[i] == b'"';
3296 let value = if quoted {
3297 i += 1;
3298 let value_start = i;
3299 while i < len && bytes[i] != b'"' {
3300 i += 1;
3301 }
3302 let value = &s[value_start..i];
3303 if i < len {
3304 i += 1;
3305 } else if strict {
3306 return Err(Error::UnterminatedQuotedString {
3307 list: s.to_string(),
3308 });
3309 }
3310 value
3311 } else {
3312 let value_start = i;
3313 while i < len && bytes[i] != b',' {
3314 i += 1;
3315 }
3316 &s[value_start..i]
3317 };
3318 if strict && map.contains_key(key) {
3319 return Err(Error::DuplicateAttribute {
3320 name: key.to_string(),
3321 });
3322 }
3323 map.insert(key.to_string(), value.to_string());
3324 if quoted {
3325 quoted_keys.insert(key.to_string());
3326 }
3327 }
3328 Ok((map, quoted_keys))
3329}
3330
3331fn filter_extra_attrs(
3344 attrs: &BTreeMap<String, String>,
3345 known: &[&str],
3346 quoted: &BTreeSet<String>,
3347) -> Result<Vec<(String, AttrValue)>> {
3348 attrs
3349 .iter()
3350 .filter(|(k, _)| !known.contains(&k.as_str()))
3351 .map(|(k, v)| {
3352 let value = if quoted.contains(k) {
3353 AttrValue::quoted(v.clone())?
3354 } else {
3355 AttrValue::bare(v.clone())?
3356 };
3357 Ok((k.clone(), value))
3358 })
3359 .collect()
3360}
3361
3362fn is_segment_defining_tag(line: &str) -> bool {
3373 const SEGMENT_SCOPED: &[&str] = &["#EXT-X-KEY:", "#EXT-X-DATERANGE:", "#EXT-X-CUE-"];
3374 SEGMENT_SCOPED.iter().any(|prefix| line.starts_with(prefix))
3375}
3376
3377fn require_attr(
3381 attrs: &BTreeMap<String, String>,
3382 key: &str,
3383 line_no: usize,
3384 line: &str,
3385 tag: &str,
3386) -> Result<String> {
3387 attrs.get(key).cloned().ok_or_else(|| Error::HlsParse {
3388 line_no,
3389 line: line.to_string(),
3390 reason: format!("{tag} missing required {key} attribute"),
3391 })
3392}
3393
3394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3403#[non_exhaustive]
3404pub enum VideoRange {
3405 Sdr,
3407 Hlg,
3409 Pq,
3411}
3412
3413impl VideoRange {
3414 pub fn name(&self) -> &'static str {
3417 match self {
3418 VideoRange::Sdr => "SDR",
3419 VideoRange::Hlg => "HLG",
3420 VideoRange::Pq => "PQ",
3421 }
3422 }
3423
3424 pub fn from_transfer_characteristics(code: u8) -> Option<Self> {
3430 const TRANSFER_BT709: u8 = 1;
3433 const TRANSFER_BT601: u8 = 6;
3434 const TRANSFER_IEC_61966_2_1: u8 = 13;
3435 const TRANSFER_BT2020_10BIT: u8 = 14;
3436 const TRANSFER_BT2020_12BIT: u8 = 15;
3437 const TRANSFER_SMPTE_ST_2084: u8 = 16;
3438 const TRANSFER_ARIB_STD_B67: u8 = 18;
3439 match code {
3440 TRANSFER_BT709
3441 | TRANSFER_BT601
3442 | TRANSFER_IEC_61966_2_1
3443 | TRANSFER_BT2020_10BIT
3444 | TRANSFER_BT2020_12BIT => Some(Self::Sdr),
3445 TRANSFER_SMPTE_ST_2084 => Some(Self::Pq),
3446 TRANSFER_ARIB_STD_B67 => Some(Self::Hlg),
3447 _ => None,
3448 }
3449 }
3450
3451 pub fn attr(self) -> (String, AttrValue) {
3457 (
3458 String::from("VIDEO-RANGE"),
3459 AttrValue::bare(self.name()).expect("VIDEO-RANGE token is a valid bare value"),
3460 )
3461 }
3462}
3463
3464broadcast_common::impl_spec_display!(VideoRange);
3465
3466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3475pub struct FrameRate(u32);
3476
3477impl FrameRate {
3478 #[must_use]
3480 pub fn from_millihertz(millihertz: u32) -> Self {
3481 Self(millihertz)
3482 }
3483
3484 #[must_use]
3486 pub fn millihertz(self) -> u32 {
3487 self.0
3488 }
3489}
3490
3491impl core::fmt::Display for FrameRate {
3492 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3493 let whole = self.0 / 1000;
3494 let frac = self.0 % 1000;
3495 write!(f, "{whole}.{frac:03}")
3496 }
3497}
3498
3499impl core::str::FromStr for FrameRate {
3500 type Err = crate::Error;
3501
3502 fn from_str(s: &str) -> Result<Self> {
3503 parse_frame_rate(s, 0, s)
3504 }
3505}
3506
3507#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3509#[non_exhaustive]
3510pub enum MediaType {
3511 #[default]
3513 Audio,
3514 Video,
3516 Subtitles,
3518 ClosedCaptions,
3520}
3521
3522impl MediaType {
3523 #[must_use]
3526 pub fn name(&self) -> &'static str {
3527 match self {
3528 MediaType::Audio => "AUDIO",
3529 MediaType::Video => "VIDEO",
3530 MediaType::Subtitles => "SUBTITLES",
3531 MediaType::ClosedCaptions => "CLOSED-CAPTIONS",
3532 }
3533 }
3534}
3535
3536broadcast_common::impl_spec_display!(MediaType);
3537
3538#[derive(Debug, Clone, PartialEq, Eq)]
3541#[non_exhaustive]
3542pub enum InstreamId {
3543 Cc(u8),
3545 Service(u8),
3548 Other(String),
3550}
3551
3552const INSTREAM_CC_PREFIX: &str = "CC";
3553const INSTREAM_SERVICE_PREFIX: &str = "SERVICE";
3554const INSTREAM_CC_RANGE: core::ops::RangeInclusive<u8> = 1..=4;
3555const INSTREAM_SERVICE_RANGE: core::ops::RangeInclusive<u8> = 1..=63;
3556
3557impl InstreamId {
3558 #[must_use]
3560 pub fn render(&self) -> String {
3561 match self {
3562 InstreamId::Cc(n) => format!("{INSTREAM_CC_PREFIX}{n}"),
3563 InstreamId::Service(n) => format!("{INSTREAM_SERVICE_PREFIX}{n}"),
3564 InstreamId::Other(s) => s.clone(),
3565 }
3566 }
3567
3568 pub fn parse_closed_captions(value: &str) -> Result<Self> {
3571 let invalid = || Error::InvalidInstreamId {
3572 value: value.to_string(),
3573 };
3574 let canonical_decimal = |digits: &str| -> Option<u8> {
3575 let well_formed = !digits.is_empty()
3576 && digits.bytes().all(|b| b.is_ascii_digit())
3577 && !digits.starts_with('0');
3578 well_formed.then(|| digits.parse().ok()).flatten()
3579 };
3580 if let Some(rest) = value.strip_prefix(INSTREAM_CC_PREFIX) {
3581 return canonical_decimal(rest)
3582 .filter(|n| INSTREAM_CC_RANGE.contains(n))
3583 .map(InstreamId::Cc)
3584 .ok_or_else(invalid);
3585 }
3586 if let Some(rest) = value.strip_prefix(INSTREAM_SERVICE_PREFIX) {
3587 return canonical_decimal(rest)
3588 .filter(|n| INSTREAM_SERVICE_RANGE.contains(n))
3589 .map(InstreamId::Service)
3590 .ok_or_else(invalid);
3591 }
3592 Err(invalid())
3593 }
3594}
3595
3596#[derive(Debug, Clone, PartialEq, Eq)]
3599#[non_exhaustive]
3600pub enum ClosedCaptionsRef {
3601 Group(String),
3603 None,
3605}
3606
3607#[derive(Debug, Clone, PartialEq, Default)]
3610#[non_exhaustive]
3611pub struct MediaRendition {
3612 pub media_type: MediaType,
3614 pub uri: Option<String>,
3616 pub group_id: String,
3618 pub language: Option<String>,
3620 pub assoc_language: Option<String>,
3622 pub name: String,
3624 pub stable_rendition_id: Option<String>,
3626 pub default: bool,
3628 pub autoselect: bool,
3630 pub forced: bool,
3632 pub instream_id: Option<InstreamId>,
3634 pub bit_depth: Option<u32>,
3636 pub sample_rate: Option<u32>,
3638 pub characteristics: Vec<String>,
3640 pub channels: Option<String>,
3642 pub extra_attrs: Vec<(String, AttrValue)>,
3644}
3645
3646#[derive(Debug, Clone, PartialEq, Default)]
3648pub struct Variant {
3649 pub bandwidth: u32,
3651 pub average_bandwidth: Option<u32>,
3658 pub frame_rate: Option<FrameRate>,
3667 pub codecs: Option<String>,
3673 pub resolution: Option<(u32, u32)>,
3675 pub uri: String,
3677 pub audio: Option<String>,
3679 pub video: Option<String>,
3681 pub subtitles: Option<String>,
3683 pub closed_captions: Option<ClosedCaptionsRef>,
3685 pub extra_attrs: Vec<(String, AttrValue)>,
3688}
3689
3690#[derive(Debug, Clone, PartialEq, Default)]
3699pub struct IFrameVariant {
3700 pub bandwidth: u32,
3702 pub average_bandwidth: Option<u32>,
3706 pub codecs: Option<String>,
3708 pub resolution: Option<(u32, u32)>,
3710 pub uri: String,
3712 pub extra_attrs: Vec<(String, AttrValue)>,
3715}
3716
3717#[derive(Debug, Clone, PartialEq, Default)]
3719pub struct MasterPlaylist {
3720 pub version: u8,
3727 pub media: Vec<MediaRendition>,
3730 pub variants: Vec<Variant>,
3732 pub iframe_variants: Vec<IFrameVariant>,
3738 pub extra_tags: Vec<String>,
3754 pub independent_segments: bool,
3756 pub start: Option<StartPoint>,
3758 pub defines: Vec<Define>,
3761 pub session_data: Vec<SessionData>,
3764 pub session_keys: Vec<SessionKey>,
3767 pub content_steering: Option<ContentSteering>,
3770}
3771
3772type PendingStreamInf = (
3779 u32,
3780 Option<u32>,
3781 Option<FrameRate>,
3782 Option<String>,
3783 Option<(u32, u32)>,
3784 Vec<(String, AttrValue)>,
3785);
3786
3787impl MasterPlaylist {
3788 pub fn to_m3u8(&self) -> Result<String> {
3806 self.validate_for_render()?;
3807 let mut s = String::new();
3808 s.push_str("#EXTM3U\n");
3809 if let Some(version) = self.effective_version() {
3810 s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
3811 }
3812
3813 for tag in &self.extra_tags {
3814 s.push_str(tag);
3815 s.push('\n');
3816 }
3817
3818 for m in &self.media {
3820 s.push_str("#EXT-X-MEDIA:TYPE=");
3821 s.push_str(m.media_type.name());
3822 if let Some(uri) = &m.uri {
3823 validate_uri(uri, "EXT-X-MEDIA URI")?;
3824 s.push_str(&format!(",URI=\"{uri}\""));
3825 }
3826 s.push_str(&format!(",GROUP-ID=\"{}\"", m.group_id));
3827 if let Some(l) = &m.language {
3828 validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
3829 s.push_str(&format!(",LANGUAGE=\"{l}\""));
3830 }
3831 if let Some(l) = &m.assoc_language {
3832 validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
3833 s.push_str(&format!(",ASSOC-LANGUAGE=\"{l}\""));
3834 }
3835 validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
3836 s.push_str(&format!(",NAME=\"{}\"", m.name));
3837 if let Some(sri) = &m.stable_rendition_id {
3838 validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
3839 s.push_str(&format!(",STABLE-RENDITION-ID=\"{sri}\""));
3840 }
3841 s.push_str(&format!(
3842 ",DEFAULT={}",
3843 if m.default { "YES" } else { "NO" }
3844 ));
3845 if m.autoselect || !m.default {
3846 s.push_str(&format!(
3847 ",AUTOSELECT={}",
3848 if m.autoselect { "YES" } else { "NO" }
3849 ));
3850 }
3851 if m.forced {
3852 s.push_str(",FORCED=YES");
3853 } else if m.media_type == MediaType::Subtitles {
3854 s.push_str(",FORCED=NO");
3856 }
3857 if let Some(ii) = &m.instream_id {
3858 let v = ii.render();
3859 validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
3860 s.push_str(&format!(",INSTREAM-ID=\"{v}\""));
3861 }
3862 if let Some(bd) = m.bit_depth {
3863 s.push_str(&format!(",BIT-DEPTH={bd}"));
3864 }
3865 if let Some(sr) = m.sample_rate {
3866 s.push_str(&format!(",SAMPLE-RATE={sr}"));
3867 }
3868 if !m.characteristics.is_empty() {
3869 let joined = m.characteristics.join(",");
3870 validate_quoted_string(&joined, "EXT-X-MEDIA CHARACTERISTICS")?;
3871 s.push_str(&format!(",CHARACTERISTICS=\"{joined}\""));
3872 }
3873 if let Some(ch) = &m.channels {
3874 validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
3875 s.push_str(&format!(",CHANNELS=\"{ch}\""));
3876 }
3877 render_attribute_list(&mut s, &m.extra_attrs);
3878 s.push('\n');
3879 }
3880
3881 if self.independent_segments {
3883 s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
3884 }
3885 for def in &self.defines {
3886 push_define_line(&mut s, def)?;
3887 }
3888 if let Some(start) = &self.start {
3889 push_start_line(&mut s, start);
3890 }
3891 for sk in &self.session_keys {
3893 push_session_key_line(&mut s, sk)?;
3894 }
3895 for sd in &self.session_data {
3896 push_session_data_line(&mut s, sd)?;
3897 }
3898 if let Some(cs) = &self.content_steering {
3899 validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
3900 s.push_str(&format!(
3901 "#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
3902 cs.server_uri
3903 ));
3904 if let Some(pid) = &cs.pathway_id {
3905 validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
3906 s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
3907 }
3908 render_attribute_list(&mut s, &cs.extra_attrs);
3909 s.push('\n');
3910 }
3911
3912 for var in &self.variants {
3913 s.push_str(&format!("#EXT-X-STREAM-INF:BANDWIDTH={}", var.bandwidth));
3914 if let Some(avg) = var.average_bandwidth {
3917 s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3918 }
3919 if let Some(codecs) = &var.codecs {
3923 validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
3924 s.push_str(&format!(",CODECS=\"{codecs}\""));
3925 }
3926 if let Some((w, h)) = var.resolution {
3927 s.push_str(&format!(",RESOLUTION={w}x{h}"));
3928 }
3929 if let Some(frame_rate) = var.frame_rate {
3933 s.push_str(&format!(",FRAME-RATE={frame_rate}"));
3934 }
3935 if let Some(g) = &var.audio {
3936 validate_quoted_string(g, "EXT-X-STREAM-INF AUDIO")?;
3937 s.push_str(&format!(",AUDIO=\"{g}\""));
3938 }
3939 if let Some(g) = &var.video {
3940 validate_quoted_string(g, "EXT-X-STREAM-INF VIDEO")?;
3941 s.push_str(&format!(",VIDEO=\"{g}\""));
3942 }
3943 if let Some(g) = &var.subtitles {
3944 validate_quoted_string(g, "EXT-X-STREAM-INF SUBTITLES")?;
3945 s.push_str(&format!(",SUBTITLES=\"{g}\""));
3946 }
3947 match &var.closed_captions {
3948 Some(ClosedCaptionsRef::Group(g)) => {
3949 validate_quoted_string(g, "EXT-X-STREAM-INF CLOSED-CAPTIONS")?;
3950 s.push_str(&format!(",CLOSED-CAPTIONS=\"{g}\""));
3951 }
3952 Some(ClosedCaptionsRef::None) => {
3953 s.push_str(",CLOSED-CAPTIONS=NONE");
3954 }
3955 None => {}
3956 }
3957 render_attribute_list(&mut s, &var.extra_attrs);
3958 s.push('\n');
3959 validate_uri(&var.uri, "variant URI")?;
3960 s.push_str(&var.uri);
3961 s.push('\n');
3962 }
3963
3964 for iv in &self.iframe_variants {
3967 s.push_str(&format!(
3968 "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
3969 iv.bandwidth
3970 ));
3971 if let Some(avg) = iv.average_bandwidth {
3974 s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3975 }
3976 if let Some(c) = &iv.codecs {
3977 s.push_str(&format!(",CODECS=\"{c}\""));
3978 }
3979 if let Some((w, h)) = iv.resolution {
3980 s.push_str(&format!(",RESOLUTION={w}x{h}"));
3981 }
3982 validate_uri(&iv.uri, "I-frame-variant URI")?;
3983 if let Some(codecs) = &iv.codecs {
3984 validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
3985 }
3986 render_attribute_list(&mut s, &iv.extra_attrs);
3987 s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
3988 }
3989
3990 Ok(s)
3991 }
3992
3993 #[must_use]
4000 pub fn dangling_group_references(&self) -> Vec<(MediaType, String)> {
4001 let group_exists = |gid: &str, mt: MediaType| -> bool {
4002 self.media
4003 .iter()
4004 .any(|m| m.group_id == gid && m.media_type == mt)
4005 };
4006 let mut dangling = Vec::new();
4007 for v in &self.variants {
4008 let named = [
4009 (MediaType::Audio, v.audio.as_ref()),
4010 (MediaType::Video, v.video.as_ref()),
4011 (MediaType::Subtitles, v.subtitles.as_ref()),
4012 (
4013 MediaType::ClosedCaptions,
4014 match &v.closed_captions {
4015 Some(ClosedCaptionsRef::Group(g)) => Some(g),
4016 _ => None,
4017 },
4018 ),
4019 ];
4020 for (mt, group) in named {
4021 if let Some(g) = group
4022 && !group_exists(g, mt)
4023 {
4024 dangling.push((mt, g.clone()));
4025 }
4026 }
4027 }
4028 dangling
4029 }
4030
4031 fn validate_for_render(&self) -> Result<()> {
4038 validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
4039 for def in &self.defines {
4040 push_define_line(&mut String::new(), def)?;
4041 }
4042 if let Some(cs) = &self.content_steering {
4043 validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
4044 if let Some(pid) = &cs.pathway_id {
4045 validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
4046 }
4047 }
4048 for var in &self.variants {
4049 validate_uri(&var.uri, "variant URI")?;
4050 if let Some(codecs) = &var.codecs {
4051 validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
4052 }
4053 }
4054 for iv in &self.iframe_variants {
4055 validate_uri(&iv.uri, "I-frame-variant URI")?;
4056 if let Some(codecs) = &iv.codecs {
4057 validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
4058 }
4059 }
4060 check_rendition_groups(&self.media)?;
4061 for m in &self.media {
4063 if m.group_id.is_empty() {
4064 return Err(Error::HlsParse {
4065 line_no: 0,
4066 line: String::new(),
4067 reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
4068 });
4069 }
4070 validate_quoted_string(&m.group_id, "EXT-X-MEDIA GROUP-ID")?;
4071 if m.name.is_empty() {
4072 return Err(Error::HlsParse {
4073 line_no: 0,
4074 line: String::new(),
4075 reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
4076 });
4077 }
4078 validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
4079 if m.media_type == MediaType::ClosedCaptions {
4080 if m.uri.is_some() {
4081 return Err(Error::HlsParse {
4082 line_no: 0,
4083 line: String::new(),
4084 reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS"
4085 .to_string(),
4086 });
4087 }
4088 if m.instream_id.is_none() {
4089 return Err(Error::HlsParse {
4090 line_no: 0,
4091 line: String::new(),
4092 reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS"
4093 .to_string(),
4094 });
4095 }
4096 } else {
4097 }
4099 if m.forced && m.media_type != MediaType::Subtitles {
4100 return Err(Error::HlsParse {
4101 line_no: 0,
4102 line: String::new(),
4103 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES"
4104 .to_string(),
4105 });
4106 }
4107 if m.bit_depth.is_some() && m.media_type != MediaType::Audio {
4108 return Err(Error::HlsParse {
4109 line_no: 0,
4110 line: String::new(),
4111 reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4112 .to_string(),
4113 });
4114 }
4115 if m.sample_rate.is_some() && m.media_type != MediaType::Audio {
4116 return Err(Error::HlsParse {
4117 line_no: 0,
4118 line: String::new(),
4119 reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4120 .to_string(),
4121 });
4122 }
4123 if m.channels.is_some() && m.media_type != MediaType::Audio {
4124 return Err(Error::HlsParse {
4125 line_no: 0,
4126 line: String::new(),
4127 reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO"
4128 .to_string(),
4129 });
4130 }
4131 if let Some(uri) = &m.uri {
4132 validate_uri(uri, "EXT-X-MEDIA URI")?;
4133 }
4134 if let Some(sri) = &m.stable_rendition_id {
4135 validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4136 }
4137 if let Some(ii) = &m.instream_id {
4138 let v = ii.render();
4139 validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
4140 let closed_captions = m.media_type == MediaType::ClosedCaptions;
4141 let cc_form = !matches!(ii, InstreamId::Other(_));
4142 if closed_captions != cc_form {
4143 return Err(Error::InstreamIdTypeMismatch {
4144 media_type: m.media_type,
4145 value: v,
4146 });
4147 }
4148 if closed_captions {
4149 InstreamId::parse_closed_captions(&v)?;
4150 }
4151 }
4152 if let Some(ch) = &m.channels {
4153 validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
4154 }
4155 }
4156 let none_used = self
4158 .variants
4159 .iter()
4160 .any(|v| matches!(v.closed_captions, Some(ClosedCaptionsRef::None)));
4161 if none_used {
4162 for v in &self.variants {
4163 if !matches!(v.closed_captions, Some(ClosedCaptionsRef::None)) {
4164 return Err(Error::HlsParse {
4165 line_no: 0,
4166 line: String::new(),
4167 reason:
4168 "EXT-X-STREAM-INF CLOSED-CAPTIONS=NONE must be used on all variants"
4169 .to_string(),
4170 });
4171 }
4172 }
4173 }
4174 for sk in &self.session_keys {
4175 push_session_key_line(&mut String::new(), sk)?;
4176 }
4177 for sd in &self.session_data {
4178 push_session_data_line(&mut String::new(), sd)?;
4179 }
4180 Ok(())
4181 }
4182
4183 pub fn parse(input: &str) -> Result<Self> {
4196 let mut version: u8 = 0;
4198 let mut variants: Vec<Variant> = Vec::new();
4199 let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
4200 let mut media: Vec<MediaRendition> = Vec::new();
4201 let mut extra_tags: Vec<String> = Vec::new();
4202 let mut saw_extm3u = false;
4203 let mut pending_stream_inf: Option<PendingStreamInf> = None;
4204 let mut independent_segments = false;
4206 let mut start: Option<StartPoint> = None;
4207 let mut defines: Vec<Define> = Vec::new();
4208 let mut session_data: Vec<SessionData> = Vec::new();
4209 let mut session_keys: Vec<SessionKey> = Vec::new();
4210 let mut content_steering: Option<ContentSteering> = None;
4211
4212 for (idx, raw_line) in input.lines().enumerate() {
4213 let line_no = idx + 1;
4214 let mut line = raw_line.trim_end_matches('\r');
4215 if line_no == 1 {
4216 line = line.strip_prefix('\u{feff}').unwrap_or(line);
4217 }
4218 let line = line.trim();
4219 if line.is_empty() {
4220 continue;
4221 }
4222
4223 if line == "#EXTM3U" {
4224 saw_extm3u = true;
4225 } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
4226 version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
4227 } else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
4228 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4229 let bandwidth_str =
4230 require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
4231 let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4232 let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4236 Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4237 None => None,
4238 };
4239 let frame_rate = match attrs.get("FRAME-RATE") {
4242 Some(v) => Some(parse_frame_rate(v, line_no, line)?),
4243 None => None,
4244 };
4245 let codecs = attrs.get("CODECS").cloned();
4249 let resolution = match attrs.get("RESOLUTION") {
4250 Some(v) => Some(parse_resolution(v, line_no, line)?),
4251 None => None,
4252 };
4253 let extra_attrs = filter_extra_attrs(
4254 &attrs,
4255 &[
4256 "BANDWIDTH",
4257 "AVERAGE-BANDWIDTH",
4258 "CODECS",
4259 "RESOLUTION",
4260 "FRAME-RATE",
4261 ],
4262 "ed,
4263 )?;
4264 if pending_stream_inf.is_some() {
4273 return Err(Error::HlsParse {
4274 line_no,
4275 line: line.to_string(),
4276 reason: "second #EXT-X-STREAM-INF before the first Variant's URI \
4277 line (RFC 8216bis §4.4.6.2)"
4278 .to_string(),
4279 });
4280 }
4281 pending_stream_inf = Some((
4282 bandwidth,
4283 average_bandwidth,
4284 frame_rate,
4285 codecs,
4286 resolution,
4287 extra_attrs,
4288 ));
4289 } else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
4290 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4291 let bandwidth_str = require_attr(
4292 &attrs,
4293 "BANDWIDTH",
4294 line_no,
4295 line,
4296 "EXT-X-I-FRAME-STREAM-INF",
4297 )?;
4298 let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4299 let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4300 Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4301 None => None,
4302 };
4303 let codecs = attrs.get("CODECS").cloned();
4304 let resolution = match attrs.get("RESOLUTION") {
4305 Some(v) => Some(parse_resolution(v, line_no, line)?),
4306 None => None,
4307 };
4308 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-I-FRAME-STREAM-INF")?;
4309 let extra_attrs = filter_extra_attrs(
4310 &attrs,
4311 &[
4312 "BANDWIDTH",
4313 "AVERAGE-BANDWIDTH",
4314 "CODECS",
4315 "RESOLUTION",
4316 "URI",
4317 ],
4318 "ed,
4319 )?;
4320 iframe_variants.push(IFrameVariant {
4321 bandwidth,
4322 average_bandwidth,
4323 codecs,
4324 resolution,
4325 uri,
4326 extra_attrs,
4327 });
4328 } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
4329 independent_segments = true;
4330 } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
4331 start = Some(parse_start(rest, line_no, line)?);
4332 } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
4333 defines.push(parse_define(rest, line_no, line)?);
4334 } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-DATA:") {
4335 session_data.push(parse_session_data(rest, line_no, line)?);
4336 } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-KEY:") {
4337 session_keys.push(parse_session_key(rest, line_no, line)?);
4338 } else if let Some(rest) = line.strip_prefix("#EXT-X-CONTENT-STEERING:") {
4339 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4340 let server_uri = require_attr(
4341 &attrs,
4342 "SERVER-URI",
4343 line_no,
4344 line,
4345 "EXT-X-CONTENT-STEERING",
4346 )?;
4347 let pathway_id = attrs.get("PATHWAY-ID").cloned();
4348 let extra_attrs =
4349 filter_extra_attrs(&attrs, &["SERVER-URI", "PATHWAY-ID"], "ed)?;
4350 content_steering = Some(ContentSteering {
4351 server_uri,
4352 pathway_id,
4353 extra_attrs,
4354 });
4355 } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA:") {
4356 media.push(parse_media_rendition(rest, line_no, line)?);
4357 } else if let Some(rest) = line.strip_prefix("#EXT") {
4358 let _ = rest;
4359 extra_tags.push(line.to_string());
4366 } else if line.starts_with('#') {
4367 } else {
4369 let (bandwidth, average_bandwidth, frame_rate, codecs, resolution, extra_attrs) =
4370 pending_stream_inf.take().ok_or_else(|| Error::HlsParse {
4371 line_no,
4372 line: line.to_string(),
4373 reason: "variant URI with no preceding #EXT-X-STREAM-INF".to_string(),
4374 })?;
4375 let mut audio = None;
4378 let mut video = None;
4379 let mut subtitles = None;
4380 let mut closed_captions = None;
4381 let mut extra_attrs_filtered = Vec::new();
4382 for (k, v) in extra_attrs {
4383 match k.as_str() {
4384 "AUDIO" => audio = Some(v.as_str().to_string()),
4385 "VIDEO" => video = Some(v.as_str().to_string()),
4386 "SUBTITLES" => subtitles = Some(v.as_str().to_string()),
4387 "CLOSED-CAPTIONS" => {
4388 if v.as_str() == "NONE" {
4389 closed_captions = Some(ClosedCaptionsRef::None);
4390 } else {
4391 closed_captions =
4392 Some(ClosedCaptionsRef::Group(v.as_str().to_string()));
4393 }
4394 }
4395 _ => extra_attrs_filtered.push((k, v)),
4396 }
4397 }
4398 variants.push(Variant {
4399 bandwidth,
4400 average_bandwidth,
4401 frame_rate,
4402 codecs,
4403 resolution,
4404 uri: line.to_string(),
4405 audio,
4406 video,
4407 subtitles,
4408 closed_captions,
4409 extra_attrs: extra_attrs_filtered,
4410 });
4411 }
4412 }
4413
4414 if !saw_extm3u {
4415 return Err(Error::HlsParse {
4416 line_no: 1,
4417 line: String::new(),
4418 reason: "missing #EXTM3U header".to_string(),
4419 });
4420 }
4421 if pending_stream_inf.is_some() {
4424 return Err(Error::HlsParse {
4425 line_no: 0,
4426 line: String::new(),
4427 reason: "#EXT-X-STREAM-INF with no following Variant URI line (RFC 8216bis \
4428 §4.4.6.2)"
4429 .to_string(),
4430 });
4431 }
4432
4433 check_rendition_groups(&media)?;
4434
4435 Ok(MasterPlaylist {
4436 version,
4437 media,
4438 variants,
4439 iframe_variants,
4440 extra_tags,
4441 independent_segments,
4442 start,
4443 defines,
4444 session_data,
4445 session_keys,
4446 content_steering,
4447 })
4448 }
4449
4450 pub fn computed_version(&self) -> Option<u8> {
4457 let mut v = scan_tag_lines_for_version(&self.extra_tags);
4462
4463 for m in &self.media {
4467 if let Some(InstreamId::Service(_)) = m.instream_id {
4468 bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
4469 }
4470 if m.instream_id.is_some() && m.media_type != MediaType::ClosedCaptions {
4471 bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
4472 }
4473 }
4474
4475 if self
4479 .defines
4480 .iter()
4481 .any(|d| matches!(d, Define::QueryParam { .. }))
4482 {
4483 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
4484 }
4485
4486 if self
4491 .extra_tags
4492 .iter()
4493 .any(|t| contains_variable_substitution(t))
4494 || self.media.iter().any(|m| {
4495 m.uri
4496 .as_ref()
4497 .is_some_and(|u| contains_variable_substitution(u))
4498 || contains_variable_substitution(&m.group_id)
4499 || contains_variable_substitution(&m.name)
4500 || m.language
4501 .as_ref()
4502 .is_some_and(|x| contains_variable_substitution(x))
4503 || m.assoc_language
4504 .as_ref()
4505 .is_some_and(|x| contains_variable_substitution(x))
4506 || m.stable_rendition_id
4507 .as_ref()
4508 .is_some_and(|x| contains_variable_substitution(x))
4509 || m.channels
4510 .as_ref()
4511 .is_some_and(|x| contains_variable_substitution(x))
4512 || m.characteristics
4513 .iter()
4514 .any(|x| contains_variable_substitution(x))
4515 })
4516 || self
4517 .variants
4518 .iter()
4519 .any(|var| contains_variable_substitution(&var.uri))
4520 || self
4521 .iframe_variants
4522 .iter()
4523 .any(|iv| contains_variable_substitution(&iv.uri))
4524 || self.master_playlist_typed_strings_use_substitution()
4525 {
4526 bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
4527 }
4528
4529 if any_typed_req_attr(
4532 self.start.as_ref(),
4533 &self.defines,
4534 &self.session_data,
4535 &self.session_keys,
4536 self.content_steering.as_ref(),
4537 &self.media,
4538 &self.variants,
4539 &self.iframe_variants,
4540 ) {
4541 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
4542 }
4543
4544 v
4545 }
4546
4547 fn master_playlist_typed_strings_use_substitution(&self) -> bool {
4553 let define_values = self.defines.iter().filter_map(|d| match d {
4554 Define::Name { value, .. } => Some(value),
4555 _ => None,
4556 });
4557 let session_data_strings = self.session_data.iter().map(|sd| match &sd.content {
4558 SessionDataContent::Value(v) => v,
4559 SessionDataContent::Uri { uri, .. } => uri,
4560 });
4561 let session_key_uris = self.session_keys.iter().filter_map(|k| k.uri.as_ref());
4562 let steering_uris = self.content_steering.iter().map(|cs| &cs.server_uri);
4563
4564 define_values
4565 .chain(session_data_strings)
4566 .chain(session_key_uris)
4567 .chain(steering_uris)
4568 .any(|s| contains_variable_substitution(s))
4569 }
4570
4571 fn effective_version(&self) -> Option<u8> {
4574 effective_version(self.version, self.computed_version())
4575 }
4576}
4577
4578fn parse_frame_rate(v: &str, line_no: usize, line: &str) -> Result<FrameRate> {
4583 let t = v.trim();
4584 if !is_valid_decimal_token(t, false) {
4585 return Err(Error::HlsParse {
4586 line_no,
4587 line: line.to_string(),
4588 reason: format!("FRAME-RATE value {v:?} is not a valid decimal number"),
4589 });
4590 }
4591 let (int_part, frac_part) = match t.split_once('.') {
4592 Some((i, f)) => (i, Some(f)),
4593 None => (t, None),
4594 };
4595 let out_of_range = || Error::HlsParse {
4596 line_no,
4597 line: line.to_string(),
4598 reason: format!("FRAME-RATE value {v:?} is out of range"),
4599 };
4600 let int_value: u32 = int_part.parse().map_err(|_| out_of_range())?;
4601 let mut digits = frac_part.unwrap_or("").bytes().map(|b| u32::from(b - b'0'));
4602 let mut next = || digits.next().unwrap_or(0);
4603 let milli = next() * 100 + next() * 10 + next();
4604 let round_up = u32::from(next() >= 5);
4605 let total = int_value
4606 .checked_mul(1000)
4607 .and_then(|i| i.checked_add(milli + round_up))
4608 .ok_or_else(out_of_range)?;
4609 Ok(FrameRate::from_millihertz(total))
4610}
4611
4612fn parse_resolution(v: &str, line_no: usize, line: &str) -> Result<(u32, u32)> {
4614 let mut split = v.splitn(2, 'x');
4615 let w = split.next().unwrap_or("");
4616 let h = split.next().ok_or_else(|| Error::HlsParse {
4617 line_no,
4618 line: line.to_string(),
4619 reason: format!("RESOLUTION value {v:?} is not of the form <width>x<height>"),
4620 })?;
4621 let width = parse_decimal_int(w, line_no, line, "RESOLUTION width")?;
4622 let height = parse_decimal_int(h, line_no, line, "RESOLUTION height")?;
4623 Ok((width, height))
4624}
4625
4626fn parse_yes_no(attrs: &BTreeMap<String, String>, attribute: &'static str) -> Result<Option<bool>> {
4627 match attrs.get(attribute).map(String::as_str) {
4628 None => Ok(None),
4629 Some("YES") => Ok(Some(true)),
4630 Some("NO") => Ok(Some(false)),
4631 Some(other) => Err(Error::InvalidYesNo {
4632 attribute,
4633 value: other.to_string(),
4634 }),
4635 }
4636}
4637
4638fn check_rendition_groups(media: &[MediaRendition]) -> Result<()> {
4639 let mut seen: BTreeSet<(&str, &str, &str)> = BTreeSet::new();
4640 let mut defaults: BTreeSet<(&str, &str)> = BTreeSet::new();
4641 for m in media {
4642 let group = (m.media_type.name(), m.group_id.as_str());
4643 if !seen.insert((m.media_type.name(), m.group_id.as_str(), m.name.as_str())) {
4644 return Err(Error::DuplicateNameInGroup {
4645 media_type: m.media_type,
4646 group_id: m.group_id.clone(),
4647 name: m.name.clone(),
4648 });
4649 }
4650 if m.default && !defaults.insert(group) {
4651 return Err(Error::DuplicateDefaultInGroup {
4652 media_type: m.media_type,
4653 group_id: m.group_id.clone(),
4654 });
4655 }
4656 }
4657 Ok(())
4658}
4659
4660fn parse_media_rendition(rest: &str, line_no: usize, line: &str) -> Result<MediaRendition> {
4661 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4662
4663 let type_str = require_attr(&attrs, "TYPE", line_no, line, "EXT-X-MEDIA")?;
4665 let media_type = match type_str.as_str() {
4666 "AUDIO" => MediaType::Audio,
4667 "VIDEO" => MediaType::Video,
4668 "SUBTITLES" => MediaType::Subtitles,
4669 "CLOSED-CAPTIONS" => MediaType::ClosedCaptions,
4670 _ => {
4671 return Err(Error::HlsParse {
4672 line_no,
4673 line: line.to_string(),
4674 reason: format!("EXT-X-MEDIA invalid TYPE {type_str:?}"),
4675 });
4676 }
4677 };
4678
4679 let group_id = require_attr(&attrs, "GROUP-ID", line_no, line, "EXT-X-MEDIA")?;
4680 if group_id.is_empty() {
4681 return Err(Error::HlsParse {
4682 line_no,
4683 line: line.to_string(),
4684 reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
4685 });
4686 }
4687 validate_quoted_string(&group_id, "EXT-X-MEDIA GROUP-ID")?;
4688
4689 let name = require_attr(&attrs, "NAME", line_no, line, "EXT-X-MEDIA")?;
4690 if name.is_empty() {
4691 return Err(Error::HlsParse {
4692 line_no,
4693 line: line.to_string(),
4694 reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
4695 });
4696 }
4697 validate_quoted_string(&name, "EXT-X-MEDIA NAME")?;
4698
4699 let uri = attrs.get("URI").cloned();
4701 if media_type == MediaType::ClosedCaptions && uri.is_some() {
4702 return Err(Error::HlsParse {
4703 line_no,
4704 line: line.to_string(),
4705 reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS".to_string(),
4706 });
4707 }
4708 if let Some(u) = &uri {
4709 validate_uri(u, "EXT-X-MEDIA URI")?;
4710 }
4711
4712 let language = attrs.get("LANGUAGE").cloned();
4713 let assoc_language = attrs.get("ASSOC-LANGUAGE").cloned();
4714 if let Some(l) = &language {
4715 validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
4716 }
4717 if let Some(l) = &assoc_language {
4718 validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
4719 }
4720
4721 let stable_rendition_id = attrs.get("STABLE-RENDITION-ID").cloned();
4722 if let Some(s) = &stable_rendition_id {
4723 validate_quoted_string(s, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4724 }
4725
4726 let default = parse_yes_no(&attrs, "DEFAULT")?.unwrap_or(false);
4728 let autoselect_attr = parse_yes_no(&attrs, "AUTOSELECT")?;
4729 let autoselect = autoselect_attr.unwrap_or(false);
4730 if default && autoselect_attr == Some(false) {
4731 return Err(Error::HlsParse {
4732 line_no,
4733 line: line.to_string(),
4734 reason: "EXT-X-MEDIA AUTOSELECT MUST be YES when DEFAULT is YES".to_string(),
4735 });
4736 }
4737
4738 let forced = parse_yes_no(&attrs, "FORCED")?.unwrap_or(false);
4740 if forced && media_type != MediaType::Subtitles {
4741 return Err(Error::HlsParse {
4743 line_no,
4744 line: line.to_string(),
4745 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4746 });
4747 }
4748 if attrs.contains_key("FORCED") && media_type != MediaType::Subtitles {
4750 return Err(Error::HlsParse {
4751 line_no,
4752 line: line.to_string(),
4753 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4754 });
4755 }
4756
4757 let instream_id = if let Some(v) = attrs.get("INSTREAM-ID") {
4759 validate_quoted_string(v, "EXT-X-MEDIA INSTREAM-ID")?;
4760 if media_type == MediaType::ClosedCaptions {
4761 Some(InstreamId::parse_closed_captions(v)?)
4762 } else {
4763 Some(InstreamId::Other(v.clone()))
4764 }
4765 } else {
4766 None
4767 };
4768 if media_type == MediaType::ClosedCaptions && instream_id.is_none() {
4769 return Err(Error::HlsParse {
4770 line_no,
4771 line: line.to_string(),
4772 reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS".to_string(),
4773 });
4774 }
4775
4776 let bit_depth = if let Some(v) = attrs.get("BIT-DEPTH") {
4778 if media_type != MediaType::Audio {
4779 return Err(Error::HlsParse {
4780 line_no,
4781 line: line.to_string(),
4782 reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4783 .to_string(),
4784 });
4785 }
4786 Some(parse_decimal_int(v, line_no, line, "BIT-DEPTH")?)
4787 } else {
4788 None
4789 };
4790 let sample_rate = if let Some(v) = attrs.get("SAMPLE-RATE") {
4791 if media_type != MediaType::Audio {
4792 return Err(Error::HlsParse {
4793 line_no,
4794 line: line.to_string(),
4795 reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4796 .to_string(),
4797 });
4798 }
4799 Some(parse_decimal_int(v, line_no, line, "SAMPLE-RATE")?)
4800 } else {
4801 None
4802 };
4803 let channels = if let Some(v) = attrs.get("CHANNELS") {
4804 if media_type != MediaType::Audio {
4805 return Err(Error::HlsParse {
4806 line_no,
4807 line: line.to_string(),
4808 reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO".to_string(),
4809 });
4810 }
4811 validate_quoted_string(v, "EXT-X-MEDIA CHANNELS")?;
4812 Some(v.clone())
4813 } else {
4814 None
4815 };
4816
4817 let characteristics = attrs
4819 .get("CHARACTERISTICS")
4820 .map(|v| {
4821 validate_quoted_string(v, "EXT-X-MEDIA CHARACTERISTICS")?;
4822 Ok(v.split(',')
4823 .map(|s| s.trim().to_string())
4824 .filter(|s| !s.is_empty())
4825 .collect::<Vec<_>>())
4826 })
4827 .transpose()?
4828 .unwrap_or_default();
4829
4830 const KNOWN: &[&str] = &[
4832 "TYPE",
4833 "URI",
4834 "GROUP-ID",
4835 "LANGUAGE",
4836 "ASSOC-LANGUAGE",
4837 "NAME",
4838 "STABLE-RENDITION-ID",
4839 "DEFAULT",
4840 "AUTOSELECT",
4841 "FORCED",
4842 "INSTREAM-ID",
4843 "BIT-DEPTH",
4844 "SAMPLE-RATE",
4845 "CHARACTERISTICS",
4846 "CHANNELS",
4847 ];
4848 let extra_attrs = filter_extra_attrs(&attrs, KNOWN, "ed)?;
4849
4850 Ok(MediaRendition {
4853 media_type,
4854 uri,
4855 group_id,
4856 language,
4857 assoc_language,
4858 name,
4859 stable_rendition_id,
4860 default,
4861 autoselect,
4862 forced,
4863 instream_id,
4864 bit_depth,
4865 sample_rate,
4866 characteristics,
4867 channels,
4868 extra_attrs,
4869 })
4870}
4871
4872pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
4904 if entries.len() < 2 {
4905 return;
4906 }
4907 for i in 1..entries.len() {
4911 let (head, tail) = entries.split_at_mut(i);
4912 let prev_init: &[u8] = head[i - 1].0;
4913 let cur = &mut tail[0];
4914 if cur.0 != prev_init {
4915 cur.1.discontinuous = true;
4916 }
4917 }
4918}
4919
4920#[cfg(test)]
4921mod tests {
4922 #[test]
4925 fn program_date_time_requires_a_time_but_not_a_zone() {
4926 let zone_less = ProgramDateTime::parse("2026-10-02T10:00:00").expect("zone-less date-time");
4927 let utc = ProgramDateTime::parse("2026-10-02T10:00:00Z").expect("UTC date-time");
4928 assert_eq!(zone_less, utc);
4929 assert!(ProgramDateTime::parse("2026-10-02").is_err(), "a bare date");
4930 assert!(
4931 ProgramDateTime::parse(" 2026-10-02 ").is_err(),
4932 "a padded bare date"
4933 );
4934 }
4935
4936 use super::*;
4937 use alloc::vec;
4938
4939 fn seg(uri: &str, duration: f64) -> MediaSegment {
4940 MediaSegment {
4941 uri: uri.into(),
4942 duration: DecimalSeconds::new(duration).unwrap(),
4943 discontinuous: false,
4944 parts: vec![],
4945 ..Default::default()
4946 }
4947 }
4948
4949 fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
4950 MediaSegment {
4951 uri: uri.into(),
4952 duration: DecimalSeconds::new(duration).unwrap(),
4953 discontinuous: true,
4954 parts: vec![],
4955 ..Default::default()
4956 }
4957 }
4958
4959 fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
4960 MediaPlaylist {
4961 version: 3,
4962 target_duration: 10,
4963 media_sequence: 0,
4964 discontinuity_sequence: 0,
4965 segments,
4966 endlist: true,
4967 extra_tags: vec![],
4968 low_latency: None,
4969 iframes_only: false,
4970 open_segment: None,
4971 ..Default::default()
4972 }
4973 }
4974
4975 #[test]
4976 fn media_playlist_basic() {
4977 let pl = MediaPlaylist {
4978 version: 3,
4979 target_duration: 10,
4980 media_sequence: 0,
4981 discontinuity_sequence: 0,
4982 segments: vec![
4983 seg("seg0.m4s", 9.009),
4984 seg("seg1.m4s", 9.009),
4985 seg("seg2.m4s", 3.003),
4986 ],
4987 endlist: true,
4988 extra_tags: vec![],
4989 low_latency: None,
4990 iframes_only: false,
4991 open_segment: None,
4992 ..Default::default()
4993 };
4994 let out = pl.to_m3u8().unwrap();
4995 assert!(out.starts_with("#EXTM3U\n"));
4996 assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
4997 assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
4998 assert_eq!(out.matches("#EXTINF:").count(), 3);
4999 assert!(out.ends_with("#EXT-X-ENDLIST\n"));
5000 assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
5002 }
5003
5004 #[test]
5005 fn media_playlist_no_endlist() {
5006 let pl = MediaPlaylist {
5007 version: 7,
5008 target_duration: 6,
5009 media_sequence: 42,
5010 discontinuity_sequence: 0,
5011 segments: vec![seg("seg.m4s", 6.000)],
5012 endlist: false,
5013 extra_tags: vec![],
5014 low_latency: None,
5015 iframes_only: false,
5016 open_segment: None,
5017 ..Default::default()
5018 };
5019 let out = pl.to_m3u8().unwrap();
5020 assert!(out.starts_with("#EXTM3U\n"));
5021 assert!(out.contains("#EXT-X-VERSION:7\n"));
5022 assert!(!out.contains("#EXT-X-ENDLIST"));
5023 }
5024
5025 #[test]
5026 fn master_playlist_basic() {
5027 let pl = MasterPlaylist {
5028 version: 6,
5029 media: vec![],
5030 variants: vec![
5031 Variant {
5032 bandwidth: 300_000,
5033 average_bandwidth: None,
5034 frame_rate: None,
5035 codecs: Some("avc1.64001e,mp4a.40.2".into()),
5036 resolution: Some((640, 360)),
5037 uri: "v300/index.m3u8".into(),
5038 ..Default::default()
5039 },
5040 Variant {
5041 bandwidth: 800_000,
5042 average_bandwidth: None,
5043 frame_rate: None,
5044 codecs: Some("avc1.640028,mp4a.40.2".into()),
5045 resolution: Some((1280, 720)),
5046 uri: "v800/index.m3u8".into(),
5047 ..Default::default()
5048 },
5049 ],
5050 iframe_variants: vec![],
5051 ..Default::default()
5052 };
5053 let out = pl.to_m3u8().unwrap();
5054 assert!(out.starts_with("#EXTM3U\n"));
5055 assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
5056 assert!(out.contains("v300/index.m3u8"));
5057 assert!(out.contains("v800/index.m3u8"));
5058 assert!(out.contains("RESOLUTION=640x360"));
5059 assert!(out.contains("RESOLUTION=1280x720"));
5060 }
5061
5062 #[test]
5063 fn master_playlist_no_resolution() {
5064 let pl = MasterPlaylist {
5065 version: 6,
5066 media: vec![],
5067 variants: vec![Variant {
5068 bandwidth: 1_000_000,
5069 average_bandwidth: None,
5070 frame_rate: None,
5071 codecs: Some("avc1.640028".into()),
5072 resolution: None,
5073 uri: "v1k/index.m3u8".into(),
5074 audio: None,
5075 video: None,
5076 subtitles: None,
5077 closed_captions: None,
5078 extra_attrs: Vec::new(),
5079 }],
5080 iframe_variants: vec![],
5081 ..Default::default()
5082 };
5083 let out = pl.to_m3u8().unwrap();
5084 assert!(!out.contains("RESOLUTION"));
5085 assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
5086 }
5087
5088 #[test]
5092 fn master_playlist_renders_average_bandwidth_after_bandwidth() {
5093 let pl = MasterPlaylist {
5094 media: vec![],
5095 variants: vec![Variant {
5096 bandwidth: 2_523_597,
5097 average_bandwidth: Some(2_190_673),
5098 frame_rate: None,
5099 codecs: Some("avc1.640020,mp4a.40.2".into()),
5100 uri: "v540/index.m3u8".into(),
5101 ..Default::default()
5102 }],
5103 ..Default::default()
5104 };
5105 let out = pl.to_m3u8().unwrap();
5106 assert!(
5107 out.contains(
5108 "#EXT-X-STREAM-INF:BANDWIDTH=2523597,AVERAGE-BANDWIDTH=2190673,\
5109 CODECS=\"avc1.640020,mp4a.40.2\""
5110 ),
5111 "out: {out}"
5112 );
5113 let reparsed = MasterPlaylist::parse(&out).unwrap();
5114 assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5115 assert_eq!(reparsed.variants[0].average_bandwidth, Some(2_190_673));
5116 }
5117
5118 #[test]
5121 fn master_playlist_omits_absent_average_bandwidth_and_round_trips_none() {
5122 let pl = MasterPlaylist {
5123 media: vec![],
5124 variants: vec![Variant {
5125 bandwidth: 800_000,
5126 average_bandwidth: None,
5127 frame_rate: None,
5128 codecs: Some("avc1.640028,mp4a.40.2".into()),
5129 uri: "v800/index.m3u8".into(),
5130 ..Default::default()
5131 }],
5132 ..Default::default()
5133 };
5134 let out = pl.to_m3u8().unwrap();
5135 assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
5136 let reparsed = MasterPlaylist::parse(&out).unwrap();
5137 assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5138 assert_eq!(reparsed.variants[0].average_bandwidth, None);
5139 }
5140
5141 #[test]
5144 fn average_bandwidth_must_be_a_decimal_integer() {
5145 let text =
5146 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x\nv/index.m3u8\n";
5147 let err = MasterPlaylist::parse(text).unwrap_err();
5148 match err {
5149 Error::HlsParse { reason, .. } => {
5150 assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5151 }
5152 other => panic!("expected HlsParse, got {other:?}"),
5153 }
5154 }
5155
5156 #[test]
5160 fn iframe_variant_renders_average_bandwidth_after_bandwidth() {
5161 let pl = MasterPlaylist {
5162 iframe_variants: vec![IFrameVariant {
5163 bandwidth: 200_000,
5164 average_bandwidth: Some(150_000),
5165 codecs: Some("hvc1.1.6.L93.B0".into()),
5166 resolution: Some((320, 240)),
5167 uri: "iframe.m3u8".into(),
5168 extra_attrs: Vec::new(),
5169 }],
5170 ..Default::default()
5171 };
5172 let out = pl.to_m3u8().unwrap();
5173 assert!(
5174 out.contains("#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=200000,AVERAGE-BANDWIDTH=150000,"),
5175 "out: {out}"
5176 );
5177 let reparsed = MasterPlaylist::parse(&out).unwrap();
5178 assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
5179 assert_eq!(reparsed.iframe_variants[0].average_bandwidth, Some(150_000));
5180 }
5181
5182 #[test]
5185 fn iframe_variant_omits_absent_average_bandwidth_and_round_trips_none() {
5186 let pl = MasterPlaylist {
5187 iframe_variants: vec![IFrameVariant {
5188 bandwidth: 200_000,
5189 average_bandwidth: None,
5190 codecs: Some("hvc1.1.6.L93.B0".into()),
5191 resolution: Some((320, 240)),
5192 uri: "iframe.m3u8".into(),
5193 extra_attrs: Vec::new(),
5194 }],
5195 ..Default::default()
5196 };
5197 let out = pl.to_m3u8().unwrap();
5198 assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
5199 let reparsed = MasterPlaylist::parse(&out).unwrap();
5200 assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
5201 assert_eq!(reparsed.iframe_variants[0].average_bandwidth, None);
5202 }
5203
5204 #[test]
5208 fn iframe_variant_average_bandwidth_not_retained_in_extra_attrs() {
5209 let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=1200,URI=\"i.m3u8\"\n";
5210 let pl = MasterPlaylist::parse(text).unwrap();
5211 assert_eq!(pl.iframe_variants[0].average_bandwidth, Some(1200));
5212 assert!(
5213 pl.iframe_variants[0].extra_attrs.is_empty(),
5214 "extra_attrs: {:?}",
5215 pl.iframe_variants[0].extra_attrs
5216 );
5217 assert!(
5218 !pl.iframe_variants[0]
5219 .extra_attrs
5220 .iter()
5221 .any(|(k, _)| k == "AVERAGE-BANDWIDTH")
5222 );
5223 }
5224
5225 #[test]
5228 fn iframe_average_bandwidth_must_be_a_decimal_integer() {
5229 let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x,URI=\"i.m3u8\"\n";
5230 let err = MasterPlaylist::parse(text).unwrap_err();
5231 match err {
5232 Error::HlsParse { reason, .. } => {
5233 assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5234 }
5235 other => panic!("expected HlsParse, got {other:?}"),
5236 }
5237 }
5238
5239 #[test]
5242 fn master_playlist_renders_frame_rate_after_resolution() {
5243 let pl = MasterPlaylist {
5244 media: vec![],
5245 variants: vec![Variant {
5246 bandwidth: 1_000_000,
5247 average_bandwidth: None,
5248 codecs: Some("avc1.640028".into()),
5249 resolution: Some((1280, 720)),
5250 frame_rate: Some(FrameRate::from_millihertz(29_970)),
5251 uri: "v/index.m3u8".into(),
5252 ..Default::default()
5253 }],
5254 ..Default::default()
5255 };
5256 let out = pl.to_m3u8().unwrap();
5257 assert!(
5258 out.contains("RESOLUTION=1280x720,FRAME-RATE=29.970"),
5259 "out: {out}"
5260 );
5261 let reparsed = MasterPlaylist::parse(&out).unwrap();
5262 assert_eq!(reparsed.variants, pl.variants);
5263 assert_eq!(
5264 reparsed.variants[0].frame_rate,
5265 Some(FrameRate::from_millihertz(29_970))
5266 );
5267 }
5268
5269 #[test]
5272 fn master_playlist_omits_absent_frame_rate_and_round_trips_none() {
5273 let pl = MasterPlaylist {
5274 media: vec![],
5275 variants: vec![Variant {
5276 bandwidth: 800_000,
5277 average_bandwidth: None,
5278 codecs: Some("avc1.640028".into()),
5279 resolution: None,
5280 frame_rate: None,
5281 uri: "v/index.m3u8".into(),
5282 ..Default::default()
5283 }],
5284 ..Default::default()
5285 };
5286 let out = pl.to_m3u8().unwrap();
5287 assert!(!out.contains("FRAME-RATE"), "out: {out}");
5288 let reparsed = MasterPlaylist::parse(&out).unwrap();
5289 assert_eq!(reparsed.variants[0].frame_rate, None);
5290 }
5291
5292 #[test]
5295 fn frame_rate_parses_various_forms() {
5296 for (text, expected) in [
5297 ("30", 30_000u32),
5298 ("29.97", 29_970u32),
5299 ("29.9704", 29_970u32),
5300 ] {
5301 let src = format!(
5302 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={text}\nv/index.m3u8\n"
5303 );
5304 let parsed = MasterPlaylist::parse(&src).unwrap();
5305 assert_eq!(
5306 parsed.variants[0].frame_rate,
5307 Some(FrameRate::from_millihertz(expected)),
5308 "text={text}"
5309 );
5310 }
5311 }
5312
5313 #[test]
5316 fn frame_rate_rejects_invalid_values() {
5317 for value in ["", "-1", "1e3", "abc", "1.2.3"] {
5318 let src = format!(
5319 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={value}\nv/index.m3u8\n"
5320 );
5321 let err = MasterPlaylist::parse(&src).unwrap_err();
5322 match err {
5323 Error::HlsParse { reason, .. } => {
5324 assert!(
5325 reason.contains("FRAME-RATE"),
5326 "value={value:?} reason={reason}"
5327 );
5328 }
5329 other => panic!("value={value:?} expected HlsParse, got {other:?}"),
5330 }
5331 }
5332 }
5333
5334 #[test]
5338 fn extinf_three_decimals() {
5339 let pl = playlist(vec![seg("s.m4s", 9.009)]);
5340 let out = pl.to_m3u8().unwrap();
5341 assert!(out.contains("#EXTINF:9.009,\n"), "{out}");
5342 }
5343
5344 #[test]
5358 fn integral_extinf_renders_as_an_integer_and_needs_no_version() {
5359 let pl = MediaPlaylist {
5362 version: 0,
5363 ..playlist(vec![seg("s.m4s", 9.0)])
5364 };
5365 let out = pl.to_m3u8().unwrap();
5366 assert!(out.contains("#EXTINF:9,\n"), "{out}");
5367 assert!(!out.contains("9.000"), "{out}");
5368 assert_eq!(
5369 pl.computed_version(),
5370 None,
5371 "a playlist with no floating-point EXTINF trips no §8 row: {out}"
5372 );
5373 assert!(!out.contains("#EXT-X-VERSION"), "{out}");
5374 let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5376 assert_eq!(
5377 reparsed.segments[0].duration,
5378 DecimalSeconds::new(9.0).unwrap()
5379 );
5380 }
5381
5382 #[test]
5389 fn extinf_rendering_and_version_derivation_never_diverge() {
5390 for (duration, expected_text, expected_version) in [
5391 (4.0_f64, "#EXTINF:4,", None),
5392 (4.004, "#EXTINF:4.004,", Some(3)),
5393 (9.9766, "#EXTINF:9.9766,", Some(3)), (4.0004, "#EXTINF:4.0004,", Some(3)), ] {
5396 let pl = playlist(vec![seg("s.m4s", duration)]);
5397 let out = pl.to_m3u8().unwrap();
5398 assert!(out.contains(expected_text), "{duration}: {out}");
5399 assert_eq!(pl.computed_version(), expected_version, "{duration}: {out}");
5400 assert_eq!(
5403 out.contains(expected_text) && expected_text.contains('.'),
5404 expected_version == Some(3),
5405 "{duration}: rendered text and §8 row 3 must agree: {out}"
5406 );
5407 let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5409 assert_eq!(reparsed.segments[0].duration.get(), duration, "{out}");
5410 }
5411 }
5412
5413 #[test]
5422 fn sub_millisecond_durations_survive_rendering() {
5423 let pl = playlist(vec![seg("main.ts", 9.9766)]);
5425 let out = pl.to_m3u8().unwrap();
5426 assert!(
5427 out.contains("#EXTINF:9.9766,\n"),
5428 "EXTINF must not be truncated to 3 decimals:\n{out}"
5429 );
5430 assert_eq!(
5431 MediaPlaylist::parse(&out).unwrap().segments[0].duration,
5432 DecimalSeconds::new(9.9766).unwrap(),
5433 "duration must survive a round trip bit-exactly"
5434 );
5435
5436 assert_eq!(format_secs(DecimalSeconds::new(0.334).unwrap()), "0.334");
5438 assert_eq!(format_secs(DecimalSeconds::new(1.5).unwrap()), "1.5");
5439 assert_eq!(format_secs(DecimalSeconds::new(6.0).unwrap()), "6");
5440 assert_eq!(
5442 format_secs(DecimalSeconds::new(2.00004).unwrap()),
5443 "2.00004"
5444 );
5445 assert_eq!(
5446 format_secs(DecimalSeconds::new(4.00008).unwrap()),
5447 "4.00008"
5448 );
5449 assert_eq!(
5450 format_signed_secs(SignedDecimalSeconds::new(-10.5).unwrap()),
5451 "-10.5"
5452 );
5453 assert_eq!(
5454 format_signed_secs(SignedDecimalSeconds::new(-2.00004).unwrap()),
5455 "-2.00004"
5456 );
5457 }
5458
5459 #[test]
5464 fn extinf_and_ll_hls_durations_reject_non_conformant_decimal_tokens() {
5465 for bad in ["nan", "inf", "-inf", "infinity", "1e10", "+3", "-5", ""] {
5466 let pl = format!("#EXTM3U\n#EXTINF:{bad},\nmain.ts\n");
5467 assert!(
5468 matches!(
5469 MediaPlaylist::parse(&pl),
5470 Err(crate::error::Error::HlsParse { .. })
5471 ),
5472 "EXTINF:{bad} must be rejected"
5473 );
5474 }
5475 for bad in ["nan", "inf", "1e5"] {
5477 let pl = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET={bad}\n");
5478 assert!(
5479 matches!(
5480 MediaPlaylist::parse(&pl),
5481 Err(crate::error::Error::HlsParse { .. })
5482 ),
5483 "TIME-OFFSET={bad} must be rejected"
5484 );
5485 }
5486 let pl = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET=-12.5\n";
5488 assert_eq!(
5489 MediaPlaylist::parse(pl).unwrap().start.unwrap().time_offset,
5490 SignedDecimalSeconds::new(-12.5).unwrap()
5491 );
5492 }
5493
5494 #[test]
5500 fn decimal_seconds_rejects_non_finite_and_negative() {
5501 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
5502 assert!(
5503 matches!(
5504 DecimalSeconds::new(bad),
5505 Err(crate::error::Error::InvalidDecimalSeconds(..))
5506 ),
5507 "DecimalSeconds::new({bad}) must be rejected"
5508 );
5509 }
5510 assert_eq!(DecimalSeconds::new(6.0).unwrap().get(), 6.0);
5512 }
5513
5514 #[test]
5517 fn signed_decimal_seconds_rejects_non_finite_but_allows_negative() {
5518 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5519 assert!(
5520 matches!(
5521 SignedDecimalSeconds::new(bad),
5522 Err(crate::error::Error::InvalidSignedDecimalSeconds(..))
5523 ),
5524 "SignedDecimalSeconds::new({bad}) must be rejected"
5525 );
5526 }
5527 assert_eq!(SignedDecimalSeconds::new(-6.0).unwrap().get(), -6.0);
5528 }
5529
5530 #[test]
5536 fn round_trip_rfc_9_11_sub_millisecond_part_durations() {
5537 let pl = MediaPlaylist {
5538 version: 9,
5539 target_duration: 4,
5540 segments: vec![MediaSegment {
5541 uri: "fileSequence271.mp4".into(),
5542 duration: DecimalSeconds::new(4.00008).unwrap(),
5543 parts: vec![
5544 PartSpec {
5545 uri: "filePart271.0.mp4".into(),
5546 duration: DecimalSeconds::new(2.00004).unwrap(),
5547 independent: true,
5548 ..Default::default()
5549 },
5550 PartSpec {
5551 uri: "filePart271.1.mp4".into(),
5552 duration: DecimalSeconds::new(0.50001).unwrap(),
5553 ..Default::default()
5554 },
5555 ],
5556 ..Default::default()
5557 }],
5558 low_latency: Some(LowLatencyConfig {
5559 part_target: Some(DecimalSeconds::new(2.00002).unwrap()),
5560 part_hold_back: Some(DecimalSeconds::new(6.00006).unwrap()),
5561 ..Default::default()
5562 }),
5563 ..Default::default()
5564 };
5565 let text = pl.to_m3u8().unwrap();
5566 assert!(text.contains("#EXTINF:4.00008,"), "{text}");
5567 assert!(text.contains("DURATION=2.00004"), "{text}");
5568 assert!(text.contains("DURATION=0.50001"), "{text}");
5569 assert!(text.contains("PART-TARGET=2.00002"), "{text}");
5570 assert_eq!(
5571 MediaPlaylist::parse(&text).expect("must parse"),
5572 pl,
5573 "sub-ms LL-HLS durations must round-trip:\n{text}"
5574 );
5575 }
5576
5577 #[test]
5580 fn discontinuity_tag_emitted_before_extinf() {
5581 let pl = playlist(vec![
5583 seg("s0.m4s", 5.0),
5584 seg_disc("s1.m4s", 5.0),
5585 seg("s2.m4s", 5.0),
5586 ]);
5587 let out = pl.to_m3u8().unwrap();
5588 assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
5589 let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
5591 let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
5592 let s1_pos = out.find("s1.m4s\n").unwrap();
5594 out[..s1_pos].rfind("#EXTINF:").unwrap()
5596 });
5597 assert!(
5598 disc_pos < extinf_pos,
5599 "#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
5600 );
5601 let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
5603 assert!(
5604 out[tag_end..].starts_with("#EXTINF:"),
5605 "#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
5606 &out[tag_end..tag_end + 20]
5607 );
5608 }
5609
5610 #[test]
5611 fn no_discontinuity_tag_when_all_continuous() {
5612 let pl = playlist(vec![
5613 seg("s0.m4s", 5.0),
5614 seg("s1.m4s", 5.0),
5615 seg("s2.m4s", 5.0),
5616 ]);
5617 let out = pl.to_m3u8().unwrap();
5618 assert!(
5619 !out.contains("#EXT-X-DISCONTINUITY\n"),
5620 "no tag when all segments are continuous"
5621 );
5622 }
5623
5624 #[test]
5625 fn discontinuity_sequence_emitted_when_nonzero() {
5626 let pl = MediaPlaylist {
5627 version: 3,
5628 target_duration: 6,
5629 media_sequence: 5,
5630 discontinuity_sequence: 2,
5631 segments: vec![seg("s5.m4s", 6.0)],
5632 endlist: false,
5633 extra_tags: vec![],
5634 low_latency: None,
5635 iframes_only: false,
5636 open_segment: None,
5637 ..Default::default()
5638 };
5639 let out = pl.to_m3u8().unwrap();
5640 assert!(
5641 out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
5642 "header must be present when n>0"
5643 );
5644 }
5645
5646 #[test]
5647 fn discontinuity_sequence_absent_when_zero() {
5648 let pl = playlist(vec![seg("s0.m4s", 6.0)]);
5649 let out = pl.to_m3u8().unwrap();
5650 assert!(
5651 !out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
5652 "header must be absent when n==0"
5653 );
5654 }
5655
5656 fn ll_config() -> LowLatencyConfig {
5659 LowLatencyConfig {
5660 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5661 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5662 preload_hint_part: None,
5663 ..Default::default()
5664 }
5665 }
5666
5667 #[test]
5668 fn server_control_without_part_target_keeps_part_hold_back() {
5669 let pl = MediaPlaylist {
5670 target_duration: 4,
5671 low_latency: Some(LowLatencyConfig {
5672 part_target: None,
5673 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5674 hold_back: Some(DecimalSeconds::new(12.0).unwrap()),
5675 ..Default::default()
5676 }),
5677 ..Default::default()
5678 };
5679 let out = pl.to_m3u8().unwrap();
5680 assert!(out.contains("PART-HOLD-BACK=1.5"), "{out}");
5681 assert!(!out.contains("#EXT-X-PART-INF"), "{out}");
5682 }
5683
5684 #[test]
5685 fn ll_hls_renders_server_control_part_inf_and_parts() {
5686 let pl = MediaPlaylist {
5687 version: 9,
5688 target_duration: 4,
5689 media_sequence: 0,
5690 discontinuity_sequence: 0,
5691 segments: vec![MediaSegment {
5692 uri: "seg-1-4.m4s".into(),
5693 duration: DecimalSeconds::new(4.0).unwrap(),
5694 discontinuous: false,
5695 parts: vec![PartSpec {
5696 uri: "part-1-1.m4s".into(),
5697 duration: DecimalSeconds::new(0.5).unwrap(),
5698 independent: true,
5699 ..Default::default()
5700 }],
5701 ..Default::default()
5702 }],
5703 endlist: false,
5704 extra_tags: vec![],
5705 low_latency: Some(ll_config()),
5706 iframes_only: false,
5707 open_segment: None,
5708 ..Default::default()
5709 };
5710 let out = pl.to_m3u8().unwrap();
5711 assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
5712 assert!(out.contains("#EXT-X-PART-INF:PART-TARGET="));
5713 assert!(out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.m4s\""));
5714 }
5715
5716 #[test]
5717 fn open_segment_renders_parts_without_extinf() {
5718 let pl = MediaPlaylist {
5719 version: 9,
5720 target_duration: 4,
5721 media_sequence: 0,
5722 discontinuity_sequence: 0,
5723 segments: vec![MediaSegment {
5724 uri: "seg-1-4.m4s".into(),
5725 duration: DecimalSeconds::new(4.0).unwrap(),
5726 discontinuous: false,
5727 parts: vec![],
5728 ..Default::default()
5729 }],
5730 endlist: false,
5731 extra_tags: vec![],
5732 low_latency: Some(ll_config()),
5733 iframes_only: false,
5734 open_segment: Some(OpenSegment::new(vec![PartSpec {
5735 uri: "part-1-5.0.m4s".into(),
5736 duration: DecimalSeconds::new(0.5).unwrap(),
5737 independent: true,
5738 ..Default::default()
5739 }])),
5740 ..Default::default()
5741 };
5742 let out = pl.to_m3u8().unwrap();
5743 assert!(
5745 out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5746 "open segment's part must render:\n{out}"
5747 );
5748 assert!(out.contains("#EXTINF:4,\n"), "{out}");
5752 assert!(out.contains("seg-1-4.m4s"));
5753 assert!(
5756 !out.contains("#EXTINF:0.500,\npart-1-5.0.m4s"),
5757 "open segment must not be rendered as a closed #EXTINF segment:\n{out}"
5758 );
5759 assert_eq!(
5761 out.matches("#EXTINF:").count(),
5762 1,
5763 "only closed segments should have #EXTINF lines; open segment must not:\n{out}"
5764 );
5765 let lines: Vec<&str> = out.lines().collect();
5766 for (i, line) in lines.iter().enumerate() {
5767 if *line == "part-1-5.0.m4s" {
5768 panic!("open part URI must not appear on its own URI line: {out}");
5769 }
5770 if line.starts_with("#EXTINF") && i + 1 < lines.len() {
5771 assert_ne!(
5772 lines[i + 1],
5773 "part-1-5.0.m4s",
5774 "open part URI must not follow an #EXTINF line:\n{out}"
5775 );
5776 }
5777 }
5778 }
5779
5780 #[test]
5784 fn open_segment_map_does_not_count_toward_version_without_low_latency() {
5785 let mut open = OpenSegment::new(vec![PartSpec {
5786 uri: "part-1-5.0.m4s".into(),
5787 duration: DecimalSeconds::new(0.5).unwrap(),
5788 independent: true,
5789 ..Default::default()
5790 }]);
5791 open.map = Some(MapTag {
5792 uri: "init.mp4".into(),
5793 byte_range: None,
5794 extra_attrs: Vec::new(),
5795 });
5796 let mut pl = MediaPlaylist {
5797 target_duration: 4,
5798 segments: vec![seg("seg-1-4.m4s", 4.0)],
5799 open_segment: Some(open),
5800 ..Default::default()
5801 };
5802 assert_eq!(
5803 pl.computed_version(),
5804 None,
5805 "map not rendered, no version row"
5806 );
5807 pl.low_latency = Some(ll_config());
5808 assert!(pl.computed_version() >= Some(6));
5809 }
5810
5811 #[test]
5812 fn open_segment_not_rendered_without_low_latency() {
5813 let pl = MediaPlaylist {
5814 version: 9,
5815 target_duration: 4,
5816 media_sequence: 0,
5817 discontinuity_sequence: 0,
5818 segments: vec![seg("seg-1-4.m4s", 4.0)],
5819 endlist: false,
5820 extra_tags: vec![],
5821 low_latency: None,
5822 iframes_only: false,
5823 open_segment: Some(OpenSegment::new(vec![PartSpec {
5824 uri: "part-1-5.0.m4s".into(),
5825 duration: DecimalSeconds::new(0.5).unwrap(),
5826 independent: true,
5827 ..Default::default()
5828 }])),
5829 ..Default::default()
5830 };
5831 let out = pl.to_m3u8().unwrap();
5832 assert!(
5833 !out.contains("part-1-5.0.m4s"),
5834 "open segment parts must not render without low_latency:\n{out}"
5835 );
5836 assert!(!out.contains("#EXT-X-PART:"));
5837 }
5838
5839 #[test]
5840 fn preload_hint_rendered_from_low_latency() {
5841 let mut ll = ll_config();
5842 ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5843 let pl = MediaPlaylist {
5844 version: 9,
5845 target_duration: 4,
5846 media_sequence: 0,
5847 discontinuity_sequence: 0,
5848 segments: vec![seg("seg-1-4.m4s", 4.0)],
5849 endlist: false,
5850 extra_tags: vec![],
5851 low_latency: Some(ll),
5852 iframes_only: false,
5853 open_segment: None,
5854 ..Default::default()
5855 };
5856 let out = pl.to_m3u8().unwrap();
5857 assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""));
5858 }
5859
5860 #[test]
5861 fn open_segment_parts_precede_preload_hint() {
5862 let mut ll = ll_config();
5863 ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5864 let pl = MediaPlaylist {
5865 version: 9,
5866 target_duration: 4,
5867 media_sequence: 0,
5868 discontinuity_sequence: 0,
5869 segments: vec![MediaSegment {
5870 uri: "seg-1-4.m4s".into(),
5871 duration: DecimalSeconds::new(4.0).unwrap(),
5872 discontinuous: false,
5873 parts: vec![],
5874 ..Default::default()
5875 }],
5876 endlist: false,
5877 extra_tags: vec![],
5878 low_latency: Some(ll),
5879 iframes_only: false,
5880 open_segment: Some(OpenSegment::new(vec![PartSpec {
5881 uri: "part-1-5.0.m4s".into(),
5882 duration: DecimalSeconds::new(0.5).unwrap(),
5883 independent: true,
5884 ..Default::default()
5885 }])),
5886 ..Default::default()
5887 };
5888 let out = pl.to_m3u8().unwrap();
5889 assert!(
5891 out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5892 "open-segment part must be present:\n{out}"
5893 );
5894 assert!(
5895 out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""),
5896 "preload-hint must be present:\n{out}"
5897 );
5898 let part_pos = out
5900 .find("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES")
5901 .expect("open-segment part line not found");
5902 let preload_pos = out
5903 .find("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\"")
5904 .expect("preload-hint line not found");
5905 assert!(
5906 part_pos < preload_pos,
5907 "open-segment #EXT-X-PART must precede #EXT-X-PRELOAD-HINT:\npart at {}, preload at {}\noutput:\n{out}",
5908 part_pos,
5909 preload_pos
5910 );
5911 }
5912
5913 #[test]
5923 fn open_segment_inherits_map_when_no_segment_has_closed_yet() {
5924 let text = "#EXTM3U\n\
5925#EXT-X-VERSION:9\n\
5926#EXT-X-TARGETDURATION:4\n\
5927#EXT-X-MEDIA-SEQUENCE:1\n\
5928#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
5929#EXT-X-PART-INF:PART-TARGET=0.5\n\
5930#EXT-X-MAP:URI=\"init-1.mp4\"\n\
5931#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.0.m4s\",INDEPENDENT=YES\n\
5932#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-1.1.m4s\"\n";
5933 let pl = MediaPlaylist::parse(text).expect("must parse");
5934 assert!(pl.segments.is_empty(), "no segment has closed yet");
5935 let open = pl.open_segment.as_ref().expect("one open segment");
5936 assert_eq!(
5937 open.map,
5938 Some(MapTag {
5939 uri: "init-1.mp4".into(),
5940 byte_range: None,
5941 extra_attrs: Vec::new(),
5942 }),
5943 "the open segment must inherit the EXT-X-MAP that precedes it, \
5944 even though no segment has closed yet"
5945 );
5946 }
5947
5948 #[test]
5952 fn round_trip_open_segment_only_with_map() {
5953 let pl = MediaPlaylist {
5954 version: 9,
5955 target_duration: 4,
5956 media_sequence: 1,
5957 discontinuity_sequence: 0,
5958 segments: vec![],
5959 open_segment: Some(
5960 OpenSegment::new(vec![PartSpec {
5961 uri: "part-1-1.0.m4s".into(),
5962 duration: DecimalSeconds::new(0.5).unwrap(),
5963 independent: true,
5964 ..Default::default()
5965 }])
5966 .with_map(MapTag {
5967 uri: "init-1.mp4".into(),
5968 byte_range: None,
5969 extra_attrs: Vec::new(),
5970 }),
5971 ),
5972 endlist: false,
5973 extra_tags: vec![],
5974 low_latency: Some(ll_config()),
5975 iframes_only: false,
5976 ..Default::default()
5977 };
5978 let text = pl.to_m3u8().unwrap();
5979 assert!(
5980 text.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
5981 "renderer must emit EXT-X-MAP for a bare open segment:\n{text}"
5982 );
5983 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
5984 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
5985 }
5986
5987 fn ll_config_full() -> LowLatencyConfig {
5990 LowLatencyConfig {
5991 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5992 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()), preload_hint_part: Some("part-9.2.m4s".into()),
5994 preload_hint_type: PreloadHintType::Part,
5995 preload_hint_byte_range_start: Some(0),
5996 preload_hint_byte_range_length: Some(1000),
5997 can_skip_until: Some(DecimalSeconds::new(24.0).unwrap()),
5998 can_block_reload: true,
5999 hold_back: None,
6000 can_skip_dateranges: false,
6001 ..Default::default()
6002 }
6003 }
6004
6005 #[test]
6006 fn round_trip_live_ll_playlist_with_parts_preload_and_server_control() {
6007 let map = MapTag {
6008 uri: "init.mp4".into(),
6009 byte_range: Some(ByteRange {
6010 length: 800,
6011 offset: Some(0),
6012 }),
6013 extra_attrs: Vec::new(),
6014 };
6015 let pl = MediaPlaylist {
6016 version: 9,
6017 target_duration: 4,
6018 media_sequence: 100,
6019 discontinuity_sequence: 0,
6020 segments: vec![MediaSegment {
6021 uri: "seg-9.m4s".into(),
6022 duration: DecimalSeconds::new(4.0).unwrap(),
6023 discontinuous: false,
6024 parts: vec![
6025 PartSpec {
6026 uri: "part-9.0.m4s".into(),
6027 duration: DecimalSeconds::new(0.5).unwrap(),
6028 independent: true,
6029 byte_range: None,
6030 gap: false,
6031 ..Default::default()
6032 },
6033 PartSpec {
6034 uri: "part-9.1.m4s".into(),
6035 duration: DecimalSeconds::new(0.5).unwrap(),
6036 independent: false,
6037 byte_range: Some(ByteRange {
6038 length: 500,
6039 offset: Some(1000),
6040 }),
6041 gap: false,
6042 ..Default::default()
6043 },
6044 ],
6045 byte_range: None,
6046 map: Some(map.clone()),
6047 ..Default::default()
6048 }],
6049 open_segment: Some(
6055 OpenSegment::new(vec![PartSpec {
6056 uri: "part-10.0.m4s".into(),
6057 duration: DecimalSeconds::new(0.5).unwrap(),
6058 independent: true,
6059 byte_range: None,
6060 gap: true,
6061 extra_attrs: Vec::new(),
6062 }])
6063 .with_map(map.clone()),
6064 ),
6065 endlist: false,
6066 extra_tags: vec![],
6067 low_latency: Some(ll_config_full()),
6068 iframes_only: false,
6069 rendition_reports: vec![RenditionReport {
6070 uri: "../audio/playlist.m3u8".into(),
6071 last_msn: 100,
6072 last_part: Some(1),
6073 extra_attrs: Vec::new(),
6074 }],
6075 skip: None,
6076 ..Default::default()
6077 };
6078 let text = pl.to_m3u8().unwrap();
6079 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6080 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6081 }
6082
6083 #[test]
6084 fn round_trip_vod_playlist_with_byteranges_map_and_endlist() {
6085 let map = MapTag {
6086 uri: "init.mp4".into(),
6087 byte_range: None,
6088 extra_attrs: Vec::new(),
6089 };
6090 let pl = MediaPlaylist {
6091 version: 6,
6092 target_duration: 10,
6093 media_sequence: 0,
6094 discontinuity_sequence: 0,
6095 segments: vec![
6096 MediaSegment {
6097 uri: "media.ts".into(),
6098 duration: DecimalSeconds::new(10.0).unwrap(),
6099 discontinuous: false,
6100 parts: vec![],
6101 byte_range: Some(ByteRange {
6102 length: 500_000,
6103 offset: Some(0),
6104 }),
6105 map: Some(map.clone()),
6106 ..Default::default()
6107 },
6108 MediaSegment {
6109 uri: "media.ts".into(),
6110 duration: DecimalSeconds::new(10.0).unwrap(),
6111 discontinuous: false,
6112 parts: vec![],
6113 byte_range: Some(ByteRange {
6116 length: 500_000,
6117 offset: None,
6118 }),
6119 map: Some(map.clone()),
6122 pre_tags: vec![
6123 "#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
6124 .into(),
6125 ],
6126 ..Default::default()
6127 },
6128 ],
6129 open_segment: None,
6130 endlist: true,
6131 low_latency: None,
6132 iframes_only: false,
6133 rendition_reports: vec![],
6134 skip: None,
6135 ..Default::default()
6136 };
6137 let text = pl.to_m3u8().unwrap();
6138 assert_eq!(
6140 text.matches("#EXT-X-MAP:").count(),
6141 1,
6142 "identical map on consecutive segments must render once:\n{text}"
6143 );
6144 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6145 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6146 }
6147
6148 #[test]
6153 fn round_trip_media_playlist_with_new_872_tags() {
6154 let pl = MediaPlaylist {
6155 version: 6,
6156 target_duration: 10,
6157 media_sequence: 0,
6158 discontinuity_sequence: 0,
6159 independent_segments: true,
6160 start: Some(StartPoint {
6161 time_offset: SignedDecimalSeconds::new(5.5).unwrap(),
6162 precise: false,
6163 extra_attrs: Vec::new(),
6164 }),
6165 defines: vec![Define::Import {
6166 name: "base".into(),
6167 extra_attrs: Vec::new(),
6168 }],
6169 playlist_type: Some(PlaylistType::Vod),
6170 segments: vec![
6171 MediaSegment {
6172 uri: "seg0.ts".into(),
6173 duration: DecimalSeconds::new(10.0).unwrap(),
6174 bitrate: Some(2000),
6175 ..Default::default()
6176 },
6177 MediaSegment {
6178 uri: "seg1.ts".into(),
6179 duration: DecimalSeconds::new(10.0).unwrap(),
6180 gap: true,
6181 bitrate: Some(2000),
6182 ..Default::default()
6183 },
6184 MediaSegment {
6185 uri: "seg2.ts".into(),
6186 duration: DecimalSeconds::new(10.0).unwrap(),
6187 bitrate: Some(1800),
6188 ..Default::default()
6189 },
6190 ],
6191 endlist: true,
6192 ..Default::default()
6193 };
6194 let text = pl.to_m3u8().unwrap();
6195 assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6196 assert!(text.contains("#EXT-X-DEFINE:IMPORT=\"base\"\n"));
6197 assert!(text.contains("#EXT-X-START:TIME-OFFSET=5.5\n"));
6198 assert!(
6199 !text.contains("PRECISE"),
6200 "PRECISE=NO must be omitted:\n{text}"
6201 );
6202 assert!(text.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
6203 assert!(text.contains("#EXT-X-GAP\n"));
6204 assert_eq!(
6207 text.matches("#EXT-X-BITRATE:").count(),
6208 2,
6209 "unchanged bitrate must not re-emit:\n{text}"
6210 );
6211 assert!(text.contains("#EXT-X-BITRATE:2000\n"));
6212 assert!(text.contains("#EXT-X-BITRATE:1800\n"));
6213 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6214 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6215 }
6216
6217 #[test]
6221 fn parse_rejects_unrecognized_playlist_type() {
6222 let text = "#EXTM3U\n\
6223#EXT-X-VERSION:3\n\
6224#EXT-X-TARGETDURATION:6\n\
6225#EXT-X-PLAYLIST-TYPE:LIVE\n\
6226#EXTINF:6.000,\n\
6227s0.m4s\n";
6228 let err = MediaPlaylist::parse(text).expect_err("unrecognized PLAYLIST-TYPE must error");
6229 assert!(matches!(err, Error::HlsParse { .. }));
6230 }
6231
6232 #[test]
6235 fn parse_rejects_define_with_wrong_attribute_count() {
6236 let none = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-DEFINE:VALUE=\"x\"\n\
6237#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6238 let err = MediaPlaylist::parse(none).expect_err("DEFINE with none of the three must error");
6239 assert!(matches!(err, Error::HlsParse { .. }));
6240
6241 let both = "#EXTM3U\n#EXT-X-VERSION:3\n\
6242#EXT-X-DEFINE:NAME=\"a\",VALUE=\"1\",IMPORT=\"b\"\n\
6243#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6244 let err = MediaPlaylist::parse(both).expect_err("DEFINE with two of the three must error");
6245 assert!(matches!(err, Error::HlsParse { .. }));
6246 }
6247
6248 #[test]
6250 fn parse_rejects_session_data_with_wrong_content_count() {
6251 let neither = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\"\n";
6252 let err = MasterPlaylist::parse(neither)
6253 .expect_err("SESSION-DATA with neither VALUE nor URI must error");
6254 assert!(matches!(err, Error::HlsParse { .. }));
6255
6256 let both = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\",VALUE=\"v\",URI=\"u\"\n";
6257 let err = MasterPlaylist::parse(both)
6258 .expect_err("SESSION-DATA with both VALUE and URI must error");
6259 assert!(matches!(err, Error::HlsParse { .. }));
6260 }
6261
6262 #[test]
6264 fn parse_rejects_malformed_session_key_iv() {
6265 let text = "#EXTM3U\n\
6266#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k\",IV=0xnotahexvalue\n";
6267 let err = MasterPlaylist::parse(text).expect_err("malformed IV must error");
6268 assert!(matches!(err, Error::InvalidIv { value } if value == "0xnotahexvalue"));
6269 }
6270
6271 #[test]
6272 fn round_trip_multivariant_playlist() {
6273 let pl = MasterPlaylist {
6274 version: 7,
6275 media: vec![],
6276 variants: vec![
6277 Variant {
6278 bandwidth: 300_000,
6279 average_bandwidth: None,
6280 frame_rate: None,
6281 codecs: Some("avc1.64001e,mp4a.40.2".into()),
6282 resolution: Some((640, 360)),
6283 uri: "v300/index.m3u8".into(),
6284 ..Default::default()
6285 },
6286 Variant {
6287 bandwidth: 800_000,
6288 average_bandwidth: None,
6289 frame_rate: None,
6290 codecs: Some("avc1.640028,mp4a.40.2".into()),
6291 resolution: Some((1280, 720)),
6292 uri: "v800/index.m3u8".into(),
6293 ..Default::default()
6294 },
6295 ],
6296 iframe_variants: vec![IFrameVariant {
6297 bandwidth: 50_000,
6298 average_bandwidth: None,
6299 codecs: Some("avc1.64001e".into()),
6300 resolution: Some((640, 360)),
6301 uri: "v300/iframe.m3u8".into(),
6302 extra_attrs: Vec::new(),
6303 }],
6304 ..Default::default()
6305 };
6306 let text = pl.to_m3u8().unwrap();
6307 let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6308 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6309 }
6310
6311 #[test]
6316 fn round_trip_multivariant_playlist_with_new_872_tags() {
6317 let pl = MasterPlaylist {
6324 version: 11,
6325 independent_segments: true,
6326 start: Some(StartPoint {
6327 time_offset: SignedDecimalSeconds::new(-10.5).unwrap(),
6328 precise: true,
6329 extra_attrs: Vec::new(),
6330 }),
6331 defines: vec![
6332 Define::Name {
6333 name: "base".into(),
6334 value: "https://cdn.example.com/video12".into(),
6335 extra_attrs: Vec::new(),
6336 },
6337 Define::QueryParam {
6338 name: "token".into(),
6339 extra_attrs: Vec::new(),
6340 },
6341 ],
6342 session_data: vec![
6343 SessionData {
6344 data_id: "com.example.lyrics".into(),
6345 content: SessionDataContent::Uri {
6346 uri: "lyrics.json".into(),
6347 format: SessionDataFormat::Json,
6348 },
6349 language: None,
6350 extra_attrs: Vec::new(),
6351 },
6352 SessionData {
6353 data_id: "com.example.title".into(),
6354 content: SessionDataContent::Value("This is an example".into()),
6355 language: Some("en".into()),
6356 extra_attrs: Vec::new(),
6357 },
6358 ],
6359 session_keys: vec![
6360 SessionKey {
6361 method: EncryptionMethod::Aes128,
6362 uri: Some("https://priv.example.com/key.php?r=52".into()),
6363 iv: None,
6364 keyformat: Some("identity".into()),
6365 keyformatversions: Some("1".into()),
6366 extra_attrs: Vec::new(),
6367 },
6368 SessionKey {
6369 method: EncryptionMethod::SampleAesCtr,
6370 uri: Some("skd://key2".into()),
6371 iv: Some([
6372 0x9c, 0x7d, 0xb8, 0x77, 0x85, 0x70, 0xd0, 0x5c, 0x3a, 0x5e, 0x3d, 0x2c,
6373 0x8a, 0xe5, 0x5e, 0x46,
6374 ]),
6375 keyformat: None,
6376 keyformatversions: None,
6377 extra_attrs: Vec::new(),
6378 },
6379 ],
6380 content_steering: Some(ContentSteering {
6381 server_uri: "/steering?video=00012".into(),
6382 pathway_id: Some("CDN-A".into()),
6383 extra_attrs: Vec::new(),
6384 }),
6385 media: vec![],
6386 variants: vec![Variant {
6387 bandwidth: 1_280_000,
6388 average_bandwidth: None,
6389 frame_rate: None,
6390 codecs: Some("avc1.64001e,mp4a.40.2".into()),
6391 resolution: Some((640, 360)),
6392 uri: "low/index.m3u8".into(),
6393 audio: None,
6394 video: None,
6395 subtitles: None,
6396 closed_captions: None,
6397 extra_attrs: Vec::new(),
6398 }],
6399 iframe_variants: vec![],
6400 extra_tags: vec![],
6401 };
6402 assert_eq!(
6403 pl.computed_version(),
6404 Some(11),
6405 "EXT-X-DEFINE with QUERYPARAM must derive §8 row 11 from the \
6406 typed `defines` field (issue #872 + #880 integration)"
6407 );
6408 let text = pl.to_m3u8().unwrap();
6409 assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6410 assert!(text.contains("#EXT-X-START:TIME-OFFSET=-10.5,PRECISE=YES\n"));
6411 assert!(
6412 text.contains(
6413 "#EXT-X-DEFINE:NAME=\"base\",VALUE=\"https://cdn.example.com/video12\"\n"
6414 )
6415 );
6416 assert!(text.contains("#EXT-X-DEFINE:QUERYPARAM=\"token\"\n"));
6417 assert!(
6418 text.contains(
6419 "#EXT-X-SESSION-DATA:DATA-ID=\"com.example.lyrics\",URI=\"lyrics.json\"\n"
6420 )
6421 );
6422 assert!(text.contains(
6423 "#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"skd://key2\",IV=0x9c7db8778570d05c3a5e3d2c8ae55e46\n"
6424 ));
6425 assert!(text.contains(
6426 "#EXT-X-CONTENT-STEERING:SERVER-URI=\"/steering?video=00012\",PATHWAY-ID=\"CDN-A\"\n"
6427 ));
6428 let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6429 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6430 }
6431
6432 #[test]
6437 fn real_world_sample_ll_playlist_from_rfc8216bis_appendix() {
6438 let text = "\
6439#EXTM3U
6440#EXT-X-VERSION:9
6441#EXT-X-TARGETDURATION:4
6442#EXT-X-MEDIA-SEQUENCE:266
6443#EXT-X-PART-INF:PART-TARGET=2.00002
6444#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=6.00006
6445#EXTINF:4.00008,
6446fileSequence268.mp4
6447#EXTINF:4.00008,
6448fileSequence269.mp4
6449#EXTINF:4.00008,
6450fileSequence270.mp4
6451#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart271.0.mp4\"
6452#EXT-X-PART:DURATION=2.00004,URI=\"filePart271.1.mp4\"
6453#EXTINF:4.00008,
6454fileSequence271.mp4
6455#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart272.0.mp4\"
6456#EXT-X-PART:DURATION=0.50001,URI=\"filePart272.1.mp4\"
6457#EXTINF:2.50005,
6458fileSequence272.mp4
6459#EXT-X-DISCONTINUITY
6460#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll273.0.mp4\"
6461#EXT-X-PART:DURATION=2.00004,URI=\"midRoll273.1.mp4\"
6462#EXTINF:4.00008,
6463midRoll273.mp4
6464#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll274.0.mp4\"
6465#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"midRoll274.1.mp4\"
6466#EXT-X-RENDITION-REPORT:URI=\"/1M/LL-HLS.m3u8\",LAST-MSN=274,LAST-PART=1
6467";
6468 let pl = MediaPlaylist::parse(text).expect("real-world LL sample must parse");
6469 assert_eq!(pl.version, 9);
6470 assert_eq!(pl.target_duration, 4);
6471 assert_eq!(pl.media_sequence, 266);
6472 assert_eq!(pl.segments.len(), 6, "{:?}", pl.segments);
6476 assert_eq!(pl.segments[4].uri, "fileSequence272.mp4");
6477 assert_eq!(pl.segments[4].parts.len(), 2);
6478 assert!(pl.segments[4].parts[0].independent);
6479 assert!(!pl.segments[4].parts[1].independent);
6480 assert_eq!(pl.segments[5].uri, "midRoll273.mp4");
6481 assert!(
6482 pl.segments[5].discontinuous,
6483 "midRoll273 follows #EXT-X-DISCONTINUITY"
6484 );
6485 let open = pl.open_segment.as_ref().expect("midRoll274 is open");
6486 assert_eq!(open.parts.len(), 1);
6487 assert_eq!(open.parts[0].uri, "midRoll274.0.mp4");
6488 let ll = pl.low_latency.as_ref().expect("LL config must be present");
6489 assert_eq!(ll.preload_hint_part.as_deref(), Some("midRoll274.1.mp4"));
6490 assert!(
6491 ll.can_block_reload,
6492 "CAN-BLOCK-RELOAD=YES in the fixture must parse to true"
6493 );
6494 assert_eq!(pl.rendition_reports.len(), 1);
6495 assert_eq!(pl.rendition_reports[0].uri, "/1M/LL-HLS.m3u8");
6496 assert_eq!(pl.rendition_reports[0].last_msn, 274);
6497 assert_eq!(pl.rendition_reports[0].last_part, Some(1));
6498 }
6499
6500 #[test]
6504 fn real_world_sample_delta_update_with_skip() {
6505 let text = "#EXTM3U\n\
6506#EXT-X-VERSION:9\n\
6507#EXT-X-TARGETDURATION:4\n\
6508#EXT-X-MEDIA-SEQUENCE:1000\n\
6509#EXT-X-PART-INF:PART-TARGET=0.5\n\
6510#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24.0,PART-HOLD-BACK=1.5\n\
6511#EXT-X-SKIP:SKIPPED-SEGMENTS=996,RECENTLY-REMOVED-DATERANGES=\"ad-1\tad-2\"\n\
6512#EXTINF:4.00000,\n\
6513fileSequence1996.mp4\n\
6514#EXTINF:4.00000,\n\
6515fileSequence1997.mp4\n";
6516 let pl = MediaPlaylist::parse(text).expect("delta update sample must parse");
6517 assert_eq!(pl.media_sequence, 1000);
6518 assert_eq!(pl.segments.len(), 2);
6519 assert_eq!(pl.segments[0].uri, "fileSequence1996.mp4");
6520 let skip = pl.skip.as_ref().expect("EXT-X-SKIP must be captured");
6521 assert_eq!(skip.skipped_segments, 996);
6522 assert_eq!(skip.recently_removed_daterange_ids, vec!["ad-1", "ad-2"]);
6523 let ll = pl.low_latency.as_ref().expect("LL config must be present");
6524 assert_eq!(ll.can_skip_until, Some(DecimalSeconds::new(24.0).unwrap()));
6525 assert!(!pl.endlist);
6526 }
6527
6528 #[test]
6531 fn skip_recently_removed_ids_reject_injection() {
6532 let text = "#EXTM3U\n#EXT-X-VERSION:10\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:0\n\
6533#EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES=\"ad-1\\tad-2\"\n\
6534#EXTINF:4,\na.mp4\n"
6535 .replace("\\t", "\t");
6536 let good = MediaPlaylist::parse(&text).unwrap();
6537 assert_eq!(good.to_m3u8().unwrap(), text);
6539 for bad in ["a\"b", "a\rb", "a\nb", "a\tb"] {
6540 let mut pl = good.clone();
6541 pl.skip.as_mut().unwrap().recently_removed_daterange_ids = vec![bad.to_string()];
6542 assert!(
6543 matches!(pl.to_m3u8(), Err(Error::InvalidQuotedString { .. })),
6544 "{bad:?}"
6545 );
6546 }
6547 }
6548
6549 #[test]
6554 fn real_world_sample_can_block_reload_no_is_parsed_not_inferred() {
6555 let text = "#EXTM3U\n\
6556#EXT-X-VERSION:9\n\
6557#EXT-X-TARGETDURATION:4\n\
6558#EXT-X-MEDIA-SEQUENCE:0\n\
6559#EXT-X-PART-INF:PART-TARGET=0.5\n\
6560#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,PART-HOLD-BACK=1.5\n\
6561#EXTINF:4.00000,\n\
6562seg0.mp4\n";
6563 let pl = MediaPlaylist::parse(text).expect("must parse");
6564 let ll = pl
6565 .low_latency
6566 .as_ref()
6567 .expect("LL config must be present (PART-INF seen)");
6568 assert!(
6569 !ll.can_block_reload,
6570 "CAN-BLOCK-RELOAD=NO must not be inferred as true just because low_latency is Some"
6571 );
6572 }
6573
6574 #[test]
6579 fn parse_defaults_can_block_reload_false_when_attribute_absent() {
6580 let text = "#EXTM3U\n\
6581#EXT-X-VERSION:9\n\
6582#EXT-X-TARGETDURATION:4\n\
6583#EXT-X-MEDIA-SEQUENCE:0\n\
6584#EXT-X-PART-INF:PART-TARGET=0.5\n\
6585#EXTINF:4.00000,\n\
6586seg0.mp4\n";
6587 let pl = MediaPlaylist::parse(text).expect("must parse");
6588 let ll = pl
6589 .low_latency
6590 .as_ref()
6591 .expect("LL config must be present (PART-INF seen)");
6592 assert!(
6593 !ll.can_block_reload,
6594 "absent CAN-BLOCK-RELOAD/SERVER-CONTROL must default to false, not true"
6595 );
6596 }
6597
6598 #[test]
6601 fn round_trip_can_block_reload_no() {
6602 let pl = MediaPlaylist {
6603 version: 9,
6604 target_duration: 4,
6605 media_sequence: 0,
6606 discontinuity_sequence: 0,
6607 segments: vec![MediaSegment {
6608 uri: "seg0.mp4".into(),
6609 duration: DecimalSeconds::new(4.0).unwrap(),
6610 ..Default::default()
6611 }],
6612 low_latency: Some(LowLatencyConfig {
6613 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
6614 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
6615 can_block_reload: false,
6616 ..Default::default()
6617 }),
6618 ..Default::default()
6619 };
6620 let text = pl.to_m3u8().unwrap();
6621 assert!(
6622 text.contains("CAN-BLOCK-RELOAD=NO"),
6623 "renderer must emit the actual value:\n{text}"
6624 );
6625 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6626 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6627 }
6628
6629 #[test]
6630 fn parse_ignores_unrecognized_tag_by_preserving_it_into_extra_tags() {
6631 let text = "#EXTM3U\n\
6632#EXT-X-VERSION:3\n\
6633#EXT-X-TARGETDURATION:6\n\
6634#EXT-X-MEDIA-SEQUENCE:0\n\
6635#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z\n\
6636#EXTINF:6.000,\n\
6637s0.m4s\n\
6638#EXT-X-ENDLIST\n";
6639 let pl = MediaPlaylist::parse(text).expect("unrecognized tag must not error");
6640 assert!(
6644 pl.segments
6645 .first()
6646 .is_some_and(|s| s.program_date_time.is_some()),
6647 "EXT-X-PROGRAM-DATE-TIME must be preserved as a typed field, not dropped: {:?}",
6648 pl.segments.first().map(|s| s.program_date_time)
6649 );
6650 }
6651
6652 #[test]
6653 fn parse_rejects_missing_targetduration() {
6654 let text = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.000,\ns0.m4s\n";
6655 let err = MediaPlaylist::parse(text).expect_err("missing TARGETDURATION must error");
6656 assert!(matches!(err, Error::HlsParse { .. }));
6657 }
6658
6659 #[test]
6660 fn parse_rejects_malformed_part_missing_duration() {
6661 let text = "#EXTM3U\n\
6662#EXT-X-VERSION:9\n\
6663#EXT-X-TARGETDURATION:4\n\
6664#EXT-X-PART-INF:PART-TARGET=0.5\n\
6665#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
6666#EXT-X-PART:URI=\"part.m4s\"\n\
6667#EXTINF:4.000,\n\
6668seg.m4s\n";
6669 let err = MediaPlaylist::parse(text).expect_err("EXT-X-PART without DURATION must error");
6670 let Error::HlsParse { reason, .. } = err else {
6671 panic!("expected Error::HlsParse, got {err:?}")
6672 };
6673 assert!(reason.contains("DURATION"), "{reason}");
6674 }
6675
6676 #[test]
6677 fn parse_rejects_variant_uri_with_no_preceding_stream_inf() {
6678 let text = "#EXTM3U\n#EXT-X-VERSION:6\nv300/index.m3u8\n";
6679 let err = MasterPlaylist::parse(text).expect_err("orphan variant URI must error");
6680 assert!(matches!(err, Error::HlsParse { .. }));
6681 }
6682
6683 #[test]
6684 fn parse_master_playlist_models_ext_x_media() {
6685 let text = "#EXTM3U\n\
6686#EXT-X-VERSION:7\n\
6687#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,AUTOSELECT=YES,URI=\"eng.m3u8\"\n\
6688#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\",AUDIO=\"aac\"\n\
6689v300/index.m3u8\n";
6690 let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must parse");
6691 assert_eq!(pl.variants.len(), 1);
6692 assert_eq!(pl.media.len(), 1);
6693 assert_eq!(pl.media[0].name, "English");
6694 }
6695
6696 fn base_media_playlist() -> MediaPlaylist {
6706 MediaPlaylist {
6707 version: 0,
6708 target_duration: 6,
6709 media_sequence: 0,
6710 segments: vec![seg("s0.m4s", 6.0)],
6711 endlist: true,
6712 ..Default::default()
6713 }
6714 }
6715
6716 fn rendered_version(m3u8: &str) -> Option<u8> {
6719 m3u8.lines()
6720 .find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
6721 .map(|v| v.parse::<u8>().expect("version must be a valid u8"))
6722 }
6723
6724 #[test]
6725 fn version_row1_no_trigger_omits_the_tag() {
6726 let out = base_media_playlist().to_m3u8().unwrap();
6727 assert_eq!(rendered_version(&out), None, "no trigger:\n{out}");
6728 }
6729
6730 #[test]
6731 fn version_row2_key_iv_triggers_v2() {
6732 let mut pl = base_media_playlist();
6733 pl.extra_tags = vec![
6734 "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",IV=0x00000000000000000000000000000001"
6735 .into(),
6736 ];
6737 let out = pl.to_m3u8().unwrap();
6738 assert_eq!(rendered_version(&out), Some(2), "{out}");
6739 }
6740
6741 #[test]
6742 fn version_row3_float_extinf_triggers_v3() {
6743 let mut pl = base_media_playlist();
6744 pl.segments = vec![seg("s0.m4s", 6.5)];
6745 let out = pl.to_m3u8().unwrap();
6746 assert_eq!(rendered_version(&out), Some(3), "{out}");
6747 }
6748
6749 #[test]
6750 fn version_row4_byterange_triggers_v4() {
6751 let mut pl = base_media_playlist();
6752 pl.segments[0].byte_range = Some(ByteRange {
6753 length: 1000,
6754 offset: Some(0),
6755 });
6756 let out = pl.to_m3u8().unwrap();
6757 assert_eq!(rendered_version(&out), Some(4), "{out}");
6758 }
6759
6760 #[test]
6761 fn version_row4_iframes_only_triggers_v4() {
6762 let mut pl = base_media_playlist();
6763 pl.iframes_only = true;
6764 let out = pl.to_m3u8().unwrap();
6765 assert_eq!(rendered_version(&out), Some(4), "{out}");
6766 }
6767
6768 #[test]
6769 fn version_row5_sample_aes_triggers_v5() {
6770 let mut pl = base_media_playlist();
6771 pl.extra_tags = vec!["#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://k\",KEYID=0x01".into()];
6772 let out = pl.to_m3u8().unwrap();
6773 assert_eq!(rendered_version(&out), Some(5), "{out}");
6774 }
6775
6776 #[test]
6777 fn version_row5_keyformat_triggers_v5() {
6778 let mut pl = base_media_playlist();
6779 pl.extra_tags = vec![
6780 "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",KEYFORMAT=\"com.example\",\
6781 KEYFORMATVERSIONS=\"1\""
6782 .into(),
6783 ];
6784 let out = pl.to_m3u8().unwrap();
6785 assert_eq!(rendered_version(&out), Some(5), "{out}");
6786 }
6787
6788 #[test]
6789 fn version_row5_map_with_iframes_only_triggers_v5_not_v6() {
6790 let mut pl = base_media_playlist();
6791 pl.iframes_only = true;
6792 pl.segments[0].map = Some(MapTag {
6793 uri: "init.mp4".into(),
6794 byte_range: None,
6795 extra_attrs: Vec::new(),
6796 });
6797 let out = pl.to_m3u8().unwrap();
6798 assert_eq!(rendered_version(&out), Some(5), "{out}");
6799 }
6800
6801 #[test]
6802 fn version_row6_map_without_iframes_only_triggers_v6() {
6803 let mut pl = base_media_playlist();
6804 pl.segments[0].map = Some(MapTag {
6805 uri: "init.mp4".into(),
6806 byte_range: None,
6807 extra_attrs: Vec::new(),
6808 });
6809 let out = pl.to_m3u8().unwrap();
6810 assert_eq!(rendered_version(&out), Some(6), "{out}");
6811 }
6812
6813 #[test]
6814 fn version_row7_media_service_instream_id_triggers_v7_multivariant_only() {
6815 let mut pl = MasterPlaylist {
6816 version: 0,
6817 media: vec![],
6818 variants: vec![Variant {
6819 bandwidth: 300_000,
6820 average_bandwidth: None,
6821 frame_rate: None,
6822 codecs: Some("avc1.64001e".into()),
6823 resolution: None,
6824 uri: "v300/index.m3u8".into(),
6825 audio: None,
6826 video: None,
6827 subtitles: None,
6828 closed_captions: None,
6829 extra_attrs: Vec::new(),
6830 }],
6831 iframe_variants: vec![],
6832 extra_tags: vec![],
6833 ..Default::default()
6834 };
6835 pl.extra_tags = vec![
6836 "#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"cc\",NAME=\"CC1\",\
6837 INSTREAM-ID=\"SERVICE1\""
6838 .into(),
6839 ];
6840 let out = pl.to_m3u8().unwrap();
6841 assert_eq!(rendered_version(&out), Some(7), "{out}");
6842 }
6843
6844 #[test]
6845 fn version_row8_variable_substitution_triggers_v8() {
6846 let mut pl = base_media_playlist();
6847 pl.segments = vec![seg("seg-{$id}.m4s", 6.0)];
6848 let out = pl.to_m3u8().unwrap();
6849 assert_eq!(rendered_version(&out), Some(8), "{out}");
6850 }
6851
6852 #[test]
6853 fn version_row8_variable_substitution_triggers_v8_multivariant() {
6854 let pl = MasterPlaylist {
6855 version: 0,
6856 media: vec![],
6857 variants: vec![Variant {
6858 bandwidth: 300_000,
6859 average_bandwidth: None,
6860 frame_rate: None,
6861 codecs: Some("avc1.64001e".into()),
6862 resolution: None,
6863 uri: "{$base}/index.m3u8".into(),
6864 audio: None,
6865 video: None,
6866 subtitles: None,
6867 closed_captions: None,
6868 extra_attrs: Vec::new(),
6869 }],
6870 iframe_variants: vec![],
6871 extra_tags: vec![],
6872 ..Default::default()
6873 };
6874 let out = pl.to_m3u8().unwrap();
6875 assert_eq!(rendered_version(&out), Some(8), "{out}");
6876 }
6877
6878 #[test]
6879 fn version_row9_skip_triggers_v9() {
6880 let mut pl = base_media_playlist();
6881 pl.skip = Some(SkipInfo {
6882 skipped_segments: 5,
6883 recently_removed_daterange_ids: vec![],
6884 ..Default::default()
6885 });
6886 let out = pl.to_m3u8().unwrap();
6887 assert_eq!(rendered_version(&out), Some(9), "{out}");
6888 }
6889
6890 #[test]
6891 fn version_row10_skip_replacing_daterange_triggers_v10() {
6892 let mut pl = base_media_playlist();
6893 pl.skip = Some(SkipInfo {
6894 skipped_segments: 5,
6895 recently_removed_daterange_ids: vec!["ad-1".into()],
6896 ..Default::default()
6897 });
6898 let out = pl.to_m3u8().unwrap();
6899 assert_eq!(rendered_version(&out), Some(10), "{out}");
6900 }
6901
6902 #[test]
6903 fn version_row11_define_queryparam_triggers_v11() {
6904 let mut pl = base_media_playlist();
6905 pl.extra_tags = vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()];
6906 let out = pl.to_m3u8().unwrap();
6907 assert_eq!(rendered_version(&out), Some(11), "{out}");
6908 }
6909
6910 #[test]
6911 fn version_row11_define_queryparam_triggers_v11_multivariant() {
6912 let pl = MasterPlaylist {
6913 version: 0,
6914 media: vec![],
6915 variants: vec![Variant {
6916 bandwidth: 300_000,
6917 average_bandwidth: None,
6918 frame_rate: None,
6919 codecs: Some("avc1.64001e".into()),
6920 resolution: None,
6921 uri: "v300/index.m3u8".into(),
6922 ..Default::default()
6923 }],
6924 iframe_variants: vec![],
6925 extra_tags: vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()],
6926 ..Default::default()
6927 };
6928 let out = pl.to_m3u8().unwrap();
6929 assert_eq!(rendered_version(&out), Some(11), "{out}");
6930 }
6931
6932 #[test]
6933 fn version_row12_req_attribute_triggers_v12() {
6934 let mut pl = base_media_playlist();
6935 pl.extra_tags = vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()];
6936 let out = pl.to_m3u8().unwrap();
6937 assert_eq!(rendered_version(&out), Some(12), "{out}");
6938 }
6939
6940 #[test]
6948 fn version_row12_req_attribute_in_per_tag_lists_triggers_v12() {
6949 let mut pl = base_media_playlist();
6950 pl.low_latency = Some(LowLatencyConfig {
6951 sc_extra_attrs: vec![(
6952 "REQ-LATENCY".to_string(),
6953 AttrValue::bare("ultra-low").unwrap(),
6954 )],
6955 ..Default::default()
6956 });
6957 let out = pl.to_m3u8().unwrap();
6958 assert!(out.contains("REQ-LATENCY=ultra-low"), "{out}");
6959 assert_eq!(rendered_version(&out), Some(12), "{out}");
6960
6961 for (list, name) in [("pi", "REQ-VIDEO"), ("ph", "REQ-INFO")] {
6963 let mut pl = base_media_playlist();
6964 let attr = vec![(name.to_string(), AttrValue::bare("1").unwrap())];
6965 let mut ll = LowLatencyConfig::default();
6966 match list {
6967 "pi" => ll.pi_extra_attrs = attr,
6968 _ => ll.ph_extra_attrs = attr,
6969 }
6970 pl.low_latency = Some(ll);
6971 let out = pl.to_m3u8().unwrap();
6972 assert_eq!(rendered_version(&out), Some(12), "{list}: {out}");
6973 }
6974 }
6975
6976 #[test]
6977 fn version_row12_req_attribute_triggers_v12_multivariant() {
6978 let pl = MasterPlaylist {
6979 version: 0,
6980 media: vec![],
6981 variants: vec![Variant {
6982 bandwidth: 300_000,
6983 average_bandwidth: None,
6984 frame_rate: None,
6985 codecs: Some("avc1.64001e".into()),
6986 resolution: None,
6987 uri: "v300/index.m3u8".into(),
6988 ..Default::default()
6989 }],
6990 iframe_variants: vec![],
6991 extra_tags: vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()],
6992 ..Default::default()
6993 };
6994 let out = pl.to_m3u8().unwrap();
6995 assert_eq!(rendered_version(&out), Some(12), "{out}");
6996 }
6997
6998 #[test]
6999 fn version_row13_media_instream_id_non_cc_triggers_v13_multivariant_only() {
7000 let pl = MasterPlaylist {
7001 version: 0,
7002 media: vec![],
7003 variants: vec![Variant {
7004 bandwidth: 300_000,
7005 average_bandwidth: None,
7006 frame_rate: None,
7007 codecs: Some("avc1.64001e".into()),
7008 resolution: None,
7009 uri: "v300/index.m3u8".into(),
7010 ..Default::default()
7011 }],
7012 iframe_variants: vec![],
7013 extra_tags: vec![
7014 "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"Eng\",INSTREAM-ID=\"CC1\"".into(),
7015 ],
7016 ..Default::default()
7017 };
7018 let out = pl.to_m3u8().unwrap();
7019 assert_eq!(rendered_version(&out), Some(13), "{out}");
7020 }
7021
7022 #[test]
7023 fn version_multivariant_no_trigger_omits_the_tag() {
7024 let pl = MasterPlaylist {
7025 version: 0,
7026 media: vec![],
7027 variants: vec![Variant {
7028 bandwidth: 300_000,
7029 average_bandwidth: None,
7030 frame_rate: None,
7031 codecs: Some("avc1.64001e".into()),
7032 resolution: None,
7033 uri: "v300/index.m3u8".into(),
7034 ..Default::default()
7035 }],
7036 iframe_variants: vec![],
7037 extra_tags: vec![],
7038 ..Default::default()
7039 };
7040 let out = pl.to_m3u8().unwrap();
7041 assert_eq!(rendered_version(&out), None, "{out}");
7042 }
7043
7044 #[test]
7052 fn named_case_1_fmp4_low_latency_renders_6_not_9() {
7053 let pl = MediaPlaylist {
7054 version: 0, target_duration: 4,
7056 media_sequence: 100,
7057 segments: vec![MediaSegment {
7058 uri: "seg-1-100.m4s".into(),
7059 duration: DecimalSeconds::new(4.0).unwrap(),
7060 ..Default::default()
7061 }],
7062 open_segment: Some(OpenSegment::new(vec![PartSpec {
7063 uri: "part-1-101.0.m4s".into(),
7064 duration: DecimalSeconds::new(0.5).unwrap(),
7065 independent: true,
7066 ..Default::default()
7067 }])),
7068 extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
7069 low_latency: Some(LowLatencyConfig {
7070 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7071 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7072 ..Default::default()
7073 }),
7074 iframes_only: false,
7075 ..Default::default()
7076 };
7077 let out = pl.to_m3u8().unwrap();
7078 assert_eq!(
7079 rendered_version(&out),
7080 Some(6),
7081 "fMP4 + low-latency must render 6, not the old hardcoded 9:\n{out}"
7082 );
7083 assert!(!out.contains("#EXT-X-VERSION:9"), "{out}");
7084 }
7085
7086 #[test]
7089 fn named_case_2_classic_mpegts_float_extinf_renders_3() {
7090 let pl = MediaPlaylist {
7091 version: 0,
7092 target_duration: 10,
7093 media_sequence: 0,
7094 segments: vec![
7095 seg("seg0.ts", 9.009),
7096 seg("seg1.ts", 9.009),
7097 seg("seg2.ts", 3.003),
7098 ],
7099 endlist: true,
7100 ..Default::default()
7101 };
7102 let out = pl.to_m3u8().unwrap();
7103 assert_eq!(rendered_version(&out), Some(3), "{out}");
7104 }
7105
7106 #[test]
7109 fn named_case_3_adding_skip_raises_version_to_9() {
7110 let mut pl = base_media_playlist();
7111 assert_eq!(
7112 rendered_version(&pl.to_m3u8().unwrap()),
7113 None,
7114 "sanity: no trigger before adding EXT-X-SKIP"
7115 );
7116 pl.skip = Some(SkipInfo {
7117 skipped_segments: 3,
7118 recently_removed_daterange_ids: vec![],
7119 ..Default::default()
7120 });
7121 let out = pl.to_m3u8().unwrap();
7122 assert_eq!(rendered_version(&out), Some(9), "{out}");
7123 }
7124
7125 #[test]
7128 fn named_case_4_no_trigger_renders_no_version_tag() {
7129 let out = base_media_playlist().to_m3u8().unwrap();
7130 assert!(!out.contains("#EXT-X-VERSION"), "{out}");
7131 }
7132
7133 #[test]
7136 fn named_case_5_sample_aes_renders_5() {
7137 let tag = cenc_ext_x_key(CencScheme::Cbcs, &[0xab; 16], "https://k.example/key")
7138 .expect("valid key_uri")
7139 .expect("cbcs must emit an EXT-X-KEY tag");
7140 let mut pl = base_media_playlist();
7141 pl.extra_tags = vec![tag];
7142 let out = pl.to_m3u8().unwrap();
7143 assert_eq!(rendered_version(&out), Some(5), "{out}");
7144 }
7145
7146 #[test]
7151 fn parse_and_round_trip_can_skip_dateranges_yes() {
7152 let text = "#EXTM3U\n\
7153#EXT-X-VERSION:9\n\
7154#EXT-X-TARGETDURATION:4\n\
7155#EXT-X-PART-INF:PART-TARGET=0.5\n\
7156#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n\
7157#EXTINF:4.000,\n\
7158seg0.m4s\n";
7159 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7160 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7161 assert!(
7162 ll.can_skip_dateranges,
7163 "CAN-SKIP-DATERANGES=YES must parse to true"
7164 );
7165 let round = pl.to_m3u8().unwrap();
7166 assert!(
7167 round.contains("CAN-SKIP-DATERANGES=YES"),
7168 "render must emit CAN-SKIP-DATERANGES=YES:\n{round}"
7169 );
7170 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7171 let re_ll = reparse
7172 .low_latency
7173 .as_ref()
7174 .expect("must have low_latency on reparse");
7175 assert!(
7176 re_ll.can_skip_dateranges,
7177 "CAN-SKIP-DATERANGES=YES must survive round trip"
7178 );
7179 }
7180
7181 #[test]
7184 fn can_skip_dateranges_not_rendered_without_can_skip_until() {
7185 let pl = MediaPlaylist {
7186 version: 9,
7187 target_duration: 4,
7188 media_sequence: 0,
7189 segments: vec![],
7190 low_latency: Some(LowLatencyConfig {
7191 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7192 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7193 can_skip_until: None,
7194 can_skip_dateranges: true,
7195 can_block_reload: true,
7196 ..Default::default()
7197 }),
7198 ..Default::default()
7199 };
7200 let out = pl.to_m3u8().unwrap();
7201 assert!(
7202 !out.contains("CAN-SKIP-DATERANGES"),
7203 "CAN-SKIP-DATERANGES must NOT render without CAN-SKIP-UNTIL:\n{out}"
7204 );
7205 }
7206
7207 #[test]
7210 fn parse_and_round_trip_hold_back() {
7211 let text = "#EXTM3U\n\
7212#EXT-X-VERSION:9\n\
7213#EXT-X-TARGETDURATION:4\n\
7214#EXT-X-PART-INF:PART-TARGET=0.5\n\
7215#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0\n\
7216#EXTINF:4.000,\n\
7217seg0.m4s\n";
7218 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7219 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7220 assert_eq!(
7221 ll.hold_back,
7222 Some(DecimalSeconds::new(12.0).unwrap()),
7223 "HOLD-BACK=12.0 must parse"
7224 );
7225 let round = pl.to_m3u8().unwrap();
7226 assert!(
7228 round.contains("HOLD-BACK=12"),
7229 "render must emit HOLD-BACK=12:\n{round}"
7230 );
7231 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7232 let re_ll = reparse
7233 .low_latency
7234 .as_ref()
7235 .expect("must have low_latency on reparse");
7236 assert_eq!(
7237 re_ll.hold_back,
7238 Some(DecimalSeconds::new(12.0).unwrap()),
7239 "HOLD-BACK must survive round trip"
7240 );
7241 }
7242
7243 #[test]
7245 fn hold_back_omitted_when_none() {
7246 let pl = MediaPlaylist {
7247 version: 9,
7248 target_duration: 4,
7249 media_sequence: 0,
7250 segments: vec![MediaSegment {
7251 uri: "seg0.m4s".into(),
7252 duration: DecimalSeconds::new(4.0).unwrap(),
7253 ..Default::default()
7254 }],
7255 low_latency: Some(LowLatencyConfig {
7256 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7257 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7258 hold_back: None,
7259 can_block_reload: true,
7260 ..Default::default()
7261 }),
7262 ..Default::default()
7263 };
7264 let out = pl.to_m3u8().unwrap();
7265 assert!(
7266 !out.contains(",HOLD-BACK="),
7267 "HOLD-BACK must be absent when None:\n{out}"
7268 );
7269 }
7270
7271 #[test]
7276 fn session_key_method_none_is_rejected() {
7277 let text = "#EXTM3U\n\
7278#EXT-X-VERSION:6\n\
7279#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7280v300/index.m3u8\n\
7281#EXT-X-SESSION-KEY:METHOD=NONE,URI=\"https://k.example/key\"\n";
7282 let err = MasterPlaylist::parse(text)
7283 .expect_err("EXT-X-SESSION-KEY with METHOD=NONE must be rejected");
7284 let Error::HlsParse { reason, .. } = err else {
7285 panic!("expected Error::HlsParse, got {err:?}")
7286 };
7287 assert!(reason.contains("NONE"), "error must mention NONE: {reason}");
7288 }
7289
7290 #[test]
7293 fn session_key_real_method_is_accepted_and_round_trips() {
7294 let text = "#EXTM3U\n\
7295#EXT-X-VERSION:6\n\
7296#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7297v300/index.m3u8\n\
7298#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://k.example/key\"\n";
7299 let pl = MasterPlaylist::parse(text).expect("legitimate session key must parse");
7300 assert_eq!(pl.session_keys.len(), 1);
7301 assert_eq!(pl.session_keys[0].method, EncryptionMethod::Aes128);
7302 let round = pl.to_m3u8().unwrap();
7303 let reparse = MasterPlaylist::parse(&round).expect("reparse must succeed");
7304 assert_eq!(reparse.session_keys.len(), 1);
7305 assert_eq!(reparse.session_keys[0].method, EncryptionMethod::Aes128);
7306 }
7307
7308 #[test]
7315 fn server_control_typed_and_req_attrs_round_trip_and_version_12() {
7316 let text = "#EXTM3U\n\
7317#EXT-X-VERSION:12\n\
7318#EXT-X-TARGETDURATION:4\n\
7319#EXT-X-PART-INF:PART-TARGET=0.5,REQ-VIDEO=720p\n\
7320#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES,REQ-LATENCY=ultra-low\n\
7321#EXTINF:4,\n\
7322seg0.m4s\n";
7323 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7324 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7325
7326 assert_eq!(ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7328 assert!(ll.can_skip_dateranges);
7329
7330 assert!(
7332 ll.pi_extra_attrs
7333 .iter()
7334 .any(|(k, v)| k == "REQ-VIDEO" && v.as_str() == "720p"),
7335 "REQ-VIDEO must survive in pi_extra_attrs"
7336 );
7337
7338 assert!(
7340 ll.sc_extra_attrs
7341 .iter()
7342 .any(|(k, v)| k == "REQ-LATENCY" && v.as_str() == "ultra-low"),
7343 "REQ-LATENCY must survive in sc_extra_attrs"
7344 );
7345
7346 assert!(ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7348 assert!(ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7349
7350 let round = pl.to_m3u8().unwrap();
7352 assert!(
7353 round.contains("#EXT-X-VERSION:12"),
7354 "must emit version 12:\n{round}"
7355 );
7356
7357 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7359 let re_ll = reparse
7360 .low_latency
7361 .as_ref()
7362 .expect("must have low_latency on reparse");
7363 assert_eq!(re_ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7364 assert!(re_ll.can_skip_dateranges);
7365 assert!(re_ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7366 assert!(re_ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7367 }
7368
7369 #[test]
7372 fn byterange_offset_plus_length_overflow_rejected() {
7373 let input = "#EXTM3U\n\
7374 #EXT-X-TARGETDURATION:10\n\
7375 #EXTINF:10,\n\
7376 #EXT-X-BYTERANGE:1@18446744073709551615\n\
7377 seg0.ts\n";
7378 let err = MediaPlaylist::parse(input).unwrap_err();
7379 assert!(
7380 matches!(&err, Error::HlsParse { line_no: 4, reason, .. } if reason.contains("overflows")),
7381 "expected overflow error: {err:?}"
7382 );
7383 }
7384
7385 #[test]
7386 fn byterange_exact_max_accepted() {
7387 let input = "#EXTM3U\n\
7388 #EXT-X-TARGETDURATION:10\n\
7389 #EXTINF:10,\n\
7390 #EXT-X-BYTERANGE:1@18446744073709551614\n\
7391 seg0.ts\n";
7392 let pl = MediaPlaylist::parse(input).expect("u64::MAX exactly must parse");
7393 assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, 1);
7394 assert_eq!(
7395 pl.segments[0].byte_range.as_ref().unwrap().offset,
7396 Some(u64::MAX - 1)
7397 );
7398 }
7399
7400 #[test]
7401 fn byterange_no_offset_max_length_accepted() {
7402 let input = "#EXTM3U\n\
7403 #EXT-X-TARGETDURATION:10\n\
7404 #EXTINF:10,\n\
7405 #EXT-X-BYTERANGE:18446744073709551615\n\
7406 seg0.ts\n";
7407 let pl = MediaPlaylist::parse(input).expect("no offset means no overflow check");
7408 assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, u64::MAX);
7409 assert!(pl.segments[0].byte_range.as_ref().unwrap().offset.is_none());
7410 }
7411
7412 #[test]
7413 fn map_byterange_overflow_rejected() {
7414 let input = "#EXTM3U\n\
7415 #EXT-X-TARGETDURATION:10\n\
7416 #EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1@18446744073709551615\"\n\
7417 #EXTINF:10,\n\
7418 seg0.ts\n";
7419 let err = MediaPlaylist::parse(input).unwrap_err();
7420 assert!(
7421 matches!(&err, Error::HlsParse { reason, .. } if reason.contains("overflows")),
7422 "MAP BYTERANGE overflow: {err:?}"
7423 );
7424 }
7425
7426 #[test]
7427 fn part_byterange_overflow_rejected() {
7428 let input = "#EXTM3U\n\
7429 #EXT-X-TARGETDURATION:10\n\
7430 #EXT-X-PART-INF:PART-TARGET=1.0\n\
7431 #EXT-X-PART:URI=\"p0.m4s\",DURATION=1.0,BYTERANGE=\"1@18446744073709551615\"\n\
7432 #EXTINF:10,\n\
7433 seg0.ts\n";
7434 let err = MediaPlaylist::parse(input).unwrap_err();
7435 assert!(
7436 matches!(&err, Error::HlsParse { reason, .. } if reason.contains("overflows")),
7437 "PART BYTERANGE overflow: {err:?}"
7438 );
7439 }
7440
7441 #[test]
7442 fn preload_hint_byterange_overflow_rejected() {
7443 let input = "#EXTM3U\n\
7444 #EXT-X-TARGETDURATION:10\n\
7445 #EXT-X-PART-INF:PART-TARGET=1.0\n\
7446 #EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next.m4s\",\
7447 BYTERANGE-START=18446744073709551615,BYTERANGE-LENGTH=1\n\
7448 #EXTINF:10,\n\
7449 seg0.ts\n";
7450 let err = MediaPlaylist::parse(input).unwrap_err();
7451 assert!(
7452 matches!(&err, Error::HlsParse { reason, .. } if reason.contains("overflows")),
7453 "PRELOAD-HINT overflow: {err:?}"
7454 );
7455 }
7456}