Skip to main content

netget/server/hls/
actions.rs

1//! HLS protocol actions.
2//!
3//! The model decides the playlist (structurally or as verbatim m3u8) and each segment's content.
4//! HTTP framing is owned by `mod.rs`.
5
6use 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            // The request head is all an HLS peer sends that is read; a declared body is
58            // refused with 413 unread. Tested in tests/server/hls/inbound_limit_test.rs.
59            .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        // Deterministic: serve a fixed single-segment VOD playlist for every
94        // request, no LLM call.
95        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        // HTTP framing happens in mod.rs from the raw action; validate the type here.
166        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}