Skip to main content

client_stepping/
client_stepping.rs

1//! Drive the sans-IO LL-HLS **client** engine (`hls_runtime::client`)
2//! against a canned Media Playlist — no socket, no real network. The
3//! playlist text itself comes from this crate's own origin engine
4//! ([`hls_runtime::server::HlsOrigin`]), so it is guaranteed
5//! well-formed LL-HLS syntax (the exact symmetric counterpart
6//! `MediaPlaylist::parse` is written against) rather than hand-typed text
7//! that could drift from what the parser actually accepts.
8//!
9//! # Usage
10//!
11//! ```bash
12//! cargo run --example client_stepping -p hls-runtime
13//! ```
14
15use std::num::NonZeroUsize;
16use std::time::Duration;
17
18use broadcast_common::Timestamp;
19use hls_runtime::client::{Action, HlsClient};
20use hls_runtime::server::{BlockingQuery, DEFAULT_TRACK_ID, HlsBody, HlsOrigin, HlsRequest};
21use media_plane::egress::{AwaitPolicy, EgressResponse, ServedEgress};
22use media_plane::trunk::{PartEntry, SegmentEntry, Trunk, TrunkConfig};
23use transmux::SegmentMeta;
24
25const PLAYLIST_URL: &str = "http://origin/live/media.m3u8";
26
27fn nz(n: usize) -> NonZeroUsize {
28    NonZeroUsize::new(n).expect("example capacity must be non-zero")
29}
30
31/// Builds a small, valid canned Media Playlist: one closed segment plus an
32/// open segment with one part already landed — enough to exercise an init
33/// fetch, a part fetch, a preload-hint prefetch, and (since this crate's own
34/// renderer defaults `CAN-BLOCK-RELOAD=YES`) a Blocking Playlist Reload for
35/// the next request.
36fn canned_playlist() -> String {
37    let trunk = Trunk::new(TrunkConfig::new(nz(16), nz(4), nz(8), nz(4), nz(16)));
38    let writer = trunk
39        .segment_writer()
40        .expect("first (and only) segment writer");
41    let origin = HlsOrigin::builder(std::sync::Arc::clone(&trunk))
42        .target_duration_secs(1.0)
43        .window_segments(nz(4))
44        .low_latency(500)
45        .build()
46        .expect("both required fields set");
47    origin.set_init(vec![0xAA; 32]);
48
49    writer.publish_part(PartEntry::new(
50        vec![0x01; 16],
51        1,
52        0,
53        Duration::from_millis(500),
54        true,
55    ));
56    writer.publish_segment(SegmentEntry::new(
57        vec![0x02; 32],
58        1,
59        Duration::from_secs(1),
60        Timestamp::from_nanos(0),
61        SegmentMeta {
62            discontinuous: false,
63        },
64    ));
65    writer.publish_part(PartEntry::new(
66        vec![0x03; 16],
67        2,
68        0,
69        Duration::from_millis(500),
70        true,
71    ));
72
73    match origin.resolve(
74        HlsRequest::Playlist {
75            track_id: DEFAULT_TRACK_ID,
76            query: BlockingQuery::default(),
77        },
78        Timestamp::from_nanos(0),
79        AwaitPolicy::new(Timestamp::from_nanos(0)),
80    ) {
81        EgressResponse::Ready {
82            body: HlsBody::Playlist(m),
83            ..
84        } => m,
85        other => panic!("expected Ready(Playlist), got {other:?}"),
86    }
87}
88
89fn main() {
90    let playlist = canned_playlist();
91    println!("--- canned media.m3u8 ---\n{playlist}");
92
93    let mut client = HlsClient::new(PLAYLIST_URL);
94
95    // The client always seeds a plain (non-blocking) GET first — it hasn't
96    // seen a playlist yet, so it doesn't know the origin supports blocking
97    // reload.
98    match client.poll() {
99        Some(Action::FetchPlaylist {
100            url,
101            blocking,
102            skip,
103        }) => {
104            assert_eq!(url, PLAYLIST_URL);
105            assert!(blocking.is_none());
106            assert!(!skip);
107            println!("action: FetchPlaylist {{ url: {url:?}, blocking: None }}");
108        }
109        other => panic!("expected the seeded FetchPlaylist, got {other:?}"),
110    }
111
112    // Feed the canned playlist in response to that (imagined) GET — no HTTP
113    // client is ever involved.
114    client
115        .on_playlist(playlist.as_bytes())
116        .expect("the canned playlist parses");
117
118    // Drain every action the client now wants performed: the closed
119    // segment's bytes, the open segment's landed part, the init segment
120    // (from `#EXT-X-MAP`), the preload-hinted next part, and finally a
121    // Blocking Playlist Reload naming the next Media Sequence Number/part.
122    let mut saw_blocking_reload = false;
123    while let Some(action) = client.poll() {
124        match &action {
125            Action::FetchResource { id, url, .. } => {
126                println!("action: FetchResource {{ id: {id:?}, url: {url:?} }}");
127            }
128            Action::FetchPlaylist {
129                url,
130                blocking: Some(b),
131                ..
132            } => {
133                println!(
134                    "action: FetchPlaylist {{ url: {url:?}, blocking: {b:?} }}  <- blocking reload"
135                );
136                saw_blocking_reload = true;
137            }
138            Action::FetchPlaylist {
139                url,
140                blocking: None,
141                ..
142            } => {
143                println!("action: FetchPlaylist {{ url: {url:?}, blocking: None }}");
144            }
145            Action::WaitMs(ms) => println!("action: WaitMs({ms})"),
146            // `Action` is `#[non_exhaustive]` — a future variant is simply
147            // not printed by this demo, not a compile break.
148            _ => {}
149        }
150    }
151    assert!(
152        saw_blocking_reload,
153        "this crate's own origin renderer defaults CAN-BLOCK-RELOAD=YES, so the \
154         next reload the client schedules must be a blocking one"
155    );
156}