use std::collections::VecDeque;
use std::fmt::Write as _;
#[derive(Debug, Clone)]
pub struct Part {
pub uri: String,
pub duration: f64,
pub independent: bool,
}
#[derive(Debug, Clone)]
pub struct Segment {
pub seq: u64,
pub duration: f64,
pub uri: String,
pub discontinuity: bool,
pub parts: Vec<Part>,
}
#[derive(Debug, Clone)]
pub struct HlsPlaylist {
target_duration: u64,
window: usize,
low_latency: bool,
part_target: f64,
media_sequence: u64,
discontinuity_sequence: u64,
segments: VecDeque<Segment>,
map_uri: Option<String>,
finished: bool,
pending_parts: Vec<Part>,
preload_hint: Option<String>,
}
impl HlsPlaylist {
pub fn new(target_duration: u64, window: usize) -> Self {
Self {
target_duration,
window: window.max(1),
low_latency: false,
part_target: 0.0,
media_sequence: 0,
discontinuity_sequence: 0,
segments: VecDeque::new(),
map_uri: None,
finished: false,
pending_parts: Vec::new(),
preload_hint: None,
}
}
pub fn low_latency(mut self, part_target: f64) -> Self {
self.low_latency = true;
self.part_target = part_target;
self
}
pub fn set_map(&mut self, uri: impl Into<String>) {
self.map_uri = Some(uri.into());
}
pub fn push(&mut self, seg: Segment) {
if self.segments.is_empty() {
self.media_sequence = seg.seq;
}
self.segments.push_back(seg);
while self.segments.len() > self.window {
if let Some(old) = self.segments.pop_front() {
if old.discontinuity {
self.discontinuity_sequence += 1;
}
self.media_sequence = self.segments.front().map(|s| s.seq).unwrap_or(old.seq + 1);
}
}
}
pub fn add_pending_part(&mut self, part: Part) {
self.pending_parts.push(part);
}
pub fn set_preload_hint(&mut self, uri: impl Into<String>) {
self.preload_hint = Some(uri.into());
}
pub fn commit_segment(&mut self, mut seg: Segment) {
seg.parts = std::mem::take(&mut self.pending_parts);
self.preload_hint = None;
self.push(seg);
}
pub fn finish(&mut self) {
self.finished = true;
self.pending_parts.clear();
self.preload_hint = None;
}
pub fn segments(&self) -> &VecDeque<Segment> {
&self.segments
}
pub fn render(&self) -> String {
let mut s = String::with_capacity(256 + self.segments.len() * 64);
s.push_str("#EXTM3U\n");
s.push_str("#EXT-X-VERSION:");
s.push_str(if self.low_latency { "9\n" } else { "3\n" });
let _ = writeln!(s, "#EXT-X-TARGETDURATION:{}", self.target_duration);
let _ = writeln!(s, "#EXT-X-MEDIA-SEQUENCE:{}", self.media_sequence);
if self.discontinuity_sequence > 0 {
let _ = writeln!(
s,
"#EXT-X-DISCONTINUITY-SEQUENCE:{}",
self.discontinuity_sequence
);
}
if self.low_latency {
let _ = writeln!(s, "#EXT-X-PART-INF:PART-TARGET={:.3}", self.part_target);
let _ = writeln!(
s,
"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={:.3}",
self.part_target * 3.0
);
}
if let Some(uri) = &self.map_uri {
let _ = writeln!(s, "#EXT-X-MAP:URI=\"{uri}\"");
}
for seg in &self.segments {
if seg.discontinuity {
s.push_str("#EXT-X-DISCONTINUITY\n");
}
for part in &seg.parts {
Self::render_part(&mut s, part);
}
let _ = writeln!(s, "#EXTINF:{:.3},", seg.duration);
s.push_str(&seg.uri);
s.push('\n');
}
if self.low_latency && !self.finished {
for part in &self.pending_parts {
Self::render_part(&mut s, part);
}
if let Some(hint) = &self.preload_hint {
let _ = writeln!(s, "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint}\"");
}
}
if self.finished {
s.push_str("#EXT-X-ENDLIST\n");
}
s
}
fn render_part(s: &mut String, part: &Part) {
let _ = write!(
s,
"#EXT-X-PART:DURATION={:.3},URI=\"{}\"",
part.duration, part.uri
);
if part.independent {
s.push_str(",INDEPENDENT=YES");
}
s.push('\n');
}
}
pub fn render_master(media_uri: &str, codecs: Option<&str>, bandwidth: u32) -> String {
let mut s = String::with_capacity(160);
s.push_str("#EXTM3U\n");
s.push_str("#EXT-X-VERSION:7\n");
let _ = write!(s, "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth}");
if let Some(c) = codecs {
let _ = write!(s, ",CODECS=\"{c}\"");
}
s.push('\n');
s.push_str(media_uri);
s.push('\n');
s
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(seq: u64, dur: f64) -> Segment {
Segment {
seq,
duration: dur,
uri: format!("seg{seq}.m4s"),
discontinuity: false,
parts: Vec::new(),
}
}
#[test]
fn renders_live_playlist_with_sliding_window() {
let mut pl = HlsPlaylist::new(6, 3);
for i in 0..5 {
pl.push(seg(i, 5.0));
}
assert_eq!(pl.segments().len(), 3);
let out = pl.render();
assert!(out.starts_with("#EXTM3U\n"));
assert!(out.contains("#EXT-X-TARGETDURATION:6\n"));
assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:2\n"));
assert!(out.contains("seg4.m4s"));
assert!(!out.contains("seg1.m4s")); assert!(!out.contains("#EXT-X-ENDLIST"));
}
#[test]
fn finish_appends_endlist() {
let mut pl = HlsPlaylist::new(6, 5);
pl.push(seg(0, 4.0));
pl.finish();
assert!(pl.render().trim_end().ends_with("#EXT-X-ENDLIST"));
}
#[test]
fn low_latency_emits_part_tags() {
let pl = HlsPlaylist::new(4, 5).low_latency(0.33);
let out = pl.render();
assert!(out.contains("#EXT-X-VERSION:9"));
assert!(out.contains("PART-TARGET=0.330"));
assert!(out.contains("CAN-BLOCK-RELOAD=YES"));
assert!(
out.contains("PART-HOLD-BACK=0.990"),
"LL-HLS requires PART-HOLD-BACK"
);
}
#[test]
fn renders_pending_parts_and_preload_hint_at_live_edge() {
let mut pl = HlsPlaylist::new(4, 5).low_latency(0.5);
pl.add_pending_part(Part {
uri: "seg0.0.m4s".into(),
duration: 0.5,
independent: true,
});
pl.add_pending_part(Part {
uri: "seg0.1.m4s".into(),
duration: 0.5,
independent: false,
});
pl.set_preload_hint("seg0.2.m4s");
let out = pl.render();
assert!(out.contains("#EXT-X-PART:DURATION=0.500,URI=\"seg0.0.m4s\",INDEPENDENT=YES"));
assert!(out.contains("#EXT-X-PART:DURATION=0.500,URI=\"seg0.1.m4s\"\n"));
assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"seg0.2.m4s\""));
}
#[test]
fn commit_segment_moves_pending_parts_into_segment() {
let mut pl = HlsPlaylist::new(4, 5).low_latency(0.5);
pl.add_pending_part(Part {
uri: "seg0.0.m4s".into(),
duration: 1.0,
independent: true,
});
pl.commit_segment(seg(0, 1.0));
let out = pl.render();
let part_pos = out.find("#EXT-X-PART").unwrap();
let inf_pos = out.find("#EXTINF").unwrap();
assert!(part_pos < inf_pos, "part precedes its segment's EXTINF");
assert!(
!out.contains("#EXT-X-PRELOAD-HINT"),
"pending cleared on commit"
);
}
}