Skip to main content

arcly_stream/packager/
playlist.rs

1//! HLS / LL-HLS media-playlist (`.m3u8`) generation.
2
3use std::collections::VecDeque;
4use std::fmt::Write as _;
5
6/// One LL-HLS partial segment (`#EXT-X-PART`).
7#[derive(Debug, Clone)]
8pub struct Part {
9    /// Part URI (relative path under the stream's storage prefix).
10    pub uri: String,
11    /// Part duration in seconds.
12    pub duration: f64,
13    /// Whether the part begins an independent (keyframe-led) access unit, set as
14    /// `INDEPENDENT=YES` so a player can start decoding from it.
15    pub independent: bool,
16}
17
18/// One segment entry in a media playlist.
19#[derive(Debug, Clone)]
20pub struct Segment {
21    /// Media sequence number (monotonic, never reused).
22    pub seq: u64,
23    /// Segment duration in seconds.
24    pub duration: f64,
25    /// Segment URI (relative path written under the stream's storage prefix).
26    pub uri: String,
27    /// Whether a discontinuity precedes this segment.
28    pub discontinuity: bool,
29    /// The LL-HLS partial segments that make up this segment (empty for plain
30    /// HLS). Rendered as `#EXT-X-PART` lines before the segment's `#EXTINF`.
31    pub parts: Vec<Part>,
32}
33
34/// A sliding-window HLS media playlist.
35///
36/// Holds the most recent `window` segments and renders a spec-compliant
37/// `#EXTM3U` document. Set `low_latency` to emit `#EXT-X-PART` hints and the
38/// LL-HLS preload/blocking tags (the segmenter supplies parts).
39#[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    /// Parts of the in-progress (not-yet-complete) segment, rendered at the live
51    /// edge after the last complete segment.
52    pending_parts: Vec<Part>,
53    /// The URI of the next part not yet available, advertised via
54    /// `#EXT-X-PRELOAD-HINT` so players can issue a blocking request for it.
55    preload_hint: Option<String>,
56}
57
58impl HlsPlaylist {
59    /// A live playlist holding `window` segments of up to `target_duration` secs.
60    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    /// Enable LL-HLS output with the given partial-segment target duration.
77    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    /// Set the fMP4 initialization-segment URI, emitted as `#EXT-X-MAP`.
84    /// Required for fragmented-MP4 renditions (HEVC, AV1, VVC).
85    pub fn set_map(&mut self, uri: impl Into<String>) {
86        self.map_uri = Some(uri.into());
87    }
88
89    /// Append a segment, evicting the oldest if the window is full.
90    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    /// Append a partial segment to the in-progress (live-edge) segment.
106    pub fn add_pending_part(&mut self, part: Part) {
107        self.pending_parts.push(part);
108    }
109
110    /// Advertise the next not-yet-available part via `#EXT-X-PRELOAD-HINT`.
111    pub fn set_preload_hint(&mut self, uri: impl Into<String>) {
112        self.preload_hint = Some(uri.into());
113    }
114
115    /// Complete the in-progress segment: push it carrying the parts accumulated
116    /// via [`add_pending_part`](Self::add_pending_part), then clear the pending
117    /// state for the next segment.
118    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    /// Mark the stream complete (appends `#EXT-X-ENDLIST`).
125    pub fn finish(&mut self) {
126        self.finished = true;
127        self.pending_parts.clear();
128        self.preload_hint = None;
129    }
130
131    /// Segments currently in the window.
132    pub fn segments(&self) -> &VecDeque<Segment> {
133        &self.segments
134    }
135
136    /// Render the playlist to an `.m3u8` string.
137    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        // `write!` formats directly into the buffer, avoiding the throwaway
143        // String each `format!` would allocate (this runs on every segment cut).
144        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            // PART-HOLD-BACK is REQUIRED by the LL-HLS spec when parts are present
156            // (≥ 3× PART-TARGET); players such as hls.js refuse the stream without
157            // it. Pair it with CAN-BLOCK-RELOAD for blocking playlist reload.
158            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            // LL-HLS: a completed segment's parts precede its #EXTINF.
172            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        // Live edge: parts of the segment still being produced, then a hint for
180        // the next part so players can issue a blocking preload request.
181        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    /// Render one `#EXT-X-PART` line.
196    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
209/// Render a multivariant (master) playlist referencing a single media playlist.
210///
211/// This is the only place an HLS stream advertises its `CODECS` attribute (e.g.
212/// `hvc1.1.6.L120.B0`). It is **required** for HEVC/H.265 (and AV1/VVC):
213/// Safari and other players will not even attempt to decode H.265 from a bare
214/// media playlist that lacks a `CODECS="hvc1.*"` entry. `BANDWIDTH` is a
215/// mandatory `#EXT-X-STREAM-INF` attribute; pass a nominal estimate.
216pub 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        // Window keeps the last 3 segments; media sequence advances to 2.
251        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")); // evicted
258        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        // Parts now precede the committed segment's #EXTINF, and pending is clear.
312        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}