1use std::collections::VecDeque;
4use std::fmt::Write as _;
5
6#[derive(Debug, Clone)]
8pub struct Part {
9 pub uri: String,
11 pub duration: f64,
13 pub independent: bool,
16}
17
18#[derive(Debug, Clone)]
20pub struct Segment {
21 pub seq: u64,
23 pub duration: f64,
25 pub uri: String,
27 pub discontinuity: bool,
29 pub parts: Vec<Part>,
32}
33
34#[derive(Debug, Clone)]
40pub struct HlsPlaylist {
41 target_duration: u64,
42 window: usize,
43 low_latency: bool,
44 part_target: f64,
45 media_sequence: u64,
46 discontinuity_sequence: u64,
47 segments: VecDeque<Segment>,
48 map_uri: Option<String>,
49 finished: bool,
50 pending_parts: Vec<Part>,
53 preload_hint: Option<String>,
56}
57
58impl HlsPlaylist {
59 pub fn new(target_duration: u64, window: usize) -> Self {
61 Self {
62 target_duration,
63 window: window.max(1),
64 low_latency: false,
65 part_target: 0.0,
66 media_sequence: 0,
67 discontinuity_sequence: 0,
68 segments: VecDeque::new(),
69 map_uri: None,
70 finished: false,
71 pending_parts: Vec::new(),
72 preload_hint: None,
73 }
74 }
75
76 pub fn low_latency(mut self, part_target: f64) -> Self {
78 self.low_latency = true;
79 self.part_target = part_target;
80 self
81 }
82
83 pub fn set_map(&mut self, uri: impl Into<String>) {
86 self.map_uri = Some(uri.into());
87 }
88
89 pub fn push(&mut self, seg: Segment) {
91 if self.segments.is_empty() {
92 self.media_sequence = seg.seq;
93 }
94 self.segments.push_back(seg);
95 while self.segments.len() > self.window {
96 if let Some(old) = self.segments.pop_front() {
97 if old.discontinuity {
98 self.discontinuity_sequence += 1;
99 }
100 self.media_sequence = self.segments.front().map(|s| s.seq).unwrap_or(old.seq + 1);
101 }
102 }
103 }
104
105 pub fn add_pending_part(&mut self, part: Part) {
107 self.pending_parts.push(part);
108 }
109
110 pub fn set_preload_hint(&mut self, uri: impl Into<String>) {
112 self.preload_hint = Some(uri.into());
113 }
114
115 pub fn commit_segment(&mut self, mut seg: Segment) {
119 seg.parts = std::mem::take(&mut self.pending_parts);
120 self.preload_hint = None;
121 self.push(seg);
122 }
123
124 pub fn finish(&mut self) {
126 self.finished = true;
127 self.pending_parts.clear();
128 self.preload_hint = None;
129 }
130
131 pub fn segments(&self) -> &VecDeque<Segment> {
133 &self.segments
134 }
135
136 pub fn render(&self) -> String {
138 let mut s = String::with_capacity(256 + self.segments.len() * 64);
139 s.push_str("#EXTM3U\n");
140 s.push_str("#EXT-X-VERSION:");
141 s.push_str(if self.low_latency { "9\n" } else { "3\n" });
142 let _ = writeln!(s, "#EXT-X-TARGETDURATION:{}", self.target_duration);
145 let _ = writeln!(s, "#EXT-X-MEDIA-SEQUENCE:{}", self.media_sequence);
146 if self.discontinuity_sequence > 0 {
147 let _ = writeln!(
148 s,
149 "#EXT-X-DISCONTINUITY-SEQUENCE:{}",
150 self.discontinuity_sequence
151 );
152 }
153 if self.low_latency {
154 let _ = writeln!(s, "#EXT-X-PART-INF:PART-TARGET={:.3}", self.part_target);
155 let _ = writeln!(
159 s,
160 "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={:.3}",
161 self.part_target * 3.0
162 );
163 }
164 if let Some(uri) = &self.map_uri {
165 let _ = writeln!(s, "#EXT-X-MAP:URI=\"{uri}\"");
166 }
167 for seg in &self.segments {
168 if seg.discontinuity {
169 s.push_str("#EXT-X-DISCONTINUITY\n");
170 }
171 for part in &seg.parts {
173 Self::render_part(&mut s, part);
174 }
175 let _ = writeln!(s, "#EXTINF:{:.3},", seg.duration);
176 s.push_str(&seg.uri);
177 s.push('\n');
178 }
179 if self.low_latency && !self.finished {
182 for part in &self.pending_parts {
183 Self::render_part(&mut s, part);
184 }
185 if let Some(hint) = &self.preload_hint {
186 let _ = writeln!(s, "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint}\"");
187 }
188 }
189 if self.finished {
190 s.push_str("#EXT-X-ENDLIST\n");
191 }
192 s
193 }
194
195 fn render_part(s: &mut String, part: &Part) {
197 let _ = write!(
198 s,
199 "#EXT-X-PART:DURATION={:.3},URI=\"{}\"",
200 part.duration, part.uri
201 );
202 if part.independent {
203 s.push_str(",INDEPENDENT=YES");
204 }
205 s.push('\n');
206 }
207}
208
209pub fn render_master(media_uri: &str, codecs: Option<&str>, bandwidth: u32) -> String {
217 let mut s = String::with_capacity(160);
218 s.push_str("#EXTM3U\n");
219 s.push_str("#EXT-X-VERSION:7\n");
220 let _ = write!(s, "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth}");
221 if let Some(c) = codecs {
222 let _ = write!(s, ",CODECS=\"{c}\"");
223 }
224 s.push('\n');
225 s.push_str(media_uri);
226 s.push('\n');
227 s
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn seg(seq: u64, dur: f64) -> Segment {
235 Segment {
236 seq,
237 duration: dur,
238 uri: format!("seg{seq}.m4s"),
239 discontinuity: false,
240 parts: Vec::new(),
241 }
242 }
243
244 #[test]
245 fn renders_live_playlist_with_sliding_window() {
246 let mut pl = HlsPlaylist::new(6, 3);
247 for i in 0..5 {
248 pl.push(seg(i, 5.0));
249 }
250 assert_eq!(pl.segments().len(), 3);
252 let out = pl.render();
253 assert!(out.starts_with("#EXTM3U\n"));
254 assert!(out.contains("#EXT-X-TARGETDURATION:6\n"));
255 assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:2\n"));
256 assert!(out.contains("seg4.m4s"));
257 assert!(!out.contains("seg1.m4s")); assert!(!out.contains("#EXT-X-ENDLIST"));
259 }
260
261 #[test]
262 fn finish_appends_endlist() {
263 let mut pl = HlsPlaylist::new(6, 5);
264 pl.push(seg(0, 4.0));
265 pl.finish();
266 assert!(pl.render().trim_end().ends_with("#EXT-X-ENDLIST"));
267 }
268
269 #[test]
270 fn low_latency_emits_part_tags() {
271 let pl = HlsPlaylist::new(4, 5).low_latency(0.33);
272 let out = pl.render();
273 assert!(out.contains("#EXT-X-VERSION:9"));
274 assert!(out.contains("PART-TARGET=0.330"));
275 assert!(out.contains("CAN-BLOCK-RELOAD=YES"));
276 assert!(
277 out.contains("PART-HOLD-BACK=0.990"),
278 "LL-HLS requires PART-HOLD-BACK"
279 );
280 }
281
282 #[test]
283 fn renders_pending_parts_and_preload_hint_at_live_edge() {
284 let mut pl = HlsPlaylist::new(4, 5).low_latency(0.5);
285 pl.add_pending_part(Part {
286 uri: "seg0.0.m4s".into(),
287 duration: 0.5,
288 independent: true,
289 });
290 pl.add_pending_part(Part {
291 uri: "seg0.1.m4s".into(),
292 duration: 0.5,
293 independent: false,
294 });
295 pl.set_preload_hint("seg0.2.m4s");
296 let out = pl.render();
297 assert!(out.contains("#EXT-X-PART:DURATION=0.500,URI=\"seg0.0.m4s\",INDEPENDENT=YES"));
298 assert!(out.contains("#EXT-X-PART:DURATION=0.500,URI=\"seg0.1.m4s\"\n"));
299 assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"seg0.2.m4s\""));
300 }
301
302 #[test]
303 fn commit_segment_moves_pending_parts_into_segment() {
304 let mut pl = HlsPlaylist::new(4, 5).low_latency(0.5);
305 pl.add_pending_part(Part {
306 uri: "seg0.0.m4s".into(),
307 duration: 1.0,
308 independent: true,
309 });
310 pl.commit_segment(seg(0, 1.0));
311 let out = pl.render();
313 let part_pos = out.find("#EXT-X-PART").unwrap();
314 let inf_pos = out.find("#EXTINF").unwrap();
315 assert!(part_pos < inf_pos, "part precedes its segment's EXTINF");
316 assert!(
317 !out.contains("#EXT-X-PRELOAD-HINT"),
318 "pending cleared on commit"
319 );
320 }
321}