Skip to main content

netget/client/hls/
mod.rs

1//! HLS client (RFC 8216): playlists fetched over HTTP and parsed for the model, segments
2//! fetched and analysed (MPEG-TS PAT/PMT, PES timestamps, continuity counters), and whole
3//! play runs โ€” a variant chosen, a live playlist reloaded as it slides โ€” summarised in one
4//! event. Every URL a playlist names is resolved against it and must stay on the stream's
5//! origin.
6pub 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
21/// A handler chain (answer โ†’ event โ†’ answer โ€ฆ) stops after this many follow-ups.
22pub const MAX_FOLLOWUP_DEPTH: usize = 8;
23pub const TIMEOUT: Duration = Duration::from_secs(15);
24/// A playlist at most.
25pub const MAX_PLAYLIST_BYTES: usize = 1024 * 1024;
26/// A media segment at most.
27pub const MAX_SEGMENT_BYTES: usize = 32 * 1024 * 1024;
28/// Segments one `hls_play` may fetch.
29pub const MAX_SEGMENTS_PER_PLAY: usize = 100;
30pub const DEFAULT_SEGMENTS_PER_PLAY: usize = 10;
31/// Segments listed in an `hls_playlist` event; the count says how many there are.
32pub const MAX_LISTED_SEGMENTS: usize = 50;
33/// Lines a playlist may have.
34pub const MAX_PLAYLIST_LINES: usize = 20_000;
35/// A live playlist that has not grown after this many reloads ends the run.
36pub const MAX_IDLE_RELOADS: usize = 6;
37const TS: usize = 188;
38
39/// One `#EXT-X-STREAM-INF` entry.
40#[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/// One media segment.
49#[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
74/// `KEY=VALUE,KEY="quoted, value"` as RFC 8216 ยง4.2 writes attribute lists.
75pub 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
99/// Parse a playlist; URIs are left as written (resolve them with [`resolve`]).
100pub 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
183/// The playlist as the `hls_playlist` event reports it.
184pub 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
231/// Resolve `uri` against `base` (RFC 3986), refusing any other origin.
232pub 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/// What one segment holds.
251#[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    /// PID โ†’ (stream type, codec name)
258    pub streams: BTreeMap<u16, (u8, String)>,
259    /// Media length by PTS: the widest span any elementary stream covers, in 90 kHz ticks.
260    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
287/// ISO/IEC 13818-1 stream types the model is likely to meet.
288pub 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
304/// The 33-bit timestamp of a PES header's PTS field (5 bytes, marker bits interleaved).
305fn 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
313/// Analyse one segment's bytes.
314pub 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/// The run an `hls_play` makes, accumulated.
415#[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
537/// Ask the handler about an event; its actions come back for the queue at `depth`.
538async 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    /// The last media playlist read; segment URIs resolve against it.
577    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        // RFC 8216 ยง6.3.4: reload after the target duration (half of it when nothing was new).
877        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}