use std::fmt::Write as FmtWrite;
#[derive(Debug, Clone)]
pub struct PartialSegment {
pub sequence: u64,
pub part_index: u32,
pub duration_ms: u32,
pub uri: String,
pub independent: bool,
}
impl PartialSegment {
#[must_use]
pub fn new(
sequence: u64,
part_index: u32,
duration_ms: u32,
uri: impl Into<String>,
independent: bool,
) -> Self {
Self {
sequence,
part_index,
duration_ms,
uri: uri.into(),
independent,
}
}
#[must_use]
pub fn duration_secs(&self) -> f64 {
self.duration_ms as f64 / 1000.0
}
#[must_use]
pub fn to_tag(&self) -> String {
let mut tag = format!(
"#EXT-X-PART:DURATION={:.5},URI=\"{}\"",
self.duration_secs(),
self.uri
);
if self.independent {
tag.push_str(",INDEPENDENT=YES");
}
tag
}
}
#[derive(Debug, Clone)]
pub struct HlsSegment {
pub sequence: u64,
pub uri: String,
pub duration_secs: f64,
pub parts: Vec<PartialSegment>,
}
impl HlsSegment {
#[must_use]
pub fn new(sequence: u64, uri: impl Into<String>, duration_secs: f64) -> Self {
Self {
sequence,
uri: uri.into(),
duration_secs,
parts: Vec::new(),
}
}
#[must_use]
pub fn to_tags(&self) -> String {
let mut out = String::new();
for part in &self.parts {
let _ = writeln!(out, "{}", part.to_tag());
}
let _ = writeln!(out, "#EXTINF:{:.5},", self.duration_secs);
let _ = writeln!(out, "{}", self.uri);
out
}
}
#[derive(Debug, Clone)]
pub struct LlHlsManifest {
pub segments: Vec<HlsSegment>,
pub partial_segments: Vec<PartialSegment>,
pub preload_hint: Option<String>,
pub can_skip_until: f32,
target_duration_secs: f64,
part_target_duration_secs: f64,
media_sequence: u64,
}
impl LlHlsManifest {
#[must_use]
pub fn new(target_duration_secs: f64, part_target_duration_secs: f64) -> Self {
Self {
segments: Vec::new(),
partial_segments: Vec::new(),
preload_hint: None,
can_skip_until: 0.0,
target_duration_secs,
part_target_duration_secs,
media_sequence: 0,
}
}
pub fn set_media_sequence(&mut self, seq: u64) {
self.media_sequence = seq;
}
#[must_use]
pub fn part_hold_back(&self) -> f64 {
self.part_target_duration_secs * 3.0
}
#[must_use]
pub fn build_m3u8(&self) -> String {
let mut out = String::with_capacity(4096);
out.push_str("#EXTM3U\n");
out.push_str("#EXT-X-VERSION:9\n");
let _ = writeln!(
out,
"#EXT-X-TARGETDURATION:{}",
self.target_duration_secs.ceil() as u64
);
let _ = writeln!(out, "#EXT-X-MEDIA-SEQUENCE:{}", self.media_sequence);
let _ = writeln!(
out,
"#EXT-X-PART-INF:PART-TARGET={:.5}",
self.part_target_duration_secs
);
let part_hold_back = self.part_hold_back();
let mut sc = format!(
"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={part_hold_back:.3}"
);
if self.can_skip_until > 0.0 {
let _ = write!(sc, ",CAN-SKIP-UNTIL={:.1}", self.can_skip_until);
}
let _ = writeln!(out, "{sc}");
for seg in &self.segments {
out.push_str(&seg.to_tags());
}
for part in &self.partial_segments {
let _ = writeln!(out, "{}", part.to_tag());
}
if let Some(hint_uri) = &self.preload_hint {
let _ = writeln!(
out,
"#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint_uri}\""
);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_manifest() -> LlHlsManifest {
LlHlsManifest::new(6.0, 0.2)
}
#[test]
fn test_partial_segment_duration_secs() {
let ps = PartialSegment::new(0, 0, 200, "part0.mp4", true);
assert!((ps.duration_secs() - 0.2).abs() < 1e-9);
}
#[test]
fn test_partial_segment_to_tag_basic() {
let ps = PartialSegment::new(1, 2, 200, "part2.mp4", false);
let tag = ps.to_tag();
assert!(tag.contains("#EXT-X-PART"));
assert!(tag.contains("DURATION=0.20000"));
assert!(tag.contains("part2.mp4"));
}
#[test]
fn test_partial_segment_independent_flag() {
let ps = PartialSegment::new(0, 0, 200, "keypart.mp4", true);
assert!(ps.to_tag().contains("INDEPENDENT=YES"));
}
#[test]
fn test_partial_segment_no_independent() {
let ps = PartialSegment::new(0, 1, 200, "p1.mp4", false);
assert!(!ps.to_tag().contains("INDEPENDENT=YES"));
}
#[test]
fn test_hls_segment_to_tags_extinf() {
let seg = HlsSegment::new(0, "seg0.ts", 6.0);
let tags = seg.to_tags();
assert!(tags.contains("#EXTINF:6.00000,"));
assert!(tags.contains("seg0.ts"));
}
#[test]
fn test_hls_segment_parts_before_extinf() {
let mut seg = HlsSegment::new(0, "seg0.ts", 6.0);
seg.parts.push(PartialSegment::new(0, 0, 200, "p0.mp4", true));
let tags = seg.to_tags();
let part_pos = tags.find("#EXT-X-PART").expect("EXT-X-PART present");
let extinf_pos = tags.find("#EXTINF").expect("EXTINF present");
assert!(part_pos < extinf_pos, "parts must precede EXTINF");
}
#[test]
fn test_manifest_part_hold_back() {
let m = make_manifest();
assert!((m.part_hold_back() - 0.6).abs() < 1e-9);
}
#[test]
fn test_build_m3u8_header_tags() {
let m = make_manifest();
let out = m.build_m3u8();
assert!(out.starts_with("#EXTM3U\n"), "must start with #EXTM3U");
assert!(out.contains("#EXT-X-VERSION:9"));
assert!(out.contains("#EXT-X-TARGETDURATION:6"));
assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0"));
}
#[test]
fn test_build_m3u8_part_inf() {
let m = make_manifest();
let out = m.build_m3u8();
assert!(out.contains("#EXT-X-PART-INF:PART-TARGET=0.20000"));
}
#[test]
fn test_build_m3u8_server_control() {
let m = make_manifest();
let out = m.build_m3u8();
assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
assert!(out.contains("PART-HOLD-BACK=0.600"));
}
#[test]
fn test_build_m3u8_can_skip_until() {
let mut m = make_manifest();
m.can_skip_until = 24.0;
let out = m.build_m3u8();
assert!(out.contains("CAN-SKIP-UNTIL=24.0"));
}
#[test]
fn test_build_m3u8_no_skip_until_when_zero() {
let m = make_manifest();
let out = m.build_m3u8();
assert!(!out.contains("CAN-SKIP-UNTIL"));
}
#[test]
fn test_build_m3u8_segment_extinf() {
let mut m = make_manifest();
m.segments.push(HlsSegment::new(0, "seg0.ts", 6.0));
let out = m.build_m3u8();
assert!(out.contains("#EXTINF:"));
assert!(out.contains("seg0.ts"));
}
#[test]
fn test_build_m3u8_partial_segments() {
let mut m = make_manifest();
m.partial_segments
.push(PartialSegment::new(1, 0, 200, "part_current.mp4", true));
let out = m.build_m3u8();
assert!(out.contains("#EXT-X-PART"));
assert!(out.contains("part_current.mp4"));
}
#[test]
fn test_build_m3u8_preload_hint() {
let mut m = make_manifest();
m.preload_hint = Some("next_part.mp4".to_owned());
let out = m.build_m3u8();
assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next_part.mp4\""));
}
#[test]
fn test_preload_hint_after_segments() {
let mut m = make_manifest();
m.segments.push(HlsSegment::new(0, "seg0.ts", 6.0));
m.preload_hint = Some("hint.mp4".to_owned());
let out = m.build_m3u8();
let seg_pos = out.find("seg0.ts").expect("segment uri present");
let hint_pos = out.find("#EXT-X-PRELOAD-HINT").expect("hint present");
assert!(hint_pos > seg_pos, "preload hint must appear after segment");
}
#[test]
fn test_media_sequence_respected() {
let mut m = make_manifest();
m.set_media_sequence(42);
let out = m.build_m3u8();
assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:42"));
}
#[test]
fn test_multiple_segments_in_output() {
let mut m = make_manifest();
for i in 0..3u64 {
m.segments
.push(HlsSegment::new(i, format!("seg{i}.ts"), 6.0));
}
let out = m.build_m3u8();
for i in 0..3 {
assert!(out.contains(&format!("seg{i}.ts")));
}
}
#[test]
fn test_segment_parts_order() {
let mut m = make_manifest();
let mut seg = HlsSegment::new(0, "seg0.ts", 6.0);
seg.parts.push(PartialSegment::new(0, 0, 200, "p0.mp4", true));
seg.parts.push(PartialSegment::new(0, 1, 200, "p1.mp4", false));
seg.parts.push(PartialSegment::new(0, 2, 200, "p2.mp4", false));
m.segments.push(seg);
let out = m.build_m3u8();
let p0 = out.find("p0.mp4").expect("p0");
let p1 = out.find("p1.mp4").expect("p1");
let p2 = out.find("p2.mp4").expect("p2");
assert!(p0 < p1 && p1 < p2);
}
#[test]
fn test_no_preload_hint_absent() {
let m = make_manifest();
assert!(!m.build_m3u8().contains("#EXT-X-PRELOAD-HINT"));
}
}