1use 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}