diff --git a/src/m3u8/playlist/mod.rs b/src/m3u8/playlist/mod.rs index 02f5ffb..8b938af 100644 --- a/src/m3u8/playlist/mod.rs +++ b/src/m3u8/playlist/mod.rs @@ -222,56 +222,126 @@ impl Playlist { return Ok(Some(Tag::ExtXPart { uri: uri.to_string(), duration: Some(duration), + independent: None, })); } } if trimmed.starts_with("EXT-X-PART-INF") { - // Example: #EXT-X-PART-INF:PART-TARGET-DURATION=5.0,PART-HOLD-BACK=2.0 + // Example: #EXT-X-PART-INF:PART-TARGET=5.0 + // Note: PART-HOLD-BACK is now in EXT-X-SERVER-CONTROL, not here let part_inf_re = Regex::new( - r#"EXT-X-PART-INF:PART-TARGET-DURATION=([\d\.]+),PART-HOLD-BACK=([\d\.]+)"#, + r#"EXT-X-PART-INF:PART-TARGET=([\d\.]+)"#, ) .unwrap(); if let Some(caps) = part_inf_re.captures(trimmed) { let part_target_duration = caps.get(1).unwrap().as_str().parse().unwrap(); - let part_hold_back = caps.get(2).map(|m| m.as_str().parse().unwrap()); return Ok(Some(Tag::ExtXPartInf { part_target_duration, - part_hold_back, + part_number: None, + })); + } + // Also try PART-TARGET-DURATION for backwards compatibility + let part_inf_re_alt = Regex::new( + r#"EXT-X-PART-INF:PART-TARGET-DURATION=([\d\.]+)"#, + ) + .unwrap(); + if let Some(caps) = part_inf_re_alt.captures(trimmed) { + let part_target_duration = caps.get(1).unwrap().as_str().parse().unwrap(); + return Ok(Some(Tag::ExtXPartInf { + part_target_duration, part_number: None, })); } } if trimmed.starts_with("EXT-X-SERVER-CONTROL") { - // Example: #EXT-X-SERVER-CONTROL:CAN-PLAY=YES,CAN-SEEK=YES,CAN-PAUSE=YES,MIN-BUFFER-TIME=10.0 - let server_control_re = Regex::new(r#"EXT-X-SERVER-CONTROL:CAN-PLAY=(\w+),CAN-SEEK=(\w+),CAN-PAUSE=(\w+),MIN-BUFFER-TIME=([\d\.]+)"#).unwrap(); - if let Some(caps) = server_control_re.captures(trimmed) { - let can_play = caps.get(1).unwrap().as_str() == "YES"; - let can_seek = caps.get(2).unwrap().as_str() == "YES"; - let can_pause = caps.get(3).unwrap().as_str() == "YES"; - let min_buffer_time = caps.get(4).unwrap().as_str().parse().unwrap(); + // Example: #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=24.0 + // Parse attributes flexibly + let mut can_play = None; + let mut can_seek = None; + let mut can_pause = None; + let mut min_buffer_time = None; + let mut can_block_reload = None; + let mut part_hold_back = None; + let mut can_skip_until = None; + + // Extract all key=value pairs + let attr_re = Regex::new(r#"([A-Z-]+)=([^,]+)"#).unwrap(); + for cap in attr_re.captures_iter(trimmed) { + let key = cap.get(1).unwrap().as_str(); + let value = cap.get(2).unwrap().as_str(); + match key { + "CAN-PLAY" => can_play = Some(value == "YES"), + "CAN-SEEK" => can_seek = Some(value == "YES"), + "CAN-PAUSE" => can_pause = Some(value == "YES"), + "MIN-BUFFER-TIME" => { + if let Ok(val) = value.parse::() { + min_buffer_time = Some(val); + } + } + "CAN-BLOCK-RELOAD" => can_block_reload = Some(value == "YES"), + "PART-HOLD-BACK" => { + if let Ok(val) = value.parse::() { + part_hold_back = Some(val); + } + } + "CAN-SKIP-UNTIL" => { + if let Ok(val) = value.parse::() { + can_skip_until = Some(val); + } + } + _ => {} + } + } + + // Only create tag if at least one attribute was found + if can_play.is_some() + || can_seek.is_some() + || can_pause.is_some() + || min_buffer_time.is_some() + || can_block_reload.is_some() + || part_hold_back.is_some() + || can_skip_until.is_some() + { return Ok(Some(Tag::ExtXServerControl { - can_play: Some(can_play), - can_seek: Some(can_seek), - can_pause: Some(can_pause), - min_buffer_time: Some(min_buffer_time), + can_play, + can_seek, + can_pause, + min_buffer_time, + can_block_reload, + part_hold_back, + can_skip_until, })); } } if trimmed.starts_with("EXT-X-SKIP") { - // Example: #EXT-X-SKIP:SKIPPED-SEGMENTS=3,URI="skip_segment2.ts" - let skip_re = - Regex::new(r#"EXT-X-SKIP:SKIPPED-SEGMENTS=(\d+),URI="([^\"]+)""#).unwrap(); - if let Some(caps) = skip_re.captures(trimmed) { - let skipped_segments = caps.get(1).unwrap().as_str().parse().unwrap(); - let uri = caps.get(2).unwrap().as_str(); + // Example: #EXT-X-SKIP:SKIPPED-SEGMENTS=3 + // Optional: ,RECENTLY-REMOVED-DATERANGES="id1\tid2" + let mut skipped_segments = None; + let mut recently_removed_dateranges = None; + + let attr_re = Regex::new(r#"([A-Z-]+)=("([^"]*)"|([^,]+))"#).unwrap(); + for cap in attr_re.captures_iter(trimmed) { + let key = cap.get(1).unwrap().as_str(); + // Use quoted value (group 3) if present, otherwise unquoted (group 4) + let value = cap.get(3).or(cap.get(4)).unwrap().as_str(); + match key { + "SKIPPED-SEGMENTS" => { + skipped_segments = value.parse::().ok(); + } + "RECENTLY-REMOVED-DATERANGES" => { + recently_removed_dateranges = Some(value.to_string()); + } + _ => {} + } + } + + if let Some(skipped) = skipped_segments { return Ok(Some(Tag::ExtXSkip { - uri: uri.to_string(), - skipped_segments, - duration: None, - reason: None, + skipped_segments: skipped, + recently_removed_dateranges, })); } } @@ -410,7 +480,7 @@ impl Playlist { if let Some(caps) = preload_hint_re.captures(trimmed) { let uri = caps.get(1).unwrap().as_str().to_string(); let byterange = Some(caps.get(2).unwrap().as_str().to_string()); - return Ok(Some(Tag::ExtXPreloadHint { uri, byterange })); + return Ok(Some(Tag::ExtXPreloadHint { type_: None, uri, byterange })); } } @@ -420,7 +490,7 @@ impl Playlist { // let metadata_line = split.get(0).unwrap(); // let segment = split.get(1).unwrap(); - let extinf_re = Regex::new(r#"EXTINF:(\d+(\.\d+)?),\s*(.*?),?\s*(\S+)"#).unwrap(); + let extinf_re = Regex::new(r#"EXTINF:(\d+(\.\d+)?),?\s*(.*?),?\s*(\S+)"#).unwrap(); if let Some(caps) = extinf_re.captures(trimmed) { let duration: f32 = caps.get(1).unwrap().as_str().parse().unwrap(); let title = caps @@ -491,9 +561,9 @@ impl Playlist { Tag::ExtXStart { time_offset, .. } if time_offset.is_empty() => { errors.push(ValidationError::InvalidStartOffset); } - Tag::ExtXSkip { duration, .. } if duration.unwrap() <= 0.0 => { + Tag::ExtXSkip { skipped_segments, .. } if *skipped_segments == 0 => { errors.push(ValidationError::InvalidSkipTag( - "Duration must be positive".to_string(), + "SKIPPED-SEGMENTS must be positive".to_string(), )); } Tag::ExtXPreloadHint { uri, .. } if uri.is_empty() => { diff --git a/src/m3u8/tags.rs b/src/m3u8/tags.rs index 53d85eb..98eff43 100644 --- a/src/m3u8/tags.rs +++ b/src/m3u8/tags.rs @@ -91,15 +91,18 @@ pub enum Tag { can_seek: Option, can_pause: Option, min_buffer_time: Option, + can_block_reload: Option, + part_hold_back: Option, + can_skip_until: Option, }, /// Represents part information. ExtXPartInf { part_target_duration: f32, - part_hold_back: Option, part_number: Option, }, /// Represents a preload hint. ExtXPreloadHint { + type_: Option, uri: String, /// Optional byte range for the preload hint. byterange: Option, @@ -110,14 +113,12 @@ pub enum Tag { ExtXPart { uri: String, duration: Option, - // additional fields if necessary + independent: Option, }, - /// Indicates a skip in the playlist. + /// Indicates skipped segments in a Playlist Delta Update (RFC 8216bis Section 4.4.5.2). ExtXSkip { - uri: String, - duration: Option, skipped_segments: u32, - reason: Option, + recently_removed_dateranges: Option, }, /// Indicates a discontinuity in the media stream. ExtXDiscontinuity, @@ -355,38 +356,57 @@ impl std::fmt::Display for Tag { can_seek, can_pause, min_buffer_time, + can_block_reload, + part_hold_back, + can_skip_until, } => { write!(f, "#EXT-X-SERVER-CONTROL")?; + let mut first = true; + if let Some(can_block_reload) = can_block_reload { + write!(f, "{}CAN-BLOCK-RELOAD={}", if first { ":" } else { "," }, if *can_block_reload { "YES" } else { "NO" })?; + first = false; + } + if let Some(part_hold_back) = part_hold_back { + write!(f, "{}PART-HOLD-BACK={}", if first { ":" } else { "," }, part_hold_back)?; + first = false; + } + if let Some(can_skip_until) = can_skip_until { + write!(f, "{}CAN-SKIP-UNTIL={}", if first { ":" } else { "," }, can_skip_until)?; + first = false; + } if let Some(can_play) = can_play { - write!(f, ",CAN-PLAY={}", if *can_play { "YES" } else { "NO" })?; + write!(f, "{}CAN-PLAY={}", if first { ":" } else { "," }, if *can_play { "YES" } else { "NO" })?; + first = false; } if let Some(can_seek) = can_seek { - write!(f, ",CAN-SEEK={}", if *can_seek { "YES" } else { "NO" })?; + write!(f, "{}CAN-SEEK={}", if first { ":" } else { "," }, if *can_seek { "YES" } else { "NO" })?; + first = false; } if let Some(can_pause) = can_pause { - write!(f, ",CAN-PAUSE={}", if *can_pause { "YES" } else { "NO" })?; + write!(f, "{}CAN-PAUSE={}", if first { ":" } else { "," }, if *can_pause { "YES" } else { "NO" })?; + first = false; } if let Some(min_buffer_time) = min_buffer_time { - write!(f, ",MIN-BUFFER-TIME={}", min_buffer_time)?; + write!(f, "{}MIN-BUFFER-TIME={}", if first { ":" } else { "," }, min_buffer_time)?; } Ok(()) } Tag::ExtXPartInf { part_target_duration, - part_hold_back, part_number, } => { write!(f, "#EXT-X-PART-INF:PART-TARGET={}", part_target_duration)?; - if let Some(part_hold_back) = part_hold_back { - write!(f, ",PART-HOLD-BACK={}", part_hold_back)?; - } if let Some(part_number) = part_number { write!(f, ",PART-NUMBER={}", part_number)?; } Ok(()) } - Tag::ExtXPreloadHint { uri, byterange } => { - let mut result = format!("#EXT-X-PRELOAD-HINT:URI=\"{}\"", uri); + Tag::ExtXPreloadHint { type_, uri, byterange } => { + let mut result = String::from("#EXT-X-PRELOAD-HINT:"); + if let Some(type_val) = type_ { + result.push_str(&format!("TYPE={},", type_val)); + } + result.push_str(&format!("URI=\"{}\"", uri)); if let Some(byterange) = byterange { result.push_str(&format!(",BYTERANGE={}", byterange)); } @@ -399,33 +419,27 @@ impl std::fmt::Display for Tag { uri, bandwidth ) } - Tag::ExtXPart { uri, duration } => { + Tag::ExtXPart { uri, duration, independent } => { write!(f, "#EXT-X-PART:URI=\"{}\"", uri)?; if let Some(duration) = duration { write!(f, ",DURATION={}", duration)?; } + if let Some(independent) = independent { + if *independent { + write!(f, ",INDEPENDENT=YES")?; + } + } Ok(()) } Tag::ExtXSkip { - uri, - duration, skipped_segments, - reason, + recently_removed_dateranges, } => { - let mut output = format!( - "#EXT-X-SKIP:URI=\"{}\",SKIPPED-SEGMENTS={}", - uri, skipped_segments - ); - - if let Some(duration) = duration { - output.push_str(&format!(",DURATION={}", duration)); - } - - if let Some(reason) = reason { - output.push_str(&format!(",REASON=\"{}\"", reason)); + write!(f, "#EXT-X-SKIP:SKIPPED-SEGMENTS={}", skipped_segments)?; + if let Some(dateranges) = recently_removed_dateranges { + write!(f, ",RECENTLY-REMOVED-DATERANGES=\"{}\"", dateranges)?; } - - write!(f, "{}", output) + Ok(()) } Tag::ExtXDiscontinuity => write!(f, "#EXT-X-DISCONTINUITY"), Tag::ExtXSessionData {