Skip to main content

gasm_host/
media.rs

1//! gasm:media: audio streams (internet radio, tracks) the runner fetches, decodes and
2//! plays itself, mixed after the guest's audio (design/media.md). The guest only
3//! controls them and reads their state and metadata; it never gets their samples.
4//!
5//! Each stream has two threads: the connection (HTTP, redirects checked against the
6//! policy, ICY metadata cut out of the audio) hands chunks to the decoder (symphonia),
7//! which resamples to the output rate and fills the stream's buffer (about 2 s). The
8//! [`MediaBus`] drains every buffer: the window runner's audio callback adds them to the
9//! game's samples (with each stream's volume and low-pass), headless runs drain them on
10//! a clock, silently, so state, position and metadata behave the same.
11
12use std::collections::{BTreeMap, VecDeque};
13use std::io::Read;
14use std::net::{IpAddr, ToSocketAddrs};
15use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
16use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel};
17use std::sync::{Arc, Mutex, OnceLock};
18use std::time::{Duration, Instant};
19
20use crate::audio::Resampler;
21use crate::net::url_host;
22
23pub const CONNECTING: u32 = 0;
24pub const PLAYING: u32 = 1;
25pub const PAUSED: u32 = 2;
26pub const FAILED: u32 = 3;
27pub const ENDED: u32 = 4;
28pub const CAN_VOLUME: u32 = 1;
29pub const CAN_LOWPASS: u32 = 2;
30pub const CAN_METADATA: u32 = 4;
31
32/// Streams open at once, per game.
33pub const MAX_STREAMS: usize = 4;
34/// Longest URL in a description.
35const MAX_URL: usize = 2048;
36/// Longest ICY title kept (bytes).
37const MAX_TITLE: usize = 512;
38/// What a stream buffers ahead, in seconds.
39const BUFFER_S: f64 = 2.0;
40/// The buffer it waits for before it counts as playing.
41const START_S: f64 = 0.25;
42/// No data for this long: the connection stalled (a live stream reconnects).
43const STALL: Duration = Duration::from_secs(15);
44/// A live stream paused for this long drops its connection (and reconnects on play).
45const PAUSE_DROP: Duration = Duration::from_secs(10);
46/// Tries to reconnect a dropped live stream (1, 2, 4 s apart) before it fails.
47const RECONNECTS: u32 = 3;
48const MAX_REDIRECTS: u32 = 5;
49
50// ---- the permission -------------------------------------------------------------------
51
52/// Who may open streams: `--allow-media` (any public host, or a list), else the player's
53/// yes when the manifest declares `"media": true` (consent subject `media`, asked up
54/// front). Private and loopback addresses are reached only when listed.
55#[derive(Clone, Debug, Default)]
56pub struct MediaPolicy {
57    /// `--allow-media`: None = not given; Some(empty) = any public host
58    pub allowed: Option<Vec<String>>,
59    /// the manifest's `"media": true`
60    pub declared: bool,
61    pub consent: Option<crate::consent::Consent>,
62}
63
64pub const CONSENT_SUBJECT: &str = "media";
65
66impl MediaPolicy {
67    pub fn new(allowed: Option<Vec<String>>) -> MediaPolicy {
68        MediaPolicy { allowed: allowed.map(|h| h.into_iter().map(|h| h.to_ascii_lowercase()).collect()), declared: false, consent: None }
69    }
70
71    fn listed(&self, host: &str) -> bool {
72        self.allowed.as_ref().is_some_and(|l| l.iter().any(|h| h == host))
73    }
74
75    /// Whether the game may have streams at all (checked when it opens one): Err(why) if not.
76    fn may_open(&self) -> Result<(), String> {
77        if self.allowed.is_some() {
78            return Ok(());
79        }
80        if !self.declared {
81            return Err("this game's manifest doesn't ask to play media (\"media\": true), and --allow-media isn't given".into());
82        }
83        if self.consent.is_none() {
84            return Err("media streams need --allow-media here".into());
85        }
86        Ok(())
87    }
88
89    /// The player's answer for this game: Ok(true) yes, Ok(false) still asking, Err(why) no.
90    fn answered(&self) -> Result<bool, String> {
91        if self.allowed.is_some() {
92            return Ok(true);
93        }
94        match self.consent.as_ref().map(|c| c.lock().unwrap().check(CONSENT_SUBJECT)) {
95            Some(Some(true)) => Ok(true),
96            Some(Some(false)) => Err("the player said no to internet audio".into()),
97            Some(None) => Ok(false),
98            None => Err("media streams need --allow-media here".into()),
99        }
100    }
101
102    /// Whether `url` may be fetched (each hop of a redirect): https, a listed host if
103    /// --allow-media has a list, and a public address unless the host is listed.
104    pub fn check_url(&self, url: &str) -> Result<(), String> {
105        if !url.starts_with("https://") {
106            return Err(format!("{url}: only https streams"));
107        }
108        let host = url_host(url).ok_or_else(|| format!("{url}: no host"))?;
109        let bare = host.trim_start_matches('[').trim_end_matches(']');
110        if let Some(list) = &self.allowed {
111            if !list.is_empty() && !self.listed(&host) && !self.listed(bare) {
112                return Err(format!("{host} isn't in --allow-media's list"));
113            }
114        }
115        if self.listed(&host) || self.listed(bare) {
116            return Ok(());
117        }
118        let port = url_port(url);
119        let addrs: Vec<IpAddr> = match bare.parse::<IpAddr>() {
120            Ok(ip) => vec![ip],
121            Err(_) => (bare, port).to_socket_addrs().map_err(|e| format!("{host}: {e}"))?.map(|a| a.ip()).collect(),
122        };
123        match addrs.iter().find(|ip| !is_public(**ip)) {
124            Some(ip) => Err(format!("{host} is a private or local address ({ip}): list it in --allow-media to reach it")),
125            None => Ok(()),
126        }
127    }
128}
129
130fn url_port(url: &str) -> u16 {
131    let rest = url.split_once("://").map_or(url, |(_, r)| r);
132    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
133    let after = authority.rsplit_once(']').map_or(authority, |(_, a)| a);
134    after.rsplit_once(':').and_then(|(_, p)| p.parse().ok()).unwrap_or(443)
135}
136
137/// Not the player's own machine or network: no loopback, private, link-local, CGNAT,
138/// unspecified, broadcast, multicast or documentation addresses.
139pub fn is_public(ip: IpAddr) -> bool {
140    match ip {
141        IpAddr::V4(v) => {
142            let o = v.octets();
143            !(v.is_loopback()
144                || v.is_private()
145                || v.is_link_local()
146                || v.is_unspecified()
147                || v.is_broadcast()
148                || v.is_multicast()
149                || v.is_documentation()
150                || o[0] == 0
151                || (o[0] == 100 && (o[1] & 0xc0) == 64)
152                || o[0] >= 240)
153        }
154        IpAddr::V6(v) => {
155            if let Some(v4) = v.to_ipv4_mapped() {
156                return is_public(IpAddr::V4(v4));
157            }
158            let s = v.segments();
159            !(v.is_loopback() || v.is_unspecified() || v.is_multicast() || (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || s[0] == 0x2001 && s[1] == 0xdb8)
160        }
161    }
162}
163
164/// A stream's description: `{"url": "https://..."}`.
165pub fn parse_desc(json: &str) -> Result<String, String> {
166    let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("not valid JSON: {e}"))?;
167    let o = v.as_object().ok_or("not a JSON object")?;
168    let url = o.get("url").and_then(|u| u.as_str()).ok_or("no \"url\"")?;
169    if url.len() > MAX_URL {
170        return Err(format!("the URL is longer than {MAX_URL} bytes"));
171    }
172    if !url.starts_with("https://") {
173        return Err("only https:// streams".into());
174    }
175    if url.bytes().any(|b| b <= b' ' || b == 0x7f) || url_host(url).is_none() {
176        return Err(format!("not a URL: {url:?}"));
177    }
178    Ok(url.to_owned())
179}
180
181// ---- the bus: buffers drained by the output (or a clock) ------------------------------
182
183/// What one stream plays: its buffer (interleaved stereo at the bus's rate) and the
184/// controls the output reads.
185pub struct Voice {
186    buf: Mutex<VecDeque<f32>>,
187    /// target gain, f32 bits
188    gain: AtomicU32,
189    /// low-pass frequency, f32 bits (0 = off)
190    lowpass: AtomicU32,
191    playing: AtomicBool,
192    /// stereo frames played (position)
193    played: AtomicU64,
194    /// the output's own state: the smoothed gain and the filter
195    dsp: Mutex<Dsp>,
196}
197
198#[derive(Default)]
199struct Dsp {
200    gain: f32,
201    hz: f32,
202    /// biquad coefficients (b0, b1, b2, a1, a2) and per-channel state (x1, x2, y1, y2)
203    k: [f32; 5],
204    s: [[f32; 4]; 2],
205}
206
207impl Voice {
208    fn new() -> Voice {
209        Voice {
210            buf: Mutex::new(VecDeque::new()),
211            gain: AtomicU32::new(1.0f32.to_bits()),
212            lowpass: AtomicU32::new(0),
213            playing: AtomicBool::new(false),
214            played: AtomicU64::new(0),
215            dsp: Mutex::new(Dsp { gain: 1.0, ..Default::default() }),
216        }
217    }
218
219    fn buffered(&self) -> usize {
220        self.buf.lock().unwrap().len() / 2
221    }
222}
223
224/// Every open stream's voice, drained at `rate` (the output's).
225pub struct MediaBus {
226    pub rate: u32,
227    voices: Mutex<Vec<Arc<Voice>>>,
228}
229
230impl MediaBus {
231    pub fn new(rate: u32) -> Arc<MediaBus> {
232        Arc::new(MediaBus { rate, voices: Mutex::new(Vec::new()) })
233    }
234
235    /// A bus no output drains: a thread plays it to nowhere in real time (headless runs,
236    /// a muted window), so streams behave as when heard.
237    pub fn silent() -> Arc<MediaBus> {
238        let bus = MediaBus::new(48_000);
239        let b = Arc::downgrade(&bus);
240        std::thread::Builder::new()
241            .name("gasm-media-clock".into())
242            .spawn(move || {
243                let mut last = Instant::now();
244                let mut owed = 0.0f64;
245                let mut scratch = Vec::new();
246                while let Some(bus) = b.upgrade() {
247                    std::thread::sleep(Duration::from_millis(20));
248                    owed += last.elapsed().as_secs_f64() * bus.rate as f64;
249                    last = Instant::now();
250                    let n = owed as usize;
251                    owed -= n as f64;
252                    scratch.clear();
253                    scratch.resize(n * 2, 0.0);
254                    bus.mix(&mut scratch);
255                }
256            })
257            .expect("media clock thread");
258        bus
259    }
260
261    fn add(&self, v: Arc<Voice>) {
262        self.voices.lock().unwrap().push(v);
263    }
264
265    fn remove(&self, v: &Arc<Voice>) {
266        self.voices.lock().unwrap().retain(|x| !Arc::ptr_eq(x, v));
267    }
268
269    /// Add every playing stream to `out` (interleaved stereo at `rate`): called by the
270    /// output for each block it plays. Never waits: a block when the list is being
271    /// changed goes without the streams.
272    pub fn mix(&self, out: &mut [f32]) {
273        let Ok(voices) = self.voices.try_lock() else { return };
274        for v in voices.iter() {
275            if !v.playing.load(Ordering::Acquire) {
276                continue;
277            }
278            let Ok(mut dsp) = v.dsp.try_lock() else { continue };
279            let target = f32::from_bits(v.gain.load(Ordering::Relaxed));
280            let hz = f32::from_bits(v.lowpass.load(Ordering::Relaxed));
281            if hz != dsp.hz {
282                dsp.hz = hz;
283                dsp.k = lowpass(hz, self.rate as f32);
284            }
285            // a volume change takes about 20 ms
286            let step = 1.0 / (0.02 * self.rate as f32);
287            let mut buf = v.buf.lock().unwrap();
288            let frames = (out.len() / 2).min(buf.len() / 2);
289            for f in 0..frames {
290                dsp.gain += (target - dsp.gain).clamp(-step, step);
291                for c in 0..2 {
292                    let x = buf.pop_front().unwrap_or(0.0);
293                    let y = if dsp.hz > 0.0 {
294                        let [b0, b1, b2, a1, a2] = dsp.k;
295                        let s = &mut dsp.s[c];
296                        let y = b0 * x + b1 * s[0] + b2 * s[1] - a1 * s[2] - a2 * s[3];
297                        *s = [x, s[0], y, s[2]];
298                        y
299                    } else {
300                        x
301                    };
302                    out[f * 2 + c] += y * dsp.gain;
303                }
304            }
305            drop(buf);
306            v.played.fetch_add(frames as u64, Ordering::Relaxed);
307        }
308    }
309}
310
311/// A second-order low-pass (RBJ's cookbook, Q = 1/sqrt(2)): (b0, b1, b2, a1, a2), a0 = 1.
312fn lowpass(hz: f32, rate: f32) -> [f32; 5] {
313    if hz <= 0.0 {
314        return [1.0, 0.0, 0.0, 0.0, 0.0];
315    }
316    let w = 2.0 * std::f32::consts::PI * hz.min(rate * 0.45) / rate;
317    let alpha = w.sin() / (2.0 * std::f32::consts::FRAC_1_SQRT_2);
318    let cos = w.cos();
319    let a0 = 1.0 + alpha;
320    let b1 = (1.0 - cos) / a0;
321    [b1 / 2.0, b1, b1 / 2.0, -2.0 * cos / a0, (1.0 - alpha) / a0]
322}
323
324// ---- a stream -------------------------------------------------------------------------
325
326/// What the guest reads about a stream (the threads write it).
327#[derive(Default)]
328struct Info {
329    state: u32,
330    error: String,
331    caps: u32,
332    duration: f64,
333    name: Option<String>,
334    title: Option<String>,
335    codec: Option<String>,
336    bitrate: Option<u32>,
337    version: u32,
338}
339
340impl Info {
341    fn metadata(&self) -> String {
342        serde_json::json!({ "name": self.name, "title": self.title, "codec": self.codec, "bitrate": self.bitrate }).to_string()
343    }
344}
345
346struct Stream {
347    voice: Arc<Voice>,
348    info: Arc<Mutex<Info>>,
349    /// the guest asked to play (the decoder starts on the first play)
350    want: Arc<AtomicBool>,
351    started: bool,
352    closed: Arc<AtomicBool>,
353    url: String,
354    /// when it was last paused (a live stream drops its connection after a while)
355    paused_at: Arc<Mutex<Option<Instant>>>,
356}
357
358/// The game's streams.
359pub struct Media {
360    policy: MediaPolicy,
361    bus: Option<Arc<MediaBus>>,
362    /// None: closed (it reads as FAILED)
363    streams: BTreeMap<i32, Option<Stream>>,
364    next: i32,
365    last_refusal: String,
366    /// the bus the output drains, when the host has one (else a silent one, made on first use)
367    output: Option<Arc<MediaBus>>,
368}
369
370impl Default for Media {
371    fn default() -> Media {
372        Media::new(MediaPolicy::default())
373    }
374}
375
376impl Media {
377    pub fn new(policy: MediaPolicy) -> Media {
378        Media { policy, bus: None, streams: BTreeMap::new(), next: 1, last_refusal: String::new(), output: None }
379    }
380
381    /// The bus the window runner's audio output drains (before the game runs).
382    pub fn set_output(&mut self, bus: Option<Arc<MediaBus>>) {
383        self.output = bus;
384    }
385
386    fn bus(&mut self) -> Arc<MediaBus> {
387        if self.bus.is_none() {
388            self.bus = Some(self.output.clone().unwrap_or_else(MediaBus::silent));
389        }
390        self.bus.clone().expect("bus")
391    }
392
393    pub fn open(&mut self, desc: &str) -> i32 {
394        let refuse = |m: &mut Media, why: String| {
395            eprintln!("[gasm] media: refused: {why}");
396            m.last_refusal = why;
397            -1
398        };
399        let url = match parse_desc(desc) {
400            Ok(u) => u,
401            Err(e) => return refuse(self, e),
402        };
403        if let Err(why) = self.policy.may_open() {
404            return refuse(self, why);
405        }
406        if self.streams.values().filter(|s| s.is_some()).count() >= MAX_STREAMS {
407            return refuse(self, format!("{MAX_STREAMS} streams are open already"));
408        }
409        let h = self.next;
410        self.next += 1;
411        let voice = Arc::new(Voice::new());
412        self.bus().add(voice.clone());
413        let info = Arc::new(Mutex::new(Info { duration: -1.0, ..Default::default() }));
414        self.streams.insert(
415            h,
416            Some(Stream { voice, info, want: Arc::new(AtomicBool::new(false)), started: false, closed: Arc::new(AtomicBool::new(false)), url, paused_at: Arc::new(Mutex::new(None)) }),
417        );
418        h
419    }
420
421    fn get(&mut self, h: i32) -> Result<Option<&mut Stream>, String> {
422        match self.streams.get_mut(&h) {
423            Some(s) => Ok(s.as_mut()),
424            None => Err(format!("gasm:media: {h} is not a stream handle")),
425        }
426    }
427
428    pub fn play(&mut self, h: i32) -> Result<(), String> {
429        let bus = self.bus();
430        let policy = self.policy.clone();
431        let Some(s) = self.get(h)? else { return Ok(()) };
432        s.want.store(true, Ordering::Release);
433        *s.paused_at.lock().unwrap() = None;
434        if !s.started {
435            s.started = true;
436            let (url, voice, info, want, closed, paused_at) = (s.url.clone(), s.voice.clone(), s.info.clone(), s.want.clone(), s.closed.clone(), s.paused_at.clone());
437            let ctl = Control { voice, info, want, closed, paused_at, rate: bus.rate };
438            std::thread::Builder::new()
439                .name("gasm-media".into())
440                .spawn(move || run(url, policy, ctl))
441                .map_err(|e| format!("gasm:media: no thread: {e}"))?;
442        }
443        Ok(())
444    }
445
446    pub fn pause(&mut self, h: i32) -> Result<(), String> {
447        let Some(s) = self.get(h)? else { return Ok(()) };
448        if s.want.swap(false, Ordering::AcqRel) {
449            s.voice.playing.store(false, Ordering::Release);
450            *s.paused_at.lock().unwrap() = Some(Instant::now());
451            let mut i = s.info.lock().unwrap();
452            if matches!(i.state, PLAYING | CONNECTING) && s.started {
453                i.state = PAUSED;
454            }
455        }
456        Ok(())
457    }
458
459    pub fn close(&mut self, h: i32) -> Result<(), String> {
460        let bus = self.bus.clone();
461        match self.streams.get_mut(&h) {
462            None => Err(format!("gasm:media: {h} is not a stream handle")),
463            Some(slot) => {
464                if let Some(s) = slot.take() {
465                    s.closed.store(true, Ordering::Release);
466                    if let Some(b) = bus {
467                        b.remove(&s.voice);
468                    }
469                }
470                Ok(())
471            }
472        }
473    }
474
475    pub fn set_volume(&mut self, h: i32, gain: f32) -> Result<(), String> {
476        let g = if gain.is_nan() { 0.0 } else { gain.clamp(0.0, 1.0) };
477        if let Some(s) = self.get(h)? {
478            s.voice.gain.store(g.to_bits(), Ordering::Relaxed);
479        }
480        Ok(())
481    }
482
483    pub fn set_lowpass(&mut self, h: i32, hz: f32) -> Result<(), String> {
484        let hz = if hz.is_nan() || hz <= 0.0 { 0.0 } else { hz.clamp(20.0, 20_000.0) };
485        if let Some(s) = self.get(h)? {
486            s.voice.lowpass.store(hz.to_bits(), Ordering::Relaxed);
487        }
488        Ok(())
489    }
490
491    fn info<R>(&mut self, h: i32, f: impl FnOnce(&Info) -> R, closed: R) -> Result<R, String> {
492        Ok(match self.get(h)? {
493            Some(s) => f(&s.info.lock().unwrap()),
494            None => closed,
495        })
496    }
497
498    pub fn caps(&mut self, h: i32) -> Result<u32, String> {
499        self.info(h, |i| i.caps, 0)
500    }
501
502    pub fn state(&mut self, h: i32) -> Result<u32, String> {
503        self.info(h, |i| i.state, FAILED)
504    }
505
506    pub fn error(&mut self, h: i32) -> Result<String, String> {
507        if h == -1 {
508            return Ok(self.last_refusal.clone());
509        }
510        self.info(h, |i| i.error.clone(), "closed".into())
511    }
512
513    pub fn position(&mut self, h: i32) -> Result<f64, String> {
514        let rate = self.bus.as_ref().map_or(48_000, |b| b.rate) as f64;
515        Ok(match self.get(h)? {
516            Some(s) => s.voice.played.load(Ordering::Relaxed) as f64 / rate,
517            None => 0.0,
518        })
519    }
520
521    pub fn duration(&mut self, h: i32) -> Result<f64, String> {
522        self.info(h, |i| i.duration, -1.0)
523    }
524
525    pub fn metadata(&mut self, h: i32) -> Result<String, String> {
526        self.info(h, Info::metadata, Info::default().metadata())
527    }
528
529    pub fn metadata_version(&mut self, h: i32) -> Result<u32, String> {
530        self.info(h, |i| i.version, 0)
531    }
532}
533
534impl Drop for Media {
535    fn drop(&mut self) {
536        for s in self.streams.values().flatten() {
537            s.closed.store(true, Ordering::Release);
538            if let Some(b) = &self.bus {
539                b.remove(&s.voice);
540            }
541        }
542    }
543}
544
545// ---- the threads ----------------------------------------------------------------------
546
547struct Control {
548    voice: Arc<Voice>,
549    info: Arc<Mutex<Info>>,
550    want: Arc<AtomicBool>,
551    closed: Arc<AtomicBool>,
552    paused_at: Arc<Mutex<Option<Instant>>>,
553    rate: u32,
554}
555
556impl Control {
557    fn closed(&self) -> bool {
558        self.closed.load(Ordering::Acquire)
559    }
560
561    fn set(&self, f: impl FnOnce(&mut Info)) {
562        f(&mut self.info.lock().unwrap());
563    }
564}
565
566fn agent() -> &'static ureq::Agent {
567    static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
568    AGENT.get_or_init(|| {
569        let roots = rustls_native_certs::load_native_certs();
570        let certs = roots.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
571        let tls = ureq::tls::TlsConfig::builder()
572            .provider(ureq::tls::TlsProvider::Rustls)
573            .root_certs(ureq::tls::RootCerts::Specific(Arc::new(certs)))
574            .unversioned_rustls_crypto_provider(Arc::new(rustls::crypto::ring::default_provider()))
575            .build();
576        // no overall timeout: a live stream never ends (a stall is found by the decoder)
577        ureq::Agent::config_builder()
578            .http_status_as_error(false)
579            .max_redirects(0)
580            .max_redirects_will_error(false)
581            .timeout_connect(Some(Duration::from_secs(30)))
582            .timeout_recv_response(Some(Duration::from_secs(30)))
583            .tls_config(tls)
584            .build()
585            .into()
586    })
587}
588
589/// What a connection learned from the response's headers.
590#[derive(Default)]
591struct Head {
592    metaint: Option<usize>,
593    name: Option<String>,
594    bitrate: Option<u32>,
595    content_type: Option<String>,
596    /// no length and ICY or chunked: a live stream (reconnects; no duration)
597    live: bool,
598}
599
600/// GET `url` (following redirects, each checked): the body's chunks go to `tx`, ICY
601/// titles to `info`. Returns the head first through `head_tx`.
602fn connect(url: &str, policy: &MediaPolicy, ctl: &Control) -> Result<(Head, ureq::Body), Refused> {
603    let mut url = url.to_owned();
604    for _ in 0..=MAX_REDIRECTS {
605        policy.check_url(&url)?;
606        let req = ureq::http::Request::builder()
607            .method("GET")
608            .uri(url.as_str())
609            .header("user-agent", crate::fetch::user_agent(None))
610            .header("icy-metadata", "1")
611            .body(())
612            .map_err(|e| format!("{url}: {e}"))?;
613        let resp = agent().run(req).map_err(|e| Refused::Retry(format!("{url}: {e}")))?;
614        let status = resp.status().as_u16();
615        let header = |n: &str| resp.headers().get(n).and_then(|v| v.to_str().ok()).map(|s| s.trim().to_owned());
616        if matches!(status, 301 | 302 | 303 | 307 | 308) {
617            let Some(loc) = header("location") else { return Err(format!("{url}: a redirect without a location").into()) };
618            url = crate::fetch::resolve_url(&url, &loc);
619            if ctl.closed() {
620                return Err(Refused::Fatal(String::new()));
621            }
622            continue;
623        }
624        if status >= 500 {
625            return Err(Refused::Retry(format!("{url}: HTTP {status}")));
626        }
627        if status != 200 {
628            return Err(Refused::Fatal(format!("{url}: HTTP {status}")));
629        }
630        let metaint = header("icy-metaint").and_then(|v| v.parse().ok()).filter(|&n: &usize| n > 0 && n <= 1 << 20);
631        let length = header("content-length").is_some();
632        let head = Head {
633            metaint,
634            name: header("icy-name").filter(|n| !n.is_empty()).map(|n| n.chars().take(MAX_TITLE).collect()),
635            bitrate: header("icy-br").and_then(|v| v.split(',').next().and_then(|b| b.trim().parse().ok())),
636            content_type: header("content-type").map(|c| c.to_ascii_lowercase()),
637            live: metaint.is_some() || header("icy-name").is_some() || !length,
638        };
639        return Ok((head, resp.into_body()));
640    }
641    Err(Refused::Fatal(format!("{url}: too many redirects")))
642}
643
644/// The connection's thread: reads the body, cuts the ICY blocks out (their titles go to
645/// `info`) and sends the audio bytes on. Ends at the body's end or when nobody listens.
646fn pump(body: ureq::Body, head_metaint: Option<usize>, info: Arc<Mutex<Info>>, tx: SyncSender<Vec<u8>>, closed: Arc<AtomicBool>) {
647    let mut reader = body.into_reader();
648    let mut icy = head_metaint.map(Icy::new);
649    let mut buf = vec![0u8; 16 * 1024];
650    loop {
651        if closed.load(Ordering::Acquire) {
652            return;
653        }
654        let n = match reader.read(&mut buf) {
655            Ok(0) | Err(_) => return,
656            Ok(n) => n,
657        };
658        let audio = match &mut icy {
659            Some(icy) => {
660                let (audio, titles) = icy.feed(&buf[..n]);
661                for t in titles {
662                    let mut i = info.lock().unwrap();
663                    if i.title.as_deref() != Some(&t) {
664                        i.title = Some(t);
665                        i.version = i.version.wrapping_add(1);
666                    }
667                }
668                audio
669            }
670            None => buf[..n].to_vec(),
671        };
672        if !audio.is_empty() && tx.send(audio).is_err() {
673            return;
674        }
675    }
676}
677
678/// ICY's in-band metadata: after every `metaint` bytes of audio, a length byte (times 16)
679/// and that much text (`StreamTitle='...';`).
680struct Icy {
681    metaint: usize,
682    /// audio bytes left before the next block
683    left: usize,
684    /// metadata bytes left in the current block, and the block so far
685    meta_left: Option<usize>,
686    meta: Vec<u8>,
687}
688
689impl Icy {
690    fn new(metaint: usize) -> Icy {
691        Icy { metaint, left: metaint, meta_left: None, meta: Vec::new() }
692    }
693
694    /// The audio in `data`, and the titles of the blocks that ended in it.
695    fn feed(&mut self, mut data: &[u8]) -> (Vec<u8>, Vec<String>) {
696        let (mut audio, mut titles) = (Vec::with_capacity(data.len()), Vec::new());
697        while !data.is_empty() {
698            match self.meta_left {
699                None if self.left > 0 => {
700                    let n = self.left.min(data.len());
701                    audio.extend_from_slice(&data[..n]);
702                    self.left -= n;
703                    data = &data[n..];
704                }
705                None => {
706                    self.meta_left = Some(data[0] as usize * 16);
707                    self.meta.clear();
708                    data = &data[1..];
709                }
710                Some(m) => {
711                    let n = m.min(data.len());
712                    self.meta.extend_from_slice(&data[..n]);
713                    data = &data[n..];
714                    if m == n {
715                        if let Some(t) = stream_title(&self.meta) {
716                            titles.push(t);
717                        }
718                        self.meta_left = None;
719                        self.left = self.metaint;
720                    } else {
721                        self.meta_left = Some(m - n);
722                    }
723                }
724            }
725            if self.meta_left == Some(0) {
726                self.meta_left = None;
727                self.left = self.metaint;
728            }
729        }
730        (audio, titles)
731    }
732}
733
734/// `StreamTitle='Artist - Song';StreamUrl='...';` -> the title (UTF-8, else Latin-1).
735pub fn stream_title(meta: &[u8]) -> Option<String> {
736    let end = meta.iter().position(|&b| b == 0).unwrap_or(meta.len());
737    let meta = &meta[..end];
738    let key = b"StreamTitle='";
739    let start = meta.windows(key.len()).position(|w| w == key)? + key.len();
740    let rest = &meta[start..];
741    let stop = rest.windows(2).position(|w| w == b"';").unwrap_or(rest.len());
742    let raw = &rest[..stop];
743    let text = match std::str::from_utf8(raw) {
744        Ok(s) => s.to_owned(),
745        Err(_) => raw.iter().map(|&b| b as char).collect(),
746    };
747    let text: String = text.chars().filter(|c| !c.is_control()).collect();
748    let text = text.trim();
749    if text.is_empty() {
750        return None;
751    }
752    let mut t = text.to_owned();
753    while t.len() > MAX_TITLE {
754        t.pop();
755    }
756    Some(t)
757}
758
759/// The decoder's input: the connection's chunks, as a reader (symphonia's source must
760/// be `Sync`, a network body isn't). No data for STALL: an error, so the stream reconnects.
761struct Pipe {
762    rx: Mutex<Receiver<Vec<u8>>>,
763    chunk: Vec<u8>,
764    at: usize,
765    closed: Arc<AtomicBool>,
766}
767
768impl Read for Pipe {
769    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
770        while self.at >= self.chunk.len() {
771            let deadline = Instant::now() + STALL;
772            loop {
773                if self.closed.load(Ordering::Acquire) {
774                    return Ok(0);
775                }
776                match self.rx.lock().unwrap().recv_timeout(Duration::from_millis(250)) {
777                    Ok(c) => {
778                        self.chunk = c;
779                        self.at = 0;
780                        break;
781                    }
782                    Err(RecvTimeoutError::Disconnected) => return Ok(0),
783                    Err(RecvTimeoutError::Timeout) if Instant::now() >= deadline => {
784                        return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "the stream stalled"));
785                    }
786                    Err(RecvTimeoutError::Timeout) => {}
787                }
788            }
789        }
790        let n = out.len().min(self.chunk.len() - self.at);
791        out[..n].copy_from_slice(&self.chunk[self.at..self.at + n]);
792        self.at += n;
793        Ok(n)
794    }
795}
796
797/// Why a connection couldn't start: worth another try (the network, a 5xx), or not
798/// (refused, not found, not audio).
799enum Refused {
800    Retry(String),
801    Fatal(String),
802}
803
804impl From<String> for Refused {
805    fn from(why: String) -> Refused {
806        Refused::Fatal(why)
807    }
808}
809
810/// How one connection ended.
811enum End {
812    /// the body ended (a track: drain and end; a live stream: reconnect)
813    Eof,
814    /// the connection broke or stalled (a live stream reconnects)
815    Dropped(String),
816    /// paused long enough to let go of a live stream (reconnect on play)
817    Released,
818    /// closed by the guest
819    Closed,
820}
821
822/// The stream's thread: connect (waiting for the player's answer first), decode, refill
823/// the buffer, reconnect a live stream that drops, and report state and metadata.
824fn run(url: String, policy: MediaPolicy, ctl: Control) {
825    // the player's answer (asked up front from the manifest; pending meanwhile)
826    loop {
827        if ctl.closed() {
828            return;
829        }
830        match policy.answered() {
831            Ok(true) => break,
832            Ok(false) => std::thread::sleep(Duration::from_millis(50)),
833            Err(why) => return fail(&ctl, why),
834        }
835    }
836    let mut tries = 0;
837    loop {
838        if ctl.closed() {
839            return;
840        }
841        // paused before (re)connecting: wait for play
842        while !ctl.want.load(Ordering::Acquire) {
843            if ctl.closed() {
844                return;
845            }
846            std::thread::sleep(Duration::from_millis(50));
847        }
848        ctl.set(|i| {
849            if i.state != PAUSED {
850                i.state = CONNECTING;
851            }
852        });
853        let began = Instant::now();
854        let outcome = connection(&url, &policy, &ctl);
855        // a connection that lasted counts as good: the next drop starts the tries anew
856        if began.elapsed() >= Duration::from_secs(10) {
857            tries = 0;
858        }
859        let live = match outcome {
860            Ok((End::Closed, _)) => return,
861            Ok((End::Eof, false)) => return finish(&ctl),
862            Ok((End::Released, _)) => {
863                tries = 0;
864                continue;
865            }
866            Ok((End::Eof | End::Dropped(_), true)) => true,
867            Ok((End::Dropped(why), false)) => return fail(&ctl, why),
868            Err(Refused::Fatal(why)) if why.is_empty() => return,
869            Err(Refused::Fatal(why)) => return fail(&ctl, why),
870            Err(Refused::Retry(why)) => {
871                if tries >= RECONNECTS {
872                    return fail(&ctl, why);
873                }
874                eprintln!("[gasm] media: {why}");
875                false
876            }
877        };
878        if live {
879            eprintln!("[gasm] media: {url}: the stream dropped, reconnecting");
880            // the game sees it at once (what's buffered still plays meanwhile)
881            ctl.set(|i| {
882                if i.state != PAUSED {
883                    i.state = CONNECTING;
884                }
885            });
886        }
887        tries += 1;
888        if tries > RECONNECTS {
889            return fail(&ctl, format!("{url}: the stream dropped {RECONNECTS} times"));
890        }
891        std::thread::sleep(Duration::from_secs(1 << (tries - 1)));
892    }
893}
894
895fn fail(ctl: &Control, why: String) {
896    eprintln!("[gasm] media: {why}");
897    ctl.voice.playing.store(false, Ordering::Release);
898    ctl.set(|i| {
899        i.state = FAILED;
900        i.error = why;
901    });
902}
903
904/// A track's end: let what's buffered play out, then ENDED.
905fn finish(ctl: &Control) {
906    while ctl.voice.buffered() > 0 && !ctl.closed() {
907        std::thread::sleep(Duration::from_millis(20));
908    }
909    ctl.voice.playing.store(false, Ordering::Release);
910    ctl.set(|i| i.state = ENDED);
911}
912
913/// One connection: Ok((how it ended, whether the stream is live)), or Err(why) if it
914/// couldn't start.
915fn connection(url: &str, policy: &MediaPolicy, ctl: &Control) -> Result<(End, bool), Refused> {
916    use symphonia::core::codecs::CodecParameters;
917    use symphonia::core::codecs::audio::AudioDecoderOptions;
918    use symphonia::core::formats::probe::Hint;
919    use symphonia::core::formats::{FormatOptions, TrackType};
920    use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
921    use symphonia::core::meta::MetadataOptions;
922
923    let (head, body) = connect(url, policy, ctl)?;
924    let live = head.live;
925    ctl.set(|i| {
926        let (name, bitrate) = (head.name.clone().or(i.name.clone()), head.bitrate.or(i.bitrate));
927        if (&name, bitrate) != (&i.name, i.bitrate) {
928            (i.name, i.bitrate) = (name, bitrate);
929            i.version = i.version.wrapping_add(1);
930        }
931    });
932    let (tx, rx) = sync_channel::<Vec<u8>>(64);
933    {
934        let (info, closed) = (ctl.info.clone(), ctl.closed.clone());
935        let metaint = head.metaint;
936        std::thread::Builder::new()
937            .name("gasm-media-net".into())
938            .spawn(move || pump(body, metaint, info, tx, closed))
939            .map_err(|e| format!("no thread: {e}"))?;
940    }
941    let pipe = Pipe { rx: Mutex::new(rx), chunk: Vec::new(), at: 0, closed: ctl.closed.clone() };
942    let mss = MediaSourceStream::new(Box::new(ReadOnlySource::new(pipe)), Default::default());
943    let mut hint = Hint::new();
944    match head.content_type.as_deref().map(|c| c.split(';').next().unwrap_or("").trim()) {
945        Some("audio/mpeg" | "audio/mp3" | "audio/mpeg3") => drop(hint.with_extension("mp3")),
946        Some("audio/aac" | "audio/aacp" | "audio/x-aac") => drop(hint.with_extension("aac")),
947        Some("audio/ogg" | "application/ogg" | "audio/vorbis") => drop(hint.with_extension("ogg")),
948        Some("audio/flac" | "audio/x-flac") => drop(hint.with_extension("flac")),
949        _ => {}
950    }
951    let mut format = symphonia::default::get_probe()
952        .probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
953        .map_err(|e| format!("{url}: not a stream gasm can play ({e})"))?;
954    let track = format.default_track(TrackType::Audio).ok_or_else(|| format!("{url}: no audio in it"))?;
955    let id = track.id;
956    let Some(CodecParameters::Audio(params)) = &track.codec_params else { return Err(format!("{url}: no audio in it").into()) };
957    let codec = symphonia::default::get_codecs()
958        .get_audio_decoder(params.codec)
959        .map(|c| c.codec.info.short_name.to_string())
960        .unwrap_or_else(|| "?".into());
961    let duration = match (track.num_frames, params.sample_rate) {
962        (Some(n), Some(r)) if !live && r > 0 => n as f64 / r as f64,
963        _ => -1.0,
964    };
965    let mut decoder = symphonia::default::get_codecs()
966        .make_audio_decoder(params, &AudioDecoderOptions::default())
967        .map_err(|e| format!("{url}: can't decode {codec} ({e})"))?;
968    ctl.set(|i| {
969        if i.codec.as_deref() != Some(codec.as_str()) {
970            i.codec = Some(codec.clone());
971            i.version = i.version.wrapping_add(1);
972        }
973        i.duration = duration;
974        i.caps = CAN_VOLUME | CAN_LOWPASS | CAN_METADATA;
975    });
976
977    let mut resampler = Resampler::new(ctl.rate);
978    let (mut src_rate, mut src_ch) = (0u32, 0usize);
979    let (mut samples, mut bytes, mut out) = (Vec::<f32>::new(), Vec::<u8>::new(), Vec::<f32>::new());
980    let cap = (BUFFER_S * ctl.rate as f64) as usize;
981    let start = (START_S * ctl.rate as f64) as usize;
982    // this connection's audio, in frames at the output's rate: PLAYING once it has START_S
983    // (after a reconnect the output may still be playing what the last one left)
984    let (mut announced, mut decoded) = (false, 0usize);
985    loop {
986        if ctl.closed() {
987            return Ok((End::Closed, live));
988        }
989        // paused: hold the buffer; a live stream lets go after a while
990        if !ctl.want.load(Ordering::Acquire) {
991            let since = *ctl.paused_at.lock().unwrap();
992            if live && since.is_some_and(|t| t.elapsed() >= PAUSE_DROP) {
993                ctl.voice.buf.lock().unwrap().clear();
994                return Ok((End::Released, live));
995            }
996            std::thread::sleep(Duration::from_millis(20));
997            continue;
998        }
999        // playing: the output takes it once enough is buffered (each connection says so)
1000        let resumed = announced && !ctl.voice.playing.load(Ordering::Acquire) && ctl.voice.buffered() >= start;
1001        if (!announced && decoded >= start) || resumed {
1002            announced = true;
1003            ctl.voice.playing.store(true, Ordering::Release);
1004            ctl.set(|i| {
1005                i.state = PLAYING;
1006                i.error.clear();
1007            });
1008        }
1009        if ctl.voice.buffered() >= cap {
1010            std::thread::sleep(Duration::from_millis(20));
1011            continue;
1012        }
1013        let packet = match format.next_packet() {
1014            Ok(Some(p)) => p,
1015            Ok(None) => return Ok((End::Eof, live)),
1016            Err(symphonia::core::errors::Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok((End::Eof, live)),
1017            Err(e) => return Ok((End::Dropped(format!("{url}: {e}")), live)),
1018        };
1019        if packet.track_id != id {
1020            continue;
1021        }
1022        let buf = match decoder.decode(&packet) {
1023            Ok(b) => b,
1024            // a damaged frame: skip it, as players do
1025            Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
1026            Err(e) => return Ok((End::Dropped(format!("{url}: {e}")), live)),
1027        };
1028        let (rate, ch) = (buf.spec().rate(), buf.spec().channels().count());
1029        if ch == 0 || rate == 0 {
1030            continue;
1031        }
1032        if (rate, ch) != (src_rate, src_ch) {
1033            (src_rate, src_ch) = (rate, ch);
1034            resampler.configure(rate, ch.min(2) as u32);
1035        }
1036        samples.clear();
1037        buf.copy_to_vec_interleaved(&mut samples);
1038        // the resampler takes mono or stereo: more channels keep the first two
1039        bytes.clear();
1040        for frame in samples.chunks(ch) {
1041            for s in &frame[..ch.min(2)] {
1042                bytes.extend_from_slice(&s.to_le_bytes());
1043            }
1044        }
1045        out.clear();
1046        resampler.process(&bytes, &mut out);
1047        decoded += out.len() / 2;
1048        ctl.voice.buf.lock().unwrap().extend(out.iter().copied());
1049    }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn descriptions() {
1058        assert_eq!(parse_desc(r#"{"url":"https://radio.example/stream"}"#).unwrap(), "https://radio.example/stream");
1059        for bad in [r#"[]"#, r#"{}"#, r#"{"url":"http://radio.example/"}"#, r#"{"url":"https://"}"#, r#"{"url":"https://a b/"}"#] {
1060            assert!(parse_desc(bad).is_err(), "{bad}");
1061        }
1062    }
1063
1064    #[test]
1065    fn addresses() {
1066        for ip in ["127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.0.1", "100.64.0.1", "0.0.0.0", "::1", "fc00::1", "fe80::1", "::ffff:192.168.0.1", "224.0.0.1"] {
1067            assert!(!is_public(ip.parse().unwrap()), "{ip}");
1068        }
1069        for ip in ["1.1.1.1", "8.8.8.8", "2606:4700::1111"] {
1070            assert!(is_public(ip.parse().unwrap()), "{ip}");
1071        }
1072        let p = MediaPolicy::new(Some(vec![]));
1073        assert!(p.check_url("https://127.0.0.1:8443/x").is_err());
1074        assert!(p.check_url("https://[::1]/x").is_err());
1075        let listed = MediaPolicy::new(Some(vec!["127.0.0.1".into()]));
1076        assert!(listed.check_url("https://127.0.0.1:8443/x").is_ok());
1077        assert!(listed.check_url("https://1.1.1.1/x").is_err(), "a list allows only its hosts");
1078        assert!(MediaPolicy::new(Some(vec![])).check_url("http://1.1.1.1/x").is_err());
1079    }
1080
1081    #[test]
1082    fn permission() {
1083        assert!(MediaPolicy::default().may_open().is_err(), "no manifest entry, no flag");
1084        let declared = MediaPolicy { declared: true, ..Default::default() };
1085        assert!(declared.may_open().is_err(), "headless: no one to ask");
1086        assert!(MediaPolicy::new(Some(vec![])).may_open().is_ok());
1087    }
1088
1089    #[test]
1090    fn icy_blocks() {
1091        let mut icy = Icy::new(4);
1092        let meta = b"StreamTitle='Band - Song';StreamUrl='';";
1093        let blocks = meta.len().div_ceil(16);
1094        let mut data = b"abcd".to_vec();
1095        data.push(blocks as u8);
1096        data.extend_from_slice(meta);
1097        data.resize(5 + blocks * 16, 0);
1098        data.extend_from_slice(b"efgh");
1099        data.push(0); // an empty block
1100        data.extend_from_slice(b"ij");
1101        // fed in odd pieces: the same result
1102        let (mut audio, mut titles) = (Vec::new(), Vec::new());
1103        for piece in data.chunks(3) {
1104            let (a, t) = icy.feed(piece);
1105            audio.extend(a);
1106            titles.extend(t);
1107        }
1108        assert_eq!(audio, b"abcdefghij");
1109        assert_eq!(titles, vec!["Band - Song".to_string()]);
1110        assert_eq!(stream_title(b"StreamTitle='Caf\xe9';"), Some("Café".into()));
1111        assert_eq!(stream_title(b"StreamTitle='';"), None);
1112    }
1113
1114    #[test]
1115    fn filter_is_unity_at_dc() {
1116        let [b0, b1, b2, a1, a2] = lowpass(1000.0, 48_000.0);
1117        assert!(((b0 + b1 + b2) / (1.0 + a1 + a2) - 1.0).abs() < 1e-4);
1118    }
1119}