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, PartialEq, Eq, Default)]
203pub struct RenditionReport {
204 pub uri: String,
207 pub last_msn: u64,
210 pub last_part: Option<u64>,
213 pub extra_attrs: Vec<(String, AttrValue)>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Default)]
221pub struct SkipInfo {
222 pub skipped_segments: u64,
224 pub recently_removed_daterange_ids: Vec<String>,
228 pub extra_attrs: Vec<(String, AttrValue)>,
231}
232
233#[derive(Debug, Clone, PartialEq, Default)]
241pub struct PartSpec {
242 pub uri: String,
244 pub duration: DecimalSeconds,
246 pub independent: bool,
249 pub byte_range: Option<ByteRange>,
254 pub gap: bool,
257 pub extra_attrs: Vec<(String, AttrValue)>,
260}
261
262#[derive(Debug, Clone, PartialEq)]
267#[non_exhaustive]
268pub struct OpenSegment {
269 pub parts: Vec<PartSpec>,
271 pub map: Option<MapTag>,
278 pub pre_tags: Vec<String>,
285 pub program_date_time: Option<ProgramDateTime>,
288}
289
290impl OpenSegment {
291 pub fn new(parts: Vec<PartSpec>) -> Self {
294 Self {
295 parts,
296 map: None,
297 pre_tags: Vec::new(),
298 program_date_time: None,
299 }
300 }
301
302 pub fn with_map(mut self, map: MapTag) -> Self {
304 self.map = Some(map);
305 self
306 }
307}
308
309#[derive(Debug, Clone, PartialEq)]
317#[non_exhaustive]
318pub struct StartPoint {
319 pub time_offset: SignedDecimalSeconds,
323 pub precise: bool,
327 pub extra_attrs: Vec<(String, AttrValue)>,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
337#[non_exhaustive]
338pub enum Define {
339 Name {
341 name: String,
343 value: String,
345 extra_attrs: Vec<(String, AttrValue)>,
348 },
349 Import {
357 name: String,
359 extra_attrs: Vec<(String, AttrValue)>,
362 },
363 QueryParam {
366 name: String,
368 extra_attrs: Vec<(String, AttrValue)>,
371 },
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381#[non_exhaustive]
382pub enum PlaylistType {
383 Event,
385 Vod,
387}
388
389impl PlaylistType {
390 pub fn name(&self) -> &'static str {
392 match self {
393 PlaylistType::Event => "EVENT",
394 PlaylistType::Vod => "VOD",
395 }
396 }
397}
398
399broadcast_common::impl_spec_display!(PlaylistType);
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
408#[non_exhaustive]
409pub enum SessionDataFormat {
410 #[default]
413 Json,
414 Raw,
416}
417
418impl SessionDataFormat {
419 pub fn name(&self) -> &'static str {
421 match self {
422 SessionDataFormat::Json => "JSON",
423 SessionDataFormat::Raw => "RAW",
424 }
425 }
426}
427
428broadcast_common::impl_spec_display!(SessionDataFormat);
429
430#[derive(Debug, Clone, PartialEq, Eq)]
434#[non_exhaustive]
435pub enum SessionDataContent {
436 Value(String),
438 Uri {
440 uri: String,
442 format: SessionDataFormat,
445 },
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
453#[non_exhaustive]
454pub struct SessionData {
455 pub data_id: String,
457 pub content: SessionDataContent,
459 pub language: Option<String>,
462 pub extra_attrs: Vec<(String, AttrValue)>,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470#[non_exhaustive]
471pub enum EncryptionMethod {
472 None,
475 Aes128,
477 SampleAes,
479 SampleAesCtr,
481 Aes256Gcm,
483}
484
485impl EncryptionMethod {
486 pub fn name(&self) -> &'static str {
488 match self {
489 EncryptionMethod::None => "NONE",
490 EncryptionMethod::Aes128 => "AES-128",
491 EncryptionMethod::SampleAes => "SAMPLE-AES",
492 EncryptionMethod::SampleAesCtr => "SAMPLE-AES-CTR",
493 EncryptionMethod::Aes256Gcm => "AES-256-GCM",
494 }
495 }
496}
497
498broadcast_common::impl_spec_display!(EncryptionMethod);
499
500#[derive(Debug, Clone, PartialEq, Eq)]
505#[non_exhaustive]
506pub struct SessionKey {
507 pub method: EncryptionMethod,
509 pub uri: Option<String>,
511 pub iv: Option<[u8; 16]>,
513 pub keyformat: Option<String>,
515 pub keyformatversions: Option<String>,
517 pub extra_attrs: Vec<(String, AttrValue)>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
525#[non_exhaustive]
526pub struct ContentSteering {
527 pub server_uri: String,
529 pub pathway_id: Option<String>,
532 pub extra_attrs: Vec<(String, AttrValue)>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544#[non_exhaustive]
545pub struct ProgramDateTime(jiff::Timestamp);
546
547impl ProgramDateTime {
548 pub fn parse(s: &str) -> crate::Result<Self> {
554 let trimmed = s.trim();
555 let ts = if let Ok(ts) = trimmed.parse::<jiff::Timestamp>() {
556 ts
557 } else if let Ok(dt) = trimmed.parse::<jiff::civil::DateTime>() {
558 dt.to_zoned(jiff::tz::TimeZone::UTC)
559 .map_err(|_| crate::Error::HlsParse {
560 line_no: 0,
561 line: trimmed.to_string(),
562 reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid date/time".to_string(),
563 })?
564 .timestamp()
565 } else {
566 return Err(crate::Error::HlsParse {
567 line_no: 0,
568 line: trimmed.to_string(),
569 reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
570 .to_string(),
571 });
572 };
573 let ms = ts.as_millisecond();
574 let ts = jiff::Timestamp::from_millisecond(ms).map_err(|_| crate::Error::HlsParse {
575 line_no: 0,
576 line: trimmed.to_string(),
577 reason: "EXT-X-PROGRAM-DATE-TIME value is out of range".to_string(),
578 })?;
579 Ok(Self(ts))
580 }
581
582 pub fn from_timestamp(ts: jiff::Timestamp) -> Self {
585 Self(jiff::Timestamp::from_millisecond(ts.as_millisecond()).unwrap_or(ts))
587 }
588
589 pub fn timestamp(&self) -> jiff::Timestamp {
591 self.0
592 }
593
594 pub fn render(&self) -> alloc::string::String {
596 self.0.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
597 }
598}
599
600impl core::str::FromStr for ProgramDateTime {
601 type Err = crate::Error;
602 fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
603 Self::parse(s)
604 }
605}
606
607impl core::fmt::Display for ProgramDateTime {
608 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
609 f.write_str(&self.render())
610 }
611}
612
613#[derive(Debug, Clone, PartialEq, Default)]
615pub struct MediaSegment {
616 pub uri: String,
618 pub duration: DecimalSeconds,
620 pub discontinuous: bool,
623 pub parts: Vec<PartSpec>,
629 pub byte_range: Option<ByteRange>,
634 pub map: Option<MapTag>,
642 pub gap: bool,
647 pub bitrate: Option<u64>,
656 pub title: Option<String>,
665 pub pre_tags: Vec<String>,
681 pub program_date_time: Option<ProgramDateTime>,
684}
685
686#[derive(Debug, Clone, PartialEq, Default)]
688pub struct MediaPlaylist {
689 pub version: u8,
696 pub target_duration: u32,
698 pub media_sequence: u64,
700 pub discontinuity_sequence: u64,
707 pub always_emit_discontinuity_sequence: bool,
712 pub segments: Vec<MediaSegment>,
714 pub open_segment: Option<OpenSegment>,
717 pub endlist: bool,
719 pub extra_tags: Vec<String>,
722 pub low_latency: Option<LowLatencyConfig>,
728 pub iframes_only: bool,
733 pub rendition_reports: Vec<RenditionReport>,
738 pub skip: Option<SkipInfo>,
743 pub independent_segments: bool,
747 pub start: Option<StartPoint>,
750 pub defines: Vec<Define>,
753 pub playlist_type: Option<PlaylistType>,
757}
758
759#[derive(Debug, Clone, PartialEq)]
765pub struct LowLatencyConfig {
766 pub part_target: Option<DecimalSeconds>,
774 pub part_hold_back: Option<DecimalSeconds>,
781 pub preload_hint_part: Option<String>,
785 pub preload_hint_type: PreloadHintType,
791 pub preload_hint_byte_range_start: Option<u64>,
794 pub preload_hint_byte_range_length: Option<u64>,
798 pub can_skip_until: Option<DecimalSeconds>,
802 pub can_block_reload: bool,
812 pub sc_extra_attrs: Vec<(String, AttrValue)>,
822 pub pi_extra_attrs: Vec<(String, AttrValue)>,
824 pub ph_extra_attrs: Vec<(String, AttrValue)>,
826 pub hold_back: Option<DecimalSeconds>,
836 pub can_skip_dateranges: bool,
843}
844
845impl LowLatencyConfig {
846 pub fn effective_part_hold_back(&self) -> Option<DecimalSeconds> {
854 let Some(part_target) = self.part_target else {
855 return self.part_hold_back;
856 };
857 let floor = 3.0 * part_target.get();
858 Some(DecimalSeconds::new_unchecked(
861 self.part_hold_back
862 .map_or(floor, |phb| phb.get().max(floor)),
863 ))
864 }
865}
866
867impl Default for LowLatencyConfig {
868 fn default() -> Self {
877 Self {
878 part_target: None,
879 part_hold_back: None,
880 preload_hint_part: None,
881 preload_hint_type: PreloadHintType::default(),
882 preload_hint_byte_range_start: None,
883 preload_hint_byte_range_length: None,
884 can_skip_until: None,
885 can_block_reload: true,
886 sc_extra_attrs: Vec::new(),
887 pi_extra_attrs: Vec::new(),
888 ph_extra_attrs: Vec::new(),
889 hold_back: None,
890 can_skip_dateranges: false,
891 }
892 }
893}
894
895const VERSION_KEY_IV: u8 = 2;
910const VERSION_FLOAT_EXTINF: u8 = 3;
912const VERSION_BYTERANGE_OR_IFRAMES_ONLY: u8 = 4;
914const VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY: u8 = 5;
920const VERSION_MAP_WITHOUT_IFRAMES_ONLY: u8 = 6;
923const VERSION_MEDIA_SERVICE_INSTREAM_ID: u8 = 7;
926const VERSION_VARIABLE_SUBSTITUTION: u8 = 8;
928const VERSION_SKIP: u8 = 9;
930const VERSION_SKIP_REPLACES_DATERANGE: u8 = 10;
934const VERSION_DEFINE_QUERYPARAM: u8 = 11;
936const VERSION_REQ_ATTRIBUTE: u8 = 12;
938const VERSION_MEDIA_INSTREAM_ID_NON_CC: u8 = 13;
941
942const VARIABLE_SUBSTITUTION_MARKER: &str = "{$";
950
951fn bump_version(v: &mut Option<u8>, n: u8) {
955 *v = Some(match *v {
956 Some(m) => m.max(n),
957 None => n,
958 });
959}
960
961fn is_fractional_duration(duration: DecimalSeconds) -> bool {
987 format_extinf(duration).contains('.')
988}
989
990fn contains_variable_substitution(s: &str) -> bool {
992 s.contains(VARIABLE_SUBSTITUTION_MARKER)
993}
994
995fn scan_tag_lines_for_version(tags: &[String]) -> Option<u8> {
1021 let mut v: Option<u8> = None;
1022 for tag in tags {
1023 if let Some(rest) = tag.strip_prefix("#EXT-X-KEY:") {
1024 let (attrs, _quoted) = parse_attribute_list(rest);
1025 if attrs.contains_key("IV") {
1026 bump_version(&mut v, VERSION_KEY_IV);
1027 }
1028 if attrs.get("METHOD").map(String::as_str) == Some("SAMPLE-AES")
1029 || attrs.contains_key("KEYFORMAT")
1030 || attrs.contains_key("KEYFORMATVERSIONS")
1031 {
1032 bump_version(
1033 &mut v,
1034 VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY,
1035 );
1036 }
1037 } else if let Some(rest) = tag.strip_prefix("#EXT-X-DEFINE:") {
1038 if parse_attribute_list(rest).0.contains_key("QUERYPARAM") {
1046 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1047 }
1048 } else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
1049 let (attrs, _quoted) = parse_attribute_list(rest);
1050 if let Some(instream_id) = attrs.get("INSTREAM-ID") {
1051 if instream_id.starts_with("SERVICE") {
1052 bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
1053 }
1054 let is_closed_captions =
1055 attrs.get("TYPE").map(String::as_str) == Some("CLOSED-CAPTIONS");
1056 if !is_closed_captions {
1057 bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
1058 }
1059 }
1060 }
1061 if let Some(colon) = tag.find(':')
1064 && parse_attribute_list(&tag[colon + 1..])
1065 .0
1066 .keys()
1067 .any(|k| k.starts_with("REQ-"))
1068 {
1069 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1070 }
1071 }
1072 v
1073}
1074
1075#[allow(clippy::too_many_arguments)]
1084fn any_typed_req_attr(
1085 start: Option<&StartPoint>,
1086 defines: &[Define],
1087 sd: &[SessionData],
1088 sk: &[SessionKey],
1089 cs: Option<&ContentSteering>,
1090 media: &[MediaRendition],
1091 variants: &[Variant],
1092 iframes: &[IFrameVariant],
1093) -> bool {
1094 let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1095 attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1096 };
1097 if start.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1098 return true;
1099 }
1100 if defines.iter().any(|d| {
1101 let attrs = match d {
1102 Define::Name { extra_attrs, .. } => extra_attrs,
1103 Define::Import { extra_attrs, .. } => extra_attrs,
1104 Define::QueryParam { extra_attrs, .. } => extra_attrs,
1105 };
1106 extra_attr_is_req(attrs)
1107 }) {
1108 return true;
1109 }
1110 if sd.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1111 return true;
1112 }
1113 if sk.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1114 return true;
1115 }
1116 if cs.is_some_and(|c| extra_attr_is_req(&c.extra_attrs)) {
1117 return true;
1118 }
1119 if media.iter().any(|m| extra_attr_is_req(&m.extra_attrs)) {
1120 return true;
1121 }
1122 if variants.iter().any(|v| extra_attr_is_req(&v.extra_attrs)) {
1123 return true;
1124 }
1125 if iframes.iter().any(|i| extra_attr_is_req(&i.extra_attrs)) {
1126 return true;
1127 }
1128 false
1129}
1130
1131fn any_media_typed_req_attr(
1135 segments: &[MediaSegment],
1136 open_segment: Option<&OpenSegment>,
1137 rendition_reports: &[RenditionReport],
1138 skip: Option<&SkipInfo>,
1139 low_latency: Option<&LowLatencyConfig>,
1140) -> bool {
1141 let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1142 attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1143 };
1144 if segments.iter().any(|s| {
1145 s.map
1146 .as_ref()
1147 .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1148 || s.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs))
1149 }) {
1150 return true;
1151 }
1152 if let Some(open) = open_segment
1153 && (open
1154 .map
1155 .as_ref()
1156 .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1157 || open.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs)))
1158 {
1159 return true;
1160 }
1161 if rendition_reports
1162 .iter()
1163 .any(|r| extra_attr_is_req(&r.extra_attrs))
1164 {
1165 return true;
1166 }
1167 if skip.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1168 return true;
1169 }
1170 if low_latency.is_some_and(|ll| {
1171 extra_attr_is_req(&ll.sc_extra_attrs)
1172 || extra_attr_is_req(&ll.pi_extra_attrs)
1173 || extra_attr_is_req(&ll.ph_extra_attrs)
1174 }) {
1175 return true;
1176 }
1177 false
1178}
1179
1180fn effective_version(explicit: u8, computed: Option<u8>) -> Option<u8> {
1185 match (explicit, computed) {
1186 (0, None) => None,
1187 (0, Some(m)) => Some(m),
1188 (e, None) => Some(e),
1189 (e, Some(m)) => Some(e.max(m)),
1190 }
1191}
1192
1193impl MediaPlaylist {
1194 pub fn computed_version(&self) -> Option<u8> {
1205 let mut v: Option<u8> = None;
1206
1207 let has_map = self.segments.iter().any(|s| s.map.is_some())
1211 || (self.low_latency.is_some()
1214 && self.open_segment.as_ref().is_some_and(|o| o.map.is_some()))
1215 || self.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MAP:"));
1216 if has_map {
1217 bump_version(
1218 &mut v,
1219 if self.iframes_only {
1220 VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY
1221 } else {
1222 VERSION_MAP_WITHOUT_IFRAMES_ONLY
1223 },
1224 );
1225 }
1226
1227 if self.iframes_only || self.segments.iter().any(|s| s.byte_range.is_some()) {
1229 bump_version(&mut v, VERSION_BYTERANGE_OR_IFRAMES_ONLY);
1230 }
1231
1232 if self
1234 .segments
1235 .iter()
1236 .any(|s| is_fractional_duration(s.duration))
1237 {
1238 bump_version(&mut v, VERSION_FLOAT_EXTINF);
1239 }
1240
1241 if let Some(skip) = &self.skip {
1243 bump_version(&mut v, VERSION_SKIP);
1244 if !skip.recently_removed_daterange_ids.is_empty() {
1245 bump_version(&mut v, VERSION_SKIP_REPLACES_DATERANGE);
1246 }
1247 }
1248
1249 if self
1254 .defines
1255 .iter()
1256 .any(|d| matches!(d, Define::QueryParam { .. }))
1257 {
1258 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1259 }
1260
1261 if self
1267 .extra_tags
1268 .iter()
1269 .any(|t| contains_variable_substitution(t))
1270 || self
1271 .segments
1272 .iter()
1273 .any(|s| contains_variable_substitution(&s.uri))
1274 || self.media_playlist_typed_strings_use_substitution()
1275 {
1276 bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
1277 }
1278
1279 {
1290 let mut opaque_tags: Vec<&String> = self.extra_tags.iter().collect();
1291 opaque_tags.extend(self.segments.iter().flat_map(|s| &s.pre_tags));
1292 if let Some(open) = &self.open_segment {
1293 opaque_tags.extend(open.pre_tags.iter());
1294 }
1295 let owned: Vec<String> = opaque_tags.into_iter().cloned().collect();
1296 if let Some(m) = scan_tag_lines_for_version(&owned) {
1297 bump_version(&mut v, m);
1298 }
1299 }
1300
1301 if any_media_typed_req_attr(
1304 &self.segments,
1305 self.open_segment
1307 .as_ref()
1308 .filter(|_| self.low_latency.is_some()),
1309 &self.rendition_reports,
1310 self.skip.as_ref(),
1311 self.low_latency.as_ref(),
1312 ) || any_typed_req_attr(
1313 self.start.as_ref(),
1314 &self.defines,
1315 &[],
1316 &[],
1317 None,
1318 &[],
1319 &[],
1320 &[],
1321 ) {
1322 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1323 }
1324
1325 v
1326 }
1327
1328 fn media_playlist_typed_strings_use_substitution(&self) -> bool {
1338 let seg_strings = self.segments.iter().flat_map(|s| {
1339 s.map
1340 .iter()
1341 .map(|m| &m.uri)
1342 .chain(s.parts.iter().map(|p| &p.uri))
1343 });
1344 let open_strings = self.open_segment.iter().flat_map(|o| {
1345 o.map
1346 .iter()
1347 .map(|m| &m.uri)
1348 .chain(o.parts.iter().map(|p| &p.uri))
1349 });
1350 let define_values = self.defines.iter().filter_map(|d| match d {
1351 Define::Name { value, .. } => Some(value),
1352 _ => None,
1353 });
1354 let report_uris = self.rendition_reports.iter().map(|r| &r.uri);
1355 let preload = self
1356 .low_latency
1357 .iter()
1358 .filter_map(|ll| ll.preload_hint_part.as_ref());
1359
1360 seg_strings
1361 .chain(open_strings)
1362 .chain(define_values)
1363 .chain(report_uris)
1364 .chain(preload)
1365 .any(|s| contains_variable_substitution(s))
1366 }
1367
1368 fn effective_version(&self) -> Option<u8> {
1373 effective_version(self.version, self.computed_version())
1374 }
1375}
1376
1377pub const KEYFORMAT_IDENTITY: &str = "identity";
1380
1381pub type MediaKey = SessionKey;
1384
1385const EXT_X_KEY_PREFIX: &str = "#EXT-X-KEY:";
1386
1387fn key_format_of(key: &MediaKey) -> &str {
1388 key.keyformat.as_deref().unwrap_or(KEYFORMAT_IDENTITY)
1389}
1390
1391#[derive(Default)]
1392struct KeyState {
1393 identity: Option<MediaKey>,
1394 others: BTreeMap<String, MediaKey>,
1395}
1396
1397impl KeyState {
1398 fn apply_tags(&mut self, tags: &[String]) -> Result<()> {
1399 for tag in tags {
1400 let Some(rest) = tag.strip_prefix(EXT_X_KEY_PREFIX) else {
1401 continue;
1402 };
1403 let key = parse_media_key(rest, 0, tag)?;
1404 if key.method != EncryptionMethod::None && key.uri.is_none() {
1405 return Err(Error::KeyWithoutUri { method: key.method });
1406 }
1407 if key.method == EncryptionMethod::None {
1408 self.identity = None;
1409 self.others.clear();
1410 } else if key_format_of(&key) == KEYFORMAT_IDENTITY {
1411 self.identity = Some(key);
1412 } else {
1413 self.others.insert(key_format_of(&key).to_string(), key);
1414 }
1415 }
1416 Ok(())
1417 }
1418
1419 fn effective(&self) -> Result<Option<MediaKey>> {
1420 if let Some(key) = &self.identity {
1421 return Ok(Some(key.clone()));
1422 }
1423 match self.others.keys().next() {
1424 Some(keyformat) => Err(Error::UnsupportedKeyFormat {
1425 keyformat: keyformat.clone(),
1426 }),
1427 None => Ok(None),
1428 }
1429 }
1430}
1431
1432impl MediaPlaylist {
1433 pub fn segment_keys(&self) -> Result<Vec<Option<MediaKey>>> {
1446 self.segment_key_results()?.into_iter().collect()
1447 }
1448
1449 pub fn segment_key_results(&self) -> Result<Vec<Result<Option<MediaKey>>>> {
1458 let mut state = KeyState::default();
1459 let mut keys = Vec::with_capacity(self.segments.len());
1460 for seg in &self.segments {
1461 state.apply_tags(&seg.pre_tags)?;
1462 keys.push(state.effective());
1463 }
1464 Ok(keys)
1465 }
1466
1467 pub fn open_segment_key(&self) -> Result<Option<MediaKey>> {
1473 let mut state = KeyState::default();
1474 for seg in &self.segments {
1475 state.apply_tags(&seg.pre_tags)?;
1476 }
1477 if let Some(open) = &self.open_segment {
1478 state.apply_tags(&open.pre_tags)?;
1479 }
1480 state.effective()
1481 }
1482}
1483
1484impl MediaPlaylist {
1485 pub fn to_m3u8(&self) -> Result<String> {
1513 self.validate_for_render()?;
1514 let mut s = String::new();
1515 s.push_str("#EXTM3U\n");
1516 if let Some(version) = self.effective_version() {
1517 s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
1518 }
1519 if self.iframes_only {
1520 s.push_str("#EXT-X-I-FRAMES-ONLY\n");
1521 }
1522 if self.independent_segments {
1525 s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
1526 }
1527 for def in &self.defines {
1528 push_define_line(&mut s, def)?;
1529 }
1530 if let Some(start) = &self.start {
1531 push_start_line(&mut s, start);
1532 }
1533 s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
1534 s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
1535 if self.discontinuity_sequence > 0 || self.always_emit_discontinuity_sequence {
1536 s.push_str(&format!(
1537 "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
1538 self.discontinuity_sequence
1539 ));
1540 }
1541 if let Some(pt) = self.playlist_type {
1543 s.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", pt.name()));
1544 }
1545
1546 if let Some(ll) = &self.low_latency {
1549 s.push_str(&format!(
1558 "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD={}",
1559 if ll.can_block_reload { "YES" } else { "NO" },
1560 ));
1561 if let Some(phb) = ll.effective_part_hold_back() {
1562 s.push_str(&format!(",PART-HOLD-BACK={}", format_secs(phb)));
1563 }
1564 if let Some(hb) = ll.hold_back {
1565 s.push_str(&format!(",HOLD-BACK={}", format_secs(hb)));
1566 }
1567 if let Some(csu) = ll.can_skip_until {
1568 s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
1569 if ll.can_skip_dateranges {
1570 s.push_str(",CAN-SKIP-DATERANGES=YES");
1571 }
1572 }
1573 render_attribute_list(&mut s, &ll.sc_extra_attrs);
1574 s.push('\n');
1575 if let Some(part_target) = ll.part_target {
1579 s.push_str(&format!(
1580 "#EXT-X-PART-INF:PART-TARGET={}",
1581 format_secs(part_target),
1582 ));
1583 render_attribute_list(&mut s, &ll.pi_extra_attrs);
1584 s.push('\n');
1585 }
1586 }
1587
1588 if let Some(skip) = &self.skip {
1591 s.push_str(&format!(
1592 "#EXT-X-SKIP:SKIPPED-SEGMENTS={}",
1593 skip.skipped_segments
1594 ));
1595 if !skip.recently_removed_daterange_ids.is_empty() {
1596 s.push_str(&format!(
1597 ",RECENTLY-REMOVED-DATERANGES=\"{}\"",
1598 skip.recently_removed_daterange_ids.join("\t")
1599 ));
1600 }
1601 render_attribute_list(&mut s, &skip.extra_attrs);
1602 s.push('\n');
1603 }
1604
1605 for tag in &self.extra_tags {
1606 s.push_str(tag);
1607 s.push('\n');
1608 }
1609
1610 for (i, seg) in self.segments.iter().enumerate() {
1611 for tag in &seg.pre_tags {
1614 s.push_str(tag);
1615 s.push('\n');
1616 }
1617 if seg.discontinuous {
1618 s.push_str("#EXT-X-DISCONTINUITY\n");
1619 }
1620 let prev_map = if i == 0 {
1624 None
1625 } else {
1626 self.segments[i - 1].map.as_ref()
1627 };
1628 if seg.map.as_ref() != prev_map
1629 && let Some(map) = &seg.map
1630 {
1631 push_map_line(&mut s, map)?;
1632 }
1633 let prev_bitrate = if i == 0 {
1636 None
1637 } else {
1638 self.segments[i - 1].bitrate
1639 };
1640 if seg.bitrate != prev_bitrate
1641 && let Some(kbps) = seg.bitrate
1642 {
1643 s.push_str(&format!("#EXT-X-BITRATE:{kbps}\n"));
1644 }
1645 if let Some(pdt) = &seg.program_date_time {
1649 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1650 }
1651 if self.low_latency.is_some() {
1654 for part in &seg.parts {
1655 push_part_line(&mut s, part)?;
1656 }
1657 }
1658 if seg.gap {
1661 s.push_str("#EXT-X-GAP\n");
1662 }
1663 match &seg.title {
1665 Some(title) => {
1666 validate_quoted_string(title, "EXTINF title")?;
1667 s.push_str(&format!(
1668 "#EXTINF:{},{title}\n",
1669 format_extinf(seg.duration)
1670 ));
1671 }
1672 None => {
1673 s.push_str(&format!("#EXTINF:{},\n", format_extinf(seg.duration)));
1674 }
1675 }
1676 if let Some(br) = &seg.byte_range {
1679 s.push_str(&format!("#EXT-X-BYTERANGE:{}\n", br.render()));
1680 }
1681 s.push_str(&seg.uri);
1682 s.push('\n');
1683 }
1684
1685 if let Some(open) = &self.open_segment {
1694 if self.low_latency.is_some() {
1695 let prev_map = self.segments.last().and_then(|s| s.map.as_ref());
1700 if open.map.as_ref() != prev_map
1701 && let Some(map) = &open.map
1702 {
1703 push_map_line(&mut s, map)?;
1704 }
1705 if let Some(pdt) = &open.program_date_time {
1707 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1708 }
1709 for part in &open.parts {
1710 push_part_line(&mut s, part)?;
1711 }
1712 } else if let Some(pdt) = &open.program_date_time {
1713 s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1714 }
1715 for tag in &open.pre_tags {
1716 s.push_str(tag);
1717 s.push('\n');
1718 }
1719 }
1720
1721 if let Some(ll) = &self.low_latency
1724 && let Some(uri) = &ll.preload_hint_part
1725 {
1726 s.push_str(&format!(
1727 "#EXT-X-PRELOAD-HINT:TYPE={},URI=\"{uri}\"",
1728 ll.preload_hint_type.name(),
1729 ));
1730 if let Some(start) = ll.preload_hint_byte_range_start {
1731 s.push_str(&format!(",BYTERANGE-START={start}"));
1732 }
1733 if let Some(len) = ll.preload_hint_byte_range_length {
1734 s.push_str(&format!(",BYTERANGE-LENGTH={len}"));
1735 }
1736 render_attribute_list(&mut s, &ll.ph_extra_attrs);
1737 s.push('\n');
1738 }
1739
1740 for rr in &self.rendition_reports {
1742 s.push_str(&format!(
1743 "#EXT-X-RENDITION-REPORT:URI=\"{}\",LAST-MSN={}",
1744 rr.uri, rr.last_msn
1745 ));
1746 if let Some(lp) = rr.last_part {
1747 s.push_str(&format!(",LAST-PART={lp}"));
1748 }
1749 render_attribute_list(&mut s, &rr.extra_attrs);
1750 s.push('\n');
1751 }
1752
1753 if self.endlist {
1754 s.push_str("#EXT-X-ENDLIST\n");
1755 }
1756
1757 Ok(s)
1758 }
1759
1760 fn validate_for_render(&self) -> Result<()> {
1767 validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
1770 for seg in &self.segments {
1771 validate_uri(&seg.uri, "segment URI")?;
1772 if let Some(title) = &seg.title {
1773 validate_quoted_string(title, "EXTINF title")?;
1774 }
1775 validate_tag_body("segment pre-tag", &seg.pre_tags)?;
1776 for part in &seg.parts {
1777 validate_uri(&part.uri, "EXT-X-PART URI")?;
1778 }
1779 if let Some(map) = &seg.map {
1780 validate_uri(&map.uri, "EXT-X-MAP URI")?;
1781 }
1782 }
1783 if let Some(open) = &self.open_segment {
1784 validate_tag_body("open-segment pre-tag", &open.pre_tags)?;
1785 for part in &open.parts {
1786 validate_uri(&part.uri, "EXT-X-PART URI")?;
1787 }
1788 if let Some(map) = &open.map {
1789 validate_uri(&map.uri, "EXT-X-MAP URI")?;
1790 }
1791 }
1792 if let Some(ll) = &self.low_latency
1793 && let Some(uri) = &ll.preload_hint_part
1794 {
1795 validate_uri(uri, "EXT-X-PRELOAD-HINT URI")?;
1796 }
1797 for report in &self.rendition_reports {
1798 validate_uri(&report.uri, "EXT-X-RENDITION-REPORT URI")?;
1799 }
1800 if let Some(skip) = &self.skip {
1801 for id in &skip.recently_removed_daterange_ids {
1804 validate_quoted_string(id, "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id")?;
1805 if id.contains('\t') {
1806 return Err(Error::InvalidQuotedString {
1807 what: "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id".to_string(),
1808 bad_char: '\t',
1809 value: id.clone(),
1810 });
1811 }
1812 }
1813 }
1814 Ok(())
1815 }
1816
1817 pub fn parse(input: &str) -> Result<Self> {
1827 let mut version: u8 = 0;
1832 let mut target_duration: Option<u32> = None;
1833 let mut media_sequence: u64 = 0;
1834 let mut discontinuity_sequence: u64 = 0;
1835 let mut saw_discontinuity_sequence = false;
1836 let mut iframes_only = false;
1837 let mut endlist = false;
1838 let mut pending_title: Option<String> = None;
1839 let mut extra_tags: Vec<String> = Vec::new();
1840 let mut segments: Vec<MediaSegment> = Vec::new();
1841 let mut rendition_reports: Vec<RenditionReport> = Vec::new();
1842 let mut skip: Option<SkipInfo> = None;
1843 let mut saw_extm3u = false;
1844 let mut independent_segments = false;
1846 let mut start: Option<StartPoint> = None;
1847 let mut defines: Vec<Define> = Vec::new();
1848 let mut playlist_type: Option<PlaylistType> = None;
1849
1850 let mut part_target: Option<DecimalSeconds> = None;
1852 let mut part_hold_back: Option<DecimalSeconds> = None;
1853 let mut can_skip_until: Option<DecimalSeconds> = None;
1854 let mut can_skip_dateranges = false;
1855 let mut hold_back: Option<DecimalSeconds> = None;
1856 let mut can_block_reload = false;
1861 let mut preload_hint_part: Option<String> = None;
1862 let mut preload_hint_type = PreloadHintType::Part;
1863 let mut preload_hint_byte_range_start: Option<u64> = None;
1864 let mut preload_hint_byte_range_length: Option<u64> = None;
1865 let mut saw_ll_tag = false;
1866 let mut sc_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1867 let mut pi_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1868 let mut ph_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1869
1870 let mut current_map: Option<MapTag> = None;
1873 let mut pending_discontinuous = false;
1874 let mut pending_byte_range: Option<ByteRange> = None;
1875 let mut pending_parts: Vec<PartSpec> = Vec::new();
1876 let mut pending_duration: Option<DecimalSeconds> = None;
1877 let mut pending_gap = false;
1880 let mut current_bitrate: Option<u64> = None;
1881 let mut pending_pre_tags: Vec<String> = Vec::new();
1884 let mut pending_program_date_time: Option<ProgramDateTime> = None;
1886
1887 for (idx, raw_line) in input.lines().enumerate() {
1888 let line_no = idx + 1;
1889 let mut line = raw_line.trim_end_matches('\r');
1890 if line_no == 1 {
1891 line = line.strip_prefix('\u{feff}').unwrap_or(line);
1892 }
1893 let line = line.trim();
1894 if line.is_empty() {
1895 continue;
1896 }
1897
1898 if line == "#EXTM3U" {
1899 saw_extm3u = true;
1900 } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
1901 version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
1902 } else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
1903 target_duration = Some(parse_decimal_int(
1904 rest,
1905 line_no,
1906 line,
1907 "EXT-X-TARGETDURATION",
1908 )?);
1909 } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
1910 media_sequence = parse_decimal_int(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
1911 } else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
1912 discontinuity_sequence =
1913 parse_decimal_int(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
1914 saw_discontinuity_sequence = true;
1915 } else if line == "#EXT-X-I-FRAMES-ONLY" {
1916 iframes_only = true;
1917 } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
1918 independent_segments = true;
1919 } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
1920 start = Some(parse_start(rest, line_no, line)?);
1921 } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
1922 defines.push(parse_define(rest, line_no, line)?);
1923 } else if let Some(rest) = line.strip_prefix("#EXT-X-PLAYLIST-TYPE:") {
1924 playlist_type = Some(match rest.trim() {
1925 "EVENT" => PlaylistType::Event,
1926 "VOD" => PlaylistType::Vod,
1927 other => {
1928 return Err(Error::HlsParse {
1929 line_no,
1930 line: line.to_string(),
1931 reason: format!(
1932 "EXT-X-PLAYLIST-TYPE value {other:?} is neither EVENT nor VOD"
1933 ),
1934 });
1935 }
1936 });
1937 } else if line == "#EXT-X-ENDLIST" {
1938 endlist = true;
1939 } else if line == "#EXT-X-DISCONTINUITY" {
1940 pending_discontinuous = true;
1941 } else if line == "#EXT-X-GAP" {
1942 pending_gap = true;
1943 } else if let Some(rest) = line.strip_prefix("#EXT-X-BITRATE:") {
1944 current_bitrate = Some(parse_decimal_int(rest, line_no, line, "EXT-X-BITRATE")?);
1945 } else if let Some(rest) = line.strip_prefix("#EXT-X-BYTERANGE:") {
1946 pending_byte_range = Some(ByteRange::parse(rest, line_no, line)?);
1947 } else if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
1948 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1949 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-MAP")?;
1950 let byte_range = match attrs.get("BYTERANGE") {
1951 Some(v) => Some(ByteRange::parse(v, line_no, line)?),
1952 None => None,
1953 };
1954 let extra_attrs = filter_extra_attrs(&attrs, &["URI", "BYTERANGE"], "ed)?;
1955 current_map = Some(MapTag {
1956 uri,
1957 byte_range,
1958 extra_attrs,
1959 });
1960 } else if let Some(rest) = line.strip_prefix("#EXTINF:") {
1961 let (dur_str, title) = match rest.split_once(',') {
1962 Some((dur, title)) => (dur, title),
1963 None => (rest, ""),
1964 };
1965 pending_duration = Some(parse_decimal_seconds(
1966 dur_str,
1967 line_no,
1968 line,
1969 "EXTINF duration",
1970 )?);
1971 pending_title = (!title.is_empty()).then(|| title.to_string());
1975 } else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
1976 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1977 if let Some(v) = attrs.get("PART-TARGET") {
1978 part_target = Some(parse_decimal_seconds(v, line_no, line, "PART-TARGET")?);
1979 }
1980 pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"], "ed)?);
1981 saw_ll_tag = true;
1982 } else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
1983 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1984 if let Some(v) = attrs.get("PART-HOLD-BACK") {
1985 part_hold_back =
1986 Some(parse_decimal_seconds(v, line_no, line, "PART-HOLD-BACK")?);
1987 }
1988 if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
1989 can_skip_until =
1990 Some(parse_decimal_seconds(v, line_no, line, "CAN-SKIP-UNTIL")?);
1991 }
1992 can_skip_dateranges =
1993 attrs.get("CAN-SKIP-DATERANGES").map(String::as_str) == Some("YES");
1994 can_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
1995 if let Some(v) = attrs.get("HOLD-BACK") {
1996 hold_back = Some(parse_decimal_seconds(v, line_no, line, "HOLD-BACK")?);
1997 }
1998 sc_extra_attrs.extend(filter_extra_attrs(
1999 &attrs,
2000 &[
2001 "PART-HOLD-BACK",
2002 "CAN-SKIP-UNTIL",
2003 "CAN-BLOCK-RELOAD",
2004 "HOLD-BACK",
2005 "CAN-SKIP-DATERANGES",
2006 ],
2007 "ed,
2008 )?);
2009 saw_ll_tag = true;
2010 } else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
2011 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2012 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-PART")?;
2013 let duration_str = attrs.get("DURATION").ok_or_else(|| Error::HlsParse {
2014 line_no,
2015 line: line.to_string(),
2016 reason: "EXT-X-PART missing required DURATION attribute".to_string(),
2017 })?;
2018 let duration =
2019 parse_decimal_seconds(duration_str, line_no, line, "EXT-X-PART DURATION")?;
2020 let independent = attrs.get("INDEPENDENT").map(String::as_str) == Some("YES");
2021 let gap = attrs.get("GAP").map(String::as_str) == Some("YES");
2022 let byte_range = match attrs.get("BYTERANGE") {
2023 Some(v) => Some(ByteRange::parse(v, line_no, line)?),
2024 None => None,
2025 };
2026 let extra_attrs = filter_extra_attrs(
2027 &attrs,
2028 &["URI", "DURATION", "INDEPENDENT", "GAP", "BYTERANGE"],
2029 "ed,
2030 )?;
2031 pending_parts.push(PartSpec {
2032 uri,
2033 duration,
2034 independent,
2035 byte_range,
2036 gap,
2037 extra_attrs,
2038 });
2039 saw_ll_tag = true;
2040 } else if let Some(rest) = line.strip_prefix("#EXT-X-PRELOAD-HINT:") {
2041 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2042 preload_hint_type = match attrs.get("TYPE").map(String::as_str) {
2043 Some("MAP") => PreloadHintType::Map,
2044 _ => PreloadHintType::Part,
2045 };
2046 preload_hint_part = Some(require_attr(
2047 &attrs,
2048 "URI",
2049 line_no,
2050 line,
2051 "EXT-X-PRELOAD-HINT",
2052 )?);
2053 if let Some(v) = attrs.get("BYTERANGE-START") {
2054 preload_hint_byte_range_start =
2055 Some(parse_decimal_int(v, line_no, line, "BYTERANGE-START")?);
2056 }
2057 if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
2058 preload_hint_byte_range_length =
2059 Some(parse_decimal_int(v, line_no, line, "BYTERANGE-LENGTH")?);
2060 }
2061 ph_extra_attrs.extend(filter_extra_attrs(
2062 &attrs,
2063 &["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
2064 "ed,
2065 )?);
2066 saw_ll_tag = true;
2067 } else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
2068 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2069 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
2070 let last_msn_str =
2074 require_attr(&attrs, "LAST-MSN", line_no, line, "EXT-X-RENDITION-REPORT")?;
2075 let last_msn = parse_decimal_int(&last_msn_str, line_no, line, "LAST-MSN")?;
2076 let last_part = match attrs.get("LAST-PART") {
2077 Some(v) => Some(parse_decimal_int(v, line_no, line, "LAST-PART")?),
2078 None => None,
2079 };
2080 let extra_attrs =
2081 filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"], "ed)?;
2082 rendition_reports.push(RenditionReport {
2083 uri,
2084 last_msn,
2085 last_part,
2086 extra_attrs,
2087 });
2088 } else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
2089 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2090 let skipped_segments_str =
2091 require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
2092 let skipped_segments =
2093 parse_decimal_int(&skipped_segments_str, line_no, line, "SKIPPED-SEGMENTS")?;
2094 let recently_removed_daterange_ids = attrs
2095 .get("RECENTLY-REMOVED-DATERANGES")
2096 .map(|v| {
2097 v.split('\t')
2098 .filter(|s| !s.is_empty())
2099 .map(ToString::to_string)
2100 .collect()
2101 })
2102 .unwrap_or_default();
2103 let extra_attrs = filter_extra_attrs(
2104 &attrs,
2105 &["SKIPPED-SEGMENTS", "RECENTLY-REMOVED-DATERANGES"],
2106 "ed,
2107 )?;
2108 skip = Some(SkipInfo {
2109 skipped_segments,
2110 recently_removed_daterange_ids,
2111 extra_attrs,
2112 });
2113 } else if let Some(rest) = line.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
2114 let pdt =
2115 ProgramDateTime::parse(rest.trim()).map_err(|_| crate::Error::HlsParse {
2116 line_no,
2117 line: line.to_string(),
2118 reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
2119 .to_string(),
2120 })?;
2121 pending_program_date_time = Some(pdt);
2122 } else if let Some(rest) = line.strip_prefix("#EXT") {
2123 let _ = rest;
2124 if is_segment_defining_tag(line) {
2132 pending_pre_tags.push(line.to_string());
2133 } else {
2134 extra_tags.push(line.to_string());
2135 }
2136 } else if line.starts_with('#') {
2137 } else {
2139 let duration = pending_duration.take().ok_or_else(|| Error::HlsParse {
2142 line_no,
2143 line: line.to_string(),
2144 reason: "media segment URI with no preceding #EXTINF".to_string(),
2145 })?;
2146 segments.push(MediaSegment {
2147 uri: line.to_string(),
2148 duration,
2149 discontinuous: core::mem::take(&mut pending_discontinuous),
2150 parts: core::mem::take(&mut pending_parts),
2151 byte_range: pending_byte_range.take(),
2152 map: current_map.clone(),
2153 gap: core::mem::take(&mut pending_gap),
2154 bitrate: current_bitrate,
2155 title: pending_title.take(),
2156 pre_tags: core::mem::take(&mut pending_pre_tags),
2157 program_date_time: pending_program_date_time.take(),
2158 });
2159 }
2160 }
2161
2162 if !saw_extm3u {
2163 return Err(Error::HlsParse {
2164 line_no: 1,
2165 line: String::new(),
2166 reason: "missing #EXTM3U header".to_string(),
2167 });
2168 }
2169 let target_duration = target_duration.ok_or_else(|| Error::HlsParse {
2170 line_no: 0,
2171 line: String::new(),
2172 reason: "missing required #EXT-X-TARGETDURATION".to_string(),
2173 })?;
2174
2175 let open_segment = if pending_parts.is_empty()
2179 && pending_pre_tags.is_empty()
2180 && pending_program_date_time.is_none()
2181 {
2182 None
2183 } else {
2184 let open = OpenSegment::new(core::mem::take(&mut pending_parts));
2185 let open = match ¤t_map {
2186 Some(map) => open.with_map(map.clone()),
2187 None => open,
2188 };
2189 Some(OpenSegment {
2190 pre_tags: core::mem::take(&mut pending_pre_tags),
2191 program_date_time: pending_program_date_time.take(),
2192 ..open
2193 })
2194 };
2195
2196 if let (Some(start), Some(len)) = (
2197 preload_hint_byte_range_start,
2198 preload_hint_byte_range_length,
2199 ) && start.checked_add(len).is_none()
2200 {
2201 return Err(Error::HlsParse {
2202 line_no: 0,
2203 line: String::new(),
2204 reason: format!(
2205 "PRELOAD-HINT BYTERANGE-START ({start}) + BYTERANGE-LENGTH ({len}) overflows u64"
2206 ),
2207 });
2208 }
2209
2210 let low_latency = if saw_ll_tag {
2211 Some(LowLatencyConfig {
2212 part_target,
2213 part_hold_back,
2214 preload_hint_part,
2215 preload_hint_type,
2216 preload_hint_byte_range_start,
2217 preload_hint_byte_range_length,
2218 can_skip_until,
2219 can_block_reload,
2220 sc_extra_attrs,
2221 pi_extra_attrs,
2222 ph_extra_attrs,
2223 hold_back,
2224 can_skip_dateranges,
2225 })
2226 } else {
2227 None
2228 };
2229
2230 Ok(MediaPlaylist {
2231 version,
2232 target_duration,
2233 media_sequence,
2234 discontinuity_sequence,
2235 always_emit_discontinuity_sequence: saw_discontinuity_sequence,
2236 segments,
2237 open_segment,
2238 endlist,
2239 extra_tags,
2240 low_latency,
2241 iframes_only,
2242 rendition_reports,
2243 skip,
2244 independent_segments,
2245 start,
2246 defines,
2247 playlist_type,
2248 })
2249 }
2250}
2251
2252fn push_part_line(s: &mut String, part: &PartSpec) -> Result<()> {
2257 validate_uri(&part.uri, "EXT-X-PART URI")?;
2258 s.push_str(&format!(
2259 "#EXT-X-PART:DURATION={},URI=\"{}\"",
2260 format_secs(part.duration),
2261 part.uri,
2262 ));
2263 if let Some(br) = &part.byte_range {
2264 s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2265 }
2266 if part.independent {
2267 s.push_str(",INDEPENDENT=YES");
2268 }
2269 if part.gap {
2270 s.push_str(",GAP=YES");
2271 }
2272 render_attribute_list(s, &part.extra_attrs);
2273 s.push('\n');
2274 Ok(())
2275}
2276
2277fn push_map_line(s: &mut String, map: &MapTag) -> Result<()> {
2280 validate_uri(&map.uri, "EXT-X-MAP URI")?;
2281 s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
2282 if let Some(br) = &map.byte_range {
2283 s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2284 }
2285 render_attribute_list(s, &map.extra_attrs);
2286 s.push('\n');
2287 Ok(())
2288}
2289
2290fn push_define_line(s: &mut String, def: &Define) -> Result<()> {
2292 match def {
2293 Define::Name {
2294 name,
2295 value,
2296 extra_attrs,
2297 } => {
2298 validate_quoted_string(name, "EXT-X-DEFINE NAME")?;
2299 validate_quoted_string(value, "EXT-X-DEFINE VALUE")?;
2300 s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
2301 render_attribute_list(s, extra_attrs);
2302 s.push('\n');
2303 }
2304 Define::Import { name, extra_attrs } => {
2305 validate_quoted_string(name, "EXT-X-DEFINE IMPORT")?;
2306 s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
2307 render_attribute_list(s, extra_attrs);
2308 s.push('\n');
2309 }
2310 Define::QueryParam { name, extra_attrs } => {
2311 validate_quoted_string(name, "EXT-X-DEFINE QUERYPARAM")?;
2312 s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
2313 render_attribute_list(s, extra_attrs);
2314 s.push('\n');
2315 }
2316 }
2317 Ok(())
2318}
2319
2320fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
2323 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2324 let present = [
2325 attrs.contains_key("NAME"),
2326 attrs.contains_key("IMPORT"),
2327 attrs.contains_key("QUERYPARAM"),
2328 ]
2329 .iter()
2330 .filter(|&&b| b)
2331 .count();
2332 if present != 1 {
2333 return Err(Error::HlsParse {
2334 line_no,
2335 line: line.to_string(),
2336 reason: "EXT-X-DEFINE must contain exactly one of NAME, IMPORT, QUERYPARAM".to_string(),
2337 });
2338 }
2339 if let Some(name) = attrs.get("NAME") {
2340 let value = require_attr(&attrs, "VALUE", line_no, line, "EXT-X-DEFINE")?;
2341 let extra_attrs = filter_extra_attrs(&attrs, &["NAME", "VALUE"], "ed)?;
2342 Ok(Define::Name {
2343 name: name.clone(),
2344 value,
2345 extra_attrs,
2346 })
2347 } else if let Some(name) = attrs.get("IMPORT") {
2348 let extra_attrs = filter_extra_attrs(&attrs, &["IMPORT"], "ed)?;
2349 Ok(Define::Import {
2350 name: name.clone(),
2351 extra_attrs,
2352 })
2353 } else {
2354 let name = attrs
2355 .get("QUERYPARAM")
2356 .expect("exactly one of the three checked above")
2357 .clone();
2358 let extra_attrs = filter_extra_attrs(&attrs, &["QUERYPARAM"], "ed)?;
2359 Ok(Define::QueryParam { name, extra_attrs })
2360 }
2361}
2362
2363#[derive(Debug, Clone, PartialEq, Eq)]
2388pub struct AttrValue {
2389 value: String,
2390 quoted: bool,
2391}
2392
2393impl AttrValue {
2394 pub fn quoted(value: impl Into<String>) -> Result<Self> {
2398 let value = value.into();
2399 if value.contains('"') || value.contains('\r') || value.contains('\n') {
2400 return Err(Error::InvalidAttrValue {
2401 value,
2402 kind: "a quoted-string",
2403 });
2404 }
2405 Ok(Self {
2406 value,
2407 quoted: true,
2408 })
2409 }
2410
2411 pub fn bare(value: impl Into<String>) -> Result<Self> {
2415 let value = value.into();
2416 if value.contains(',')
2417 || value.contains('"')
2418 || value.contains('\r')
2419 || value.contains('\n')
2420 || value.contains(char::is_whitespace)
2421 {
2422 return Err(Error::InvalidAttrValue {
2423 value,
2424 kind: "a bare value",
2425 });
2426 }
2427 Ok(Self {
2428 value,
2429 quoted: false,
2430 })
2431 }
2432
2433 #[must_use]
2435 pub fn as_str(&self) -> &str {
2436 &self.value
2437 }
2438
2439 #[must_use]
2441 pub fn is_quoted(&self) -> bool {
2442 self.quoted
2443 }
2444
2445 pub fn for_attr(name: &str, value: impl Into<String>) -> Result<Self> {
2460 let value = value.into();
2461 match known_attr_value_kind(name) {
2462 Some(AttrValueKind::Quoted) => Self::quoted(value),
2463 Some(AttrValueKind::QuotedUnless(bare)) => {
2464 if value == bare {
2465 Self::bare(value)
2466 } else {
2467 Self::quoted(value)
2468 }
2469 }
2470 None => {
2471 if value.is_empty()
2472 || value.contains(',')
2473 || value.contains('"')
2474 || value.contains(char::is_whitespace)
2475 {
2476 Self::quoted(value)
2477 } else {
2478 Self::bare(value)
2479 }
2480 }
2481 }
2482 }
2483}
2484
2485#[cfg(feature = "serde")]
2493impl serde::Serialize for AttrValue {
2494 fn serialize<S: serde::Serializer>(
2495 &self,
2496 serializer: S,
2497 ) -> core::result::Result<S::Ok, S::Error> {
2498 use serde::ser::SerializeStruct;
2499 let mut s = serializer.serialize_struct("AttrValue", 2)?;
2500 s.serialize_field("value", &self.value)?;
2501 s.serialize_field("quoted", &self.quoted)?;
2502 s.end()
2503 }
2504}
2505
2506#[cfg(feature = "serde")]
2507impl<'de> serde::Deserialize<'de> for AttrValue {
2508 fn deserialize<D: serde::Deserializer<'de>>(
2509 deserializer: D,
2510 ) -> core::result::Result<Self, D::Error> {
2511 #[derive(serde::Deserialize)]
2512 struct Raw {
2513 value: String,
2514 quoted: bool,
2515 }
2516 let raw = Raw::deserialize(deserializer)?;
2517 if raw.quoted {
2518 AttrValue::quoted(raw.value)
2519 } else {
2520 AttrValue::bare(raw.value)
2521 }
2522 .map_err(serde::de::Error::custom)
2523 }
2524}
2525
2526enum AttrValueKind {
2532 Quoted,
2534 QuotedUnless(&'static str),
2537}
2538
2539fn known_attr_value_kind(name: &str) -> Option<AttrValueKind> {
2545 match name {
2546 "AUDIO"
2547 | "VIDEO"
2548 | "SUBTITLES"
2549 | "PATHWAY-ID"
2550 | "STABLE-VARIANT-ID"
2551 | "STABLE-RENDITION-ID"
2552 | "SUPPLEMENTAL-CODECS"
2553 | "REQ-VIDEO-LAYOUT"
2554 | "ALLOWED-CPC" => Some(AttrValueKind::Quoted),
2555 "CLOSED-CAPTIONS" => Some(AttrValueKind::QuotedUnless("NONE")),
2556 _ => None,
2557 }
2558}
2559
2560pub fn render_attribute_list(s: &mut String, attrs: &[(String, AttrValue)]) {
2582 for (name, value) in attrs {
2583 s.push(',');
2584 s.push_str(name);
2585 s.push('=');
2586 if value.is_quoted() {
2587 s.push('"');
2588 s.push_str(value.as_str());
2589 s.push('"');
2590 } else {
2591 s.push_str(value.as_str());
2592 }
2593 }
2594}
2595
2596const QUOTED_STRING_FORBIDDEN: &[char] = &['"', '\r', '\n'];
2604
2605fn validate_quoted_string(value: &str, what: &str) -> Result<()> {
2608 if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2609 return Err(Error::InvalidQuotedString {
2610 what: what.to_string(),
2611 bad_char: bad,
2612 value: value.to_string(),
2613 });
2614 }
2615 Ok(())
2616}
2617
2618fn validate_tag_body(what: &str, tags: &[String]) -> Result<()> {
2623 for tag in tags {
2624 if let Some(bad) = tag.chars().find(|c| *c == '\r' || *c == '\n') {
2625 return Err(Error::InvalidQuotedString {
2626 what: what.to_string(),
2627 bad_char: bad,
2628 value: tag.clone(),
2629 });
2630 }
2631 }
2632 Ok(())
2633}
2634
2635fn validate_uri(value: &str, what: &str) -> Result<()> {
2638 if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2639 return Err(Error::InvalidUri {
2640 what: what.to_string(),
2641 bad_char: bad,
2642 value: value.to_string(),
2643 });
2644 }
2645 Ok(())
2646}
2647
2648fn push_start_line(s: &mut String, start: &StartPoint) {
2650 s.push_str(&format!(
2651 "#EXT-X-START:TIME-OFFSET={}",
2652 format_signed_secs(start.time_offset)
2653 ));
2654 if start.precise {
2655 s.push_str(",PRECISE=YES");
2656 }
2657 render_attribute_list(s, &start.extra_attrs);
2658 s.push('\n');
2659}
2660
2661fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
2663 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2664 let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
2665 let time_offset = parse_signed_decimal_seconds(&time_offset_str, line_no, line, "TIME-OFFSET")?;
2666 let precise = attrs.get("PRECISE").map(String::as_str) == Some("YES");
2667 let extra_attrs = filter_extra_attrs(&attrs, &["TIME-OFFSET", "PRECISE"], "ed)?;
2668 Ok(StartPoint {
2669 time_offset,
2670 precise,
2671 extra_attrs,
2672 })
2673}
2674
2675fn push_session_data_line(s: &mut String, sd: &SessionData) -> Result<()> {
2677 validate_quoted_string(&sd.data_id, "EXT-X-SESSION-DATA DATA-ID")?;
2678 s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
2679 match &sd.content {
2680 SessionDataContent::Value(v) => {
2681 validate_quoted_string(v, "EXT-X-SESSION-DATA VALUE")?;
2682 s.push_str(&format!(",VALUE=\"{v}\""));
2683 }
2684 SessionDataContent::Uri { uri, format } => {
2685 validate_uri(uri, "EXT-X-SESSION-DATA URI")?;
2686 s.push_str(&format!(",URI=\"{uri}\""));
2687 if *format == SessionDataFormat::Raw {
2688 s.push_str(",FORMAT=RAW");
2689 }
2690 }
2691 }
2692 if let Some(lang) = &sd.language {
2693 validate_quoted_string(lang, "EXT-X-SESSION-DATA LANGUAGE")?;
2694 s.push_str(&format!(",LANGUAGE=\"{lang}\""));
2695 }
2696 render_attribute_list(s, &sd.extra_attrs);
2697 s.push('\n');
2698 Ok(())
2699}
2700
2701fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
2703 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2704 let data_id = require_attr(&attrs, "DATA-ID", line_no, line, "EXT-X-SESSION-DATA")?;
2705 let value = attrs.get("VALUE");
2706 let uri = attrs.get("URI");
2707 let content = match (value, uri) {
2708 (Some(v), None) => SessionDataContent::Value(v.clone()),
2709 (None, Some(u)) => {
2710 let format = match attrs.get("FORMAT").map(String::as_str) {
2711 Some("RAW") => SessionDataFormat::Raw,
2712 _ => SessionDataFormat::Json,
2713 };
2714 SessionDataContent::Uri {
2715 uri: u.clone(),
2716 format,
2717 }
2718 }
2719 (Some(_), Some(_)) => {
2720 return Err(Error::HlsParse {
2721 line_no,
2722 line: line.to_string(),
2723 reason: "EXT-X-SESSION-DATA must not contain both VALUE and URI".to_string(),
2724 });
2725 }
2726 (None, None) => {
2727 return Err(Error::HlsParse {
2728 line_no,
2729 line: line.to_string(),
2730 reason: "EXT-X-SESSION-DATA must contain either VALUE or URI".to_string(),
2731 });
2732 }
2733 };
2734 let language = attrs.get("LANGUAGE").cloned();
2735 let known: &[&str] = match &content {
2736 SessionDataContent::Value(_) => &["DATA-ID", "VALUE", "LANGUAGE"],
2737 SessionDataContent::Uri { .. } => &["DATA-ID", "URI", "FORMAT", "LANGUAGE"],
2738 };
2739 let extra_attrs = filter_extra_attrs(&attrs, known, "ed)?;
2740 Ok(SessionData {
2741 data_id,
2742 content,
2743 language,
2744 extra_attrs,
2745 })
2746}
2747
2748fn push_session_key_line(s: &mut String, sk: &SessionKey) -> Result<()> {
2750 s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
2751 if let Some(uri) = &sk.uri {
2752 validate_uri(uri, "EXT-X-SESSION-KEY URI")?;
2753 s.push_str(&format!(",URI=\"{uri}\""));
2754 }
2755 if let Some(iv) = &sk.iv {
2756 s.push_str(&format!(",IV=0x{}", hex_encode(iv)));
2757 }
2758 if let Some(kf) = &sk.keyformat {
2759 validate_quoted_string(kf, "EXT-X-SESSION-KEY KEYFORMAT")?;
2760 s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
2761 }
2762 if let Some(kfv) = &sk.keyformatversions {
2763 validate_quoted_string(kfv, "EXT-X-SESSION-KEY KEYFORMATVERSIONS")?;
2764 s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
2765 }
2766 render_attribute_list(s, &sk.extra_attrs);
2767 s.push('\n');
2768 Ok(())
2769}
2770
2771fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2774 parse_key_attributes(rest, line_no, line, "EXT-X-SESSION-KEY", false)
2775}
2776
2777fn parse_media_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2779 parse_key_attributes(rest, line_no, line, "EXT-X-KEY", true)
2780}
2781
2782fn parse_key_attributes(
2783 rest: &str,
2784 line_no: usize,
2785 line: &str,
2786 tag: &str,
2787 allow_none: bool,
2788) -> Result<SessionKey> {
2789 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2790 let method_str = require_attr(&attrs, "METHOD", line_no, line, tag)?;
2791 let method = match method_str.as_str() {
2792 "NONE" if allow_none => EncryptionMethod::None,
2793 "NONE" => {
2794 return Err(Error::HlsParse {
2795 line_no,
2796 line: line.to_string(),
2797 reason: "EXT-X-SESSION-KEY METHOD MUST NOT be NONE (RFC 8216bis §4.4.6.5)"
2798 .to_string(),
2799 });
2800 }
2801 "AES-128" => EncryptionMethod::Aes128,
2802 "SAMPLE-AES" => EncryptionMethod::SampleAes,
2803 "SAMPLE-AES-CTR" => EncryptionMethod::SampleAesCtr,
2804 "AES-256-GCM" => EncryptionMethod::Aes256Gcm,
2805 other => {
2806 return Err(Error::HlsParse {
2807 line_no,
2808 line: line.to_string(),
2809 reason: format!("{tag} METHOD value {other:?} is not recognized"),
2810 });
2811 }
2812 };
2813 if method == EncryptionMethod::None && attrs.len() > 1 {
2814 return Err(Error::HlsParse {
2815 line_no,
2816 line: line.to_string(),
2817 reason: format!(
2818 "{tag} METHOD=NONE MUST NOT carry other attributes (RFC 8216bis §4.4.4.4)"
2819 ),
2820 });
2821 }
2822 let uri = attrs.get("URI").cloned();
2823 let iv = match attrs.get("IV") {
2824 Some(v) => Some(parse_iv(v)?),
2825 None => None,
2826 };
2827 let keyformat = attrs.get("KEYFORMAT").cloned();
2828 let keyformatversions = attrs.get("KEYFORMATVERSIONS").cloned();
2829 let extra_attrs = filter_extra_attrs(
2830 &attrs,
2831 &["METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS"],
2832 "ed,
2833 )?;
2834 Ok(SessionKey {
2835 method,
2836 uri,
2837 iv,
2838 keyformat,
2839 keyformatversions,
2840 extra_attrs,
2841 })
2842}
2843
2844const IV_HEX_DIGITS: usize = 32;
2850
2851fn parse_iv(s: &str) -> Result<[u8; 16]> {
2852 let hex = s
2853 .strip_prefix("0x")
2854 .or_else(|| s.strip_prefix("0X"))
2855 .filter(|h| h.len() == IV_HEX_DIGITS && h.bytes().all(|b| b.is_ascii_hexdigit()))
2856 .ok_or_else(|| Error::InvalidIv {
2857 value: s.to_string(),
2858 })?;
2859 let mut out = [0u8; 16];
2860 for i in 0..16 {
2861 out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex digits");
2863 }
2864 Ok(out)
2865}
2866
2867fn format_signed_secs(v: SignedDecimalSeconds) -> String {
2873 let v = v.get();
2874 if v < 0.0 {
2875 format!("-{}", format_secs(DecimalSeconds::new_unchecked(-v)))
2877 } else {
2878 format_secs(DecimalSeconds::new_unchecked(v))
2879 }
2880}
2881
2882fn format_secs(v: DecimalSeconds) -> String {
2898 let v = v.get();
2902 let millis = (v * 1000.0 + 0.5) as u64;
2903 let whole = millis / 1000;
2904 let frac = millis % 1000;
2905 let compact = if frac == 0 {
2906 format!("{whole}")
2907 } else {
2908 let mut f = format!("{frac:03}");
2909 while f.ends_with('0') {
2910 f.pop();
2911 }
2912 format!("{whole}.{f}")
2913 };
2914 if compact.parse::<f64>() == Ok(v) {
2915 return compact;
2916 }
2917 format!("{v}")
2918}
2919
2920fn format_extinf(v: DecimalSeconds) -> String {
2949 let v = v.get();
2951 if let Some(whole) = whole_seconds(v) {
2952 return format!("{whole}");
2953 }
2954 let three = format!("{v:.3}");
2955 if three.parse::<f64>() == Ok(v) {
2956 return three;
2957 }
2958 format!("{v}")
2959}
2960
2961fn whole_seconds(v: f64) -> Option<u64> {
2969 if !v.is_finite() || !(0.0..WHOLE_SECONDS_CAST_LIMIT).contains(&v) {
2970 return None;
2971 }
2972 let whole = v as u64;
2973 (whole as f64 == v).then_some(whole)
2974}
2975
2976const WHOLE_SECONDS_CAST_LIMIT: f64 = 9_007_199_254_740_992.0; fn parse_decimal_float(
2990 s: &str,
2991 line_no: usize,
2992 line: &str,
2993 what: &str,
2994 signed: bool,
2995) -> Result<f64> {
2996 let t = s.trim();
2997 if !is_valid_decimal_token(t, signed) {
2998 return Err(Error::HlsParse {
2999 line_no,
3000 line: line.to_string(),
3001 reason: format!("{what} value {s:?} is not a valid decimal number"),
3002 });
3003 }
3004 t.parse::<f64>().map_err(|_| Error::HlsParse {
3007 line_no,
3008 line: line.to_string(),
3009 reason: format!("{what} value {s:?} is not a valid number"),
3010 })
3011}
3012
3013fn parse_decimal_int<T: core::str::FromStr>(
3020 s: &str,
3021 line_no: usize,
3022 line: &str,
3023 what: &str,
3024) -> Result<T> {
3025 let t = s.trim();
3026 if !is_valid_decimal_token(t, false) {
3027 return Err(Error::HlsParse {
3028 line_no,
3029 line: line.to_string(),
3030 reason: format!("{what} value {s:?} is not a valid decimal integer"),
3031 });
3032 }
3033 t.parse::<T>().map_err(|_| Error::HlsParse {
3034 line_no,
3035 line: line.to_string(),
3036 reason: format!("{what} value {s:?} is not a valid number"),
3037 })
3038}
3039
3040fn is_valid_decimal_token(s: &str, signed: bool) -> bool {
3051 let s = s.strip_prefix('-').filter(|_| signed).unwrap_or(s);
3052 let (int_part, frac_part) = match s.split_once('.') {
3053 Some((i, f)) => (i, Some(f)),
3054 None => (s, None),
3055 };
3056 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3057 return false;
3058 }
3059 match frac_part {
3060 Some(f) => !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()),
3061 None => true,
3062 }
3063}
3064
3065#[derive(Debug, Clone, Copy, PartialEq)]
3075pub struct DecimalSeconds(f64);
3076
3077impl DecimalSeconds {
3078 pub const ZERO: Self = Self(0.0);
3080
3081 pub fn new(value: f64) -> Result<Self> {
3084 if value.is_finite() && value >= 0.0 {
3085 Ok(Self(value))
3086 } else {
3087 Err(Error::InvalidDecimalSeconds(value))
3088 }
3089 }
3090
3091 #[must_use]
3093 pub fn get(self) -> f64 {
3094 self.0
3095 }
3096
3097 fn new_unchecked(value: f64) -> Self {
3100 debug_assert!(value.is_finite() && value >= 0.0, "{value} must be valid");
3101 Self(value)
3102 }
3103}
3104
3105impl Default for DecimalSeconds {
3106 fn default() -> Self {
3107 Self::ZERO
3108 }
3109}
3110
3111impl core::fmt::Display for DecimalSeconds {
3112 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3116 core::fmt::Display::fmt(&self.0, f)
3117 }
3118}
3119
3120#[derive(Debug, Clone, Copy, PartialEq)]
3124pub struct SignedDecimalSeconds(f64);
3125
3126impl SignedDecimalSeconds {
3127 pub const ZERO: Self = Self(0.0);
3129
3130 pub fn new(value: f64) -> Result<Self> {
3132 if value.is_finite() {
3133 Ok(Self(value))
3134 } else {
3135 Err(Error::InvalidSignedDecimalSeconds(value))
3136 }
3137 }
3138
3139 #[must_use]
3141 pub fn get(self) -> f64 {
3142 self.0
3143 }
3144
3145 fn new_unchecked(value: f64) -> Self {
3148 debug_assert!(value.is_finite(), "{value} must be finite");
3149 Self(value)
3150 }
3151}
3152
3153impl core::fmt::Display for SignedDecimalSeconds {
3154 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3155 core::fmt::Display::fmt(&self.0, f)
3156 }
3157}
3158
3159fn parse_decimal_seconds(
3165 s: &str,
3166 line_no: usize,
3167 line: &str,
3168 what: &str,
3169) -> Result<DecimalSeconds> {
3170 parse_decimal_float(s, line_no, line, what, false).map(DecimalSeconds::new_unchecked)
3173}
3174
3175fn parse_signed_decimal_seconds(
3179 s: &str,
3180 line_no: usize,
3181 line: &str,
3182 what: &str,
3183) -> Result<SignedDecimalSeconds> {
3184 parse_decimal_float(s, line_no, line, what, true).map(SignedDecimalSeconds::new_unchecked)
3186}
3187
3188pub fn parse_attribute_list(s: &str) -> (BTreeMap<String, String>, BTreeSet<String>) {
3203 scan_attribute_list(s, false).unwrap_or_default()
3204}
3205
3206pub fn parse_attribute_list_strict(
3212 s: &str,
3213) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3214 scan_attribute_list(s, true)
3215}
3216
3217fn scan_attribute_list(
3218 s: &str,
3219 strict: bool,
3220) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3221 let mut map = BTreeMap::new();
3222 let mut quoted_keys = BTreeSet::new();
3223 let bytes = s.as_bytes();
3224 let len = bytes.len();
3225 let mut i = 0;
3226 while i < len {
3227 while i < len && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) {
3228 i += 1;
3229 }
3230 if i >= len {
3231 break;
3232 }
3233 let key_start = i;
3234 while i < len && bytes[i] != b'=' {
3235 i += 1;
3236 }
3237 if i >= len {
3238 break;
3239 }
3240 let key = &s[key_start..i];
3241 i += 1;
3242 let quoted = i < len && bytes[i] == b'"';
3243 let value = if quoted {
3244 i += 1;
3245 let value_start = i;
3246 while i < len && bytes[i] != b'"' {
3247 i += 1;
3248 }
3249 let value = &s[value_start..i];
3250 if i < len {
3251 i += 1;
3252 } else if strict {
3253 return Err(Error::UnterminatedQuotedString {
3254 list: s.to_string(),
3255 });
3256 }
3257 value
3258 } else {
3259 let value_start = i;
3260 while i < len && bytes[i] != b',' {
3261 i += 1;
3262 }
3263 &s[value_start..i]
3264 };
3265 if strict && map.contains_key(key) {
3266 return Err(Error::DuplicateAttribute {
3267 name: key.to_string(),
3268 });
3269 }
3270 map.insert(key.to_string(), value.to_string());
3271 if quoted {
3272 quoted_keys.insert(key.to_string());
3273 }
3274 }
3275 Ok((map, quoted_keys))
3276}
3277
3278fn filter_extra_attrs(
3291 attrs: &BTreeMap<String, String>,
3292 known: &[&str],
3293 quoted: &BTreeSet<String>,
3294) -> Result<Vec<(String, AttrValue)>> {
3295 attrs
3296 .iter()
3297 .filter(|(k, _)| !known.contains(&k.as_str()))
3298 .map(|(k, v)| {
3299 let value = if quoted.contains(k) {
3300 AttrValue::quoted(v.clone())?
3301 } else {
3302 AttrValue::bare(v.clone())?
3303 };
3304 Ok((k.clone(), value))
3305 })
3306 .collect()
3307}
3308
3309fn is_segment_defining_tag(line: &str) -> bool {
3320 const SEGMENT_SCOPED: &[&str] = &["#EXT-X-KEY:", "#EXT-X-DATERANGE:", "#EXT-X-CUE-"];
3321 SEGMENT_SCOPED.iter().any(|prefix| line.starts_with(prefix))
3322}
3323
3324fn require_attr(
3328 attrs: &BTreeMap<String, String>,
3329 key: &str,
3330 line_no: usize,
3331 line: &str,
3332 tag: &str,
3333) -> Result<String> {
3334 attrs.get(key).cloned().ok_or_else(|| Error::HlsParse {
3335 line_no,
3336 line: line.to_string(),
3337 reason: format!("{tag} missing required {key} attribute"),
3338 })
3339}
3340
3341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3350#[non_exhaustive]
3351pub enum VideoRange {
3352 Sdr,
3354 Hlg,
3356 Pq,
3358}
3359
3360impl VideoRange {
3361 pub fn name(&self) -> &'static str {
3364 match self {
3365 VideoRange::Sdr => "SDR",
3366 VideoRange::Hlg => "HLG",
3367 VideoRange::Pq => "PQ",
3368 }
3369 }
3370
3371 pub fn from_transfer_characteristics(code: u8) -> Option<Self> {
3377 const TRANSFER_BT709: u8 = 1;
3380 const TRANSFER_BT601: u8 = 6;
3381 const TRANSFER_IEC_61966_2_1: u8 = 13;
3382 const TRANSFER_BT2020_10BIT: u8 = 14;
3383 const TRANSFER_BT2020_12BIT: u8 = 15;
3384 const TRANSFER_SMPTE_ST_2084: u8 = 16;
3385 const TRANSFER_ARIB_STD_B67: u8 = 18;
3386 match code {
3387 TRANSFER_BT709
3388 | TRANSFER_BT601
3389 | TRANSFER_IEC_61966_2_1
3390 | TRANSFER_BT2020_10BIT
3391 | TRANSFER_BT2020_12BIT => Some(Self::Sdr),
3392 TRANSFER_SMPTE_ST_2084 => Some(Self::Pq),
3393 TRANSFER_ARIB_STD_B67 => Some(Self::Hlg),
3394 _ => None,
3395 }
3396 }
3397
3398 pub fn attr(self) -> (String, AttrValue) {
3404 (
3405 String::from("VIDEO-RANGE"),
3406 AttrValue::bare(self.name()).expect("VIDEO-RANGE token is a valid bare value"),
3407 )
3408 }
3409}
3410
3411broadcast_common::impl_spec_display!(VideoRange);
3412
3413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3422pub struct FrameRate(u32);
3423
3424impl FrameRate {
3425 #[must_use]
3427 pub fn from_millihertz(millihertz: u32) -> Self {
3428 Self(millihertz)
3429 }
3430
3431 #[must_use]
3433 pub fn millihertz(self) -> u32 {
3434 self.0
3435 }
3436}
3437
3438impl core::fmt::Display for FrameRate {
3439 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3440 let whole = self.0 / 1000;
3441 let frac = self.0 % 1000;
3442 write!(f, "{whole}.{frac:03}")
3443 }
3444}
3445
3446impl core::str::FromStr for FrameRate {
3447 type Err = crate::Error;
3448
3449 fn from_str(s: &str) -> Result<Self> {
3450 parse_frame_rate(s, 0, s)
3451 }
3452}
3453
3454#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3456#[non_exhaustive]
3457pub enum MediaType {
3458 #[default]
3460 Audio,
3461 Video,
3463 Subtitles,
3465 ClosedCaptions,
3467}
3468
3469impl MediaType {
3470 #[must_use]
3473 pub fn name(&self) -> &'static str {
3474 match self {
3475 MediaType::Audio => "AUDIO",
3476 MediaType::Video => "VIDEO",
3477 MediaType::Subtitles => "SUBTITLES",
3478 MediaType::ClosedCaptions => "CLOSED-CAPTIONS",
3479 }
3480 }
3481}
3482
3483broadcast_common::impl_spec_display!(MediaType);
3484
3485#[derive(Debug, Clone, PartialEq, Eq)]
3488#[non_exhaustive]
3489pub enum InstreamId {
3490 Cc(u8),
3492 Service(u8),
3495 Other(String),
3497}
3498
3499const INSTREAM_CC_PREFIX: &str = "CC";
3500const INSTREAM_SERVICE_PREFIX: &str = "SERVICE";
3501const INSTREAM_CC_RANGE: core::ops::RangeInclusive<u8> = 1..=4;
3502const INSTREAM_SERVICE_RANGE: core::ops::RangeInclusive<u8> = 1..=63;
3503
3504impl InstreamId {
3505 #[must_use]
3507 pub fn render(&self) -> String {
3508 match self {
3509 InstreamId::Cc(n) => format!("{INSTREAM_CC_PREFIX}{n}"),
3510 InstreamId::Service(n) => format!("{INSTREAM_SERVICE_PREFIX}{n}"),
3511 InstreamId::Other(s) => s.clone(),
3512 }
3513 }
3514
3515 pub fn parse_closed_captions(value: &str) -> Result<Self> {
3518 let invalid = || Error::InvalidInstreamId {
3519 value: value.to_string(),
3520 };
3521 let canonical_decimal = |digits: &str| -> Option<u8> {
3522 let well_formed = !digits.is_empty()
3523 && digits.bytes().all(|b| b.is_ascii_digit())
3524 && !digits.starts_with('0');
3525 well_formed.then(|| digits.parse().ok()).flatten()
3526 };
3527 if let Some(rest) = value.strip_prefix(INSTREAM_CC_PREFIX) {
3528 return canonical_decimal(rest)
3529 .filter(|n| INSTREAM_CC_RANGE.contains(n))
3530 .map(InstreamId::Cc)
3531 .ok_or_else(invalid);
3532 }
3533 if let Some(rest) = value.strip_prefix(INSTREAM_SERVICE_PREFIX) {
3534 return canonical_decimal(rest)
3535 .filter(|n| INSTREAM_SERVICE_RANGE.contains(n))
3536 .map(InstreamId::Service)
3537 .ok_or_else(invalid);
3538 }
3539 Err(invalid())
3540 }
3541}
3542
3543#[derive(Debug, Clone, PartialEq, Eq)]
3546#[non_exhaustive]
3547pub enum ClosedCaptionsRef {
3548 Group(String),
3550 None,
3552}
3553
3554#[derive(Debug, Clone, PartialEq, Default)]
3557#[non_exhaustive]
3558pub struct MediaRendition {
3559 pub media_type: MediaType,
3561 pub uri: Option<String>,
3563 pub group_id: String,
3565 pub language: Option<String>,
3567 pub assoc_language: Option<String>,
3569 pub name: String,
3571 pub stable_rendition_id: Option<String>,
3573 pub default: bool,
3575 pub autoselect: bool,
3577 pub forced: bool,
3579 pub instream_id: Option<InstreamId>,
3581 pub bit_depth: Option<u32>,
3583 pub sample_rate: Option<u32>,
3585 pub characteristics: Vec<String>,
3587 pub channels: Option<String>,
3589 pub extra_attrs: Vec<(String, AttrValue)>,
3591}
3592
3593#[derive(Debug, Clone, PartialEq, Default)]
3595pub struct Variant {
3596 pub bandwidth: u32,
3598 pub average_bandwidth: Option<u32>,
3605 pub frame_rate: Option<FrameRate>,
3614 pub codecs: Option<String>,
3620 pub resolution: Option<(u32, u32)>,
3622 pub uri: String,
3624 pub audio: Option<String>,
3626 pub video: Option<String>,
3628 pub subtitles: Option<String>,
3630 pub closed_captions: Option<ClosedCaptionsRef>,
3632 pub extra_attrs: Vec<(String, AttrValue)>,
3635}
3636
3637#[derive(Debug, Clone, PartialEq, Default)]
3646pub struct IFrameVariant {
3647 pub bandwidth: u32,
3649 pub average_bandwidth: Option<u32>,
3653 pub codecs: Option<String>,
3655 pub resolution: Option<(u32, u32)>,
3657 pub uri: String,
3659 pub extra_attrs: Vec<(String, AttrValue)>,
3662}
3663
3664#[derive(Debug, Clone, PartialEq, Default)]
3666pub struct MasterPlaylist {
3667 pub version: u8,
3674 pub media: Vec<MediaRendition>,
3677 pub variants: Vec<Variant>,
3679 pub iframe_variants: Vec<IFrameVariant>,
3685 pub extra_tags: Vec<String>,
3701 pub independent_segments: bool,
3703 pub start: Option<StartPoint>,
3705 pub defines: Vec<Define>,
3708 pub session_data: Vec<SessionData>,
3711 pub session_keys: Vec<SessionKey>,
3714 pub content_steering: Option<ContentSteering>,
3717}
3718
3719type PendingStreamInf = (
3726 u32,
3727 Option<u32>,
3728 Option<FrameRate>,
3729 Option<String>,
3730 Option<(u32, u32)>,
3731 Vec<(String, AttrValue)>,
3732);
3733
3734impl MasterPlaylist {
3735 pub fn to_m3u8(&self) -> Result<String> {
3753 self.validate_for_render()?;
3754 let mut s = String::new();
3755 s.push_str("#EXTM3U\n");
3756 if let Some(version) = self.effective_version() {
3757 s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
3758 }
3759
3760 for tag in &self.extra_tags {
3761 s.push_str(tag);
3762 s.push('\n');
3763 }
3764
3765 for m in &self.media {
3767 s.push_str("#EXT-X-MEDIA:TYPE=");
3768 s.push_str(m.media_type.name());
3769 if let Some(uri) = &m.uri {
3770 validate_uri(uri, "EXT-X-MEDIA URI")?;
3771 s.push_str(&format!(",URI=\"{uri}\""));
3772 }
3773 s.push_str(&format!(",GROUP-ID=\"{}\"", m.group_id));
3774 if let Some(l) = &m.language {
3775 validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
3776 s.push_str(&format!(",LANGUAGE=\"{l}\""));
3777 }
3778 if let Some(l) = &m.assoc_language {
3779 validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
3780 s.push_str(&format!(",ASSOC-LANGUAGE=\"{l}\""));
3781 }
3782 validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
3783 s.push_str(&format!(",NAME=\"{}\"", m.name));
3784 if let Some(sri) = &m.stable_rendition_id {
3785 validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
3786 s.push_str(&format!(",STABLE-RENDITION-ID=\"{sri}\""));
3787 }
3788 s.push_str(&format!(
3789 ",DEFAULT={}",
3790 if m.default { "YES" } else { "NO" }
3791 ));
3792 if m.autoselect || !m.default {
3793 s.push_str(&format!(
3794 ",AUTOSELECT={}",
3795 if m.autoselect { "YES" } else { "NO" }
3796 ));
3797 }
3798 if m.forced {
3799 s.push_str(",FORCED=YES");
3800 } else if m.media_type == MediaType::Subtitles {
3801 s.push_str(",FORCED=NO");
3803 }
3804 if let Some(ii) = &m.instream_id {
3805 let v = ii.render();
3806 validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
3807 s.push_str(&format!(",INSTREAM-ID=\"{v}\""));
3808 }
3809 if let Some(bd) = m.bit_depth {
3810 s.push_str(&format!(",BIT-DEPTH={bd}"));
3811 }
3812 if let Some(sr) = m.sample_rate {
3813 s.push_str(&format!(",SAMPLE-RATE={sr}"));
3814 }
3815 if !m.characteristics.is_empty() {
3816 let joined = m.characteristics.join(",");
3817 validate_quoted_string(&joined, "EXT-X-MEDIA CHARACTERISTICS")?;
3818 s.push_str(&format!(",CHARACTERISTICS=\"{joined}\""));
3819 }
3820 if let Some(ch) = &m.channels {
3821 validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
3822 s.push_str(&format!(",CHANNELS=\"{ch}\""));
3823 }
3824 render_attribute_list(&mut s, &m.extra_attrs);
3825 s.push('\n');
3826 }
3827
3828 if self.independent_segments {
3830 s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
3831 }
3832 for def in &self.defines {
3833 push_define_line(&mut s, def)?;
3834 }
3835 if let Some(start) = &self.start {
3836 push_start_line(&mut s, start);
3837 }
3838 for sk in &self.session_keys {
3840 push_session_key_line(&mut s, sk)?;
3841 }
3842 for sd in &self.session_data {
3843 push_session_data_line(&mut s, sd)?;
3844 }
3845 if let Some(cs) = &self.content_steering {
3846 validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
3847 s.push_str(&format!(
3848 "#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
3849 cs.server_uri
3850 ));
3851 if let Some(pid) = &cs.pathway_id {
3852 validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
3853 s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
3854 }
3855 render_attribute_list(&mut s, &cs.extra_attrs);
3856 s.push('\n');
3857 }
3858
3859 for var in &self.variants {
3860 s.push_str(&format!("#EXT-X-STREAM-INF:BANDWIDTH={}", var.bandwidth));
3861 if let Some(avg) = var.average_bandwidth {
3864 s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3865 }
3866 if let Some(codecs) = &var.codecs {
3870 validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
3871 s.push_str(&format!(",CODECS=\"{codecs}\""));
3872 }
3873 if let Some((w, h)) = var.resolution {
3874 s.push_str(&format!(",RESOLUTION={w}x{h}"));
3875 }
3876 if let Some(frame_rate) = var.frame_rate {
3880 s.push_str(&format!(",FRAME-RATE={frame_rate}"));
3881 }
3882 if let Some(g) = &var.audio {
3883 validate_quoted_string(g, "EXT-X-STREAM-INF AUDIO")?;
3884 s.push_str(&format!(",AUDIO=\"{g}\""));
3885 }
3886 if let Some(g) = &var.video {
3887 validate_quoted_string(g, "EXT-X-STREAM-INF VIDEO")?;
3888 s.push_str(&format!(",VIDEO=\"{g}\""));
3889 }
3890 if let Some(g) = &var.subtitles {
3891 validate_quoted_string(g, "EXT-X-STREAM-INF SUBTITLES")?;
3892 s.push_str(&format!(",SUBTITLES=\"{g}\""));
3893 }
3894 match &var.closed_captions {
3895 Some(ClosedCaptionsRef::Group(g)) => {
3896 validate_quoted_string(g, "EXT-X-STREAM-INF CLOSED-CAPTIONS")?;
3897 s.push_str(&format!(",CLOSED-CAPTIONS=\"{g}\""));
3898 }
3899 Some(ClosedCaptionsRef::None) => {
3900 s.push_str(",CLOSED-CAPTIONS=NONE");
3901 }
3902 None => {}
3903 }
3904 render_attribute_list(&mut s, &var.extra_attrs);
3905 s.push('\n');
3906 validate_uri(&var.uri, "variant URI")?;
3907 s.push_str(&var.uri);
3908 s.push('\n');
3909 }
3910
3911 for iv in &self.iframe_variants {
3914 s.push_str(&format!(
3915 "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
3916 iv.bandwidth
3917 ));
3918 if let Some(avg) = iv.average_bandwidth {
3921 s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3922 }
3923 if let Some(c) = &iv.codecs {
3924 s.push_str(&format!(",CODECS=\"{c}\""));
3925 }
3926 if let Some((w, h)) = iv.resolution {
3927 s.push_str(&format!(",RESOLUTION={w}x{h}"));
3928 }
3929 validate_uri(&iv.uri, "I-frame-variant URI")?;
3930 if let Some(codecs) = &iv.codecs {
3931 validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
3932 }
3933 render_attribute_list(&mut s, &iv.extra_attrs);
3934 s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
3935 }
3936
3937 Ok(s)
3938 }
3939
3940 fn validate_for_render(&self) -> Result<()> {
3947 validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
3948 for def in &self.defines {
3949 push_define_line(&mut String::new(), def)?;
3950 }
3951 if let Some(cs) = &self.content_steering {
3952 validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
3953 if let Some(pid) = &cs.pathway_id {
3954 validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
3955 }
3956 }
3957 for var in &self.variants {
3958 validate_uri(&var.uri, "variant URI")?;
3959 if let Some(codecs) = &var.codecs {
3960 validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
3961 }
3962 }
3963 for iv in &self.iframe_variants {
3964 validate_uri(&iv.uri, "I-frame-variant URI")?;
3965 if let Some(codecs) = &iv.codecs {
3966 validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
3967 }
3968 }
3969 check_rendition_groups(&self.media)?;
3970 for m in &self.media {
3972 if m.group_id.is_empty() {
3973 return Err(Error::HlsParse {
3974 line_no: 0,
3975 line: String::new(),
3976 reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
3977 });
3978 }
3979 validate_quoted_string(&m.group_id, "EXT-X-MEDIA GROUP-ID")?;
3980 if m.name.is_empty() {
3981 return Err(Error::HlsParse {
3982 line_no: 0,
3983 line: String::new(),
3984 reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
3985 });
3986 }
3987 validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
3988 if m.media_type == MediaType::ClosedCaptions {
3989 if m.uri.is_some() {
3990 return Err(Error::HlsParse {
3991 line_no: 0,
3992 line: String::new(),
3993 reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS"
3994 .to_string(),
3995 });
3996 }
3997 if m.instream_id.is_none() {
3998 return Err(Error::HlsParse {
3999 line_no: 0,
4000 line: String::new(),
4001 reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS"
4002 .to_string(),
4003 });
4004 }
4005 } else {
4006 }
4008 if m.forced && m.media_type != MediaType::Subtitles {
4009 return Err(Error::HlsParse {
4010 line_no: 0,
4011 line: String::new(),
4012 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES"
4013 .to_string(),
4014 });
4015 }
4016 if m.bit_depth.is_some() && m.media_type != MediaType::Audio {
4017 return Err(Error::HlsParse {
4018 line_no: 0,
4019 line: String::new(),
4020 reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4021 .to_string(),
4022 });
4023 }
4024 if m.sample_rate.is_some() && m.media_type != MediaType::Audio {
4025 return Err(Error::HlsParse {
4026 line_no: 0,
4027 line: String::new(),
4028 reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4029 .to_string(),
4030 });
4031 }
4032 if m.channels.is_some() && m.media_type != MediaType::Audio {
4033 return Err(Error::HlsParse {
4034 line_no: 0,
4035 line: String::new(),
4036 reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO"
4037 .to_string(),
4038 });
4039 }
4040 if let Some(uri) = &m.uri {
4041 validate_uri(uri, "EXT-X-MEDIA URI")?;
4042 }
4043 if let Some(sri) = &m.stable_rendition_id {
4044 validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4045 }
4046 if let Some(ii) = &m.instream_id {
4047 let v = ii.render();
4048 validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
4049 let closed_captions = m.media_type == MediaType::ClosedCaptions;
4050 let cc_form = !matches!(ii, InstreamId::Other(_));
4051 if closed_captions != cc_form {
4052 return Err(Error::InstreamIdTypeMismatch {
4053 media_type: m.media_type,
4054 value: v,
4055 });
4056 }
4057 if closed_captions {
4058 InstreamId::parse_closed_captions(&v)?;
4059 }
4060 }
4061 if let Some(ch) = &m.channels {
4062 validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
4063 }
4064 }
4065 let none_used = self
4067 .variants
4068 .iter()
4069 .any(|v| matches!(v.closed_captions, Some(ClosedCaptionsRef::None)));
4070 if none_used {
4071 for v in &self.variants {
4072 if !matches!(v.closed_captions, Some(ClosedCaptionsRef::None)) {
4073 return Err(Error::HlsParse {
4074 line_no: 0,
4075 line: String::new(),
4076 reason:
4077 "EXT-X-STREAM-INF CLOSED-CAPTIONS=NONE must be used on all variants"
4078 .to_string(),
4079 });
4080 }
4081 }
4082 }
4083 let group_exists = |gid: &str, mt: MediaType| -> bool {
4084 self.media
4085 .iter()
4086 .any(|m| m.group_id == gid && m.media_type == mt)
4087 };
4088 for v in &self.variants {
4089 if let Some(g) = &v.audio
4090 && !group_exists(g, MediaType::Audio)
4091 {
4092 return Err(Error::HlsParse {
4093 line_no: 0,
4094 line: String::new(),
4095 reason: format!("EXT-X-STREAM-INF AUDIO group {g:?} not found"),
4096 });
4097 }
4098 if let Some(g) = &v.video
4099 && !group_exists(g, MediaType::Video)
4100 {
4101 return Err(Error::HlsParse {
4102 line_no: 0,
4103 line: String::new(),
4104 reason: format!("EXT-X-STREAM-INF VIDEO group {g:?} not found"),
4105 });
4106 }
4107 if let Some(g) = &v.subtitles
4108 && !group_exists(g, MediaType::Subtitles)
4109 {
4110 return Err(Error::HlsParse {
4111 line_no: 0,
4112 line: String::new(),
4113 reason: format!("EXT-X-STREAM-INF SUBTITLES group {g:?} not found"),
4114 });
4115 }
4116 if let Some(ClosedCaptionsRef::Group(g)) = &v.closed_captions
4117 && !group_exists(g, MediaType::ClosedCaptions)
4118 {
4119 return Err(Error::HlsParse {
4120 line_no: 0,
4121 line: String::new(),
4122 reason: format!("EXT-X-STREAM-INF CLOSED-CAPTIONS group {g:?} not found"),
4123 });
4124 }
4125 }
4126 for sk in &self.session_keys {
4127 push_session_key_line(&mut String::new(), sk)?;
4128 }
4129 for sd in &self.session_data {
4130 push_session_data_line(&mut String::new(), sd)?;
4131 }
4132 Ok(())
4133 }
4134
4135 pub fn parse(input: &str) -> Result<Self> {
4148 let mut version: u8 = 0;
4150 let mut variants: Vec<Variant> = Vec::new();
4151 let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
4152 let mut media: Vec<MediaRendition> = Vec::new();
4153 let mut extra_tags: Vec<String> = Vec::new();
4154 let mut saw_extm3u = false;
4155 let mut pending_stream_inf: Option<PendingStreamInf> = None;
4156 let mut independent_segments = false;
4158 let mut start: Option<StartPoint> = None;
4159 let mut defines: Vec<Define> = Vec::new();
4160 let mut session_data: Vec<SessionData> = Vec::new();
4161 let mut session_keys: Vec<SessionKey> = Vec::new();
4162 let mut content_steering: Option<ContentSteering> = None;
4163
4164 for (idx, raw_line) in input.lines().enumerate() {
4165 let line_no = idx + 1;
4166 let mut line = raw_line.trim_end_matches('\r');
4167 if line_no == 1 {
4168 line = line.strip_prefix('\u{feff}').unwrap_or(line);
4169 }
4170 let line = line.trim();
4171 if line.is_empty() {
4172 continue;
4173 }
4174
4175 if line == "#EXTM3U" {
4176 saw_extm3u = true;
4177 } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
4178 version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
4179 } else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
4180 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4181 let bandwidth_str =
4182 require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
4183 let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4184 let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4188 Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4189 None => None,
4190 };
4191 let frame_rate = match attrs.get("FRAME-RATE") {
4194 Some(v) => Some(parse_frame_rate(v, line_no, line)?),
4195 None => None,
4196 };
4197 let codecs = attrs.get("CODECS").cloned();
4201 let resolution = match attrs.get("RESOLUTION") {
4202 Some(v) => Some(parse_resolution(v, line_no, line)?),
4203 None => None,
4204 };
4205 let extra_attrs = filter_extra_attrs(
4206 &attrs,
4207 &[
4208 "BANDWIDTH",
4209 "AVERAGE-BANDWIDTH",
4210 "CODECS",
4211 "RESOLUTION",
4212 "FRAME-RATE",
4213 ],
4214 "ed,
4215 )?;
4216 if pending_stream_inf.is_some() {
4225 return Err(Error::HlsParse {
4226 line_no,
4227 line: line.to_string(),
4228 reason: "second #EXT-X-STREAM-INF before the first Variant's URI \
4229 line (RFC 8216bis §4.4.6.2)"
4230 .to_string(),
4231 });
4232 }
4233 pending_stream_inf = Some((
4234 bandwidth,
4235 average_bandwidth,
4236 frame_rate,
4237 codecs,
4238 resolution,
4239 extra_attrs,
4240 ));
4241 } else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
4242 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4243 let bandwidth_str = require_attr(
4244 &attrs,
4245 "BANDWIDTH",
4246 line_no,
4247 line,
4248 "EXT-X-I-FRAME-STREAM-INF",
4249 )?;
4250 let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4251 let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4252 Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4253 None => None,
4254 };
4255 let codecs = attrs.get("CODECS").cloned();
4256 let resolution = match attrs.get("RESOLUTION") {
4257 Some(v) => Some(parse_resolution(v, line_no, line)?),
4258 None => None,
4259 };
4260 let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-I-FRAME-STREAM-INF")?;
4261 let extra_attrs = filter_extra_attrs(
4262 &attrs,
4263 &[
4264 "BANDWIDTH",
4265 "AVERAGE-BANDWIDTH",
4266 "CODECS",
4267 "RESOLUTION",
4268 "URI",
4269 ],
4270 "ed,
4271 )?;
4272 iframe_variants.push(IFrameVariant {
4273 bandwidth,
4274 average_bandwidth,
4275 codecs,
4276 resolution,
4277 uri,
4278 extra_attrs,
4279 });
4280 } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
4281 independent_segments = true;
4282 } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
4283 start = Some(parse_start(rest, line_no, line)?);
4284 } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
4285 defines.push(parse_define(rest, line_no, line)?);
4286 } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-DATA:") {
4287 session_data.push(parse_session_data(rest, line_no, line)?);
4288 } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-KEY:") {
4289 session_keys.push(parse_session_key(rest, line_no, line)?);
4290 } else if let Some(rest) = line.strip_prefix("#EXT-X-CONTENT-STEERING:") {
4291 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4292 let server_uri = require_attr(
4293 &attrs,
4294 "SERVER-URI",
4295 line_no,
4296 line,
4297 "EXT-X-CONTENT-STEERING",
4298 )?;
4299 let pathway_id = attrs.get("PATHWAY-ID").cloned();
4300 let extra_attrs =
4301 filter_extra_attrs(&attrs, &["SERVER-URI", "PATHWAY-ID"], "ed)?;
4302 content_steering = Some(ContentSteering {
4303 server_uri,
4304 pathway_id,
4305 extra_attrs,
4306 });
4307 } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA:") {
4308 media.push(parse_media_rendition(rest, line_no, line)?);
4309 } else if let Some(rest) = line.strip_prefix("#EXT") {
4310 let _ = rest;
4311 extra_tags.push(line.to_string());
4318 } else if line.starts_with('#') {
4319 } else {
4321 let (bandwidth, average_bandwidth, frame_rate, codecs, resolution, extra_attrs) =
4322 pending_stream_inf.take().ok_or_else(|| Error::HlsParse {
4323 line_no,
4324 line: line.to_string(),
4325 reason: "variant URI with no preceding #EXT-X-STREAM-INF".to_string(),
4326 })?;
4327 let mut audio = None;
4330 let mut video = None;
4331 let mut subtitles = None;
4332 let mut closed_captions = None;
4333 let mut extra_attrs_filtered = Vec::new();
4334 for (k, v) in extra_attrs {
4335 match k.as_str() {
4336 "AUDIO" => audio = Some(v.as_str().to_string()),
4337 "VIDEO" => video = Some(v.as_str().to_string()),
4338 "SUBTITLES" => subtitles = Some(v.as_str().to_string()),
4339 "CLOSED-CAPTIONS" => {
4340 if v.as_str() == "NONE" {
4341 closed_captions = Some(ClosedCaptionsRef::None);
4342 } else {
4343 closed_captions =
4344 Some(ClosedCaptionsRef::Group(v.as_str().to_string()));
4345 }
4346 }
4347 _ => extra_attrs_filtered.push((k, v)),
4348 }
4349 }
4350 variants.push(Variant {
4351 bandwidth,
4352 average_bandwidth,
4353 frame_rate,
4354 codecs,
4355 resolution,
4356 uri: line.to_string(),
4357 audio,
4358 video,
4359 subtitles,
4360 closed_captions,
4361 extra_attrs: extra_attrs_filtered,
4362 });
4363 }
4364 }
4365
4366 if !saw_extm3u {
4367 return Err(Error::HlsParse {
4368 line_no: 1,
4369 line: String::new(),
4370 reason: "missing #EXTM3U header".to_string(),
4371 });
4372 }
4373 if pending_stream_inf.is_some() {
4376 return Err(Error::HlsParse {
4377 line_no: 0,
4378 line: String::new(),
4379 reason: "#EXT-X-STREAM-INF with no following Variant URI line (RFC 8216bis \
4380 §4.4.6.2)"
4381 .to_string(),
4382 });
4383 }
4384
4385 check_rendition_groups(&media)?;
4386
4387 Ok(MasterPlaylist {
4388 version,
4389 media,
4390 variants,
4391 iframe_variants,
4392 extra_tags,
4393 independent_segments,
4394 start,
4395 defines,
4396 session_data,
4397 session_keys,
4398 content_steering,
4399 })
4400 }
4401
4402 pub fn computed_version(&self) -> Option<u8> {
4409 let mut v = scan_tag_lines_for_version(&self.extra_tags);
4414
4415 for m in &self.media {
4419 if let Some(InstreamId::Service(_)) = m.instream_id {
4420 bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
4421 }
4422 if m.instream_id.is_some() && m.media_type != MediaType::ClosedCaptions {
4423 bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
4424 }
4425 }
4426
4427 if self
4431 .defines
4432 .iter()
4433 .any(|d| matches!(d, Define::QueryParam { .. }))
4434 {
4435 bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
4436 }
4437
4438 if self
4443 .extra_tags
4444 .iter()
4445 .any(|t| contains_variable_substitution(t))
4446 || self.media.iter().any(|m| {
4447 m.uri
4448 .as_ref()
4449 .is_some_and(|u| contains_variable_substitution(u))
4450 || contains_variable_substitution(&m.group_id)
4451 || contains_variable_substitution(&m.name)
4452 || m.language
4453 .as_ref()
4454 .is_some_and(|x| contains_variable_substitution(x))
4455 || m.assoc_language
4456 .as_ref()
4457 .is_some_and(|x| contains_variable_substitution(x))
4458 || m.stable_rendition_id
4459 .as_ref()
4460 .is_some_and(|x| contains_variable_substitution(x))
4461 || m.channels
4462 .as_ref()
4463 .is_some_and(|x| contains_variable_substitution(x))
4464 || m.characteristics
4465 .iter()
4466 .any(|x| contains_variable_substitution(x))
4467 })
4468 || self
4469 .variants
4470 .iter()
4471 .any(|var| contains_variable_substitution(&var.uri))
4472 || self
4473 .iframe_variants
4474 .iter()
4475 .any(|iv| contains_variable_substitution(&iv.uri))
4476 || self.master_playlist_typed_strings_use_substitution()
4477 {
4478 bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
4479 }
4480
4481 if any_typed_req_attr(
4484 self.start.as_ref(),
4485 &self.defines,
4486 &self.session_data,
4487 &self.session_keys,
4488 self.content_steering.as_ref(),
4489 &self.media,
4490 &self.variants,
4491 &self.iframe_variants,
4492 ) {
4493 bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
4494 }
4495
4496 v
4497 }
4498
4499 fn master_playlist_typed_strings_use_substitution(&self) -> bool {
4505 let define_values = self.defines.iter().filter_map(|d| match d {
4506 Define::Name { value, .. } => Some(value),
4507 _ => None,
4508 });
4509 let session_data_strings = self.session_data.iter().map(|sd| match &sd.content {
4510 SessionDataContent::Value(v) => v,
4511 SessionDataContent::Uri { uri, .. } => uri,
4512 });
4513 let session_key_uris = self.session_keys.iter().filter_map(|k| k.uri.as_ref());
4514 let steering_uris = self.content_steering.iter().map(|cs| &cs.server_uri);
4515
4516 define_values
4517 .chain(session_data_strings)
4518 .chain(session_key_uris)
4519 .chain(steering_uris)
4520 .any(|s| contains_variable_substitution(s))
4521 }
4522
4523 fn effective_version(&self) -> Option<u8> {
4526 effective_version(self.version, self.computed_version())
4527 }
4528}
4529
4530fn parse_frame_rate(v: &str, line_no: usize, line: &str) -> Result<FrameRate> {
4535 let t = v.trim();
4536 if !is_valid_decimal_token(t, false) {
4537 return Err(Error::HlsParse {
4538 line_no,
4539 line: line.to_string(),
4540 reason: format!("FRAME-RATE value {v:?} is not a valid decimal number"),
4541 });
4542 }
4543 let (int_part, frac_part) = match t.split_once('.') {
4544 Some((i, f)) => (i, Some(f)),
4545 None => (t, None),
4546 };
4547 let out_of_range = || Error::HlsParse {
4548 line_no,
4549 line: line.to_string(),
4550 reason: format!("FRAME-RATE value {v:?} is out of range"),
4551 };
4552 let int_value: u32 = int_part.parse().map_err(|_| out_of_range())?;
4553 let mut digits = frac_part.unwrap_or("").bytes().map(|b| u32::from(b - b'0'));
4554 let mut next = || digits.next().unwrap_or(0);
4555 let milli = next() * 100 + next() * 10 + next();
4556 let round_up = u32::from(next() >= 5);
4557 let total = int_value
4558 .checked_mul(1000)
4559 .and_then(|i| i.checked_add(milli + round_up))
4560 .ok_or_else(out_of_range)?;
4561 Ok(FrameRate::from_millihertz(total))
4562}
4563
4564fn parse_resolution(v: &str, line_no: usize, line: &str) -> Result<(u32, u32)> {
4566 let mut split = v.splitn(2, 'x');
4567 let w = split.next().unwrap_or("");
4568 let h = split.next().ok_or_else(|| Error::HlsParse {
4569 line_no,
4570 line: line.to_string(),
4571 reason: format!("RESOLUTION value {v:?} is not of the form <width>x<height>"),
4572 })?;
4573 let width = parse_decimal_int(w, line_no, line, "RESOLUTION width")?;
4574 let height = parse_decimal_int(h, line_no, line, "RESOLUTION height")?;
4575 Ok((width, height))
4576}
4577
4578fn parse_yes_no(attrs: &BTreeMap<String, String>, attribute: &'static str) -> Result<Option<bool>> {
4579 match attrs.get(attribute).map(String::as_str) {
4580 None => Ok(None),
4581 Some("YES") => Ok(Some(true)),
4582 Some("NO") => Ok(Some(false)),
4583 Some(other) => Err(Error::InvalidYesNo {
4584 attribute,
4585 value: other.to_string(),
4586 }),
4587 }
4588}
4589
4590fn check_rendition_groups(media: &[MediaRendition]) -> Result<()> {
4591 let mut seen: BTreeSet<(&str, &str, &str)> = BTreeSet::new();
4592 let mut defaults: BTreeSet<(&str, &str)> = BTreeSet::new();
4593 for m in media {
4594 let group = (m.media_type.name(), m.group_id.as_str());
4595 if !seen.insert((m.media_type.name(), m.group_id.as_str(), m.name.as_str())) {
4596 return Err(Error::DuplicateNameInGroup {
4597 media_type: m.media_type,
4598 group_id: m.group_id.clone(),
4599 name: m.name.clone(),
4600 });
4601 }
4602 if m.default && !defaults.insert(group) {
4603 return Err(Error::DuplicateDefaultInGroup {
4604 media_type: m.media_type,
4605 group_id: m.group_id.clone(),
4606 });
4607 }
4608 }
4609 Ok(())
4610}
4611
4612fn parse_media_rendition(rest: &str, line_no: usize, line: &str) -> Result<MediaRendition> {
4613 let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4614
4615 let type_str = require_attr(&attrs, "TYPE", line_no, line, "EXT-X-MEDIA")?;
4617 let media_type = match type_str.as_str() {
4618 "AUDIO" => MediaType::Audio,
4619 "VIDEO" => MediaType::Video,
4620 "SUBTITLES" => MediaType::Subtitles,
4621 "CLOSED-CAPTIONS" => MediaType::ClosedCaptions,
4622 _ => {
4623 return Err(Error::HlsParse {
4624 line_no,
4625 line: line.to_string(),
4626 reason: format!("EXT-X-MEDIA invalid TYPE {type_str:?}"),
4627 });
4628 }
4629 };
4630
4631 let group_id = require_attr(&attrs, "GROUP-ID", line_no, line, "EXT-X-MEDIA")?;
4632 if group_id.is_empty() {
4633 return Err(Error::HlsParse {
4634 line_no,
4635 line: line.to_string(),
4636 reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
4637 });
4638 }
4639 validate_quoted_string(&group_id, "EXT-X-MEDIA GROUP-ID")?;
4640
4641 let name = require_attr(&attrs, "NAME", line_no, line, "EXT-X-MEDIA")?;
4642 if name.is_empty() {
4643 return Err(Error::HlsParse {
4644 line_no,
4645 line: line.to_string(),
4646 reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
4647 });
4648 }
4649 validate_quoted_string(&name, "EXT-X-MEDIA NAME")?;
4650
4651 let uri = attrs.get("URI").cloned();
4653 if media_type == MediaType::ClosedCaptions && uri.is_some() {
4654 return Err(Error::HlsParse {
4655 line_no,
4656 line: line.to_string(),
4657 reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS".to_string(),
4658 });
4659 }
4660 if let Some(u) = &uri {
4661 validate_uri(u, "EXT-X-MEDIA URI")?;
4662 }
4663
4664 let language = attrs.get("LANGUAGE").cloned();
4665 let assoc_language = attrs.get("ASSOC-LANGUAGE").cloned();
4666 if let Some(l) = &language {
4667 validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
4668 }
4669 if let Some(l) = &assoc_language {
4670 validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
4671 }
4672
4673 let stable_rendition_id = attrs.get("STABLE-RENDITION-ID").cloned();
4674 if let Some(s) = &stable_rendition_id {
4675 validate_quoted_string(s, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4676 }
4677
4678 let default = parse_yes_no(&attrs, "DEFAULT")?.unwrap_or(false);
4680 let autoselect_attr = parse_yes_no(&attrs, "AUTOSELECT")?;
4681 let autoselect = autoselect_attr.unwrap_or(false);
4682 if default && autoselect_attr == Some(false) {
4683 return Err(Error::HlsParse {
4684 line_no,
4685 line: line.to_string(),
4686 reason: "EXT-X-MEDIA AUTOSELECT MUST be YES when DEFAULT is YES".to_string(),
4687 });
4688 }
4689
4690 let forced = parse_yes_no(&attrs, "FORCED")?.unwrap_or(false);
4692 if forced && media_type != MediaType::Subtitles {
4693 return Err(Error::HlsParse {
4695 line_no,
4696 line: line.to_string(),
4697 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4698 });
4699 }
4700 if attrs.contains_key("FORCED") && media_type != MediaType::Subtitles {
4702 return Err(Error::HlsParse {
4703 line_no,
4704 line: line.to_string(),
4705 reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4706 });
4707 }
4708
4709 let instream_id = if let Some(v) = attrs.get("INSTREAM-ID") {
4711 validate_quoted_string(v, "EXT-X-MEDIA INSTREAM-ID")?;
4712 if media_type == MediaType::ClosedCaptions {
4713 Some(InstreamId::parse_closed_captions(v)?)
4714 } else {
4715 Some(InstreamId::Other(v.clone()))
4716 }
4717 } else {
4718 None
4719 };
4720 if media_type == MediaType::ClosedCaptions && instream_id.is_none() {
4721 return Err(Error::HlsParse {
4722 line_no,
4723 line: line.to_string(),
4724 reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS".to_string(),
4725 });
4726 }
4727
4728 let bit_depth = if let Some(v) = attrs.get("BIT-DEPTH") {
4730 if media_type != MediaType::Audio {
4731 return Err(Error::HlsParse {
4732 line_no,
4733 line: line.to_string(),
4734 reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4735 .to_string(),
4736 });
4737 }
4738 Some(parse_decimal_int(v, line_no, line, "BIT-DEPTH")?)
4739 } else {
4740 None
4741 };
4742 let sample_rate = if let Some(v) = attrs.get("SAMPLE-RATE") {
4743 if media_type != MediaType::Audio {
4744 return Err(Error::HlsParse {
4745 line_no,
4746 line: line.to_string(),
4747 reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4748 .to_string(),
4749 });
4750 }
4751 Some(parse_decimal_int(v, line_no, line, "SAMPLE-RATE")?)
4752 } else {
4753 None
4754 };
4755 let channels = if let Some(v) = attrs.get("CHANNELS") {
4756 if media_type != MediaType::Audio {
4757 return Err(Error::HlsParse {
4758 line_no,
4759 line: line.to_string(),
4760 reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO".to_string(),
4761 });
4762 }
4763 validate_quoted_string(v, "EXT-X-MEDIA CHANNELS")?;
4764 Some(v.clone())
4765 } else {
4766 None
4767 };
4768
4769 let characteristics = attrs
4771 .get("CHARACTERISTICS")
4772 .map(|v| {
4773 validate_quoted_string(v, "EXT-X-MEDIA CHARACTERISTICS")?;
4774 Ok(v.split(',')
4775 .map(|s| s.trim().to_string())
4776 .filter(|s| !s.is_empty())
4777 .collect::<Vec<_>>())
4778 })
4779 .transpose()?
4780 .unwrap_or_default();
4781
4782 const KNOWN: &[&str] = &[
4784 "TYPE",
4785 "URI",
4786 "GROUP-ID",
4787 "LANGUAGE",
4788 "ASSOC-LANGUAGE",
4789 "NAME",
4790 "STABLE-RENDITION-ID",
4791 "DEFAULT",
4792 "AUTOSELECT",
4793 "FORCED",
4794 "INSTREAM-ID",
4795 "BIT-DEPTH",
4796 "SAMPLE-RATE",
4797 "CHARACTERISTICS",
4798 "CHANNELS",
4799 ];
4800 let extra_attrs = filter_extra_attrs(&attrs, KNOWN, "ed)?;
4801
4802 Ok(MediaRendition {
4805 media_type,
4806 uri,
4807 group_id,
4808 language,
4809 assoc_language,
4810 name,
4811 stable_rendition_id,
4812 default,
4813 autoselect,
4814 forced,
4815 instream_id,
4816 bit_depth,
4817 sample_rate,
4818 characteristics,
4819 channels,
4820 extra_attrs,
4821 })
4822}
4823
4824pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
4856 if entries.len() < 2 {
4857 return;
4858 }
4859 for i in 1..entries.len() {
4863 let (head, tail) = entries.split_at_mut(i);
4864 let prev_init: &[u8] = head[i - 1].0;
4865 let cur = &mut tail[0];
4866 if cur.0 != prev_init {
4867 cur.1.discontinuous = true;
4868 }
4869 }
4870}
4871
4872#[cfg(test)]
4873mod tests {
4874 use super::*;
4875
4876 fn seg(uri: &str, duration: f64) -> MediaSegment {
4877 MediaSegment {
4878 uri: uri.into(),
4879 duration: DecimalSeconds::new(duration).unwrap(),
4880 discontinuous: false,
4881 parts: vec![],
4882 ..Default::default()
4883 }
4884 }
4885
4886 fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
4887 MediaSegment {
4888 uri: uri.into(),
4889 duration: DecimalSeconds::new(duration).unwrap(),
4890 discontinuous: true,
4891 parts: vec![],
4892 ..Default::default()
4893 }
4894 }
4895
4896 fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
4897 MediaPlaylist {
4898 version: 3,
4899 target_duration: 10,
4900 media_sequence: 0,
4901 discontinuity_sequence: 0,
4902 segments,
4903 endlist: true,
4904 extra_tags: vec![],
4905 low_latency: None,
4906 iframes_only: false,
4907 open_segment: None,
4908 ..Default::default()
4909 }
4910 }
4911
4912 #[test]
4913 fn media_playlist_basic() {
4914 let pl = MediaPlaylist {
4915 version: 3,
4916 target_duration: 10,
4917 media_sequence: 0,
4918 discontinuity_sequence: 0,
4919 segments: vec![
4920 seg("seg0.m4s", 9.009),
4921 seg("seg1.m4s", 9.009),
4922 seg("seg2.m4s", 3.003),
4923 ],
4924 endlist: true,
4925 extra_tags: vec![],
4926 low_latency: None,
4927 iframes_only: false,
4928 open_segment: None,
4929 ..Default::default()
4930 };
4931 let out = pl.to_m3u8().unwrap();
4932 assert!(out.starts_with("#EXTM3U\n"));
4933 assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
4934 assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
4935 assert_eq!(out.matches("#EXTINF:").count(), 3);
4936 assert!(out.ends_with("#EXT-X-ENDLIST\n"));
4937 assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
4939 }
4940
4941 #[test]
4942 fn media_playlist_no_endlist() {
4943 let pl = MediaPlaylist {
4944 version: 7,
4945 target_duration: 6,
4946 media_sequence: 42,
4947 discontinuity_sequence: 0,
4948 segments: vec![seg("seg.m4s", 6.000)],
4949 endlist: false,
4950 extra_tags: vec![],
4951 low_latency: None,
4952 iframes_only: false,
4953 open_segment: None,
4954 ..Default::default()
4955 };
4956 let out = pl.to_m3u8().unwrap();
4957 assert!(out.starts_with("#EXTM3U\n"));
4958 assert!(out.contains("#EXT-X-VERSION:7\n"));
4959 assert!(!out.contains("#EXT-X-ENDLIST"));
4960 }
4961
4962 #[test]
4963 fn master_playlist_basic() {
4964 let pl = MasterPlaylist {
4965 version: 6,
4966 media: vec![],
4967 variants: vec![
4968 Variant {
4969 bandwidth: 300_000,
4970 average_bandwidth: None,
4971 frame_rate: None,
4972 codecs: Some("avc1.64001e,mp4a.40.2".into()),
4973 resolution: Some((640, 360)),
4974 uri: "v300/index.m3u8".into(),
4975 ..Default::default()
4976 },
4977 Variant {
4978 bandwidth: 800_000,
4979 average_bandwidth: None,
4980 frame_rate: None,
4981 codecs: Some("avc1.640028,mp4a.40.2".into()),
4982 resolution: Some((1280, 720)),
4983 uri: "v800/index.m3u8".into(),
4984 ..Default::default()
4985 },
4986 ],
4987 iframe_variants: vec![],
4988 ..Default::default()
4989 };
4990 let out = pl.to_m3u8().unwrap();
4991 assert!(out.starts_with("#EXTM3U\n"));
4992 assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
4993 assert!(out.contains("v300/index.m3u8"));
4994 assert!(out.contains("v800/index.m3u8"));
4995 assert!(out.contains("RESOLUTION=640x360"));
4996 assert!(out.contains("RESOLUTION=1280x720"));
4997 }
4998
4999 #[test]
5000 fn master_playlist_no_resolution() {
5001 let pl = MasterPlaylist {
5002 version: 6,
5003 media: vec![],
5004 variants: vec![Variant {
5005 bandwidth: 1_000_000,
5006 average_bandwidth: None,
5007 frame_rate: None,
5008 codecs: Some("avc1.640028".into()),
5009 resolution: None,
5010 uri: "v1k/index.m3u8".into(),
5011 audio: None,
5012 video: None,
5013 subtitles: None,
5014 closed_captions: None,
5015 extra_attrs: Vec::new(),
5016 }],
5017 iframe_variants: vec![],
5018 ..Default::default()
5019 };
5020 let out = pl.to_m3u8().unwrap();
5021 assert!(!out.contains("RESOLUTION"));
5022 assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
5023 }
5024
5025 #[test]
5029 fn master_playlist_renders_average_bandwidth_after_bandwidth() {
5030 let pl = MasterPlaylist {
5031 media: vec![],
5032 variants: vec![Variant {
5033 bandwidth: 2_523_597,
5034 average_bandwidth: Some(2_190_673),
5035 frame_rate: None,
5036 codecs: Some("avc1.640020,mp4a.40.2".into()),
5037 uri: "v540/index.m3u8".into(),
5038 ..Default::default()
5039 }],
5040 ..Default::default()
5041 };
5042 let out = pl.to_m3u8().unwrap();
5043 assert!(
5044 out.contains(
5045 "#EXT-X-STREAM-INF:BANDWIDTH=2523597,AVERAGE-BANDWIDTH=2190673,\
5046 CODECS=\"avc1.640020,mp4a.40.2\""
5047 ),
5048 "out: {out}"
5049 );
5050 let reparsed = MasterPlaylist::parse(&out).unwrap();
5051 assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5052 assert_eq!(reparsed.variants[0].average_bandwidth, Some(2_190_673));
5053 }
5054
5055 #[test]
5058 fn master_playlist_omits_absent_average_bandwidth_and_round_trips_none() {
5059 let pl = MasterPlaylist {
5060 media: vec![],
5061 variants: vec![Variant {
5062 bandwidth: 800_000,
5063 average_bandwidth: None,
5064 frame_rate: None,
5065 codecs: Some("avc1.640028,mp4a.40.2".into()),
5066 uri: "v800/index.m3u8".into(),
5067 ..Default::default()
5068 }],
5069 ..Default::default()
5070 };
5071 let out = pl.to_m3u8().unwrap();
5072 assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
5073 let reparsed = MasterPlaylist::parse(&out).unwrap();
5074 assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5075 assert_eq!(reparsed.variants[0].average_bandwidth, None);
5076 }
5077
5078 #[test]
5081 fn average_bandwidth_must_be_a_decimal_integer() {
5082 let text =
5083 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x\nv/index.m3u8\n";
5084 let err = MasterPlaylist::parse(text).unwrap_err();
5085 match err {
5086 Error::HlsParse { reason, .. } => {
5087 assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5088 }
5089 other => panic!("expected HlsParse, got {other:?}"),
5090 }
5091 }
5092
5093 #[test]
5097 fn iframe_variant_renders_average_bandwidth_after_bandwidth() {
5098 let pl = MasterPlaylist {
5099 iframe_variants: vec![IFrameVariant {
5100 bandwidth: 200_000,
5101 average_bandwidth: Some(150_000),
5102 codecs: Some("hvc1.1.6.L93.B0".into()),
5103 resolution: Some((320, 240)),
5104 uri: "iframe.m3u8".into(),
5105 extra_attrs: Vec::new(),
5106 }],
5107 ..Default::default()
5108 };
5109 let out = pl.to_m3u8().unwrap();
5110 assert!(
5111 out.contains("#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=200000,AVERAGE-BANDWIDTH=150000,"),
5112 "out: {out}"
5113 );
5114 let reparsed = MasterPlaylist::parse(&out).unwrap();
5115 assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
5116 assert_eq!(reparsed.iframe_variants[0].average_bandwidth, Some(150_000));
5117 }
5118
5119 #[test]
5122 fn iframe_variant_omits_absent_average_bandwidth_and_round_trips_none() {
5123 let pl = MasterPlaylist {
5124 iframe_variants: vec![IFrameVariant {
5125 bandwidth: 200_000,
5126 average_bandwidth: None,
5127 codecs: Some("hvc1.1.6.L93.B0".into()),
5128 resolution: Some((320, 240)),
5129 uri: "iframe.m3u8".into(),
5130 extra_attrs: Vec::new(),
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.iframe_variants, pl.iframe_variants);
5138 assert_eq!(reparsed.iframe_variants[0].average_bandwidth, None);
5139 }
5140
5141 #[test]
5145 fn iframe_variant_average_bandwidth_not_retained_in_extra_attrs() {
5146 let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=1200,URI=\"i.m3u8\"\n";
5147 let pl = MasterPlaylist::parse(text).unwrap();
5148 assert_eq!(pl.iframe_variants[0].average_bandwidth, Some(1200));
5149 assert!(
5150 pl.iframe_variants[0].extra_attrs.is_empty(),
5151 "extra_attrs: {:?}",
5152 pl.iframe_variants[0].extra_attrs
5153 );
5154 assert!(
5155 !pl.iframe_variants[0]
5156 .extra_attrs
5157 .iter()
5158 .any(|(k, _)| k == "AVERAGE-BANDWIDTH")
5159 );
5160 }
5161
5162 #[test]
5165 fn iframe_average_bandwidth_must_be_a_decimal_integer() {
5166 let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x,URI=\"i.m3u8\"\n";
5167 let err = MasterPlaylist::parse(text).unwrap_err();
5168 match err {
5169 Error::HlsParse { reason, .. } => {
5170 assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5171 }
5172 other => panic!("expected HlsParse, got {other:?}"),
5173 }
5174 }
5175
5176 #[test]
5179 fn master_playlist_renders_frame_rate_after_resolution() {
5180 let pl = MasterPlaylist {
5181 media: vec![],
5182 variants: vec![Variant {
5183 bandwidth: 1_000_000,
5184 average_bandwidth: None,
5185 codecs: Some("avc1.640028".into()),
5186 resolution: Some((1280, 720)),
5187 frame_rate: Some(FrameRate::from_millihertz(29_970)),
5188 uri: "v/index.m3u8".into(),
5189 ..Default::default()
5190 }],
5191 ..Default::default()
5192 };
5193 let out = pl.to_m3u8().unwrap();
5194 assert!(
5195 out.contains("RESOLUTION=1280x720,FRAME-RATE=29.970"),
5196 "out: {out}"
5197 );
5198 let reparsed = MasterPlaylist::parse(&out).unwrap();
5199 assert_eq!(reparsed.variants, pl.variants);
5200 assert_eq!(
5201 reparsed.variants[0].frame_rate,
5202 Some(FrameRate::from_millihertz(29_970))
5203 );
5204 }
5205
5206 #[test]
5209 fn master_playlist_omits_absent_frame_rate_and_round_trips_none() {
5210 let pl = MasterPlaylist {
5211 media: vec![],
5212 variants: vec![Variant {
5213 bandwidth: 800_000,
5214 average_bandwidth: None,
5215 codecs: Some("avc1.640028".into()),
5216 resolution: None,
5217 frame_rate: None,
5218 uri: "v/index.m3u8".into(),
5219 ..Default::default()
5220 }],
5221 ..Default::default()
5222 };
5223 let out = pl.to_m3u8().unwrap();
5224 assert!(!out.contains("FRAME-RATE"), "out: {out}");
5225 let reparsed = MasterPlaylist::parse(&out).unwrap();
5226 assert_eq!(reparsed.variants[0].frame_rate, None);
5227 }
5228
5229 #[test]
5232 fn frame_rate_parses_various_forms() {
5233 for (text, expected) in [
5234 ("30", 30_000u32),
5235 ("29.97", 29_970u32),
5236 ("29.9704", 29_970u32),
5237 ] {
5238 let src = format!(
5239 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={text}\nv/index.m3u8\n"
5240 );
5241 let parsed = MasterPlaylist::parse(&src).unwrap();
5242 assert_eq!(
5243 parsed.variants[0].frame_rate,
5244 Some(FrameRate::from_millihertz(expected)),
5245 "text={text}"
5246 );
5247 }
5248 }
5249
5250 #[test]
5253 fn frame_rate_rejects_invalid_values() {
5254 for value in ["", "-1", "1e3", "abc", "1.2.3"] {
5255 let src = format!(
5256 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={value}\nv/index.m3u8\n"
5257 );
5258 let err = MasterPlaylist::parse(&src).unwrap_err();
5259 match err {
5260 Error::HlsParse { reason, .. } => {
5261 assert!(
5262 reason.contains("FRAME-RATE"),
5263 "value={value:?} reason={reason}"
5264 );
5265 }
5266 other => panic!("value={value:?} expected HlsParse, got {other:?}"),
5267 }
5268 }
5269 }
5270
5271 #[test]
5275 fn extinf_three_decimals() {
5276 let pl = playlist(vec![seg("s.m4s", 9.009)]);
5277 let out = pl.to_m3u8().unwrap();
5278 assert!(out.contains("#EXTINF:9.009,\n"), "{out}");
5279 }
5280
5281 #[test]
5295 fn integral_extinf_renders_as_an_integer_and_needs_no_version() {
5296 let pl = MediaPlaylist {
5299 version: 0,
5300 ..playlist(vec![seg("s.m4s", 9.0)])
5301 };
5302 let out = pl.to_m3u8().unwrap();
5303 assert!(out.contains("#EXTINF:9,\n"), "{out}");
5304 assert!(!out.contains("9.000"), "{out}");
5305 assert_eq!(
5306 pl.computed_version(),
5307 None,
5308 "a playlist with no floating-point EXTINF trips no §8 row: {out}"
5309 );
5310 assert!(!out.contains("#EXT-X-VERSION"), "{out}");
5311 let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5313 assert_eq!(
5314 reparsed.segments[0].duration,
5315 DecimalSeconds::new(9.0).unwrap()
5316 );
5317 }
5318
5319 #[test]
5326 fn extinf_rendering_and_version_derivation_never_diverge() {
5327 for (duration, expected_text, expected_version) in [
5328 (4.0_f64, "#EXTINF:4,", None),
5329 (4.004, "#EXTINF:4.004,", Some(3)),
5330 (9.9766, "#EXTINF:9.9766,", Some(3)), (4.0004, "#EXTINF:4.0004,", Some(3)), ] {
5333 let pl = playlist(vec![seg("s.m4s", duration)]);
5334 let out = pl.to_m3u8().unwrap();
5335 assert!(out.contains(expected_text), "{duration}: {out}");
5336 assert_eq!(pl.computed_version(), expected_version, "{duration}: {out}");
5337 assert_eq!(
5340 out.contains(expected_text) && expected_text.contains('.'),
5341 expected_version == Some(3),
5342 "{duration}: rendered text and §8 row 3 must agree: {out}"
5343 );
5344 let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5346 assert_eq!(reparsed.segments[0].duration.get(), duration, "{out}");
5347 }
5348 }
5349
5350 #[test]
5359 fn sub_millisecond_durations_survive_rendering() {
5360 let pl = playlist(vec![seg("main.ts", 9.9766)]);
5362 let out = pl.to_m3u8().unwrap();
5363 assert!(
5364 out.contains("#EXTINF:9.9766,\n"),
5365 "EXTINF must not be truncated to 3 decimals:\n{out}"
5366 );
5367 assert_eq!(
5368 MediaPlaylist::parse(&out).unwrap().segments[0].duration,
5369 DecimalSeconds::new(9.9766).unwrap(),
5370 "duration must survive a round trip bit-exactly"
5371 );
5372
5373 assert_eq!(format_secs(DecimalSeconds::new(0.334).unwrap()), "0.334");
5375 assert_eq!(format_secs(DecimalSeconds::new(1.5).unwrap()), "1.5");
5376 assert_eq!(format_secs(DecimalSeconds::new(6.0).unwrap()), "6");
5377 assert_eq!(
5379 format_secs(DecimalSeconds::new(2.00004).unwrap()),
5380 "2.00004"
5381 );
5382 assert_eq!(
5383 format_secs(DecimalSeconds::new(4.00008).unwrap()),
5384 "4.00008"
5385 );
5386 assert_eq!(
5387 format_signed_secs(SignedDecimalSeconds::new(-10.5).unwrap()),
5388 "-10.5"
5389 );
5390 assert_eq!(
5391 format_signed_secs(SignedDecimalSeconds::new(-2.00004).unwrap()),
5392 "-2.00004"
5393 );
5394 }
5395
5396 #[test]
5401 fn extinf_and_ll_hls_durations_reject_non_conformant_decimal_tokens() {
5402 for bad in ["nan", "inf", "-inf", "infinity", "1e10", "+3", "-5", ""] {
5403 let pl = format!("#EXTM3U\n#EXTINF:{bad},\nmain.ts\n");
5404 assert!(
5405 MediaPlaylist::parse(&pl).is_err(),
5406 "EXTINF:{bad} must be rejected"
5407 );
5408 }
5409 for bad in ["nan", "inf", "1e5"] {
5411 let pl = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET={bad}\n");
5412 assert!(
5413 MediaPlaylist::parse(&pl).is_err(),
5414 "TIME-OFFSET={bad} must be rejected"
5415 );
5416 }
5417 let pl = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET=-12.5\n";
5419 assert_eq!(
5420 MediaPlaylist::parse(pl).unwrap().start.unwrap().time_offset,
5421 SignedDecimalSeconds::new(-12.5).unwrap()
5422 );
5423 }
5424
5425 #[test]
5431 fn decimal_seconds_rejects_non_finite_and_negative() {
5432 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
5433 assert!(
5434 DecimalSeconds::new(bad).is_err(),
5435 "DecimalSeconds::new({bad}) must be rejected"
5436 );
5437 }
5438 assert_eq!(DecimalSeconds::new(6.0).unwrap().get(), 6.0);
5440 }
5441
5442 #[test]
5445 fn signed_decimal_seconds_rejects_non_finite_but_allows_negative() {
5446 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5447 assert!(
5448 SignedDecimalSeconds::new(bad).is_err(),
5449 "SignedDecimalSeconds::new({bad}) must be rejected"
5450 );
5451 }
5452 assert_eq!(SignedDecimalSeconds::new(-6.0).unwrap().get(), -6.0);
5453 }
5454
5455 #[test]
5461 fn round_trip_rfc_9_11_sub_millisecond_part_durations() {
5462 let pl = MediaPlaylist {
5463 version: 9,
5464 target_duration: 4,
5465 segments: vec![MediaSegment {
5466 uri: "fileSequence271.mp4".into(),
5467 duration: DecimalSeconds::new(4.00008).unwrap(),
5468 parts: vec![
5469 PartSpec {
5470 uri: "filePart271.0.mp4".into(),
5471 duration: DecimalSeconds::new(2.00004).unwrap(),
5472 independent: true,
5473 ..Default::default()
5474 },
5475 PartSpec {
5476 uri: "filePart271.1.mp4".into(),
5477 duration: DecimalSeconds::new(0.50001).unwrap(),
5478 ..Default::default()
5479 },
5480 ],
5481 ..Default::default()
5482 }],
5483 low_latency: Some(LowLatencyConfig {
5484 part_target: Some(DecimalSeconds::new(2.00002).unwrap()),
5485 part_hold_back: Some(DecimalSeconds::new(6.00006).unwrap()),
5486 ..Default::default()
5487 }),
5488 ..Default::default()
5489 };
5490 let text = pl.to_m3u8().unwrap();
5491 assert!(text.contains("#EXTINF:4.00008,"), "{text}");
5492 assert!(text.contains("DURATION=2.00004"), "{text}");
5493 assert!(text.contains("DURATION=0.50001"), "{text}");
5494 assert!(text.contains("PART-TARGET=2.00002"), "{text}");
5495 assert_eq!(
5496 MediaPlaylist::parse(&text).expect("must parse"),
5497 pl,
5498 "sub-ms LL-HLS durations must round-trip:\n{text}"
5499 );
5500 }
5501
5502 #[test]
5505 fn discontinuity_tag_emitted_before_extinf() {
5506 let pl = playlist(vec![
5508 seg("s0.m4s", 5.0),
5509 seg_disc("s1.m4s", 5.0),
5510 seg("s2.m4s", 5.0),
5511 ]);
5512 let out = pl.to_m3u8().unwrap();
5513 assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
5514 let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
5516 let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
5517 let s1_pos = out.find("s1.m4s\n").unwrap();
5519 out[..s1_pos].rfind("#EXTINF:").unwrap()
5521 });
5522 assert!(
5523 disc_pos < extinf_pos,
5524 "#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
5525 );
5526 let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
5528 assert!(
5529 out[tag_end..].starts_with("#EXTINF:"),
5530 "#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
5531 &out[tag_end..tag_end + 20]
5532 );
5533 }
5534
5535 #[test]
5536 fn no_discontinuity_tag_when_all_continuous() {
5537 let pl = playlist(vec![
5538 seg("s0.m4s", 5.0),
5539 seg("s1.m4s", 5.0),
5540 seg("s2.m4s", 5.0),
5541 ]);
5542 let out = pl.to_m3u8().unwrap();
5543 assert!(
5544 !out.contains("#EXT-X-DISCONTINUITY\n"),
5545 "no tag when all segments are continuous"
5546 );
5547 }
5548
5549 #[test]
5550 fn discontinuity_sequence_emitted_when_nonzero() {
5551 let pl = MediaPlaylist {
5552 version: 3,
5553 target_duration: 6,
5554 media_sequence: 5,
5555 discontinuity_sequence: 2,
5556 segments: vec![seg("s5.m4s", 6.0)],
5557 endlist: false,
5558 extra_tags: vec![],
5559 low_latency: None,
5560 iframes_only: false,
5561 open_segment: None,
5562 ..Default::default()
5563 };
5564 let out = pl.to_m3u8().unwrap();
5565 assert!(
5566 out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
5567 "header must be present when n>0"
5568 );
5569 }
5570
5571 #[test]
5572 fn discontinuity_sequence_absent_when_zero() {
5573 let pl = playlist(vec![seg("s0.m4s", 6.0)]);
5574 let out = pl.to_m3u8().unwrap();
5575 assert!(
5576 !out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
5577 "header must be absent when n==0"
5578 );
5579 }
5580
5581 fn ll_config() -> LowLatencyConfig {
5584 LowLatencyConfig {
5585 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5586 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5587 preload_hint_part: None,
5588 ..Default::default()
5589 }
5590 }
5591
5592 #[test]
5593 fn server_control_without_part_target_keeps_part_hold_back() {
5594 let pl = MediaPlaylist {
5595 target_duration: 4,
5596 low_latency: Some(LowLatencyConfig {
5597 part_target: None,
5598 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5599 hold_back: Some(DecimalSeconds::new(12.0).unwrap()),
5600 ..Default::default()
5601 }),
5602 ..Default::default()
5603 };
5604 let out = pl.to_m3u8().unwrap();
5605 assert!(out.contains("PART-HOLD-BACK=1.5"), "{out}");
5606 assert!(!out.contains("#EXT-X-PART-INF"), "{out}");
5607 }
5608
5609 #[test]
5610 fn ll_hls_renders_server_control_part_inf_and_parts() {
5611 let pl = MediaPlaylist {
5612 version: 9,
5613 target_duration: 4,
5614 media_sequence: 0,
5615 discontinuity_sequence: 0,
5616 segments: vec![MediaSegment {
5617 uri: "seg-1-4.m4s".into(),
5618 duration: DecimalSeconds::new(4.0).unwrap(),
5619 discontinuous: false,
5620 parts: vec![PartSpec {
5621 uri: "part-1-1.m4s".into(),
5622 duration: DecimalSeconds::new(0.5).unwrap(),
5623 independent: true,
5624 ..Default::default()
5625 }],
5626 ..Default::default()
5627 }],
5628 endlist: false,
5629 extra_tags: vec![],
5630 low_latency: Some(ll_config()),
5631 iframes_only: false,
5632 open_segment: None,
5633 ..Default::default()
5634 };
5635 let out = pl.to_m3u8().unwrap();
5636 assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
5637 assert!(out.contains("#EXT-X-PART-INF:PART-TARGET="));
5638 assert!(out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.m4s\""));
5639 }
5640
5641 #[test]
5642 fn open_segment_renders_parts_without_extinf() {
5643 let pl = MediaPlaylist {
5644 version: 9,
5645 target_duration: 4,
5646 media_sequence: 0,
5647 discontinuity_sequence: 0,
5648 segments: vec![MediaSegment {
5649 uri: "seg-1-4.m4s".into(),
5650 duration: DecimalSeconds::new(4.0).unwrap(),
5651 discontinuous: false,
5652 parts: vec![],
5653 ..Default::default()
5654 }],
5655 endlist: false,
5656 extra_tags: vec![],
5657 low_latency: Some(ll_config()),
5658 iframes_only: false,
5659 open_segment: Some(OpenSegment::new(vec![PartSpec {
5660 uri: "part-1-5.0.m4s".into(),
5661 duration: DecimalSeconds::new(0.5).unwrap(),
5662 independent: true,
5663 ..Default::default()
5664 }])),
5665 ..Default::default()
5666 };
5667 let out = pl.to_m3u8().unwrap();
5668 assert!(
5670 out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5671 "open segment's part must render:\n{out}"
5672 );
5673 assert!(out.contains("#EXTINF:4,\n"), "{out}");
5677 assert!(out.contains("seg-1-4.m4s"));
5678 assert!(
5681 !out.contains("#EXTINF:0.500,\npart-1-5.0.m4s"),
5682 "open segment must not be rendered as a closed #EXTINF segment:\n{out}"
5683 );
5684 assert_eq!(
5686 out.matches("#EXTINF:").count(),
5687 1,
5688 "only closed segments should have #EXTINF lines; open segment must not:\n{out}"
5689 );
5690 let lines: Vec<&str> = out.lines().collect();
5691 for (i, line) in lines.iter().enumerate() {
5692 if *line == "part-1-5.0.m4s" {
5693 panic!("open part URI must not appear on its own URI line: {out}");
5694 }
5695 if line.starts_with("#EXTINF") && i + 1 < lines.len() {
5696 assert_ne!(
5697 lines[i + 1],
5698 "part-1-5.0.m4s",
5699 "open part URI must not follow an #EXTINF line:\n{out}"
5700 );
5701 }
5702 }
5703 }
5704
5705 #[test]
5709 fn open_segment_map_does_not_count_toward_version_without_low_latency() {
5710 let mut open = OpenSegment::new(vec![PartSpec {
5711 uri: "part-1-5.0.m4s".into(),
5712 duration: DecimalSeconds::new(0.5).unwrap(),
5713 independent: true,
5714 ..Default::default()
5715 }]);
5716 open.map = Some(MapTag {
5717 uri: "init.mp4".into(),
5718 byte_range: None,
5719 extra_attrs: Vec::new(),
5720 });
5721 let mut pl = MediaPlaylist {
5722 target_duration: 4,
5723 segments: vec![seg("seg-1-4.m4s", 4.0)],
5724 open_segment: Some(open),
5725 ..Default::default()
5726 };
5727 assert_eq!(
5728 pl.computed_version(),
5729 None,
5730 "map not rendered, no version row"
5731 );
5732 pl.low_latency = Some(ll_config());
5733 assert!(pl.computed_version() >= Some(6));
5734 }
5735
5736 #[test]
5737 fn open_segment_not_rendered_without_low_latency() {
5738 let pl = MediaPlaylist {
5739 version: 9,
5740 target_duration: 4,
5741 media_sequence: 0,
5742 discontinuity_sequence: 0,
5743 segments: vec![seg("seg-1-4.m4s", 4.0)],
5744 endlist: false,
5745 extra_tags: vec![],
5746 low_latency: None,
5747 iframes_only: false,
5748 open_segment: Some(OpenSegment::new(vec![PartSpec {
5749 uri: "part-1-5.0.m4s".into(),
5750 duration: DecimalSeconds::new(0.5).unwrap(),
5751 independent: true,
5752 ..Default::default()
5753 }])),
5754 ..Default::default()
5755 };
5756 let out = pl.to_m3u8().unwrap();
5757 assert!(
5758 !out.contains("part-1-5.0.m4s"),
5759 "open segment parts must not render without low_latency:\n{out}"
5760 );
5761 assert!(!out.contains("#EXT-X-PART:"));
5762 }
5763
5764 #[test]
5765 fn preload_hint_rendered_from_low_latency() {
5766 let mut ll = ll_config();
5767 ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5768 let pl = MediaPlaylist {
5769 version: 9,
5770 target_duration: 4,
5771 media_sequence: 0,
5772 discontinuity_sequence: 0,
5773 segments: vec![seg("seg-1-4.m4s", 4.0)],
5774 endlist: false,
5775 extra_tags: vec![],
5776 low_latency: Some(ll),
5777 iframes_only: false,
5778 open_segment: None,
5779 ..Default::default()
5780 };
5781 let out = pl.to_m3u8().unwrap();
5782 assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""));
5783 }
5784
5785 #[test]
5786 fn open_segment_parts_precede_preload_hint() {
5787 let mut ll = ll_config();
5788 ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5789 let pl = MediaPlaylist {
5790 version: 9,
5791 target_duration: 4,
5792 media_sequence: 0,
5793 discontinuity_sequence: 0,
5794 segments: vec![MediaSegment {
5795 uri: "seg-1-4.m4s".into(),
5796 duration: DecimalSeconds::new(4.0).unwrap(),
5797 discontinuous: false,
5798 parts: vec![],
5799 ..Default::default()
5800 }],
5801 endlist: false,
5802 extra_tags: vec![],
5803 low_latency: Some(ll),
5804 iframes_only: false,
5805 open_segment: Some(OpenSegment::new(vec![PartSpec {
5806 uri: "part-1-5.0.m4s".into(),
5807 duration: DecimalSeconds::new(0.5).unwrap(),
5808 independent: true,
5809 ..Default::default()
5810 }])),
5811 ..Default::default()
5812 };
5813 let out = pl.to_m3u8().unwrap();
5814 assert!(
5816 out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5817 "open-segment part must be present:\n{out}"
5818 );
5819 assert!(
5820 out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""),
5821 "preload-hint must be present:\n{out}"
5822 );
5823 let part_pos = out
5825 .find("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES")
5826 .expect("open-segment part line not found");
5827 let preload_pos = out
5828 .find("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\"")
5829 .expect("preload-hint line not found");
5830 assert!(
5831 part_pos < preload_pos,
5832 "open-segment #EXT-X-PART must precede #EXT-X-PRELOAD-HINT:\npart at {}, preload at {}\noutput:\n{out}",
5833 part_pos,
5834 preload_pos
5835 );
5836 }
5837
5838 #[test]
5848 fn open_segment_inherits_map_when_no_segment_has_closed_yet() {
5849 let text = "#EXTM3U\n\
5850#EXT-X-VERSION:9\n\
5851#EXT-X-TARGETDURATION:4\n\
5852#EXT-X-MEDIA-SEQUENCE:1\n\
5853#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
5854#EXT-X-PART-INF:PART-TARGET=0.5\n\
5855#EXT-X-MAP:URI=\"init-1.mp4\"\n\
5856#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.0.m4s\",INDEPENDENT=YES\n\
5857#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-1.1.m4s\"\n";
5858 let pl = MediaPlaylist::parse(text).expect("must parse");
5859 assert!(pl.segments.is_empty(), "no segment has closed yet");
5860 let open = pl.open_segment.as_ref().expect("one open segment");
5861 assert_eq!(
5862 open.map,
5863 Some(MapTag {
5864 uri: "init-1.mp4".into(),
5865 byte_range: None,
5866 extra_attrs: Vec::new(),
5867 }),
5868 "the open segment must inherit the EXT-X-MAP that precedes it, \
5869 even though no segment has closed yet"
5870 );
5871 }
5872
5873 #[test]
5877 fn round_trip_open_segment_only_with_map() {
5878 let pl = MediaPlaylist {
5879 version: 9,
5880 target_duration: 4,
5881 media_sequence: 1,
5882 discontinuity_sequence: 0,
5883 segments: vec![],
5884 open_segment: Some(
5885 OpenSegment::new(vec![PartSpec {
5886 uri: "part-1-1.0.m4s".into(),
5887 duration: DecimalSeconds::new(0.5).unwrap(),
5888 independent: true,
5889 ..Default::default()
5890 }])
5891 .with_map(MapTag {
5892 uri: "init-1.mp4".into(),
5893 byte_range: None,
5894 extra_attrs: Vec::new(),
5895 }),
5896 ),
5897 endlist: false,
5898 extra_tags: vec![],
5899 low_latency: Some(ll_config()),
5900 iframes_only: false,
5901 ..Default::default()
5902 };
5903 let text = pl.to_m3u8().unwrap();
5904 assert!(
5905 text.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
5906 "renderer must emit EXT-X-MAP for a bare open segment:\n{text}"
5907 );
5908 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
5909 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
5910 }
5911
5912 fn ll_config_full() -> LowLatencyConfig {
5915 LowLatencyConfig {
5916 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5917 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()), preload_hint_part: Some("part-9.2.m4s".into()),
5919 preload_hint_type: PreloadHintType::Part,
5920 preload_hint_byte_range_start: Some(0),
5921 preload_hint_byte_range_length: Some(1000),
5922 can_skip_until: Some(DecimalSeconds::new(24.0).unwrap()),
5923 can_block_reload: true,
5924 hold_back: None,
5925 can_skip_dateranges: false,
5926 ..Default::default()
5927 }
5928 }
5929
5930 #[test]
5931 fn round_trip_live_ll_playlist_with_parts_preload_and_server_control() {
5932 let map = MapTag {
5933 uri: "init.mp4".into(),
5934 byte_range: Some(ByteRange {
5935 length: 800,
5936 offset: Some(0),
5937 }),
5938 extra_attrs: Vec::new(),
5939 };
5940 let pl = MediaPlaylist {
5941 version: 9,
5942 target_duration: 4,
5943 media_sequence: 100,
5944 discontinuity_sequence: 0,
5945 segments: vec![MediaSegment {
5946 uri: "seg-9.m4s".into(),
5947 duration: DecimalSeconds::new(4.0).unwrap(),
5948 discontinuous: false,
5949 parts: vec![
5950 PartSpec {
5951 uri: "part-9.0.m4s".into(),
5952 duration: DecimalSeconds::new(0.5).unwrap(),
5953 independent: true,
5954 byte_range: None,
5955 gap: false,
5956 ..Default::default()
5957 },
5958 PartSpec {
5959 uri: "part-9.1.m4s".into(),
5960 duration: DecimalSeconds::new(0.5).unwrap(),
5961 independent: false,
5962 byte_range: Some(ByteRange {
5963 length: 500,
5964 offset: Some(1000),
5965 }),
5966 gap: false,
5967 ..Default::default()
5968 },
5969 ],
5970 byte_range: None,
5971 map: Some(map.clone()),
5972 ..Default::default()
5973 }],
5974 open_segment: Some(
5980 OpenSegment::new(vec![PartSpec {
5981 uri: "part-10.0.m4s".into(),
5982 duration: DecimalSeconds::new(0.5).unwrap(),
5983 independent: true,
5984 byte_range: None,
5985 gap: true,
5986 extra_attrs: Vec::new(),
5987 }])
5988 .with_map(map.clone()),
5989 ),
5990 endlist: false,
5991 extra_tags: vec![],
5992 low_latency: Some(ll_config_full()),
5993 iframes_only: false,
5994 rendition_reports: vec![RenditionReport {
5995 uri: "../audio/playlist.m3u8".into(),
5996 last_msn: 100,
5997 last_part: Some(1),
5998 extra_attrs: Vec::new(),
5999 }],
6000 skip: None,
6001 ..Default::default()
6002 };
6003 let text = pl.to_m3u8().unwrap();
6004 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6005 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6006 }
6007
6008 #[test]
6009 fn round_trip_vod_playlist_with_byteranges_map_and_endlist() {
6010 let map = MapTag {
6011 uri: "init.mp4".into(),
6012 byte_range: None,
6013 extra_attrs: Vec::new(),
6014 };
6015 let pl = MediaPlaylist {
6016 version: 6,
6017 target_duration: 10,
6018 media_sequence: 0,
6019 discontinuity_sequence: 0,
6020 segments: vec![
6021 MediaSegment {
6022 uri: "media.ts".into(),
6023 duration: DecimalSeconds::new(10.0).unwrap(),
6024 discontinuous: false,
6025 parts: vec![],
6026 byte_range: Some(ByteRange {
6027 length: 500_000,
6028 offset: Some(0),
6029 }),
6030 map: Some(map.clone()),
6031 ..Default::default()
6032 },
6033 MediaSegment {
6034 uri: "media.ts".into(),
6035 duration: DecimalSeconds::new(10.0).unwrap(),
6036 discontinuous: false,
6037 parts: vec![],
6038 byte_range: Some(ByteRange {
6041 length: 500_000,
6042 offset: None,
6043 }),
6044 map: Some(map.clone()),
6047 pre_tags: vec![
6048 "#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
6049 .into(),
6050 ],
6051 ..Default::default()
6052 },
6053 ],
6054 open_segment: None,
6055 endlist: true,
6056 low_latency: None,
6057 iframes_only: false,
6058 rendition_reports: vec![],
6059 skip: None,
6060 ..Default::default()
6061 };
6062 let text = pl.to_m3u8().unwrap();
6063 assert_eq!(
6065 text.matches("#EXT-X-MAP:").count(),
6066 1,
6067 "identical map on consecutive segments must render once:\n{text}"
6068 );
6069 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6070 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6071 }
6072
6073 #[test]
6078 fn round_trip_media_playlist_with_new_872_tags() {
6079 let pl = MediaPlaylist {
6080 version: 6,
6081 target_duration: 10,
6082 media_sequence: 0,
6083 discontinuity_sequence: 0,
6084 independent_segments: true,
6085 start: Some(StartPoint {
6086 time_offset: SignedDecimalSeconds::new(5.5).unwrap(),
6087 precise: false,
6088 extra_attrs: Vec::new(),
6089 }),
6090 defines: vec![Define::Import {
6091 name: "base".into(),
6092 extra_attrs: Vec::new(),
6093 }],
6094 playlist_type: Some(PlaylistType::Vod),
6095 segments: vec![
6096 MediaSegment {
6097 uri: "seg0.ts".into(),
6098 duration: DecimalSeconds::new(10.0).unwrap(),
6099 bitrate: Some(2000),
6100 ..Default::default()
6101 },
6102 MediaSegment {
6103 uri: "seg1.ts".into(),
6104 duration: DecimalSeconds::new(10.0).unwrap(),
6105 gap: true,
6106 bitrate: Some(2000),
6107 ..Default::default()
6108 },
6109 MediaSegment {
6110 uri: "seg2.ts".into(),
6111 duration: DecimalSeconds::new(10.0).unwrap(),
6112 bitrate: Some(1800),
6113 ..Default::default()
6114 },
6115 ],
6116 endlist: true,
6117 ..Default::default()
6118 };
6119 let text = pl.to_m3u8().unwrap();
6120 assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6121 assert!(text.contains("#EXT-X-DEFINE:IMPORT=\"base\"\n"));
6122 assert!(text.contains("#EXT-X-START:TIME-OFFSET=5.5\n"));
6123 assert!(
6124 !text.contains("PRECISE"),
6125 "PRECISE=NO must be omitted:\n{text}"
6126 );
6127 assert!(text.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
6128 assert!(text.contains("#EXT-X-GAP\n"));
6129 assert_eq!(
6132 text.matches("#EXT-X-BITRATE:").count(),
6133 2,
6134 "unchanged bitrate must not re-emit:\n{text}"
6135 );
6136 assert!(text.contains("#EXT-X-BITRATE:2000\n"));
6137 assert!(text.contains("#EXT-X-BITRATE:1800\n"));
6138 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6139 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6140 }
6141
6142 #[test]
6146 fn parse_rejects_unrecognized_playlist_type() {
6147 let text = "#EXTM3U\n\
6148#EXT-X-VERSION:3\n\
6149#EXT-X-TARGETDURATION:6\n\
6150#EXT-X-PLAYLIST-TYPE:LIVE\n\
6151#EXTINF:6.000,\n\
6152s0.m4s\n";
6153 let err = MediaPlaylist::parse(text).expect_err("unrecognized PLAYLIST-TYPE must error");
6154 assert!(matches!(err, Error::HlsParse { .. }));
6155 }
6156
6157 #[test]
6160 fn parse_rejects_define_with_wrong_attribute_count() {
6161 let none = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-DEFINE:VALUE=\"x\"\n\
6162#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6163 let err = MediaPlaylist::parse(none).expect_err("DEFINE with none of the three must error");
6164 assert!(matches!(err, Error::HlsParse { .. }));
6165
6166 let both = "#EXTM3U\n#EXT-X-VERSION:3\n\
6167#EXT-X-DEFINE:NAME=\"a\",VALUE=\"1\",IMPORT=\"b\"\n\
6168#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6169 let err = MediaPlaylist::parse(both).expect_err("DEFINE with two of the three must error");
6170 assert!(matches!(err, Error::HlsParse { .. }));
6171 }
6172
6173 #[test]
6175 fn parse_rejects_session_data_with_wrong_content_count() {
6176 let neither = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\"\n";
6177 let err = MasterPlaylist::parse(neither)
6178 .expect_err("SESSION-DATA with neither VALUE nor URI must error");
6179 assert!(matches!(err, Error::HlsParse { .. }));
6180
6181 let both = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\",VALUE=\"v\",URI=\"u\"\n";
6182 let err = MasterPlaylist::parse(both)
6183 .expect_err("SESSION-DATA with both VALUE and URI must error");
6184 assert!(matches!(err, Error::HlsParse { .. }));
6185 }
6186
6187 #[test]
6189 fn parse_rejects_malformed_session_key_iv() {
6190 let text = "#EXTM3U\n\
6191#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k\",IV=0xnotahexvalue\n";
6192 let err = MasterPlaylist::parse(text).expect_err("malformed IV must error");
6193 assert!(matches!(err, Error::InvalidIv { value } if value == "0xnotahexvalue"));
6194 }
6195
6196 #[test]
6197 fn round_trip_multivariant_playlist() {
6198 let pl = MasterPlaylist {
6199 version: 7,
6200 media: vec![],
6201 variants: vec![
6202 Variant {
6203 bandwidth: 300_000,
6204 average_bandwidth: None,
6205 frame_rate: None,
6206 codecs: Some("avc1.64001e,mp4a.40.2".into()),
6207 resolution: Some((640, 360)),
6208 uri: "v300/index.m3u8".into(),
6209 ..Default::default()
6210 },
6211 Variant {
6212 bandwidth: 800_000,
6213 average_bandwidth: None,
6214 frame_rate: None,
6215 codecs: Some("avc1.640028,mp4a.40.2".into()),
6216 resolution: Some((1280, 720)),
6217 uri: "v800/index.m3u8".into(),
6218 ..Default::default()
6219 },
6220 ],
6221 iframe_variants: vec![IFrameVariant {
6222 bandwidth: 50_000,
6223 average_bandwidth: None,
6224 codecs: Some("avc1.64001e".into()),
6225 resolution: Some((640, 360)),
6226 uri: "v300/iframe.m3u8".into(),
6227 extra_attrs: Vec::new(),
6228 }],
6229 ..Default::default()
6230 };
6231 let text = pl.to_m3u8().unwrap();
6232 let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6233 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6234 }
6235
6236 #[test]
6241 fn round_trip_multivariant_playlist_with_new_872_tags() {
6242 let pl = MasterPlaylist {
6249 version: 11,
6250 independent_segments: true,
6251 start: Some(StartPoint {
6252 time_offset: SignedDecimalSeconds::new(-10.5).unwrap(),
6253 precise: true,
6254 extra_attrs: Vec::new(),
6255 }),
6256 defines: vec![
6257 Define::Name {
6258 name: "base".into(),
6259 value: "https://cdn.example.com/video12".into(),
6260 extra_attrs: Vec::new(),
6261 },
6262 Define::QueryParam {
6263 name: "token".into(),
6264 extra_attrs: Vec::new(),
6265 },
6266 ],
6267 session_data: vec![
6268 SessionData {
6269 data_id: "com.example.lyrics".into(),
6270 content: SessionDataContent::Uri {
6271 uri: "lyrics.json".into(),
6272 format: SessionDataFormat::Json,
6273 },
6274 language: None,
6275 extra_attrs: Vec::new(),
6276 },
6277 SessionData {
6278 data_id: "com.example.title".into(),
6279 content: SessionDataContent::Value("This is an example".into()),
6280 language: Some("en".into()),
6281 extra_attrs: Vec::new(),
6282 },
6283 ],
6284 session_keys: vec![
6285 SessionKey {
6286 method: EncryptionMethod::Aes128,
6287 uri: Some("https://priv.example.com/key.php?r=52".into()),
6288 iv: None,
6289 keyformat: Some("identity".into()),
6290 keyformatversions: Some("1".into()),
6291 extra_attrs: Vec::new(),
6292 },
6293 SessionKey {
6294 method: EncryptionMethod::SampleAesCtr,
6295 uri: Some("skd://key2".into()),
6296 iv: Some([
6297 0x9c, 0x7d, 0xb8, 0x77, 0x85, 0x70, 0xd0, 0x5c, 0x3a, 0x5e, 0x3d, 0x2c,
6298 0x8a, 0xe5, 0x5e, 0x46,
6299 ]),
6300 keyformat: None,
6301 keyformatversions: None,
6302 extra_attrs: Vec::new(),
6303 },
6304 ],
6305 content_steering: Some(ContentSteering {
6306 server_uri: "/steering?video=00012".into(),
6307 pathway_id: Some("CDN-A".into()),
6308 extra_attrs: Vec::new(),
6309 }),
6310 media: vec![],
6311 variants: vec![Variant {
6312 bandwidth: 1_280_000,
6313 average_bandwidth: None,
6314 frame_rate: None,
6315 codecs: Some("avc1.64001e,mp4a.40.2".into()),
6316 resolution: Some((640, 360)),
6317 uri: "low/index.m3u8".into(),
6318 audio: None,
6319 video: None,
6320 subtitles: None,
6321 closed_captions: None,
6322 extra_attrs: Vec::new(),
6323 }],
6324 iframe_variants: vec![],
6325 extra_tags: vec![],
6326 };
6327 assert_eq!(
6328 pl.computed_version(),
6329 Some(11),
6330 "EXT-X-DEFINE with QUERYPARAM must derive §8 row 11 from the \
6331 typed `defines` field (issue #872 + #880 integration)"
6332 );
6333 let text = pl.to_m3u8().unwrap();
6334 assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6335 assert!(text.contains("#EXT-X-START:TIME-OFFSET=-10.5,PRECISE=YES\n"));
6336 assert!(
6337 text.contains(
6338 "#EXT-X-DEFINE:NAME=\"base\",VALUE=\"https://cdn.example.com/video12\"\n"
6339 )
6340 );
6341 assert!(text.contains("#EXT-X-DEFINE:QUERYPARAM=\"token\"\n"));
6342 assert!(
6343 text.contains(
6344 "#EXT-X-SESSION-DATA:DATA-ID=\"com.example.lyrics\",URI=\"lyrics.json\"\n"
6345 )
6346 );
6347 assert!(text.contains(
6348 "#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"skd://key2\",IV=0x9c7db8778570d05c3a5e3d2c8ae55e46\n"
6349 ));
6350 assert!(text.contains(
6351 "#EXT-X-CONTENT-STEERING:SERVER-URI=\"/steering?video=00012\",PATHWAY-ID=\"CDN-A\"\n"
6352 ));
6353 let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6354 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6355 }
6356
6357 #[test]
6362 fn real_world_sample_ll_playlist_from_rfc8216bis_appendix() {
6363 let text = "\
6364#EXTM3U
6365#EXT-X-VERSION:9
6366#EXT-X-TARGETDURATION:4
6367#EXT-X-MEDIA-SEQUENCE:266
6368#EXT-X-PART-INF:PART-TARGET=2.00002
6369#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=6.00006
6370#EXTINF:4.00008,
6371fileSequence268.mp4
6372#EXTINF:4.00008,
6373fileSequence269.mp4
6374#EXTINF:4.00008,
6375fileSequence270.mp4
6376#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart271.0.mp4\"
6377#EXT-X-PART:DURATION=2.00004,URI=\"filePart271.1.mp4\"
6378#EXTINF:4.00008,
6379fileSequence271.mp4
6380#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart272.0.mp4\"
6381#EXT-X-PART:DURATION=0.50001,URI=\"filePart272.1.mp4\"
6382#EXTINF:2.50005,
6383fileSequence272.mp4
6384#EXT-X-DISCONTINUITY
6385#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll273.0.mp4\"
6386#EXT-X-PART:DURATION=2.00004,URI=\"midRoll273.1.mp4\"
6387#EXTINF:4.00008,
6388midRoll273.mp4
6389#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll274.0.mp4\"
6390#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"midRoll274.1.mp4\"
6391#EXT-X-RENDITION-REPORT:URI=\"/1M/LL-HLS.m3u8\",LAST-MSN=274,LAST-PART=1
6392";
6393 let pl = MediaPlaylist::parse(text).expect("real-world LL sample must parse");
6394 assert_eq!(pl.version, 9);
6395 assert_eq!(pl.target_duration, 4);
6396 assert_eq!(pl.media_sequence, 266);
6397 assert_eq!(pl.segments.len(), 6, "{:?}", pl.segments);
6401 assert_eq!(pl.segments[4].uri, "fileSequence272.mp4");
6402 assert_eq!(pl.segments[4].parts.len(), 2);
6403 assert!(pl.segments[4].parts[0].independent);
6404 assert!(!pl.segments[4].parts[1].independent);
6405 assert_eq!(pl.segments[5].uri, "midRoll273.mp4");
6406 assert!(
6407 pl.segments[5].discontinuous,
6408 "midRoll273 follows #EXT-X-DISCONTINUITY"
6409 );
6410 let open = pl.open_segment.as_ref().expect("midRoll274 is open");
6411 assert_eq!(open.parts.len(), 1);
6412 assert_eq!(open.parts[0].uri, "midRoll274.0.mp4");
6413 let ll = pl.low_latency.as_ref().expect("LL config must be present");
6414 assert_eq!(ll.preload_hint_part.as_deref(), Some("midRoll274.1.mp4"));
6415 assert!(
6416 ll.can_block_reload,
6417 "CAN-BLOCK-RELOAD=YES in the fixture must parse to true"
6418 );
6419 assert_eq!(pl.rendition_reports.len(), 1);
6420 assert_eq!(pl.rendition_reports[0].uri, "/1M/LL-HLS.m3u8");
6421 assert_eq!(pl.rendition_reports[0].last_msn, 274);
6422 assert_eq!(pl.rendition_reports[0].last_part, Some(1));
6423 }
6424
6425 #[test]
6429 fn real_world_sample_delta_update_with_skip() {
6430 let text = "#EXTM3U\n\
6431#EXT-X-VERSION:9\n\
6432#EXT-X-TARGETDURATION:4\n\
6433#EXT-X-MEDIA-SEQUENCE:1000\n\
6434#EXT-X-PART-INF:PART-TARGET=0.5\n\
6435#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24.0,PART-HOLD-BACK=1.5\n\
6436#EXT-X-SKIP:SKIPPED-SEGMENTS=996,RECENTLY-REMOVED-DATERANGES=\"ad-1\tad-2\"\n\
6437#EXTINF:4.00000,\n\
6438fileSequence1996.mp4\n\
6439#EXTINF:4.00000,\n\
6440fileSequence1997.mp4\n";
6441 let pl = MediaPlaylist::parse(text).expect("delta update sample must parse");
6442 assert_eq!(pl.media_sequence, 1000);
6443 assert_eq!(pl.segments.len(), 2);
6444 assert_eq!(pl.segments[0].uri, "fileSequence1996.mp4");
6445 let skip = pl.skip.as_ref().expect("EXT-X-SKIP must be captured");
6446 assert_eq!(skip.skipped_segments, 996);
6447 assert_eq!(skip.recently_removed_daterange_ids, vec!["ad-1", "ad-2"]);
6448 let ll = pl.low_latency.as_ref().expect("LL config must be present");
6449 assert_eq!(ll.can_skip_until, Some(DecimalSeconds::new(24.0).unwrap()));
6450 assert!(!pl.endlist);
6451 }
6452
6453 #[test]
6456 fn skip_recently_removed_ids_reject_injection() {
6457 let text = "#EXTM3U\n#EXT-X-VERSION:10\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:0\n\
6458#EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES=\"ad-1\\tad-2\"\n\
6459#EXTINF:4,\na.mp4\n"
6460 .replace("\\t", "\t");
6461 let good = MediaPlaylist::parse(&text).unwrap();
6462 assert_eq!(good.to_m3u8().unwrap(), text);
6464 for bad in ["a\"b", "a\rb", "a\nb", "a\tb"] {
6465 let mut pl = good.clone();
6466 pl.skip.as_mut().unwrap().recently_removed_daterange_ids = vec![bad.to_string()];
6467 assert!(
6468 matches!(pl.to_m3u8(), Err(Error::InvalidQuotedString { .. })),
6469 "{bad:?}"
6470 );
6471 }
6472 }
6473
6474 #[test]
6479 fn real_world_sample_can_block_reload_no_is_parsed_not_inferred() {
6480 let text = "#EXTM3U\n\
6481#EXT-X-VERSION:9\n\
6482#EXT-X-TARGETDURATION:4\n\
6483#EXT-X-MEDIA-SEQUENCE:0\n\
6484#EXT-X-PART-INF:PART-TARGET=0.5\n\
6485#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,PART-HOLD-BACK=1.5\n\
6486#EXTINF:4.00000,\n\
6487seg0.mp4\n";
6488 let pl = MediaPlaylist::parse(text).expect("must parse");
6489 let ll = pl
6490 .low_latency
6491 .as_ref()
6492 .expect("LL config must be present (PART-INF seen)");
6493 assert!(
6494 !ll.can_block_reload,
6495 "CAN-BLOCK-RELOAD=NO must not be inferred as true just because low_latency is Some"
6496 );
6497 }
6498
6499 #[test]
6504 fn parse_defaults_can_block_reload_false_when_attribute_absent() {
6505 let text = "#EXTM3U\n\
6506#EXT-X-VERSION:9\n\
6507#EXT-X-TARGETDURATION:4\n\
6508#EXT-X-MEDIA-SEQUENCE:0\n\
6509#EXT-X-PART-INF:PART-TARGET=0.5\n\
6510#EXTINF:4.00000,\n\
6511seg0.mp4\n";
6512 let pl = MediaPlaylist::parse(text).expect("must parse");
6513 let ll = pl
6514 .low_latency
6515 .as_ref()
6516 .expect("LL config must be present (PART-INF seen)");
6517 assert!(
6518 !ll.can_block_reload,
6519 "absent CAN-BLOCK-RELOAD/SERVER-CONTROL must default to false, not true"
6520 );
6521 }
6522
6523 #[test]
6526 fn round_trip_can_block_reload_no() {
6527 let pl = MediaPlaylist {
6528 version: 9,
6529 target_duration: 4,
6530 media_sequence: 0,
6531 discontinuity_sequence: 0,
6532 segments: vec![MediaSegment {
6533 uri: "seg0.mp4".into(),
6534 duration: DecimalSeconds::new(4.0).unwrap(),
6535 ..Default::default()
6536 }],
6537 low_latency: Some(LowLatencyConfig {
6538 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
6539 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
6540 can_block_reload: false,
6541 ..Default::default()
6542 }),
6543 ..Default::default()
6544 };
6545 let text = pl.to_m3u8().unwrap();
6546 assert!(
6547 text.contains("CAN-BLOCK-RELOAD=NO"),
6548 "renderer must emit the actual value:\n{text}"
6549 );
6550 let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6551 assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6552 }
6553
6554 #[test]
6555 fn parse_ignores_unrecognized_tag_by_preserving_it_into_extra_tags() {
6556 let text = "#EXTM3U\n\
6557#EXT-X-VERSION:3\n\
6558#EXT-X-TARGETDURATION:6\n\
6559#EXT-X-MEDIA-SEQUENCE:0\n\
6560#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z\n\
6561#EXTINF:6.000,\n\
6562s0.m4s\n\
6563#EXT-X-ENDLIST\n";
6564 let pl = MediaPlaylist::parse(text).expect("unrecognized tag must not error");
6565 assert!(
6569 pl.segments
6570 .first()
6571 .is_some_and(|s| s.program_date_time.is_some()),
6572 "EXT-X-PROGRAM-DATE-TIME must be preserved as a typed field, not dropped: {:?}",
6573 pl.segments.first().map(|s| s.program_date_time)
6574 );
6575 }
6576
6577 #[test]
6578 fn parse_rejects_missing_targetduration() {
6579 let text = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.000,\ns0.m4s\n";
6580 let err = MediaPlaylist::parse(text).expect_err("missing TARGETDURATION must error");
6581 assert!(matches!(err, Error::HlsParse { .. }));
6582 }
6583
6584 #[test]
6585 fn parse_rejects_malformed_part_missing_duration() {
6586 let text = "#EXTM3U\n\
6587#EXT-X-VERSION:9\n\
6588#EXT-X-TARGETDURATION:4\n\
6589#EXT-X-PART-INF:PART-TARGET=0.5\n\
6590#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
6591#EXT-X-PART:URI=\"part.m4s\"\n\
6592#EXTINF:4.000,\n\
6593seg.m4s\n";
6594 let err = MediaPlaylist::parse(text).expect_err("EXT-X-PART without DURATION must error");
6595 let Error::HlsParse { reason, .. } = err else {
6596 panic!("expected Error::HlsParse, got {err:?}")
6597 };
6598 assert!(reason.contains("DURATION"), "{reason}");
6599 }
6600
6601 #[test]
6602 fn parse_rejects_variant_uri_with_no_preceding_stream_inf() {
6603 let text = "#EXTM3U\n#EXT-X-VERSION:6\nv300/index.m3u8\n";
6604 let err = MasterPlaylist::parse(text).expect_err("orphan variant URI must error");
6605 assert!(matches!(err, Error::HlsParse { .. }));
6606 }
6607
6608 #[test]
6609 fn parse_master_playlist_models_ext_x_media() {
6610 let text = "#EXTM3U\n\
6611#EXT-X-VERSION:7\n\
6612#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,AUTOSELECT=YES,URI=\"eng.m3u8\"\n\
6613#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\",AUDIO=\"aac\"\n\
6614v300/index.m3u8\n";
6615 let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must parse");
6616 assert_eq!(pl.variants.len(), 1);
6617 assert_eq!(pl.media.len(), 1);
6618 assert_eq!(pl.media[0].name, "English");
6619 }
6620
6621 fn base_media_playlist() -> MediaPlaylist {
6631 MediaPlaylist {
6632 version: 0,
6633 target_duration: 6,
6634 media_sequence: 0,
6635 segments: vec![seg("s0.m4s", 6.0)],
6636 endlist: true,
6637 ..Default::default()
6638 }
6639 }
6640
6641 fn rendered_version(m3u8: &str) -> Option<u8> {
6644 m3u8.lines()
6645 .find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
6646 .map(|v| v.parse::<u8>().expect("version must be a valid u8"))
6647 }
6648
6649 #[test]
6650 fn version_row1_no_trigger_omits_the_tag() {
6651 let out = base_media_playlist().to_m3u8().unwrap();
6652 assert_eq!(rendered_version(&out), None, "no trigger:\n{out}");
6653 }
6654
6655 #[test]
6656 fn version_row2_key_iv_triggers_v2() {
6657 let mut pl = base_media_playlist();
6658 pl.extra_tags = vec![
6659 "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",IV=0x00000000000000000000000000000001"
6660 .into(),
6661 ];
6662 let out = pl.to_m3u8().unwrap();
6663 assert_eq!(rendered_version(&out), Some(2), "{out}");
6664 }
6665
6666 #[test]
6667 fn version_row3_float_extinf_triggers_v3() {
6668 let mut pl = base_media_playlist();
6669 pl.segments = vec![seg("s0.m4s", 6.5)];
6670 let out = pl.to_m3u8().unwrap();
6671 assert_eq!(rendered_version(&out), Some(3), "{out}");
6672 }
6673
6674 #[test]
6675 fn version_row4_byterange_triggers_v4() {
6676 let mut pl = base_media_playlist();
6677 pl.segments[0].byte_range = Some(ByteRange {
6678 length: 1000,
6679 offset: Some(0),
6680 });
6681 let out = pl.to_m3u8().unwrap();
6682 assert_eq!(rendered_version(&out), Some(4), "{out}");
6683 }
6684
6685 #[test]
6686 fn version_row4_iframes_only_triggers_v4() {
6687 let mut pl = base_media_playlist();
6688 pl.iframes_only = true;
6689 let out = pl.to_m3u8().unwrap();
6690 assert_eq!(rendered_version(&out), Some(4), "{out}");
6691 }
6692
6693 #[test]
6694 fn version_row5_sample_aes_triggers_v5() {
6695 let mut pl = base_media_playlist();
6696 pl.extra_tags = vec!["#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://k\",KEYID=0x01".into()];
6697 let out = pl.to_m3u8().unwrap();
6698 assert_eq!(rendered_version(&out), Some(5), "{out}");
6699 }
6700
6701 #[test]
6702 fn version_row5_keyformat_triggers_v5() {
6703 let mut pl = base_media_playlist();
6704 pl.extra_tags = vec![
6705 "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",KEYFORMAT=\"com.example\",\
6706 KEYFORMATVERSIONS=\"1\""
6707 .into(),
6708 ];
6709 let out = pl.to_m3u8().unwrap();
6710 assert_eq!(rendered_version(&out), Some(5), "{out}");
6711 }
6712
6713 #[test]
6714 fn version_row5_map_with_iframes_only_triggers_v5_not_v6() {
6715 let mut pl = base_media_playlist();
6716 pl.iframes_only = true;
6717 pl.segments[0].map = Some(MapTag {
6718 uri: "init.mp4".into(),
6719 byte_range: None,
6720 extra_attrs: Vec::new(),
6721 });
6722 let out = pl.to_m3u8().unwrap();
6723 assert_eq!(rendered_version(&out), Some(5), "{out}");
6724 }
6725
6726 #[test]
6727 fn version_row6_map_without_iframes_only_triggers_v6() {
6728 let mut pl = base_media_playlist();
6729 pl.segments[0].map = Some(MapTag {
6730 uri: "init.mp4".into(),
6731 byte_range: None,
6732 extra_attrs: Vec::new(),
6733 });
6734 let out = pl.to_m3u8().unwrap();
6735 assert_eq!(rendered_version(&out), Some(6), "{out}");
6736 }
6737
6738 #[test]
6739 fn version_row7_media_service_instream_id_triggers_v7_multivariant_only() {
6740 let mut pl = MasterPlaylist {
6741 version: 0,
6742 media: vec![],
6743 variants: vec![Variant {
6744 bandwidth: 300_000,
6745 average_bandwidth: None,
6746 frame_rate: None,
6747 codecs: Some("avc1.64001e".into()),
6748 resolution: None,
6749 uri: "v300/index.m3u8".into(),
6750 audio: None,
6751 video: None,
6752 subtitles: None,
6753 closed_captions: None,
6754 extra_attrs: Vec::new(),
6755 }],
6756 iframe_variants: vec![],
6757 extra_tags: vec![],
6758 ..Default::default()
6759 };
6760 pl.extra_tags = vec![
6761 "#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"cc\",NAME=\"CC1\",\
6762 INSTREAM-ID=\"SERVICE1\""
6763 .into(),
6764 ];
6765 let out = pl.to_m3u8().unwrap();
6766 assert_eq!(rendered_version(&out), Some(7), "{out}");
6767 }
6768
6769 #[test]
6770 fn version_row8_variable_substitution_triggers_v8() {
6771 let mut pl = base_media_playlist();
6772 pl.segments = vec![seg("seg-{$id}.m4s", 6.0)];
6773 let out = pl.to_m3u8().unwrap();
6774 assert_eq!(rendered_version(&out), Some(8), "{out}");
6775 }
6776
6777 #[test]
6778 fn version_row8_variable_substitution_triggers_v8_multivariant() {
6779 let pl = MasterPlaylist {
6780 version: 0,
6781 media: vec![],
6782 variants: vec![Variant {
6783 bandwidth: 300_000,
6784 average_bandwidth: None,
6785 frame_rate: None,
6786 codecs: Some("avc1.64001e".into()),
6787 resolution: None,
6788 uri: "{$base}/index.m3u8".into(),
6789 audio: None,
6790 video: None,
6791 subtitles: None,
6792 closed_captions: None,
6793 extra_attrs: Vec::new(),
6794 }],
6795 iframe_variants: vec![],
6796 extra_tags: vec![],
6797 ..Default::default()
6798 };
6799 let out = pl.to_m3u8().unwrap();
6800 assert_eq!(rendered_version(&out), Some(8), "{out}");
6801 }
6802
6803 #[test]
6804 fn version_row9_skip_triggers_v9() {
6805 let mut pl = base_media_playlist();
6806 pl.skip = Some(SkipInfo {
6807 skipped_segments: 5,
6808 recently_removed_daterange_ids: vec![],
6809 ..Default::default()
6810 });
6811 let out = pl.to_m3u8().unwrap();
6812 assert_eq!(rendered_version(&out), Some(9), "{out}");
6813 }
6814
6815 #[test]
6816 fn version_row10_skip_replacing_daterange_triggers_v10() {
6817 let mut pl = base_media_playlist();
6818 pl.skip = Some(SkipInfo {
6819 skipped_segments: 5,
6820 recently_removed_daterange_ids: vec!["ad-1".into()],
6821 ..Default::default()
6822 });
6823 let out = pl.to_m3u8().unwrap();
6824 assert_eq!(rendered_version(&out), Some(10), "{out}");
6825 }
6826
6827 #[test]
6828 fn version_row11_define_queryparam_triggers_v11() {
6829 let mut pl = base_media_playlist();
6830 pl.extra_tags = vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()];
6831 let out = pl.to_m3u8().unwrap();
6832 assert_eq!(rendered_version(&out), Some(11), "{out}");
6833 }
6834
6835 #[test]
6836 fn version_row11_define_queryparam_triggers_v11_multivariant() {
6837 let pl = MasterPlaylist {
6838 version: 0,
6839 media: vec![],
6840 variants: vec![Variant {
6841 bandwidth: 300_000,
6842 average_bandwidth: None,
6843 frame_rate: None,
6844 codecs: Some("avc1.64001e".into()),
6845 resolution: None,
6846 uri: "v300/index.m3u8".into(),
6847 ..Default::default()
6848 }],
6849 iframe_variants: vec![],
6850 extra_tags: vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()],
6851 ..Default::default()
6852 };
6853 let out = pl.to_m3u8().unwrap();
6854 assert_eq!(rendered_version(&out), Some(11), "{out}");
6855 }
6856
6857 #[test]
6858 fn version_row12_req_attribute_triggers_v12() {
6859 let mut pl = base_media_playlist();
6860 pl.extra_tags = vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()];
6861 let out = pl.to_m3u8().unwrap();
6862 assert_eq!(rendered_version(&out), Some(12), "{out}");
6863 }
6864
6865 #[test]
6873 fn version_row12_req_attribute_in_per_tag_lists_triggers_v12() {
6874 let mut pl = base_media_playlist();
6875 pl.low_latency = Some(LowLatencyConfig {
6876 sc_extra_attrs: vec![(
6877 "REQ-LATENCY".to_string(),
6878 AttrValue::bare("ultra-low").unwrap(),
6879 )],
6880 ..Default::default()
6881 });
6882 let out = pl.to_m3u8().unwrap();
6883 assert!(out.contains("REQ-LATENCY=ultra-low"), "{out}");
6884 assert_eq!(rendered_version(&out), Some(12), "{out}");
6885
6886 for (list, name) in [("pi", "REQ-VIDEO"), ("ph", "REQ-INFO")] {
6888 let mut pl = base_media_playlist();
6889 let attr = vec![(name.to_string(), AttrValue::bare("1").unwrap())];
6890 let mut ll = LowLatencyConfig::default();
6891 match list {
6892 "pi" => ll.pi_extra_attrs = attr,
6893 _ => ll.ph_extra_attrs = attr,
6894 }
6895 pl.low_latency = Some(ll);
6896 let out = pl.to_m3u8().unwrap();
6897 assert_eq!(rendered_version(&out), Some(12), "{list}: {out}");
6898 }
6899 }
6900
6901 #[test]
6902 fn version_row12_req_attribute_triggers_v12_multivariant() {
6903 let pl = MasterPlaylist {
6904 version: 0,
6905 media: vec![],
6906 variants: vec![Variant {
6907 bandwidth: 300_000,
6908 average_bandwidth: None,
6909 frame_rate: None,
6910 codecs: Some("avc1.64001e".into()),
6911 resolution: None,
6912 uri: "v300/index.m3u8".into(),
6913 ..Default::default()
6914 }],
6915 iframe_variants: vec![],
6916 extra_tags: vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()],
6917 ..Default::default()
6918 };
6919 let out = pl.to_m3u8().unwrap();
6920 assert_eq!(rendered_version(&out), Some(12), "{out}");
6921 }
6922
6923 #[test]
6924 fn version_row13_media_instream_id_non_cc_triggers_v13_multivariant_only() {
6925 let pl = MasterPlaylist {
6926 version: 0,
6927 media: vec![],
6928 variants: vec![Variant {
6929 bandwidth: 300_000,
6930 average_bandwidth: None,
6931 frame_rate: None,
6932 codecs: Some("avc1.64001e".into()),
6933 resolution: None,
6934 uri: "v300/index.m3u8".into(),
6935 ..Default::default()
6936 }],
6937 iframe_variants: vec![],
6938 extra_tags: vec![
6939 "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"Eng\",INSTREAM-ID=\"CC1\"".into(),
6940 ],
6941 ..Default::default()
6942 };
6943 let out = pl.to_m3u8().unwrap();
6944 assert_eq!(rendered_version(&out), Some(13), "{out}");
6945 }
6946
6947 #[test]
6948 fn version_multivariant_no_trigger_omits_the_tag() {
6949 let pl = MasterPlaylist {
6950 version: 0,
6951 media: vec![],
6952 variants: vec![Variant {
6953 bandwidth: 300_000,
6954 average_bandwidth: None,
6955 frame_rate: None,
6956 codecs: Some("avc1.64001e".into()),
6957 resolution: None,
6958 uri: "v300/index.m3u8".into(),
6959 ..Default::default()
6960 }],
6961 iframe_variants: vec![],
6962 extra_tags: vec![],
6963 ..Default::default()
6964 };
6965 let out = pl.to_m3u8().unwrap();
6966 assert_eq!(rendered_version(&out), None, "{out}");
6967 }
6968
6969 #[test]
6977 fn named_case_1_fmp4_low_latency_renders_6_not_9() {
6978 let pl = MediaPlaylist {
6979 version: 0, target_duration: 4,
6981 media_sequence: 100,
6982 segments: vec![MediaSegment {
6983 uri: "seg-1-100.m4s".into(),
6984 duration: DecimalSeconds::new(4.0).unwrap(),
6985 ..Default::default()
6986 }],
6987 open_segment: Some(OpenSegment::new(vec![PartSpec {
6988 uri: "part-1-101.0.m4s".into(),
6989 duration: DecimalSeconds::new(0.5).unwrap(),
6990 independent: true,
6991 ..Default::default()
6992 }])),
6993 extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
6994 low_latency: Some(LowLatencyConfig {
6995 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
6996 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
6997 ..Default::default()
6998 }),
6999 iframes_only: false,
7000 ..Default::default()
7001 };
7002 let out = pl.to_m3u8().unwrap();
7003 assert_eq!(
7004 rendered_version(&out),
7005 Some(6),
7006 "fMP4 + low-latency must render 6, not the old hardcoded 9:\n{out}"
7007 );
7008 assert!(!out.contains("#EXT-X-VERSION:9"), "{out}");
7009 }
7010
7011 #[test]
7014 fn named_case_2_classic_mpegts_float_extinf_renders_3() {
7015 let pl = MediaPlaylist {
7016 version: 0,
7017 target_duration: 10,
7018 media_sequence: 0,
7019 segments: vec![
7020 seg("seg0.ts", 9.009),
7021 seg("seg1.ts", 9.009),
7022 seg("seg2.ts", 3.003),
7023 ],
7024 endlist: true,
7025 ..Default::default()
7026 };
7027 let out = pl.to_m3u8().unwrap();
7028 assert_eq!(rendered_version(&out), Some(3), "{out}");
7029 }
7030
7031 #[test]
7034 fn named_case_3_adding_skip_raises_version_to_9() {
7035 let mut pl = base_media_playlist();
7036 assert_eq!(
7037 rendered_version(&pl.to_m3u8().unwrap()),
7038 None,
7039 "sanity: no trigger before adding EXT-X-SKIP"
7040 );
7041 pl.skip = Some(SkipInfo {
7042 skipped_segments: 3,
7043 recently_removed_daterange_ids: vec![],
7044 ..Default::default()
7045 });
7046 let out = pl.to_m3u8().unwrap();
7047 assert_eq!(rendered_version(&out), Some(9), "{out}");
7048 }
7049
7050 #[test]
7053 fn named_case_4_no_trigger_renders_no_version_tag() {
7054 let out = base_media_playlist().to_m3u8().unwrap();
7055 assert!(!out.contains("#EXT-X-VERSION"), "{out}");
7056 }
7057
7058 #[test]
7061 fn named_case_5_sample_aes_renders_5() {
7062 let tag = cenc_ext_x_key(CencScheme::Cbcs, &[0xab; 16], "https://k.example/key")
7063 .expect("valid key_uri")
7064 .expect("cbcs must emit an EXT-X-KEY tag");
7065 let mut pl = base_media_playlist();
7066 pl.extra_tags = vec![tag];
7067 let out = pl.to_m3u8().unwrap();
7068 assert_eq!(rendered_version(&out), Some(5), "{out}");
7069 }
7070
7071 #[test]
7076 fn parse_and_round_trip_can_skip_dateranges_yes() {
7077 let text = "#EXTM3U\n\
7078#EXT-X-VERSION:9\n\
7079#EXT-X-TARGETDURATION:4\n\
7080#EXT-X-PART-INF:PART-TARGET=0.5\n\
7081#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n\
7082#EXTINF:4.000,\n\
7083seg0.m4s\n";
7084 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7085 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7086 assert!(
7087 ll.can_skip_dateranges,
7088 "CAN-SKIP-DATERANGES=YES must parse to true"
7089 );
7090 let round = pl.to_m3u8().unwrap();
7091 assert!(
7092 round.contains("CAN-SKIP-DATERANGES=YES"),
7093 "render must emit CAN-SKIP-DATERANGES=YES:\n{round}"
7094 );
7095 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7096 let re_ll = reparse
7097 .low_latency
7098 .as_ref()
7099 .expect("must have low_latency on reparse");
7100 assert!(
7101 re_ll.can_skip_dateranges,
7102 "CAN-SKIP-DATERANGES=YES must survive round trip"
7103 );
7104 }
7105
7106 #[test]
7109 fn can_skip_dateranges_not_rendered_without_can_skip_until() {
7110 let pl = MediaPlaylist {
7111 version: 9,
7112 target_duration: 4,
7113 media_sequence: 0,
7114 segments: vec![],
7115 low_latency: Some(LowLatencyConfig {
7116 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7117 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7118 can_skip_until: None,
7119 can_skip_dateranges: true,
7120 can_block_reload: true,
7121 ..Default::default()
7122 }),
7123 ..Default::default()
7124 };
7125 let out = pl.to_m3u8().unwrap();
7126 assert!(
7127 !out.contains("CAN-SKIP-DATERANGES"),
7128 "CAN-SKIP-DATERANGES must NOT render without CAN-SKIP-UNTIL:\n{out}"
7129 );
7130 }
7131
7132 #[test]
7135 fn parse_and_round_trip_hold_back() {
7136 let text = "#EXTM3U\n\
7137#EXT-X-VERSION:9\n\
7138#EXT-X-TARGETDURATION:4\n\
7139#EXT-X-PART-INF:PART-TARGET=0.5\n\
7140#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0\n\
7141#EXTINF:4.000,\n\
7142seg0.m4s\n";
7143 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7144 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7145 assert_eq!(
7146 ll.hold_back,
7147 Some(DecimalSeconds::new(12.0).unwrap()),
7148 "HOLD-BACK=12.0 must parse"
7149 );
7150 let round = pl.to_m3u8().unwrap();
7151 assert!(
7153 round.contains("HOLD-BACK=12"),
7154 "render must emit HOLD-BACK=12:\n{round}"
7155 );
7156 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7157 let re_ll = reparse
7158 .low_latency
7159 .as_ref()
7160 .expect("must have low_latency on reparse");
7161 assert_eq!(
7162 re_ll.hold_back,
7163 Some(DecimalSeconds::new(12.0).unwrap()),
7164 "HOLD-BACK must survive round trip"
7165 );
7166 }
7167
7168 #[test]
7170 fn hold_back_omitted_when_none() {
7171 let pl = MediaPlaylist {
7172 version: 9,
7173 target_duration: 4,
7174 media_sequence: 0,
7175 segments: vec![MediaSegment {
7176 uri: "seg0.m4s".into(),
7177 duration: DecimalSeconds::new(4.0).unwrap(),
7178 ..Default::default()
7179 }],
7180 low_latency: Some(LowLatencyConfig {
7181 part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7182 part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7183 hold_back: None,
7184 can_block_reload: true,
7185 ..Default::default()
7186 }),
7187 ..Default::default()
7188 };
7189 let out = pl.to_m3u8().unwrap();
7190 assert!(
7191 !out.contains(",HOLD-BACK="),
7192 "HOLD-BACK must be absent when None:\n{out}"
7193 );
7194 }
7195
7196 #[test]
7201 fn session_key_method_none_is_rejected() {
7202 let text = "#EXTM3U\n\
7203#EXT-X-VERSION:6\n\
7204#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7205v300/index.m3u8\n\
7206#EXT-X-SESSION-KEY:METHOD=NONE,URI=\"https://k.example/key\"\n";
7207 let err = MasterPlaylist::parse(text)
7208 .expect_err("EXT-X-SESSION-KEY with METHOD=NONE must be rejected");
7209 let Error::HlsParse { reason, .. } = err else {
7210 panic!("expected Error::HlsParse, got {err:?}")
7211 };
7212 assert!(reason.contains("NONE"), "error must mention NONE: {reason}");
7213 }
7214
7215 #[test]
7218 fn session_key_real_method_is_accepted_and_round_trips() {
7219 let text = "#EXTM3U\n\
7220#EXT-X-VERSION:6\n\
7221#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7222v300/index.m3u8\n\
7223#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://k.example/key\"\n";
7224 let pl = MasterPlaylist::parse(text).expect("legitimate session key must parse");
7225 assert_eq!(pl.session_keys.len(), 1);
7226 assert_eq!(pl.session_keys[0].method, EncryptionMethod::Aes128);
7227 let round = pl.to_m3u8().unwrap();
7228 let reparse = MasterPlaylist::parse(&round).expect("reparse must succeed");
7229 assert_eq!(reparse.session_keys.len(), 1);
7230 assert_eq!(reparse.session_keys[0].method, EncryptionMethod::Aes128);
7231 }
7232
7233 #[test]
7240 fn server_control_typed_and_req_attrs_round_trip_and_version_12() {
7241 let text = "#EXTM3U\n\
7242#EXT-X-VERSION:12\n\
7243#EXT-X-TARGETDURATION:4\n\
7244#EXT-X-PART-INF:PART-TARGET=0.5,REQ-VIDEO=720p\n\
7245#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\
7246#EXTINF:4,\n\
7247seg0.m4s\n";
7248 let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7249 let ll = pl.low_latency.as_ref().expect("must have low_latency");
7250
7251 assert_eq!(ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7253 assert!(ll.can_skip_dateranges);
7254
7255 assert!(
7257 ll.pi_extra_attrs
7258 .iter()
7259 .any(|(k, v)| k == "REQ-VIDEO" && v.as_str() == "720p"),
7260 "REQ-VIDEO must survive in pi_extra_attrs"
7261 );
7262
7263 assert!(
7265 ll.sc_extra_attrs
7266 .iter()
7267 .any(|(k, v)| k == "REQ-LATENCY" && v.as_str() == "ultra-low"),
7268 "REQ-LATENCY must survive in sc_extra_attrs"
7269 );
7270
7271 assert!(ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7273 assert!(ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7274
7275 let round = pl.to_m3u8().unwrap();
7277 assert!(
7278 round.contains("#EXT-X-VERSION:12"),
7279 "must emit version 12:\n{round}"
7280 );
7281
7282 let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7284 let re_ll = reparse
7285 .low_latency
7286 .as_ref()
7287 .expect("must have low_latency on reparse");
7288 assert_eq!(re_ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7289 assert!(re_ll.can_skip_dateranges);
7290 assert!(re_ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7291 assert!(re_ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7292 }
7293
7294 #[test]
7297 fn byterange_offset_plus_length_overflow_rejected() {
7298 let input = "#EXTM3U\n\
7299 #EXT-X-TARGETDURATION:10\n\
7300 #EXTINF:10,\n\
7301 #EXT-X-BYTERANGE:1@18446744073709551615\n\
7302 seg0.ts\n";
7303 let err = MediaPlaylist::parse(input).unwrap_err();
7304 let msg = err.to_string();
7305 assert!(msg.contains("overflows"), "expected overflow error: {msg}");
7306 }
7307
7308 #[test]
7309 fn byterange_exact_max_accepted() {
7310 let input = "#EXTM3U\n\
7311 #EXT-X-TARGETDURATION:10\n\
7312 #EXTINF:10,\n\
7313 #EXT-X-BYTERANGE:1@18446744073709551614\n\
7314 seg0.ts\n";
7315 let pl = MediaPlaylist::parse(input).expect("u64::MAX exactly must parse");
7316 assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, 1);
7317 assert_eq!(
7318 pl.segments[0].byte_range.as_ref().unwrap().offset,
7319 Some(u64::MAX - 1)
7320 );
7321 }
7322
7323 #[test]
7324 fn byterange_no_offset_max_length_accepted() {
7325 let input = "#EXTM3U\n\
7326 #EXT-X-TARGETDURATION:10\n\
7327 #EXTINF:10,\n\
7328 #EXT-X-BYTERANGE:18446744073709551615\n\
7329 seg0.ts\n";
7330 let pl = MediaPlaylist::parse(input).expect("no offset means no overflow check");
7331 assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, u64::MAX);
7332 assert!(pl.segments[0].byte_range.as_ref().unwrap().offset.is_none());
7333 }
7334
7335 #[test]
7336 fn map_byterange_overflow_rejected() {
7337 let input = "#EXTM3U\n\
7338 #EXT-X-TARGETDURATION:10\n\
7339 #EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1@18446744073709551615\"\n\
7340 #EXTINF:10,\n\
7341 seg0.ts\n";
7342 let err = MediaPlaylist::parse(input).unwrap_err();
7343 assert!(
7344 err.to_string().contains("overflows"),
7345 "MAP BYTERANGE overflow: {err}"
7346 );
7347 }
7348
7349 #[test]
7350 fn part_byterange_overflow_rejected() {
7351 let input = "#EXTM3U\n\
7352 #EXT-X-TARGETDURATION:10\n\
7353 #EXT-X-PART-INF:PART-TARGET=1.0\n\
7354 #EXT-X-PART:URI=\"p0.m4s\",DURATION=1.0,BYTERANGE=\"1@18446744073709551615\"\n\
7355 #EXTINF:10,\n\
7356 seg0.ts\n";
7357 let err = MediaPlaylist::parse(input).unwrap_err();
7358 assert!(
7359 err.to_string().contains("overflows"),
7360 "PART BYTERANGE overflow: {err}"
7361 );
7362 }
7363
7364 #[test]
7365 fn preload_hint_byterange_overflow_rejected() {
7366 let input = "#EXTM3U\n\
7367 #EXT-X-TARGETDURATION:10\n\
7368 #EXT-X-PART-INF:PART-TARGET=1.0\n\
7369 #EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next.m4s\",\
7370 BYTERANGE-START=18446744073709551615,BYTERANGE-LENGTH=1\n\
7371 #EXTINF:10,\n\
7372 seg0.ts\n";
7373 let err = MediaPlaylist::parse(input).unwrap_err();
7374 assert!(
7375 err.to_string().contains("overflows"),
7376 "PRELOAD-HINT overflow: {err}"
7377 );
7378 }
7379}