1pub mod actions;
7use crate::client::http_fetch::FetchClient;
8use crate::client::llm_budget::call_llm_for_client;
9use crate::llm::actions::client_trait::{Client, ClientActionResult};
10use crate::logging::emit::Log;
11use crate::protocol::{ConnectContext, Event};
12use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
13use crate::state::{AccessLogOwner, ClientStatus};
14pub use actions::HlsClientProtocol;
15use anyhow::{bail, ensure, Context, Result};
16use serde_json::{json, Value};
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use std::net::SocketAddr;
19use std::time::Duration;
20
21pub const MAX_FOLLOWUP_DEPTH: usize = 8;
23pub const TIMEOUT: Duration = Duration::from_secs(15);
24pub const MAX_PLAYLIST_BYTES: usize = 1024 * 1024;
26pub const MAX_SEGMENT_BYTES: usize = 32 * 1024 * 1024;
28pub const MAX_SEGMENTS_PER_PLAY: usize = 100;
30pub const DEFAULT_SEGMENTS_PER_PLAY: usize = 10;
31pub const MAX_LISTED_SEGMENTS: usize = 50;
33pub const MAX_PLAYLIST_LINES: usize = 20_000;
35pub const MAX_IDLE_RELOADS: usize = 6;
37const TS: usize = 188;
38
39#[derive(Debug, Clone)]
41pub struct Variant {
42 pub index: usize,
43 pub uri: String,
44 pub bandwidth: Option<u64>,
45 pub attributes: BTreeMap<String, String>,
46}
47
48#[derive(Debug, Clone)]
50pub struct Segment {
51 pub sequence: u64,
52 pub uri: String,
53 pub duration: f64,
54 pub title: String,
55}
56
57#[derive(Debug, Clone)]
58pub enum Playlist {
59 Master {
60 variants: Vec<Variant>,
61 renditions: Vec<BTreeMap<String, String>>,
62 },
63 Media {
64 target_duration: Option<f64>,
65 media_sequence: u64,
66 playlist_type: Option<String>,
67 endlist: bool,
68 segments: Vec<Segment>,
69 encryption: Option<String>,
70 map: Option<String>,
71 },
72}
73
74pub fn attributes(list: &str) -> BTreeMap<String, String> {
76 let mut out = BTreeMap::new();
77 let mut rest = list.trim();
78 while !rest.is_empty() {
79 let Some((key, after)) = rest.split_once('=') else {
80 break;
81 };
82 let (value, after) = if let Some(quoted) = after.strip_prefix('"') {
83 match quoted.split_once('"') {
84 Some((v, a)) => (v.to_string(), a),
85 None => (quoted.to_string(), ""),
86 }
87 } else {
88 match after.split_once(',') {
89 Some((v, a)) => (v.trim().to_string(), a),
90 None => (after.trim().to_string(), ""),
91 }
92 };
93 out.insert(key.trim().to_ascii_uppercase(), value);
94 rest = after.trim_start_matches(',').trim_start();
95 }
96 out
97}
98
99pub fn parse_playlist(text: &str) -> Result<Playlist> {
101 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
102 ensure!(
103 lines.next().map(|l| l.trim_start_matches('\u{feff}')) == Some("#EXTM3U"),
104 "not a playlist: the first line is not #EXTM3U"
105 );
106 let mut variants = Vec::new();
107 let mut renditions = Vec::new();
108 let mut pending_variant: Option<BTreeMap<String, String>> = None;
109 let mut target_duration = None;
110 let mut media_sequence = 0u64;
111 let mut playlist_type = None;
112 let mut endlist = false;
113 let mut segments = Vec::new();
114 let mut encryption = None;
115 let mut map = None;
116 let mut pending_inf: Option<(f64, String)> = None;
117 for (n, line) in lines.enumerate() {
118 ensure!(
119 n < MAX_PLAYLIST_LINES,
120 "playlist longer than {MAX_PLAYLIST_LINES} lines"
121 );
122 if let Some(tag) = line.strip_prefix('#') {
123 let (name, value) = tag.split_once(':').unwrap_or((tag, ""));
124 match name {
125 "EXT-X-STREAM-INF" => pending_variant = Some(attributes(value)),
126 "EXT-X-MEDIA" => renditions.push(attributes(value)),
127 "EXT-X-TARGETDURATION" => target_duration = value.trim().parse().ok(),
128 "EXT-X-MEDIA-SEQUENCE" => media_sequence = value.trim().parse().unwrap_or(0),
129 "EXT-X-PLAYLIST-TYPE" => playlist_type = Some(value.trim().to_string()),
130 "EXT-X-ENDLIST" => endlist = true,
131 "EXT-X-KEY" => {
132 let method = attributes(value).remove("METHOD").unwrap_or_default();
133 encryption = (method != "NONE" && !method.is_empty()).then_some(method);
134 }
135 "EXT-X-MAP" => map = attributes(value).remove("URI"),
136 "EXTINF" => {
137 let (d, t) = value.split_once(',').unwrap_or((value, ""));
138 let d: f64 = d.trim().parse().context("EXTINF without a duration")?;
139 ensure!(d.is_finite() && d >= 0.0, "EXTINF duration {d}");
140 pending_inf = Some((d, t.trim().to_string()));
141 }
142 _ => {}
143 }
144 continue;
145 }
146 if let Some(attrs) = pending_variant.take() {
147 variants.push(Variant {
148 index: variants.len(),
149 uri: line.to_string(),
150 bandwidth: attrs.get("BANDWIDTH").and_then(|b| b.parse().ok()),
151 attributes: attrs,
152 });
153 } else if let Some((duration, title)) = pending_inf.take() {
154 segments.push(Segment {
155 sequence: media_sequence + segments.len() as u64,
156 uri: line.to_string(),
157 duration,
158 title,
159 });
160 }
161 }
162 if !variants.is_empty() {
163 ensure!(
164 segments.is_empty(),
165 "a playlist cannot list both variants and segments"
166 );
167 return Ok(Playlist::Master {
168 variants,
169 renditions,
170 });
171 }
172 Ok(Playlist::Media {
173 target_duration,
174 media_sequence,
175 playlist_type,
176 endlist,
177 segments,
178 encryption,
179 map,
180 })
181}
182
183pub fn describe(playlist: &Playlist) -> Value {
185 match playlist {
186 Playlist::Master {
187 variants,
188 renditions,
189 } => json!({
190 "kind": "master",
191 "variants": variants.iter().map(variant_json).collect::<Vec<_>>(),
192 "renditions": renditions.iter().map(|r| json!({
193 "type": r.get("TYPE"), "group_id": r.get("GROUP-ID"), "name": r.get("NAME"),
194 "language": r.get("LANGUAGE"), "uri": r.get("URI"),
195 })).collect::<Vec<_>>(),
196 }),
197 Playlist::Media {
198 target_duration,
199 media_sequence,
200 playlist_type,
201 endlist,
202 segments,
203 encryption,
204 map,
205 } => json!({
206 "kind": "media",
207 "target_duration": target_duration,
208 "media_sequence": media_sequence,
209 "playlist_type": playlist_type,
210 "endlist": endlist,
211 "segments": segments.iter().take(MAX_LISTED_SEGMENTS).map(|s| json!({
212 "sequence": s.sequence, "uri": s.uri, "duration": s.duration, "title": s.title,
213 })).collect::<Vec<_>>(),
214 "segment_count": segments.len(),
215 "total_duration": (segments.iter().map(|s| s.duration).sum::<f64>() * 1000.0).round() / 1000.0,
216 "encryption": encryption,
217 "map": map,
218 }),
219 }
220}
221
222fn variant_json(v: &Variant) -> Value {
223 json!({
224 "index": v.index, "uri": v.uri, "bandwidth": v.bandwidth,
225 "average_bandwidth": v.attributes.get("AVERAGE-BANDWIDTH").and_then(|b| b.parse::<u64>().ok()),
226 "resolution": v.attributes.get("RESOLUTION"), "codecs": v.attributes.get("CODECS"),
227 "frame_rate": v.attributes.get("FRAME-RATE").and_then(|f| f.parse::<f64>().ok()),
228 })
229}
230
231pub fn resolve(base: &str, uri: &str) -> Result<String> {
233 let base_url = url::Url::parse(base).with_context(|| format!("{base:?} is not a URL"))?;
234 let joined = base_url
235 .join(uri)
236 .with_context(|| format!("{uri:?} is not a URI"))?;
237 ensure!(
238 joined.origin() == base_url.origin(),
239 "{uri:?} is on {}, but this client is bound to {}",
240 joined.origin().ascii_serialization(),
241 base_url.origin().ascii_serialization()
242 );
243 ensure!(
244 joined.username().is_empty() && joined.password().is_none(),
245 "{uri:?} carries credentials"
246 );
247 Ok(joined.into())
248}
249
250#[derive(Debug, Default, Clone)]
252pub struct SegmentReport {
253 pub container: &'static str,
254 pub ts_packets: u64,
255 pub sync_errors: u64,
256 pub continuity_errors: u64,
257 pub streams: BTreeMap<u16, (u8, String)>,
259 pub pts_span: Option<u64>,
261}
262
263impl SegmentReport {
264 pub fn duration_ms(&self) -> Option<u64> {
265 self.pts_span.map(|t| t / 90)
266 }
267 pub fn codecs(&self) -> BTreeSet<String> {
268 self.streams.values().map(|(_, c)| c.clone()).collect()
269 }
270 pub fn to_json(&self) -> Value {
271 let mut v = json!({"container": self.container});
272 if self.container == "mpegts" {
273 v["ts_packets"] = json!(self.ts_packets);
274 v["sync_errors"] = json!(self.sync_errors);
275 v["continuity_errors"] = json!(self.continuity_errors);
276 v["streams"] = json!(self
277 .streams
278 .iter()
279 .map(|(pid, (t, c))| json!({"pid": pid, "stream_type": t, "codec": c}))
280 .collect::<Vec<_>>());
281 v["duration_ms"] = json!(self.duration_ms());
282 }
283 v
284 }
285}
286
287pub fn codec_name(stream_type: u8) -> String {
289 match stream_type {
290 0x01 => "mpeg1-video".into(),
291 0x02 => "mpeg2-video".into(),
292 0x03 | 0x04 => "mp3".into(),
293 0x0f => "aac".into(),
294 0x11 => "aac-latm".into(),
295 0x15 => "id3".into(),
296 0x1b => "h264".into(),
297 0x24 => "hevc".into(),
298 0x81 => "ac3".into(),
299 0x87 => "eac3".into(),
300 other => format!("0x{other:02x}"),
301 }
302}
303
304fn pts(b: &[u8]) -> u64 {
306 (u64::from(b[0] >> 1 & 0x07) << 30)
307 | (u64::from(b[1]) << 22)
308 | (u64::from(b[2] >> 1) << 15)
309 | (u64::from(b[3]) << 7)
310 | u64::from(b[4] >> 1)
311}
312
313pub fn analyse_segment(data: &[u8]) -> SegmentReport {
315 let mut r = SegmentReport::default();
316 if data.len() >= 8 && matches!(&data[4..8], b"ftyp" | b"styp" | b"moof" | b"sidx") {
317 r.container = "fmp4";
318 return r;
319 }
320 if data.first() != Some(&0x47) {
321 r.container = if data.starts_with(b"ID3") {
322 "packed"
323 } else {
324 "unknown"
325 };
326 return r;
327 }
328 r.container = "mpegts";
329 let mut pmt_pids = BTreeSet::new();
330 let mut last_cc: BTreeMap<u16, u8> = BTreeMap::new();
331 let mut spans: BTreeMap<u16, (u64, u64)> = BTreeMap::new();
332 let chunks = data.chunks(TS);
333 for p in chunks {
334 if p.len() < TS {
335 r.sync_errors += 1;
336 continue;
337 }
338 if p[0] != 0x47 {
339 r.sync_errors += 1;
340 continue;
341 }
342 r.ts_packets += 1;
343 let pid = (u16::from(p[1] & 0x1f) << 8) | u16::from(p[2]);
344 let start = p[1] & 0x40 != 0;
345 let afc = (p[3] >> 4) & 0x03;
346 let has_payload = afc & 0x01 != 0;
347 let cc = p[3] & 0x0f;
348 let discontinuity = afc & 0x02 != 0 && p[4] > 0 && p[5] & 0x80 != 0;
349 if pid != 0x1fff && has_payload {
350 if let Some(prev) = last_cc.insert(pid, cc) {
351 if !discontinuity && cc != (prev + 1) & 0x0f && cc != prev {
352 r.continuity_errors += 1;
353 }
354 }
355 }
356 if !start || !has_payload {
357 continue;
358 }
359 let mut i = 4;
360 if afc & 0x02 != 0 {
361 i += 1 + p[4] as usize;
362 }
363 let Some(payload) = p.get(i..) else { continue };
364 if pid == 0 || pmt_pids.contains(&pid) {
365 let Some(&pointer) = payload.first() else {
366 continue;
367 };
368 let Some(section) = payload.get(1 + pointer as usize..) else {
369 continue;
370 };
371 if section.len() < 12 {
372 continue;
373 }
374 let length = (((section[1] & 0x0f) as usize) << 8) | section[2] as usize;
375 let end = (3 + length).min(section.len()).saturating_sub(4);
376 if pid == 0 && section[0] == 0x00 {
377 let mut j = 8;
378 while j + 4 <= end {
379 let program = u16::from_be_bytes([section[j], section[j + 1]]);
380 let pmt = (u16::from(section[j + 2] & 0x1f) << 8) | u16::from(section[j + 3]);
381 if program != 0 {
382 pmt_pids.insert(pmt);
383 }
384 j += 4;
385 }
386 } else if section[0] == 0x02 {
387 let info = (((section[10] & 0x0f) as usize) << 8) | section[11] as usize;
388 let mut j = 12 + info;
389 while j + 5 <= end {
390 let kind = section[j];
391 let es_pid =
392 (u16::from(section[j + 1] & 0x1f) << 8) | u16::from(section[j + 2]);
393 r.streams.insert(es_pid, (kind, codec_name(kind)));
394 let es_info =
395 (((section[j + 3] & 0x0f) as usize) << 8) | section[j + 4] as usize;
396 j += 5 + es_info;
397 }
398 }
399 } else if payload.len() >= 14
400 && payload[..3] == [0, 0, 1]
401 && payload[7] & 0x80 != 0
402 && r.streams.contains_key(&pid)
403 {
404 let t = pts(&payload[9..14]);
405 let e = spans.entry(pid).or_insert((t, t));
406 e.0 = e.0.min(t);
407 e.1 = e.1.max(t);
408 }
409 }
410 r.pts_span = spans.values().map(|(a, b)| b - a).max();
411 r
412}
413
414#[derive(Default)]
416struct Run {
417 segments: u64,
418 first: Option<u64>,
419 last: Option<u64>,
420 gaps: u64,
421 reloads: u64,
422 endlist: bool,
423 bytes: u64,
424 duration_ms: u64,
425 playlist_duration: f64,
426 containers: BTreeSet<&'static str>,
427 codecs: BTreeSet<String>,
428 continuity_errors: u64,
429 failed: Vec<Value>,
430}
431
432struct Http {
433 fetch: FetchClient,
434}
435
436impl Http {
437 async fn get(&self, url: &str, max: usize) -> Result<(u16, Vec<u8>)> {
438 let response = self.fetch.get(url).send().await?;
439 let status = response.status().as_u16();
440 if let Some(len) = response.content_length() {
441 ensure!(len as usize <= max, "{url} is {len} bytes (at most {max})");
442 }
443 let body = response.bytes().await?;
444 ensure!(body.len() <= max, "{url} is longer than {max} bytes");
445 Ok((status, body.to_vec()))
446 }
447
448 async fn playlist(&self, url: &str) -> Result<(u16, Option<Playlist>)> {
449 let (status, body) = self.get(url, MAX_PLAYLIST_BYTES).await?;
450 if status != 200 {
451 return Ok((status, None));
452 }
453 let text = String::from_utf8(body).context("the playlist is not UTF-8")?;
454 Ok((status, Some(parse_playlist(&text)?)))
455 }
456}
457
458pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
459 let url = if ctx.remote_addr.contains("://") {
460 ctx.remote_addr.clone()
461 } else {
462 format!("http://{}/index.m3u8", ctx.remote_addr)
463 };
464 let parsed =
465 url::Url::parse(&url).context("remote_addr must be a playlist URL or host:port")?;
466 ensure!(
467 matches!(parsed.scheme(), "http" | "https"),
468 "an HLS stream is an http(s) URL"
469 );
470 let host = parsed
471 .host_str()
472 .context("the URL names no host")?
473 .to_string();
474 let port = parsed.port_or_known_default().unwrap_or(80);
475 let timeout = Duration::from_secs(
476 ctx.startup_params
477 .as_ref()
478 .map(|p| p.get_optional_u64("timeout_secs"))
479 .transpose()?
480 .flatten()
481 .unwrap_or(TIMEOUT.as_secs()),
482 );
483 ensure!(
484 (1..=120).contains(&timeout.as_secs()),
485 "timeout_secs must be 1..=120"
486 );
487 crate::client::http_fetch::check_url(&url)?;
488 #[cfg(not(target_arch = "wasm32"))]
489 let fetch = FetchClient::from_reqwest(
490 crate::llm::ollama_client::configured_for_endpoint(
491 reqwest::Client::builder()
492 .timeout(timeout)
493 .redirect(crate::client::http_fetch::same_origin_redirects()),
494 &url,
495 )
496 .build()?,
497 );
498 #[cfg(target_arch = "wasm32")]
499 let fetch = FetchClient::transport(timeout);
500 let fetch = fetch
501 .with_max_body(MAX_SEGMENT_BYTES)
502 .with_user_agent(concat!("NetGet/", env!("CARGO_PKG_VERSION")));
503 let local: SocketAddr = match tokio::net::lookup_host((host.as_str(), port)).await?.next() {
504 Some(addr) if addr.is_ipv4() => "0.0.0.0:0".parse()?,
505 Some(_) => "[::]:0".parse()?,
506 None => bail!("HLS server address {host} did not resolve"),
507 };
508 ctx.state
509 .update_client_status(ctx.client_id, ClientStatus::Connected)
510 .await;
511 let external =
512 crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
513 let session_ctx = ctx.clone();
514 let task = tokio::spawn(async move {
515 let result = session(&session_ctx, Http { fetch }, url, external).await;
516 let status = match result {
517 Ok(()) => ClientStatus::Disconnected,
518 Err(e) => {
519 Log::new(Some(&session_ctx.status_tx)).warn(format!("HLS client ended: {e}"));
520 ClientStatus::Error(e.to_string())
521 }
522 };
523 session_ctx
524 .state
525 .update_client_status(session_ctx.client_id, status)
526 .await;
527 session_ctx
528 .state
529 .remove_client_handle(session_ctx.client_id)
530 .await;
531 let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
532 });
533 ctx.state.register_client_task(ctx.client_id, task).await;
534 Ok(local)
535}
536
537async fn ask(ctx: &ConnectContext, event: &Event) -> Vec<Value> {
539 let state = &ctx.state;
540 let instruction = state
541 .get_instruction_for_client(ctx.client_id)
542 .await
543 .unwrap_or_default();
544 let memory = state
545 .get_memory_for_client(ctx.client_id)
546 .await
547 .unwrap_or_default();
548 match call_llm_for_client(
549 &ctx.llm_client,
550 state,
551 ctx.client_id.to_string(),
552 &instruction,
553 &memory,
554 Some(event),
555 &HlsClientProtocol,
556 &ctx.status_tx,
557 )
558 .await
559 {
560 Ok(result) => {
561 if let Some(memory) = result.memory_updates {
562 state.set_memory_for_client(ctx.client_id, memory).await;
563 }
564 result.actions
565 }
566 Err(e) => {
567 Log::new(Some(&ctx.status_tx)).warn(format!("HLS client handler: {e}"));
568 Vec::new()
569 }
570 }
571}
572
573struct Session {
574 http: Http,
575 url: String,
576 media_base: String,
578}
579
580async fn session(
581 ctx: &ConnectContext,
582 http: Http,
583 url: String,
584 mut external: tokio::sync::mpsc::Receiver<ClientCommand>,
585) -> Result<()> {
586 let mut s = Session {
587 http,
588 media_base: url.clone(),
589 url,
590 };
591 let mut queue: VecDeque<(Value, usize)> = VecDeque::new();
592 let ready = Event::new(&actions::READY_EVENT, json!({"url": s.url}));
593 queue.extend(ask(ctx, &ready).await.into_iter().map(|a| (a, 1)));
594 loop {
595 let (action, depth, command) = match queue.pop_front() {
596 Some((a, d)) => (a, d, None),
597 None => match external.recv().await {
598 Some(c) => (c.action.clone(), 0, Some(c)),
599 None => return Ok(()),
600 },
601 };
602 let checked = HlsClientProtocol.execute_action(action.clone());
603 let data = match checked {
604 Ok(ClientActionResult::Disconnect) => {
605 if let Some(c) = command {
606 crate::client::command_support::reply(c, Ok(ClientSendOutcome::Disconnected));
607 }
608 return Ok(());
609 }
610 Ok(ClientActionResult::Custom { data, .. }) => data,
611 Ok(_) => continue,
612 Err(e) => {
613 match command {
614 Some(c) => crate::client::command_support::reply(
615 c,
616 Ok(ClientSendOutcome::Rejected {
617 error: e.to_string(),
618 }),
619 ),
620 None => Log::new(Some(&ctx.status_tx)).warn(format!("HLS action refused: {e}")),
621 }
622 continue;
623 }
624 };
625 let event = perform(&mut s, &data).await;
626 if let Some(c) = command {
627 ctx.state
628 .record_access_log(
629 AccessLogOwner::Client(ctx.client_id.as_u32()),
630 "HLS",
631 None,
632 "injected_action",
633 action.clone(),
634 vec![json!({"event": event.event_type.id})],
635 )
636 .await;
637 crate::client::command_support::reply(
638 c,
639 Ok(ClientSendOutcome::Executed {
640 detail: event.data.to_string(),
641 }),
642 );
643 }
644 if depth >= MAX_FOLLOWUP_DEPTH {
645 Log::new(Some(&ctx.status_tx)).warn(format!(
646 "HLS handler chain stopped after {MAX_FOLLOWUP_DEPTH} follow-ups"
647 ));
648 ctx.state
649 .record_access_log(
650 AccessLogOwner::Client(ctx.client_id.as_u32()),
651 "HLS",
652 None,
653 event.event_type.id.as_str(),
654 event.data.clone(),
655 vec![json!({"handled": false, "reason": "follow-up depth"})],
656 )
657 .await;
658 continue;
659 }
660 let next = ask(ctx, &event).await;
661 for a in next.into_iter().rev() {
662 queue.push_front((a, depth + 1));
663 }
664 }
665}
666
667fn uri_or<'a>(data: &'a Value, default: &'a str) -> &'a str {
668 data.get("uri").and_then(Value::as_str).unwrap_or(default)
669}
670
671async fn perform(s: &mut Session, data: &Value) -> Event {
672 match data["type"].as_str().unwrap_or_default() {
673 actions::GET_PLAYLIST => {
674 let target = match resolve(&s.url, uri_or(data, &s.url)) {
675 Ok(t) => t,
676 Err(e) => {
677 return Event::new(
678 &actions::PLAYLIST_EVENT,
679 json!({"url": uri_or(data, &s.url), "error": e.to_string()}),
680 )
681 }
682 };
683 let mut ev = json!({"url": target});
684 match s.http.playlist(&target).await {
685 Ok((status, playlist)) => {
686 ev["status"] = json!(status);
687 match playlist {
688 Some(p) => {
689 if matches!(p, Playlist::Media { .. }) {
690 s.media_base = target.clone();
691 }
692 merge(&mut ev, describe(&p));
693 }
694 None => ev["error"] = json!(format!("HTTP {status}")),
695 }
696 }
697 Err(e) => ev["error"] = json!(format!("{e:#}")),
698 }
699 Event::new(&actions::PLAYLIST_EVENT, ev)
700 }
701 actions::GET_SEGMENT => {
702 let ev = match resolve(&s.media_base, uri_or(data, "")) {
703 Ok(target) => segment_event(&s.http, &target).await.0,
704 Err(e) => json!({"url": uri_or(data, ""), "error": e.to_string()}),
705 };
706 Event::new(&actions::SEGMENT_EVENT, ev)
707 }
708 _ => {
709 let ev = play(s, data).await;
710 Event::new(&actions::PLAYED_EVENT, ev)
711 }
712 }
713}
714
715fn merge(into: &mut Value, from: Value) {
716 if let (Some(a), Value::Object(b)) = (into.as_object_mut(), from) {
717 a.extend(b);
718 }
719}
720
721async fn segment_event(http: &Http, url: &str) -> (Value, Option<(usize, SegmentReport)>) {
722 let mut ev = json!({"url": url});
723 match http.get(url, MAX_SEGMENT_BYTES).await {
724 Ok((status, body)) => {
725 ev["status"] = json!(status);
726 if status == 200 {
727 let report = analyse_segment(&body);
728 ev["bytes"] = json!(body.len());
729 merge(&mut ev, report.to_json());
730 return (ev, Some((body.len(), report)));
731 }
732 ev["error"] = json!(format!("HTTP {status}"));
733 }
734 Err(e) => ev["error"] = json!(format!("{e:#}")),
735 }
736 (ev, None)
737}
738
739async fn play(s: &mut Session, data: &Value) -> Value {
740 let start = uri_or(data, &s.url).to_string();
741 let max = data
742 .get("max_segments")
743 .and_then(Value::as_u64)
744 .map_or(DEFAULT_SEGMENTS_PER_PLAY, |n| n as usize)
745 .min(MAX_SEGMENTS_PER_PLAY);
746 let mut out = json!({"url": start});
747 let mut run = Run::default();
748 let result = play_run(s, data, &start, max, &mut out, &mut run).await;
749 if let Err(e) = result {
750 out["error"] = json!(format!("{e:#}"));
751 }
752 merge(
753 &mut out,
754 json!({
755 "segments": run.segments, "first_sequence": run.first, "last_sequence": run.last,
756 "gaps": run.gaps, "reloads": run.reloads, "endlist": run.endlist, "bytes": run.bytes,
757 "duration_ms": run.duration_ms,
758 "playlist_duration": (run.playlist_duration * 1000.0).round() / 1000.0,
759 "containers": run.containers, "codecs": run.codecs,
760 "continuity_errors": run.continuity_errors, "failed": run.failed,
761 }),
762 );
763 out
764}
765
766async fn play_run(
767 s: &mut Session,
768 data: &Value,
769 start: &str,
770 max: usize,
771 out: &mut Value,
772 run: &mut Run,
773) -> Result<()> {
774 let first_url = resolve(&s.url, start)?;
775 out["url"] = json!(first_url);
776 let (status, playlist) = s.http.playlist(&first_url).await?;
777 let mut playlist = playlist.with_context(|| format!("{first_url}: HTTP {status}"))?;
778 let mut media_url = first_url.clone();
779 if let Playlist::Master { variants, .. } = &playlist {
780 ensure!(
781 !variants.is_empty(),
782 "the master playlist lists no variants"
783 );
784 let wanted = data.get("variant").filter(|v| !v.is_null());
785 let chosen = match wanted {
786 Some(Value::String(w)) if w == "lowest" => {
787 variants.iter().min_by_key(|v| v.bandwidth.unwrap_or(0))
788 }
789 None => variants.iter().max_by_key(|v| v.bandwidth.unwrap_or(0)),
790 Some(Value::String(w)) if w == "highest" => {
791 variants.iter().max_by_key(|v| v.bandwidth.unwrap_or(0))
792 }
793 Some(w) => {
794 let i = w
795 .as_u64()
796 .or_else(|| w.as_str().and_then(|s| s.parse().ok()))
797 .unwrap_or(u64::MAX) as usize;
798 variants.get(i)
799 }
800 }
801 .context("no such variant")?
802 .clone();
803 out["variant"] = variant_json(&chosen);
804 media_url = resolve(&first_url, &chosen.uri)?;
805 let (status, media) = s.http.playlist(&media_url).await?;
806 playlist = media.with_context(|| format!("{media_url}: HTTP {status}"))?;
807 ensure!(
808 matches!(playlist, Playlist::Media { .. }),
809 "the variant's playlist is another master playlist"
810 );
811 }
812 out["media_playlist"] = json!(media_url);
813 s.media_base = media_url.clone();
814 let mut next_seq: Option<u64> = None;
815 let mut idle = 0usize;
816 loop {
817 let Playlist::Media {
818 target_duration,
819 segments,
820 endlist,
821 encryption,
822 ..
823 } = &playlist
824 else {
825 bail!("expected a media playlist");
826 };
827 run.endlist = *endlist;
828 let mut fetched_any = false;
829 for seg in segments {
830 if next_seq.is_some_and(|n| seg.sequence < n) {
831 continue;
832 }
833 if run.segments as usize + run.failed.len() >= max {
834 return Ok(());
835 }
836 if let Some(n) = next_seq {
837 run.gaps += seg.sequence - n;
838 }
839 next_seq = Some(seg.sequence + 1);
840 fetched_any = true;
841 run.first.get_or_insert(seg.sequence);
842 run.last = Some(seg.sequence);
843 run.playlist_duration += seg.duration;
844 let url = match resolve(&media_url, &seg.uri) {
845 Ok(u) => u,
846 Err(e) => {
847 run.failed
848 .push(json!({"uri": seg.uri, "error": e.to_string()}));
849 continue;
850 }
851 };
852 let (ev, report) = segment_event(&s.http, &url).await;
853 match report {
854 Some((bytes, r)) => {
855 run.segments += 1;
856 run.bytes += bytes as u64;
857 run.containers.insert(r.container);
858 if encryption.is_none() {
859 run.codecs.extend(r.codecs());
860 run.duration_ms += r.duration_ms().unwrap_or(0);
861 run.continuity_errors += r.continuity_errors;
862 }
863 }
864 None => run
865 .failed
866 .push(json!({"uri": seg.uri, "error": ev["error"]})),
867 }
868 }
869 if *endlist || run.segments as usize + run.failed.len() >= max {
870 return Ok(());
871 }
872 idle = if fetched_any { 0 } else { idle + 1 };
873 if idle >= MAX_IDLE_RELOADS {
874 bail!("the live playlist stopped growing");
875 }
876 let wait = target_duration.unwrap_or(2.0).clamp(0.5, 30.0);
878 let wait = if fetched_any { wait } else { wait / 2.0 };
879 tokio::time::sleep(Duration::from_secs_f64(wait)).await;
880 run.reloads += 1;
881 let (status, again) = s.http.playlist(&media_url).await?;
882 playlist = again.with_context(|| format!("{media_url}: HTTP {status} on reload"))?;
883 }
884}