1use crate::llm::actions::{
7 protocol_trait::{ActionResult, Protocol, Server},
8 ActionDefinition, Parameter,
9};
10use crate::protocol::log_template::LogTemplate;
11use crate::protocol::EventType;
12use crate::state::app_state::AppState;
13use anyhow::{Context, Result};
14use serde_json::json;
15use std::sync::LazyLock;
16
17pub struct HlsProtocol;
18
19impl HlsProtocol {
20 pub fn new() -> Self {
21 Self
22 }
23}
24
25impl Default for HlsProtocol {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl Protocol for HlsProtocol {
32 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
33 vec![]
34 }
35 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
36 vec![
37 hls_playlist_response_action(),
38 hls_segment_response_action(),
39 ]
40 }
41 fn protocol_name(&self) -> &'static str {
42 "HLS"
43 }
44 fn get_event_types(&self) -> Vec<EventType> {
45 vec![HLS_PLAYLIST_EVENT.clone(), HLS_SEGMENT_EVENT.clone()]
46 }
47 fn stack_name(&self) -> &'static str {
48 "ETH>IP>TCP>HTTP>HLS"
49 }
50 fn keywords(&self) -> Vec<&'static str> {
51 vec!["hls", "http live streaming", "m3u8 playlist", "hls segment"]
52 }
53 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
54 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
55 ProtocolMetadataV2::builder()
56 .state(DevelopmentState::Experimental)
57 .max_inbound_bytes(crate::server::hls::MAX_REQUEST_HEAD_BYTES)
60 .implementation(
61 "Minimal HTTP/1.1 server routing .m3u8 vs segment requests to distinct events; \
62 playlists assembled from structured segment lists or served verbatim",
63 )
64 .llm_control(
65 "The playlist (variants/segment list) and every segment's body (text, or \
66 hex-encoded binary the server decodes)",
67 )
68 .e2e_testing(
69 "curl fetches m3u8 then segments; byte-literal assertions on #EXTM3U structure and \
70 Content-Type (mock LLM)",
71 )
72 .notes(
73 "The server does NOT synthesize MPEG-TS. curl and the m3u8 structure validate fully; \
74 a real media player (ffplay/VLC) needs valid segment bytes, which the model must \
75 supply hex-encoded (e.g. a real .ts). Text segment bodies are for structural tests, \
76 not playback.",
77 )
78 .request_only("HLS is HTTP request/response; a server cannot send a peer anything unprompted")
79 .build()
80 }
81 fn description(&self) -> &'static str {
82 "HLS server serving an m3u8 playlist and media segments over HTTP"
83 }
84 fn example_prompt(&self) -> &'static str {
85 "Start an HLS server on port 8080 serving a 3-segment VOD playlist at /stream.m3u8."
86 }
87 fn group_name(&self) -> &'static str {
88 "Web & File"
89 }
90 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
91 use crate::llm::actions::StartupExamples;
92
93 let script = r##"import json, sys
96data = json.load(sys.stdin)
97event = data["event"]
98if data["event_type_id"] == "hls_playlist_request":
99 actions = [{"type": "hls_playlist_response",
100 "playlist": "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:10.0,\nsegment0.ts\n#EXT-X-ENDLIST\n"}]
101else:
102 actions = []
103print(json.dumps({"actions": actions}))"##;
104
105 StartupExamples::new(
106 json!({
107 "type": "open_server",
108 "port": 8080,
109 "base_stack": "hls",
110 "instruction": "Serve an HLS VOD playlist at /stream.m3u8 listing 3 segments (seg0.ts, seg1.ts, seg2.ts), each 6 seconds. For each segment request, return a short text placeholder body."
111 }),
112 json!({
113 "type": "open_server",
114 "port": 8080,
115 "base_stack": "hls",
116 "event_handlers": [{
117 "event_pattern": "hls_playlist_request",
118 "handler": {"type": "script", "language": "python", "code": script}
119 }]
120 }),
121 json!({
122 "type": "open_server",
123 "port": 8080,
124 "base_stack": "hls",
125 "event_handlers": [{
126 "event_pattern": "hls_playlist_request",
127 "handler": {
128 "type": "static",
129 "actions": [{
130 "type": "hls_playlist_response",
131 "target_duration": 6,
132 "segments": [
133 {"uri": "seg0.ts", "duration": 6.0},
134 {"uri": "seg1.ts", "duration": 6.0}
135 ]
136 }]
137 }
138 }]
139 }),
140 )
141 }
142}
143
144impl Server for HlsProtocol {
145 fn spawn(
146 &self,
147 ctx: crate::protocol::SpawnContext,
148 ) -> std::pin::Pin<
149 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
150 > {
151 Box::pin(async move {
152 use crate::server::hls::HlsServer;
153 HlsServer::spawn_with_llm_actions(
154 ctx.legacy_listen_addr(),
155 ctx.llm_client,
156 ctx.state,
157 ctx.status_tx,
158 ctx.server_id,
159 )
160 .await
161 })
162 }
163
164 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
165 let action_type = action
167 .get("type")
168 .and_then(|v| v.as_str())
169 .context("Missing 'type' field in action")?;
170 match action_type {
171 "hls_playlist_response" | "hls_segment_response" => Ok(ActionResult::NoAction),
172 _ => Err(anyhow::anyhow!("Unknown HLS action: {}", action_type)),
173 }
174 }
175}
176
177pub static HLS_PLAYLIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
178 EventType::new(
179 "hls_playlist_request",
180 "A client requested an .m3u8 playlist. Answer with hls_playlist_response, either giving a \
181 verbatim `playlist` string or a structured `segments` list the server renders into a \
182 valid media playlist.",
183 json!({
184 "type": "hls_playlist_response",
185 "target_duration": 6,
186 "segments": [
187 {"uri": "seg0.ts", "duration": 6.0},
188 {"uri": "seg1.ts", "duration": 6.0}
189 ]
190 }),
191 )
192 .with_actions(vec![hls_playlist_response_action()])
193 .with_log_template(
194 LogTemplate::new()
195 .with_info("HLS playlist {path}")
196 .with_debug("HLS playlist request {path}")
197 .with_trace("HLS: {json_pretty(.)}"),
198 )
199});
200
201pub static HLS_SEGMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
202 EventType::new(
203 "hls_segment_request",
204 "A client requested a media segment (any non-.m3u8 path). Answer with hls_segment_response \
205 carrying the segment body — text via `content`, or binary via `data` with encoding=\"hex\".",
206 json!({
207 "type": "hls_segment_response",
208 "content_type": "video/mp2t",
209 "content": "<segment placeholder bytes>"
210 }),
211 )
212 .with_actions(vec![hls_segment_response_action()])
213 .with_log_template(
214 LogTemplate::new()
215 .with_info("HLS segment {path}")
216 .with_debug("HLS segment request {path}")
217 .with_trace("HLS: {json_pretty(.)}"),
218 )
219});
220
221fn hls_playlist_response_action() -> ActionDefinition {
222 ActionDefinition {
223 name: "hls_playlist_response".to_string(),
224 description: "Serve an HLS .m3u8 playlist. Provide either `playlist` (verbatim m3u8) or \
225 `segments` (structured; the server assembles the playlist)."
226 .to_string(),
227 parameters: vec![
228 Parameter {
229 name: "playlist".to_string(),
230 type_hint: "string".to_string(),
231 description: "Verbatim m3u8 text (starts with #EXTM3U). Overrides `segments`."
232 .to_string(),
233 required: false,
234 },
235 Parameter {
236 name: "segments".to_string(),
237 type_hint: "array".to_string(),
238 description: "Array of {uri, duration} objects the server renders into a media \
239 playlist."
240 .to_string(),
241 required: false,
242 },
243 Parameter {
244 name: "target_duration".to_string(),
245 type_hint: "number".to_string(),
246 description: "EXT-X-TARGETDURATION seconds. Defaults to the ceil of the longest \
247 segment."
248 .to_string(),
249 required: false,
250 },
251 Parameter {
252 name: "version".to_string(),
253 type_hint: "number".to_string(),
254 description: "EXT-X-VERSION (default 3).".to_string(),
255 required: false,
256 },
257 Parameter {
258 name: "media_sequence".to_string(),
259 type_hint: "number".to_string(),
260 description: "EXT-X-MEDIA-SEQUENCE (default 0).".to_string(),
261 required: false,
262 },
263 Parameter {
264 name: "ended".to_string(),
265 type_hint: "boolean".to_string(),
266 description: "Whether to append #EXT-X-ENDLIST (VOD). Default true.".to_string(),
267 required: false,
268 },
269 Parameter {
270 name: "status_code".to_string(),
271 type_hint: "number".to_string(),
272 description: "HTTP status code (default 200).".to_string(),
273 required: false,
274 },
275 ],
276 example: json!({
277 "type": "hls_playlist_response",
278 "target_duration": 6,
279 "version": 3,
280 "segments": [
281 {"uri": "seg0.ts", "duration": 6.0},
282 {"uri": "seg1.ts", "duration": 6.0},
283 {"uri": "seg2.ts", "duration": 4.5}
284 ]
285 }),
286 log_template: Some(LogTemplate::new().with_info("→ HLS playlist {status_code}")),
287 }
288}
289
290fn hls_segment_response_action() -> ActionDefinition {
291 ActionDefinition {
292 name: "hls_segment_response".to_string(),
293 description: "Serve an HLS media segment body.".to_string(),
294 parameters: vec![
295 Parameter {
296 name: "content".to_string(),
297 type_hint: "string".to_string(),
298 description: "Segment body as UTF-8 text (for structural/placeholder use)."
299 .to_string(),
300 required: false,
301 },
302 Parameter {
303 name: "data".to_string(),
304 type_hint: "string".to_string(),
305 description: "Segment body as binary. Pair with encoding=\"hex\"; the server \
306 hex-decodes it for real (the only sanctioned base-N path, for genuine \
307 MPEG-TS bytes)."
308 .to_string(),
309 required: false,
310 },
311 Parameter {
312 name: "encoding".to_string(),
313 type_hint: "string".to_string(),
314 description: "\"utf8\" (default) or \"hex\" — declares how `data` is encoded. Never \
315 sniffed."
316 .to_string(),
317 required: false,
318 },
319 Parameter {
320 name: "content_type".to_string(),
321 type_hint: "string".to_string(),
322 description: "MIME type (default video/mp2t; use video/iso.segment for fMP4)."
323 .to_string(),
324 required: false,
325 },
326 Parameter {
327 name: "status_code".to_string(),
328 type_hint: "number".to_string(),
329 description: "HTTP status code (default 200; use 404 to reject).".to_string(),
330 required: false,
331 },
332 ],
333 example: json!({
334 "type": "hls_segment_response",
335 "content_type": "video/mp2t",
336 "encoding": "hex",
337 "data": "474000100000b00d0001c100000001f0002ab104b2"
338 }),
339 log_template: Some(LogTemplate::new().with_info("→ HLS segment {status_code}")),
340 }
341}