Skip to main content

ll_hls_runtime/server/
engine.rs

1//! Blocking-reload/part-availability *decision* logic and playlist rendering
2//! for the LL-HLS origin — moved out of `multimux::output::llhls` (issue
3//! #663/#717 Stage 2). Sans-IO: every function here is a synchronous poll
4//! returning an outcome enum, never a `Future` — the caller (an async
5//! adapter, e.g. `multimux`) turns `WouldBlock` into an actual wait using
6//! [`super::MediaStore::listen`] (see this module's parent doc for the wait
7//! loop shape).
8//!
9//! Master/media playlist tags are RFC 8216 §4.3.4 (`#EXT-X-STREAM-INF`) and
10//! §4.3.3 (`#EXTM3U`/`#EXT-X-VERSION`, rendered by [`media_playlist_m3u8`]);
11//! the blocking reload query parameters (`_HLS_msn`/`_HLS_part`) are the
12//! Blocking Playlist Reload mechanism of RFC 8216bis §6.2.5.2 — the client
13//! asks the origin to hold the response open until the requested Media
14//! Sequence Number/part is available, bounded so the origin never hangs
15//! indefinitely (the bound itself — a wall-clock timeout — is the adapter's
16//! job, not this module's: sans-IO code has no clock).
17
18use transmux::hls::{LowLatencyConfig, MediaPlaylist, MediaSegment, OpenSegment, PartSpec};
19
20use super::store::MediaStore;
21
22/// Track id for the single rendition served per stream (no multi-track/
23/// multi-rendition support yet).
24pub const DEFAULT_TRACK_ID: u32 = 1;
25
26/// Placeholder `BANDWIDTH` (bits/second) advertised in the master playlist's
27/// `#EXT-X-STREAM-INF` — actual encoded bitrate isn't measured, so a single
28/// fixed estimate is used for the single variant served.
29const PLACEHOLDER_BANDWIDTH_BPS: u64 = 5_000_000;
30
31/// RFC 8216bis §6.2.5.2 (SHOULD): a `_HLS_msn` unreasonably far in the future
32/// should be rejected rather than always blocking to the caller's timeout — a
33/// legitimate client only ever asks for the segment/part right after the one
34/// it already has, so anything more than a few segments beyond the current
35/// live edge is either a malfunctioning client or abuse.
36const ABUSE_MSN_FUTURE_BOUND: u64 = 4;
37
38/// HLS requires HLS protocol version 9 (RFC 8216bis §4.4.3.7/§4.4.3.8: the
39/// `#EXT-X-PART-INF`/`#EXT-X-PART` directives this renderer always emits
40/// require it).
41const LL_HLS_VERSION: u8 = 9;
42
43/// RFC 8216bis / Apple LL-HLS §4.4.3.7: `#EXT-X-SERVER-CONTROL`'s
44/// `PART-HOLD-BACK` attribute MUST be at least 3x the part target duration
45/// (`#EXT-X-PART-INF`'s `PART-TARGET`).
46const PART_HOLD_BACK_MULTIPLIER: f64 = 3.0;
47
48/// Render the LL-HLS media playlist for `track_id` from `store`'s current
49/// segments/live parts.
50///
51/// RFC 8216bis §4.4.4.9: an in-progress (not yet closed) segment MUST NOT
52/// be advertised with an `#EXTINF`/URI pair — that segment has no fetchable
53/// resource yet — it may only appear as trailing `#EXT-X-PART` lines.
54/// `transmux::hls::MediaPlaylist::open_segment` is exactly this
55/// representation: its parts render as trailing `#EXT-X-PART` lines with
56/// no `#EXTINF`/URI, so the in-progress segment's parts and the
57/// `#EXT-X-PRELOAD-HINT` for the next, not-yet-available part are both
58/// rendered by `to_m3u8()` itself — this function only supplies the URI
59/// scheme (`part-<track>-<seq>.<idx>.m4s`) and the part metadata.
60pub fn media_playlist_m3u8(store: &MediaStore, track_id: u32) -> String {
61    // Read these *before* taking `with_segments_and_parts`'s lock below —
62    // `MediaStore::max_segment_duration` takes the same `inner` mutex
63    // itself, and `std::sync::Mutex` is not reentrant, so calling it from
64    // inside the `with_segments_and_parts` closure (as a previous version of
65    // this function did) self-deadlocks the calling thread the first time
66    // this function is ever invoked with any segment present. Caught by a
67    // real network round trip against a live `MediaStore` (issue #717 slice
68    // 5's acceptance test) — the existing test suite only ever called this
69    // function directly (never over HTTP with two concurrently-scheduled
70    // tasks), which happened to never trip the deadlock detector but hung
71    // just the same once actually exercised end-to-end. **Preserve this
72    // ordering** — see `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`
73    // and issue #663/#717.
74    let target_duration_secs = store.target_duration_secs();
75    let max_segment_duration = store.max_segment_duration();
76    store.with_segments_and_parts(|store_segments, live_parts| {
77        let media_sequence = store_segments
78            .front()
79            .map(|s| u64::from(s.segment_seq))
80            .or_else(|| live_parts.first().map(|p| u64::from(p.segment_seq)))
81            .unwrap_or(1);
82        let segments: Vec<MediaSegment> = store_segments
83            .iter()
84            .map(|s| MediaSegment {
85                uri: format!("seg-{track_id}-{}.m4s", s.segment_seq),
86                duration: s.duration,
87                discontinuous: false,
88                parts: Vec::new(),
89                ..Default::default()
90            })
91            .collect();
92        let part_target = f64::from(store.part_target_ms()) / 1000.0;
93        // The in-progress segment's live parts + the next (not yet available)
94        // part's preload-hint URI.
95        let open_seq = live_parts.first().map(|p| p.segment_seq);
96        let open_segment = open_seq.map(|seq| {
97            OpenSegment::new(
98                live_parts
99                    .iter()
100                    .filter(|p| p.segment_seq == seq)
101                    .map(|p| PartSpec {
102                        uri: format!("part-{track_id}-{}.{}.m4s", p.segment_seq, p.part_index),
103                        duration: p.duration,
104                        independent: p.independent,
105                        ..Default::default()
106                    })
107                    .collect(),
108            )
109        });
110        let next_part_hint = open_seq.map(|seq| {
111            let next_idx = live_parts
112                .iter()
113                .filter(|p| p.segment_seq == seq)
114                .map(|p| p.part_index)
115                .max()
116                .map(|idx| idx + 1)
117                .unwrap_or(0);
118            format!("part-{track_id}-{seq}.{next_idx}.m4s")
119        });
120        // RFC 8216bis §4.4.3.1 (MUST): every Media Segment's EXTINF duration,
121        // rounded to the nearest integer, MUST be <= TARGETDURATION. The
122        // segmenter cuts on the next keyframe *after* the configured target,
123        // so a real segment routinely exceeds it — advertising the
124        // configured target alone can under-declare. Use whichever is
125        // larger, rounded (not the configured value's `ceil()` alone).
126        let target_duration = target_duration_secs.max(max_segment_duration).round() as u32;
127        let playlist = MediaPlaylist {
128            version: LL_HLS_VERSION,
129            target_duration,
130            media_sequence,
131            discontinuity_sequence: 0,
132            segments,
133            open_segment,
134            endlist: false,
135            extra_tags: vec![format!("#EXT-X-MAP:URI=\"init-{track_id}.mp4\"")],
136            low_latency: Some(LowLatencyConfig {
137                part_target,
138                part_hold_back: part_target * PART_HOLD_BACK_MULTIPLIER,
139                preload_hint_part: next_part_hint,
140                ..Default::default()
141            }),
142            iframes_only: false,
143            ..Default::default()
144        };
145        playlist.to_m3u8()
146    })
147}
148
149/// A minimal single-variant master playlist pointing at `media_playlist_name`
150/// (the caller's configured media-playlist filename — e.g. multimux's
151/// `Config::playlist_name`, defaulting to `"media.m3u8"`) — the same
152/// regardless of `MediaStore` state (no multi-rendition support yet), so this
153/// takes no store argument.
154pub fn master_playlist_m3u8(media_playlist_name: &str) -> String {
155    format!(
156        "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH={PLACEHOLDER_BANDWIDTH_BPS}\n{media_playlist_name}\n"
157    )
158}
159
160/// Blocking playlist reload query parameters (RFC 8216bis §6.2.5.2) — the
161/// sans-IO counterpart of an adapter's own (likely serde-`Deserialize`)
162/// query-string type; the adapter maps its wire query params into this.
163#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
164pub struct BlockingQuery {
165    /// The Media Sequence Number the client already has, plus one — the
166    /// origin should not respond until a segment/part beyond this is ready.
167    pub hls_msn: Option<u64>,
168    /// The part index (within `hls_msn`) the client is waiting for.
169    pub hls_part: Option<u32>,
170}
171
172/// The result of [`MediaStore::resolve_playlist`]: either the rendered
173/// playlist is ready now, the request is malformed/abusive (RFC 8216bis
174/// §6.2.5.2 abuse prevention — reject immediately, no wait), or the awaited
175/// segment/part isn't available *yet* (the caller should wait for the next
176/// change notification and re-resolve).
177#[derive(Debug, Clone, PartialEq, Eq)]
178#[non_exhaustive]
179pub enum PlaylistOutcome {
180    /// The rendered media playlist body.
181    Ready(String),
182    /// The awaited condition (`hls_msn`/`hls_part`) isn't satisfied yet —
183    /// wait for [`super::MediaStore::listen`] and re-resolve.
184    WouldBlock,
185    /// The request is malformed (`hls_part` without `hls_msn`) or abusive
186    /// (`hls_msn` unreasonably far beyond the live edge) — reject now, don't
187    /// wait.
188    BadRequest,
189}
190
191/// `Cache-Control` policy an adapter applies to a resolved resource —
192/// playlists are always re-fetched for liveness (not modeled here since
193/// [`PlaylistOutcome::Ready`] is playlist-only), while a produced init/
194/// segment/part byte range never changes once produced.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum CachePolicy {
198    /// Safe to cache indefinitely — a given URI's bytes never change once
199    /// produced (each segment/part is generated exactly once under a unique
200    /// filename).
201    Immutable,
202    /// Must always be re-fetched (liveness-sensitive).
203    NoCache,
204}
205
206impl CachePolicy {
207    /// The spec/field-enum label (workspace #204 convention): a stable,
208    /// lowercase token per policy, suitable for logs/metrics/`Cache-Control`
209    /// diagnostics.
210    pub fn name(&self) -> &'static str {
211        match self {
212            CachePolicy::Immutable => "immutable",
213            CachePolicy::NoCache => "no-cache",
214        }
215    }
216}
217
218broadcast_common::impl_spec_display!(CachePolicy);
219
220/// The result of [`MediaStore::resolve_resource`]: the resource's bytes are
221/// ready, the request should wait (a preload-hinted part not yet produced),
222/// or the resource does not (and, for a part whose segment already closed
223/// without it, will never) exist.
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[non_exhaustive]
226pub enum ResourceOutcome {
227    /// The resource's bytes, plus the cache policy an adapter should apply.
228    Ready {
229        /// The resolved bytes.
230        bytes: Vec<u8>,
231        /// `Cache-Control` policy for these bytes.
232        cache: CachePolicy,
233    },
234    /// A preload-hinted part that hasn't been produced yet — wait for
235    /// [`super::MediaStore::listen`] and re-resolve.
236    WouldBlock,
237    /// The named resource does not exist and never will (unknown filename
238    /// shape, or a part whose segment closed without ever producing it).
239    NotFound,
240}
241
242impl MediaStore {
243    /// Resolve a `GET media.m3u8` request against `_HLS_msn`/`_HLS_part`
244    /// blocking-reload semantics (RFC 8216bis §6.2.5.2), rendering
245    /// [`media_playlist_m3u8`] for `track_id` once the awaited condition is
246    /// satisfied (or immediately, if `query` carries no blocking
247    /// parameters).
248    ///
249    /// `_HLS_msn` alone waits for segment `msn` to **close**; `_HLS_msn`+
250    /// `_HLS_part` waits only for that part of the (possibly still open)
251    /// segment — these are genuinely different conditions (treating a bare
252    /// `_HLS_msn` as `_HLS_part=0` would resolve as soon as the segment
253    /// merely opens with one live part, before it has an `#EXTINF`/URI at
254    /// all). `_HLS_part` without `_HLS_msn` is meaningless (a part is only
255    /// addressable relative to a segment) and a `_HLS_msn` unreasonably far
256    /// beyond the live edge is either a broken client or abuse — both
257    /// [`PlaylistOutcome::BadRequest`] immediately rather than
258    /// [`PlaylistOutcome::WouldBlock`]ing.
259    pub fn resolve_playlist(&self, track_id: u32, query: BlockingQuery) -> PlaylistOutcome {
260        if query.hls_part.is_some() && query.hls_msn.is_none() {
261            return PlaylistOutcome::BadRequest;
262        }
263        if let Some(msn) = query.hls_msn {
264            let (current_max_msn, _) = self.latest_progress();
265            if msn > u64::from(current_max_msn) + ABUSE_MSN_FUTURE_BOUND {
266                return PlaylistOutcome::BadRequest;
267            }
268            let satisfied = match query.hls_part {
269                Some(part) => {
270                    let (in_progress_seg_seq, part_count) = self.latest_progress();
271                    u64::from(in_progress_seg_seq) > msn
272                        || (u64::from(in_progress_seg_seq) == msn && part_count > part)
273                }
274                None => u64::from(self.last_closed_segment_seq()) >= msn,
275            };
276            if !satisfied {
277                return PlaylistOutcome::WouldBlock;
278            }
279        }
280        PlaylistOutcome::Ready(media_playlist_m3u8(self, track_id))
281    }
282
283    /// Resolve a dynamic origin filename (`init-{track}.mp4`, `seg-{track}-
284    /// {seq}.m4s`, `part-{track}-{seq}.{idx}.m4s`) to its bytes.
285    ///
286    /// A part request is the preload-hinted Partial Segment a client fetches
287    /// ahead of time (RFC 8216bis §6.2.2, §6.3.1). If the origin promised it
288    /// via `#EXT-X-PRELOAD-HINT` but hasn't produced it yet,
289    /// [`ResourceOutcome::WouldBlock`] — the caller should hold the request
290    /// open (not 404 immediately, which spams errors and defeats low
291    /// latency). [`ResourceOutcome::NotFound`] is returned **promptly**
292    /// (without the caller needing to wait out its own timeout) once the
293    /// part can no longer appear: its segment has closed (now only
294    /// addressable as a whole segment via `seg-…`), or the in-progress
295    /// segment has advanced past it — a legitimate 404 the client answers by
296    /// fetching the next segment/part.
297    pub fn resolve_resource(&self, name: &str) -> ResourceOutcome {
298        if let Some((seq, idx)) = parse_part(name) {
299            return match self.part_bytes(seq, idx) {
300                Some(bytes) => ResourceOutcome::Ready {
301                    bytes,
302                    cache: CachePolicy::Immutable,
303                },
304                None => {
305                    let (in_progress_seg_seq, _) = self.latest_progress();
306                    if in_progress_seg_seq > seq || self.segment_bytes(seq).is_some() {
307                        ResourceOutcome::NotFound
308                    } else {
309                        ResourceOutcome::WouldBlock
310                    }
311                }
312            };
313        }
314        match resolve_file(self, name) {
315            Some(bytes) => ResourceOutcome::Ready {
316                bytes,
317                cache: CachePolicy::Immutable,
318            },
319            None => ResourceOutcome::NotFound,
320        }
321    }
322}
323
324/// Parse a `part-{track}-{seq}.{idx}.m4s` dynamic filename into `(seq, idx)`,
325/// or `None` if it isn't a part filename (or its numeric fields don't parse).
326/// `{track}` is validated but unused (see [`resolve_file`]).
327fn parse_part(file: &str) -> Option<(u32, u32)> {
328    let rest = file.strip_prefix("part-")?.strip_suffix(".m4s")?;
329    let (track_seq, idx) = rest.rsplit_once('.')?;
330    let (track, seq) = track_seq.split_once('-')?;
331    track.parse::<u32>().ok()?;
332    Some((seq.parse().ok()?, idx.parse().ok()?))
333}
334
335/// Parse a dynamic origin filename and fetch its bytes from `store`:
336/// - `init-{track}.mp4` -> [`MediaStore::init_bytes`]
337/// - `seg-{track}-{seq}.m4s` -> [`MediaStore::segment_bytes`]
338///
339/// Part filenames (`part-{track}-{seq}.{idx}.m4s`) are handled separately in
340/// [`MediaStore::resolve_resource`] (they can block until available — see
341/// [`parse_part`]), not here. `{track}` is validated as a number but
342/// otherwise unused: `store` holds a single track's data (see
343/// [`DEFAULT_TRACK_ID`]). Returns `None` (-> 404) for any filename that
344/// doesn't match one of these shapes, or whose numeric fields don't parse.
345fn resolve_file(store: &MediaStore, file: &str) -> Option<Vec<u8>> {
346    if let Some(rest) = file.strip_prefix("init-") {
347        let track = rest.strip_suffix(".mp4")?;
348        track.parse::<u32>().ok()?;
349        return store.init_bytes();
350    }
351    if let Some(rest) = file.strip_prefix("seg-") {
352        let rest = rest.strip_suffix(".m4s")?;
353        let (track, seq) = rest.split_once('-')?;
354        track.parse::<u32>().ok()?;
355        let seq: u32 = seq.parse().ok()?;
356        return store.segment_bytes(seq);
357    }
358    None
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use transmux::ll_hls::{PartInfo, SegmentInfo};
365
366    fn part(seq: u32, idx: u32) -> PartInfo {
367        PartInfo {
368            bytes: vec![0x10 + idx as u8; 4],
369            duration: 0.5,
370            independent: idx == 0,
371            segment_seq: seq,
372            part_index: idx,
373        }
374    }
375
376    fn seg(seq: u32) -> SegmentInfo {
377        SegmentInfo {
378            bytes: vec![0x20 + seq as u8; 8],
379            duration: 4.0,
380            segment_seq: seq,
381            part_count: 2,
382        }
383    }
384
385    /// A populated store: a closed segment 1, plus two live parts of
386    /// in-progress segment 2 -- so `latest_progress()` is `(2, 2)`.
387    fn make_store() -> MediaStore {
388        let store = MediaStore::new(4.0, 500, 4);
389        store.set_init(vec![0xAA; 8]);
390        store.add_segment(seg(1));
391        store.add_part(part(2, 0));
392        store.add_part(part(2, 1));
393        store
394    }
395
396    #[test]
397    fn cache_policy_name_and_display_agree() {
398        for (policy, label) in [
399            (CachePolicy::Immutable, "immutable"),
400            (CachePolicy::NoCache, "no-cache"),
401        ] {
402            assert_eq!(policy.name(), label);
403            assert_eq!(policy.to_string(), label);
404        }
405    }
406
407    #[test]
408    fn master_playlist_has_stream_inf() {
409        let m = master_playlist_m3u8("media.m3u8");
410        assert!(m.contains("#EXTM3U"));
411        assert!(m.contains("#EXT-X-STREAM-INF"));
412        assert!(m.contains("media.m3u8"));
413    }
414
415    #[test]
416    fn master_playlist_points_at_configured_playlist_name() {
417        let m = master_playlist_m3u8("index.m3u8");
418        assert!(m.contains("index.m3u8"));
419        assert!(!m.contains("media.m3u8"));
420    }
421
422    #[test]
423    fn resolve_playlist_no_query_is_ready_now() {
424        let store = make_store();
425        let outcome = store.resolve_playlist(DEFAULT_TRACK_ID, BlockingQuery::default());
426        match outcome {
427            PlaylistOutcome::Ready(body) => assert!(body.contains("#EXT-X-PART"), "body: {body}"),
428            other => panic!("expected Ready, got {other:?}"),
429        }
430    }
431
432    #[test]
433    fn resolve_playlist_already_satisfied_earlier_msn_is_ready() {
434        // latest_progress() for the store is (2, 2): asking for msn=1 (an
435        // earlier segment) is already satisfied and must not WouldBlock.
436        let store = make_store();
437        let outcome = store.resolve_playlist(
438            DEFAULT_TRACK_ID,
439            BlockingQuery {
440                hls_msn: Some(1),
441                hls_part: Some(0),
442            },
443        );
444        assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
445    }
446
447    #[test]
448    fn resolve_playlist_already_satisfied_same_msn_lower_part_is_ready() {
449        // in_progress_seg_seq == msn and part_count(2) > part(1): satisfied.
450        let store = make_store();
451        let outcome = store.resolve_playlist(
452            DEFAULT_TRACK_ID,
453            BlockingQuery {
454                hls_msn: Some(2),
455                hls_part: Some(1),
456            },
457        );
458        assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
459    }
460
461    #[test]
462    fn resolve_playlist_msn_only_waits_for_closed_segment_not_just_open_parts() {
463        // make_store()'s segment 2 is OPEN with 2 live parts
464        // (latest_progress() == (2, 2)) but not yet CLOSED. RFC 8216bis
465        // §6.2.5.2: a bare `_HLS_msn=2` (no `_HLS_part`) must WouldBlock, not
466        // resolve merely because it has live parts — treating this as
467        // `_HLS_part=0` (satisfied by part_count(2) > 0) would wrongly return
468        // Ready here.
469        let store = make_store();
470        let outcome = store.resolve_playlist(
471            DEFAULT_TRACK_ID,
472            BlockingQuery {
473                hls_msn: Some(2),
474                hls_part: None,
475            },
476        );
477        assert_eq!(outcome, PlaylistOutcome::WouldBlock);
478
479        // Once segment 2 actually closes, the same query resolves.
480        store.add_segment(seg(2));
481        let outcome = store.resolve_playlist(
482            DEFAULT_TRACK_ID,
483            BlockingQuery {
484                hls_msn: Some(2),
485                hls_part: None,
486            },
487        );
488        match outcome {
489            PlaylistOutcome::Ready(body) => assert!(
490                body.contains("seg-1-2.m4s"),
491                "resolved playlist must show segment 2 as closed: {body}"
492            ),
493            other => panic!("expected Ready after close, got {other:?}"),
494        }
495    }
496
497    #[test]
498    fn resolve_playlist_msn_within_bound_would_block_until_part_lands() {
499        // Sanity check for the abuse-bound logic: a legitimate
500        // just-ahead-of-live-edge msn/part WouldBlocks (not BadRequest), then
501        // resolves once the part lands.
502        let store = make_store(); // latest_progress() == (2, 2)
503        let outcome = store.resolve_playlist(
504            DEFAULT_TRACK_ID,
505            BlockingQuery {
506                hls_msn: Some(2),
507                hls_part: Some(2),
508            },
509        );
510        assert_eq!(outcome, PlaylistOutcome::WouldBlock);
511
512        store.add_part(part(2, 2));
513        let outcome = store.resolve_playlist(
514            DEFAULT_TRACK_ID,
515            BlockingQuery {
516                hls_msn: Some(2),
517                hls_part: Some(2),
518            },
519        );
520        assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
521    }
522
523    #[test]
524    fn resolve_playlist_far_future_msn_rejected() {
525        // latest_progress() for make_store() is (2, 2). A `_HLS_msn` 1000
526        // ahead of the live edge is not a legitimate blocking-reload request
527        // (RFC 8216bis §6.2.5.2 abuse prevention) — BadRequest immediately.
528        let store = make_store();
529        let outcome = store.resolve_playlist(
530            DEFAULT_TRACK_ID,
531            BlockingQuery {
532                hls_msn: Some(1002),
533                hls_part: None,
534            },
535        );
536        assert_eq!(outcome, PlaylistOutcome::BadRequest);
537    }
538
539    #[test]
540    fn resolve_playlist_part_without_msn_rejected() {
541        // RFC 8216bis §6.2.5.2: `_HLS_part` without `_HLS_msn` is
542        // meaningless (a part is only addressable relative to a segment).
543        let store = make_store();
544        let outcome = store.resolve_playlist(
545            DEFAULT_TRACK_ID,
546            BlockingQuery {
547                hls_msn: None,
548                hls_part: Some(0),
549            },
550        );
551        assert_eq!(outcome, PlaylistOutcome::BadRequest);
552    }
553
554    #[test]
555    fn resolve_resource_init_present() {
556        let store = make_store();
557        let outcome = store.resolve_resource("init-1.mp4");
558        match outcome {
559            ResourceOutcome::Ready { bytes, cache } => {
560                assert_eq!(bytes, vec![0xAA; 8]);
561                assert_eq!(cache, CachePolicy::Immutable);
562            }
563            other => panic!("expected Ready, got {other:?}"),
564        }
565    }
566
567    #[test]
568    fn resolve_resource_segment_present_and_absent() {
569        let store = make_store();
570        match store.resolve_resource("seg-1-1.m4s") {
571            ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x21; 8]),
572            other => panic!("expected Ready, got {other:?}"),
573        }
574        assert_eq!(
575            store.resolve_resource("seg-1-99.m4s"),
576            ResourceOutcome::NotFound
577        );
578    }
579
580    #[test]
581    fn resolve_resource_part_present() {
582        let store = make_store();
583        match store.resolve_resource("part-1-2.0.m4s") {
584            ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x10; 4]),
585            other => panic!("expected Ready, got {other:?}"),
586        }
587    }
588
589    #[test]
590    fn resolve_resource_part_not_yet_produced_would_block() {
591        // part-1-2.2 is the preload-hinted next part of in-progress segment 2
592        // (which currently has parts .0 and .1). Not yet produced -> WouldBlock,
593        // not NotFound (the caller waits, doesn't 404 immediately).
594        let store = make_store();
595        assert_eq!(
596            store.resolve_resource("part-1-2.2.m4s"),
597            ResourceOutcome::WouldBlock
598        );
599        store.add_part(part(2, 2));
600        match store.resolve_resource("part-1-2.2.m4s") {
601            ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x12; 4]),
602            other => panic!("expected Ready once produced, got {other:?}"),
603        }
604    }
605
606    #[test]
607    fn resolve_resource_part_not_found_once_segment_closes_without_it() {
608        // part-1-2.9 will never be produced. Once segment 2 closes (advancing
609        // the in-progress segment), the part must resolve NotFound promptly —
610        // not WouldBlock forever.
611        let store = make_store();
612        assert_eq!(
613            store.resolve_resource("part-1-2.9.m4s"),
614            ResourceOutcome::WouldBlock,
615            "not yet decidable while segment 2 is still open"
616        );
617        store.add_segment(seg(2));
618        assert_eq!(
619            store.resolve_resource("part-1-2.9.m4s"),
620            ResourceOutcome::NotFound,
621            "must resolve NotFound once segment 2 has closed without producing it"
622        );
623    }
624
625    #[test]
626    fn resolve_resource_part_served_from_recent_after_close() {
627        // Segment 2 has live parts .0 and .1; close it. Its final part must
628        // still resolve Ready (from recent_parts) — an in-flight
629        // preload-hint request racing the segment close must not NotFound.
630        let store = make_store();
631        store.add_segment(seg(2)); // close segment 2, moving its parts to recent_parts
632        match store.resolve_resource("part-1-2.1.m4s") {
633            ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x11; 4]),
634            other => panic!("a just-closed segment's part must still resolve Ready, got {other:?}"),
635        }
636    }
637
638    #[test]
639    fn resolve_resource_part_of_old_segment_not_found() {
640        // Segment 1 closed in make_store() with no parts recorded and is old
641        // enough to be past the recent-parts retention window, so its parts
642        // resolve NotFound without ever WouldBlocking (they will never be
643        // produced and aren't individually addressable anymore).
644        let store = make_store();
645        assert_eq!(
646            store.resolve_resource("part-1-1.0.m4s"),
647            ResourceOutcome::NotFound
648        );
649    }
650
651    #[test]
652    fn resolve_resource_unmatched_filename_not_found() {
653        let store = make_store();
654        assert_eq!(
655            store.resolve_resource("not-a-thing.txt"),
656            ResourceOutcome::NotFound
657        );
658    }
659
660    // --- Playlist-rendering content tests (moved from
661    // `multimux::output::llhls`, which now delegates rendering here) ---
662
663    fn plain_seg(seq: u32, parts: u32) -> SegmentInfo {
664        SegmentInfo {
665            bytes: vec![seq as u8; 8],
666            duration: 4.0,
667            segment_seq: seq,
668            part_count: parts,
669        }
670    }
671    fn plain_part(seq: u32, idx: u32) -> PartInfo {
672        PartInfo {
673            bytes: vec![idx as u8; 4],
674            duration: 0.5,
675            independent: idx == 0,
676            segment_seq: seq,
677            part_index: idx,
678        }
679    }
680
681    #[test]
682    fn playlist_has_llhls_tags_and_parts() {
683        let s = MediaStore::new(4.0, 500, 4);
684        s.set_init(vec![0; 4]);
685        s.add_part(plain_part(1, 0));
686        s.add_part(plain_part(1, 1));
687        let m = media_playlist_m3u8(&s, 1);
688        assert!(m.contains("#EXT-X-PART-INF"), "PART-INF present");
689        assert!(
690            m.contains("#EXT-X-SERVER-CONTROL"),
691            "SERVER-CONTROL present"
692        );
693        assert!(m.contains("#EXT-X-PART"), "at least one PART");
694        assert!(
695            m.contains("part-1-1.0.m4s") || m.contains("part-1-1.1.m4s"),
696            "part URI"
697        );
698    }
699
700    #[test]
701    fn open_segment_has_parts_but_no_extinf() {
702        let s = MediaStore::new(4.0, 500, 4);
703        s.set_init(vec![0; 4]);
704        s.add_part(plain_part(1, 0));
705        s.add_part(plain_part(1, 1));
706        let m = media_playlist_m3u8(&s, 1);
707        // The in-progress segment's parts are advertised...
708        assert!(m.contains("#EXT-X-PART"), "at least one PART line");
709        assert!(m.contains("part-1-1.0.m4s"), "part 0 URI present");
710        assert!(m.contains("part-1-1.1.m4s"), "part 1 URI present");
711        // ...but RFC 8216bis §4.4.4.9: no premature #EXTINF/URI for the
712        // not-yet-closed segment itself — "seg-1-1.m4s" must not appear
713        // anywhere (it isn't fetchable; that segment hasn't been closed).
714        assert!(
715            !m.contains("seg-1-1.m4s"),
716            "no full-segment URI for the open segment: {m}"
717        );
718        assert!(
719            !m.contains("#EXTINF"),
720            "no EXTINF for the open segment: {m}"
721        );
722    }
723
724    #[test]
725    fn final_part_fetchable_after_its_segment_closes() {
726        // The segmenter emits a segment's final part and then closes the
727        // segment in the same step. A preload-hint request for that final part
728        // is typically in flight when the close happens, so it must remain
729        // fetchable afterwards (from recent_parts) rather than 404 — the LL-HLS
730        // preload-hint boundary bug.
731        let s = MediaStore::new(4.0, 500, 4);
732        s.set_init(vec![0; 4]);
733        s.add_part(plain_part(1, 0));
734        s.add_part(plain_part(1, 1)); // .1 is this segment's final part
735        s.add_segment(plain_seg(1, 2)); // close segment 1 (moves its parts to recent_parts)
736        assert_eq!(
737            s.resolve_resource("part-1-1.1.m4s"),
738            ResourceOutcome::Ready {
739                bytes: vec![1; 4],
740                cache: CachePolicy::Immutable
741            },
742            "final part of a just-closed segment must still be individually fetchable"
743        );
744        assert_eq!(
745            s.resolve_resource("part-1-1.0.m4s"),
746            ResourceOutcome::Ready {
747                bytes: vec![0; 4],
748                cache: CachePolicy::Immutable
749            },
750            "earlier parts too"
751        );
752        // A genuinely-nonexistent part of the closed segment is NotFound.
753        assert_eq!(
754            s.resolve_resource("part-1-1.9.m4s"),
755            ResourceOutcome::NotFound
756        );
757        // Closing does not resurrect parts into the rendered open segment: the
758        // playlist advertises the whole segment, not its parts.
759        let m = media_playlist_m3u8(&s, 1);
760        assert!(
761            m.contains("seg-1-1.m4s"),
762            "closed segment rendered whole: {m}"
763        );
764        assert!(
765            !m.contains("part-1-1."),
766            "closed parts not rendered as open: {m}"
767        );
768    }
769
770    #[test]
771    fn live_parts_capped_when_segment_never_closes() {
772        // target_duration_secs=4.0, part_target_ms=500 -> cap =
773        // ceil(4.0 / 0.5) + 4 margin = 12 (see
774        // `super::super::store::compute_max_live_parts`).
775        let s = MediaStore::new(4.0, 500, 4);
776        let cap = super::super::store::compute_max_live_parts(4.0, 500);
777        assert_eq!(cap, 12, "sanity-check the expected cap for these params");
778        s.set_init(vec![0; 4]);
779
780        // Push far more parts than the cap into a single never-closed
781        // segment (no add_segment call) — RAM must stay bounded.
782        for i in 0..(cap as u32 * 5) {
783            s.add_part(plain_part(1, i));
784        }
785        assert_eq!(
786            s.live_part_count(),
787            cap,
788            "live_parts must stay capped even though the segment never closed"
789        );
790
791        // The playlist must still render correctly from the capped parts:
792        // only the most recent (highest-index) parts survive.
793        let m = media_playlist_m3u8(&s, 1);
794        assert!(m.contains("#EXT-X-PART"), "still has PART lines: {m}");
795        let last_idx = cap as u32 * 5 - 1;
796        assert!(
797            m.contains(&format!("part-1-1.{last_idx}.m4s")),
798            "most recent part must survive the cap: {m}"
799        );
800        let first_idx = cap as u32 * 5 - cap as u32;
801        assert!(
802            !m.contains(&format!("part-1-1.{}.m4s", first_idx - 1)),
803            "an older part beyond the cap must have been dropped: {m}"
804        );
805    }
806
807    // --- P2 LL-HLS spec-conformance fixes (audit-llhls #1/#2/#3/#4) ---
808
809    #[test]
810    fn target_duration_is_max_of_configured_and_actual_segment_duration() {
811        // Configured target is 4.0s, but the segmenter cuts on the next
812        // keyframe after the target so a real segment can run long (7.5s
813        // here) — RFC 8216bis §4.4.3.1 (MUST) requires TARGETDURATION to be
814        // >= every EXTINF, rounded. The old hardcoded
815        // `ceil(target_duration_secs)` would render `4`, violating the MUST.
816        let s = MediaStore::new(4.0, 500, 4);
817        s.set_init(vec![0; 4]);
818        let mut long_seg = plain_seg(1, 2);
819        long_seg.duration = 7.5;
820        s.add_segment(long_seg);
821        let m = media_playlist_m3u8(&s, 1);
822        assert!(
823            m.contains("#EXT-X-TARGETDURATION:8"),
824            "TARGETDURATION must be round(7.5)=8, not the configured target (4): {m}"
825        );
826    }
827
828    #[test]
829    fn target_duration_falls_back_to_configured_when_segments_are_short() {
830        let s = MediaStore::new(4.0, 500, 4);
831        s.set_init(vec![0; 4]);
832        s.add_segment(plain_seg(1, 2)); // plain_seg's fixed duration is 4.0
833        let m = media_playlist_m3u8(&s, 1);
834        assert!(
835            m.contains("#EXT-X-TARGETDURATION:4"),
836            "unchanged behaviour when no segment exceeds the configured target: {m}"
837        );
838    }
839}