Skip to main content

netget/client/hls/
actions.rs

1//! What the model does as an HLS client: read a playlist (master or media), fetch a segment,
2//! or play a stream — pick a variant, follow a live playlist as it slides, fetch every segment
3//! — and hear what arrived, summarised. Rust owns URL resolution, playlist parsing, reloads
4//! and the MPEG-TS analysis.
5use crate::llm::actions::{
6    client_trait::{Client, ClientActionResult},
7    protocol_trait::Protocol,
8    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
9};
10use crate::protocol::log_template::LogTemplate;
11use crate::protocol::{ConnectContext, EventType};
12use crate::state::app_state::AppState;
13use anyhow::{bail, ensure, Result};
14use serde_json::{json, Value};
15use std::sync::LazyLock;
16
17pub const GET_PLAYLIST: &str = "hls_get_playlist";
18pub const GET_SEGMENT: &str = "hls_get_segment";
19pub const PLAY: &str = "hls_play";
20
21#[derive(Default)]
22pub struct HlsClientProtocol;
23impl HlsClientProtocol {
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
30    Parameter {
31        name: name.into(),
32        type_hint: kind.into(),
33        description: description.into(),
34        required,
35    }
36}
37
38fn action(
39    name: &str,
40    description: &str,
41    parameters: Vec<Parameter>,
42    example: Value,
43) -> ActionDefinition {
44    ActionDefinition {
45        name: name.into(),
46        description: description.into(),
47        parameters,
48        example,
49        log_template: Some(LogTemplate::new().with_info(format!("-> HLS {name}"))),
50    }
51}
52
53pub fn actions() -> Vec<ActionDefinition> {
54    vec![
55        action(
56            GET_PLAYLIST,
57            "Fetch and parse a playlist: a master playlist lists its variants, a media playlist its segments.",
58            vec![p("uri", "string", "Playlist URI, relative to the stream URL or absolute on the same server (default the stream URL)", false)],
59            json!({"type": GET_PLAYLIST}),
60        ),
61        action(
62            GET_SEGMENT,
63            "Fetch one media segment and report what it holds (container, streams, codecs, duration by timestamps).",
64            vec![p("uri", "string", "Segment URI as the media playlist gave it (resolved against the last media playlist)", true)],
65            json!({"type": GET_SEGMENT, "uri": "seg00000.ts"}),
66        ),
67        action(
68            PLAY,
69            "Play a stream: choose a variant of a master playlist, fetch its segments in order, reload a live playlist as it slides, and report the whole run.",
70            vec![
71                p("uri", "string", "Master or media playlist URI (default the stream URL)", false),
72                p("variant", "string", "For a master playlist: highest or lowest bandwidth, or a variant's index (default highest)", false),
73                p("max_segments", "number", "Segments to fetch at most, 1..=100 (default 10)", false),
74            ],
75            json!({"type": PLAY, "variant": "highest", "max_segments": 10}),
76        ),
77        action("disconnect", "Stop this HLS client.", vec![], json!({"type": "disconnect"})),
78    ]
79}
80
81fn event(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
82    EventType::new(id, description, json!({"type": PLAY}))
83        .with_parameters(params)
84        .with_actions(actions())
85}
86
87pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
88    event(
89        "hls_ready",
90        "The client is ready to read the stream at url.",
91        vec![p("url", "string", "The stream's playlist URL", true)],
92    )
93});
94
95pub static PLAYLIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
96    event("hls_playlist", "A playlist arrived, parsed (or the reason it could not be read).", vec![
97        p("url", "string", "The playlist's URL", true),
98        p("status", "number", "HTTP status of the answer", false),
99        p("kind", "string", "master or media", false),
100        p("variants", "array", "Master: [{index, uri, bandwidth, average_bandwidth, resolution, codecs, frame_rate}]", false),
101        p("renditions", "array", "Master: alternative renditions [{type, group_id, name, language, uri}]", false),
102        p("target_duration", "number", "Media: EXT-X-TARGETDURATION in seconds", false),
103        p("media_sequence", "number", "Media: the first segment's sequence number", false),
104        p("playlist_type", "string", "Media: VOD or EVENT when declared", false),
105        p("endlist", "boolean", "Media: true when the playlist is complete (no more segments will be added)", false),
106        p("segments", "array", "Media: the first 50 segments [{sequence, uri, duration, title}]", false),
107        p("segment_count", "number", "Media: how many segments the playlist lists", false),
108        p("total_duration", "number", "Media: the sum of the segments' EXTINF durations in seconds", false),
109        p("encryption", "string", "Media: the EXT-X-KEY METHOD when it is not NONE (segments are then not analysed)", false),
110        p("map", "string", "Media: the EXT-X-MAP initialisation section's URI (fMP4)", false),
111        p("error", "string", "Why the playlist could not be read", false),
112    ])
113});
114
115pub static SEGMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
116    event(
117        "hls_segment",
118        "A segment arrived and was analysed (or the reason it could not be fetched).",
119        vec![
120            p("url", "string", "The segment's URL", true),
121            p("status", "number", "HTTP status of the answer", false),
122            p("bytes", "number", "Segment size in bytes", false),
123            p(
124                "container",
125                "string",
126                "mpegts, fmp4, packed (an ID3-led audio segment) or unknown",
127                false,
128            ),
129            p("ts_packets", "number", "MPEG-TS: 188-byte packets", false),
130            p(
131                "streams",
132                "array",
133                "MPEG-TS: elementary streams from the PMT [{pid, stream_type, codec}]",
134                false,
135            ),
136            p(
137                "duration_ms",
138                "number",
139                "MPEG-TS: media length by presentation timestamps",
140                false,
141            ),
142            p(
143                "continuity_errors",
144                "number",
145                "MPEG-TS: continuity-counter gaps (lost or reordered packets)",
146                false,
147            ),
148            p(
149                "sync_errors",
150                "number",
151                "MPEG-TS: packets without the 0x47 sync byte, or trailing bytes",
152                false,
153            ),
154            p(
155                "error",
156                "string",
157                "Why the segment could not be fetched",
158                false,
159            ),
160        ],
161    )
162});
163
164pub static PLAYED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
165    event("hls_played", "A play run ended: what was fetched and what it held.", vec![
166        p("url", "string", "The playlist the run started from", true),
167        p("variant", "object", "The variant chosen from a master playlist {index, uri, bandwidth, resolution, codecs}", false),
168        p("media_playlist", "string", "The media playlist played", false),
169        p("segments", "number", "Segments fetched", true),
170        p("first_sequence", "number", "Media sequence number of the first segment fetched", false),
171        p("last_sequence", "number", "Media sequence number of the last segment fetched", false),
172        p("gaps", "number", "Sequence numbers a live playlist slid past before they were fetched", true),
173        p("reloads", "number", "Times the media playlist was reloaded", true),
174        p("endlist", "boolean", "Whether the playlist was complete when the run ended", true),
175        p("bytes", "number", "Bytes of media fetched", true),
176        p("duration_ms", "number", "Media length by timestamps, summed over segments", true),
177        p("playlist_duration", "number", "The fetched segments' EXTINF durations summed, in seconds", true),
178        p("containers", "array", "Containers seen", true),
179        p("codecs", "array", "Codecs seen across the segments", true),
180        p("continuity_errors", "number", "Continuity-counter gaps within segments", true),
181        p("failed", "array", "Segments that could not be fetched [{uri, error}]", true),
182        p("error", "string", "Why the run stopped early", false),
183    ])
184});
185
186pub fn check(v: &Value) -> Result<()> {
187    let uri = |required: bool| -> Result<()> {
188        match v.get("uri").filter(|x| !x.is_null()) {
189            Some(u) => {
190                let u = u.as_str().unwrap_or_default();
191                ensure!(
192                    !u.is_empty() && u.len() <= 2048 && !u.contains(['\r', '\n', ' ']),
193                    "uri is a URI from a playlist"
194                );
195            }
196            None => ensure!(!required, "uri is required"),
197        }
198        Ok(())
199    };
200    match v["type"].as_str().unwrap_or_default() {
201        GET_PLAYLIST => uri(false)?,
202        GET_SEGMENT => uri(true)?,
203        PLAY => {
204            uri(false)?;
205            if let Some(var) = v.get("variant").filter(|x| !x.is_null()) {
206                let ok = match var {
207                    Value::String(s) => {
208                        matches!(s.as_str(), "highest" | "lowest") || s.parse::<usize>().is_ok()
209                    }
210                    Value::Number(n) => n.as_u64().is_some(),
211                    _ => false,
212                };
213                ensure!(ok, "variant is highest, lowest or an index");
214            }
215            if let Some(n) = v.get("max_segments").filter(|x| !x.is_null()) {
216                let n = n.as_u64().unwrap_or(0);
217                ensure!(
218                    (1..=super::MAX_SEGMENTS_PER_PLAY as u64).contains(&n),
219                    "max_segments is 1..={}",
220                    super::MAX_SEGMENTS_PER_PLAY
221                );
222            }
223        }
224        other => bail!("Unknown HLS client action {other:?}"),
225    }
226    Ok(())
227}
228
229impl Protocol for HlsClientProtocol {
230    fn protocol_name(&self) -> &'static str {
231        "HLS"
232    }
233    fn stack_name(&self) -> &'static str {
234        "ETH>IP>TCP>HTTP>HLS"
235    }
236    fn keywords(&self) -> Vec<&'static str> {
237        vec![
238            "hls client",
239            "hls",
240            "m3u8",
241            "http live streaming",
242            "hls player",
243        ]
244    }
245    fn description(&self) -> &'static str {
246        "HLS client: reads master and media playlists, plays VOD and live streams segment by segment and reports what the media holds"
247    }
248    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
249        actions()
250    }
251    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
252        vec![]
253    }
254    fn get_event_types(&self) -> Vec<EventType> {
255        vec![
256            READY_EVENT.clone(),
257            PLAYLIST_EVENT.clone(),
258            SEGMENT_EVENT.clone(),
259            PLAYED_EVENT.clone(),
260        ]
261    }
262    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
263        vec![ParameterDefinition {
264            name: "timeout_secs".into(),
265            type_hint: "number".into(),
266            description: "Seconds (1..=120) each playlist or segment request may take".into(),
267            required: false,
268            example: json!(15),
269            default: Some(json!(super::TIMEOUT.as_secs())),
270        }]
271    }
272    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
273        use crate::protocol::metadata::*;
274        ProtocolMetadataV2::builder()
275            .state(DevelopmentState::Experimental)
276            .privilege_requirement(PrivilegeRequirement::None)
277            .implementation("Shared http_fetch client (reqwest natively); RFC 8216 playlist parsing (master variants and renditions, media segments, live reloads at the target duration); MPEG-TS analysis of each segment (PAT/PMT streams and codecs, PES timestamps, continuity counters)")
278            .llm_control("Which playlist to read, which variant to play, how much of it, and what to make of the media")
279            .e2e_testing("tests/client/hls: an ffmpeg-authored VOD stream with a master playlist and two variants, and a live GStreamer hlssink2 stream whose playlist slides while it is played, both served by Python's http.server")
280            .notes("Plays by fetching, not by decoding: no decryption (AES-128 segments are reported, not analysed), no fMP4 sample analysis, no LL-HLS partial segments, one origin. A handler chain stops after 8 follow-ups.")
281            .max_inbound_bytes(super::MAX_SEGMENT_BYTES)
282            .build()
283    }
284    fn example_prompt(&self) -> &'static str {
285        "Open http://127.0.0.1:8000/master.m3u8, play its best variant for ten segments and tell me what the stream holds"
286    }
287    fn get_startup_examples(&self) -> StartupExamples {
288        let llm = json!({"type":"open_client","protocol":"hls","remote_addr":"http://127.0.0.1:8000/master.m3u8",
289            "instruction":"Play the highest variant and report the codecs"});
290        let mut static_example = llm.clone();
291        static_example["event_handlers"] = json!([
292            {"event_pattern":"hls_ready","handler":{"type":"static","actions":[{"type":PLAY,"variant":"highest","max_segments":5}]}},
293            {"event_pattern":"hls_played","handler":{"type":"static","actions":[]}}
294        ]);
295        let mut scripted = llm.clone();
296        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
297            "code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']\na=[{'type':'hls_get_playlist'}] if t=='hls_ready' else ([{'type':'hls_play'}] if t=='hls_playlist' and i['event'].get('kind') else [])\nprint(json.dumps({'actions':a}))"}}]);
298        StartupExamples::new(llm, scripted, static_example)
299    }
300    fn group_name(&self) -> &'static str {
301        "Web & File"
302    }
303}
304
305impl Client for HlsClientProtocol {
306    fn connect(
307        &self,
308        ctx: ConnectContext,
309    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
310    {
311        Box::pin(super::connect(ctx))
312    }
313    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
314        let name = v["type"].as_str().unwrap_or_default().to_string();
315        if name == "disconnect" {
316            return Ok(ClientActionResult::Disconnect);
317        }
318        check(&v)?;
319        Ok(ClientActionResult::Custom { name, data: v })
320    }
321}