Skip to main content

arcly_stream/packager/
playlist.rs

1//! HLS / LL-HLS media-playlist (`.m3u8`) generation.
2
3use std::collections::VecDeque;
4
5/// One segment entry in a media playlist.
6#[derive(Debug, Clone)]
7pub struct Segment {
8    /// Media sequence number (monotonic, never reused).
9    pub seq: u64,
10    /// Segment duration in seconds.
11    pub duration: f64,
12    /// Segment URI (relative path written under the stream's storage prefix).
13    pub uri: String,
14    /// Whether a discontinuity precedes this segment.
15    pub discontinuity: bool,
16}
17
18/// A sliding-window HLS media playlist.
19///
20/// Holds the most recent `window` segments and renders a spec-compliant
21/// `#EXTM3U` document. Set `low_latency` to emit `#EXT-X-PART` hints and the
22/// LL-HLS preload/blocking tags (the segmenter supplies parts).
23#[derive(Debug, Clone)]
24pub struct HlsPlaylist {
25    target_duration: u64,
26    window: usize,
27    low_latency: bool,
28    part_target: f64,
29    media_sequence: u64,
30    discontinuity_sequence: u64,
31    segments: VecDeque<Segment>,
32    map_uri: Option<String>,
33    finished: bool,
34}
35
36impl HlsPlaylist {
37    /// A live playlist holding `window` segments of up to `target_duration` secs.
38    pub fn new(target_duration: u64, window: usize) -> Self {
39        Self {
40            target_duration,
41            window: window.max(1),
42            low_latency: false,
43            part_target: 0.0,
44            media_sequence: 0,
45            discontinuity_sequence: 0,
46            segments: VecDeque::new(),
47            map_uri: None,
48            finished: false,
49        }
50    }
51
52    /// Enable LL-HLS output with the given partial-segment target duration.
53    pub fn low_latency(mut self, part_target: f64) -> Self {
54        self.low_latency = true;
55        self.part_target = part_target;
56        self
57    }
58
59    /// Set the fMP4 initialization-segment URI, emitted as `#EXT-X-MAP`.
60    /// Required for fragmented-MP4 renditions (HEVC, AV1, VVC).
61    pub fn set_map(&mut self, uri: impl Into<String>) {
62        self.map_uri = Some(uri.into());
63    }
64
65    /// Append a segment, evicting the oldest if the window is full.
66    pub fn push(&mut self, seg: Segment) {
67        if self.segments.is_empty() {
68            self.media_sequence = seg.seq;
69        }
70        self.segments.push_back(seg);
71        while self.segments.len() > self.window {
72            if let Some(old) = self.segments.pop_front() {
73                if old.discontinuity {
74                    self.discontinuity_sequence += 1;
75                }
76                self.media_sequence = self.segments.front().map(|s| s.seq).unwrap_or(old.seq + 1);
77            }
78        }
79    }
80
81    /// Mark the stream complete (appends `#EXT-X-ENDLIST`).
82    pub fn finish(&mut self) {
83        self.finished = true;
84    }
85
86    /// Segments currently in the window.
87    pub fn segments(&self) -> &VecDeque<Segment> {
88        &self.segments
89    }
90
91    /// Render the playlist to an `.m3u8` string.
92    pub fn render(&self) -> String {
93        let mut s = String::with_capacity(256 + self.segments.len() * 64);
94        s.push_str("#EXTM3U\n");
95        s.push_str("#EXT-X-VERSION:");
96        s.push_str(if self.low_latency { "9\n" } else { "3\n" });
97        s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
98        s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
99        if self.discontinuity_sequence > 0 {
100            s.push_str(&format!(
101                "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
102                self.discontinuity_sequence
103            ));
104        }
105        if self.low_latency {
106            s.push_str(&format!(
107                "#EXT-X-PART-INF:PART-TARGET={:.3}\n",
108                self.part_target
109            ));
110            s.push_str("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES\n");
111        }
112        if let Some(uri) = &self.map_uri {
113            s.push_str(&format!("#EXT-X-MAP:URI=\"{uri}\"\n"));
114        }
115        for seg in &self.segments {
116            if seg.discontinuity {
117                s.push_str("#EXT-X-DISCONTINUITY\n");
118            }
119            s.push_str(&format!("#EXTINF:{:.3},\n", seg.duration));
120            s.push_str(&seg.uri);
121            s.push('\n');
122        }
123        if self.finished {
124            s.push_str("#EXT-X-ENDLIST\n");
125        }
126        s
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn seg(seq: u64, dur: f64) -> Segment {
135        Segment {
136            seq,
137            duration: dur,
138            uri: format!("seg{seq}.m4s"),
139            discontinuity: false,
140        }
141    }
142
143    #[test]
144    fn renders_live_playlist_with_sliding_window() {
145        let mut pl = HlsPlaylist::new(6, 3);
146        for i in 0..5 {
147            pl.push(seg(i, 5.0));
148        }
149        // Window keeps the last 3 segments; media sequence advances to 2.
150        assert_eq!(pl.segments().len(), 3);
151        let out = pl.render();
152        assert!(out.starts_with("#EXTM3U\n"));
153        assert!(out.contains("#EXT-X-TARGETDURATION:6\n"));
154        assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:2\n"));
155        assert!(out.contains("seg4.m4s"));
156        assert!(!out.contains("seg1.m4s")); // evicted
157        assert!(!out.contains("#EXT-X-ENDLIST"));
158    }
159
160    #[test]
161    fn finish_appends_endlist() {
162        let mut pl = HlsPlaylist::new(6, 5);
163        pl.push(seg(0, 4.0));
164        pl.finish();
165        assert!(pl.render().trim_end().ends_with("#EXT-X-ENDLIST"));
166    }
167
168    #[test]
169    fn low_latency_emits_part_tags() {
170        let pl = HlsPlaylist::new(4, 5).low_latency(0.33);
171        let out = pl.render();
172        assert!(out.contains("#EXT-X-VERSION:9"));
173        assert!(out.contains("PART-TARGET=0.330"));
174        assert!(out.contains("CAN-BLOCK-RELOAD=YES"));
175    }
176}