Skip to main content

phemios_core/
radio.rs

1//! Explicit Radio Browser directory access, separate from Aède credentials and catalog truth.
2use crate::podcast::PublicResolver;
3use serde_json::{Value, json};
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5use ureq::unversioned::transport::DefaultConnector;
6mod policy;
7use policy::*;
8const LIMIT: u64 = 1024 * 1024;
9
10/// A bounded directory client independent of Aède sessions.
11///
12/// Construction is offline. Each command shares a 24-second discovery/retry budget,
13/// accepts at most three validated mirrors, and refuses redirects, private DNS targets
14/// and oversized bodies. External-service traffic never inherits proxy credentials.
15#[derive(Clone)]
16pub struct Directory {
17    agent: ureq::Agent,
18    mirrors: Vec<String>,
19    discovered: Option<Instant>,
20    next: usize,
21}
22impl Default for Directory {
23    fn default() -> Self {
24        let config = ureq::Agent::config_builder()
25            .max_redirects(0)
26            .proxy(None)
27            .build();
28        Self {
29            agent: ureq::Agent::with_parts(config, DefaultConnector::default(), PublicResolver),
30            mirrors: Vec::new(),
31            discovered: None,
32            next: 0,
33        }
34    }
35}
36impl Directory {
37    fn read(
38        &self,
39        started: Instant,
40        host: &str,
41        path: &str,
42        query: &[(&str, &str)],
43    ) -> Result<Value, String> {
44        let mut request = self
45            .agent
46            .get(format!("{host}{path}"))
47            .config()
48            .timeout_global(Some(request_timeout(started.elapsed())?))
49            .build()
50            .header(
51                "User-Agent",
52                concat!(
53                    "Phemios/",
54                    env!("CARGO_PKG_VERSION"),
55                    " (Radio Browser desktop client)"
56                ),
57            )
58            .header("Accept", "application/json");
59        for (key, value) in query {
60            request = request.query(key, value);
61        }
62        let mut response = request.call().map_err(request_failure)?;
63        let bytes = response
64            .body_mut()
65            .with_config()
66            .limit(LIMIT)
67            .read_to_vec()
68            .map_err(|_| "Réponse Radio Browser trop grande ou interrompue.")?;
69        serde_json::from_slice(&bytes).map_err(|_| "Réponse Radio Browser invalide.".into())
70    }
71    fn candidates(&mut self, started: Instant) -> Result<Vec<String>, String> {
72        if self
73            .discovered
74            .is_none_or(|time| time.elapsed() >= Duration::from_secs(600))
75        {
76            // std supports forward DNS but not portable PTR lookup; use the documented server-list fallback.
77            match discover_mirrors(|host| self.read(started, host, "/json/servers", &[])) {
78                Ok(hosts) => {
79                    self.mirrors = hosts;
80                    self.discovered = Some(Instant::now());
81                }
82                Err(error) if self.mirrors.is_empty() => return Err(error),
83                Err(_) => {} // Previously validated mirrors remain a usable fallback during bootstrap outages.
84            }
85            self.next = SystemTime::now()
86                .duration_since(UNIX_EPOCH)
87                .map_or(0, |d| d.subsec_nanos() as usize)
88                % self.mirrors.len();
89        }
90        let mut hosts = Vec::with_capacity(self.mirrors.len().min(3));
91        for _ in 0..self.mirrors.len().min(3) {
92            hosts.push(self.mirrors[self.next % self.mirrors.len()].clone());
93            self.next = self.next.wrapping_add(1);
94        }
95        if hosts.is_empty() {
96            return Err("Aucun serveur Radio Browser disponible.".into());
97        }
98        Ok(hosts)
99    }
100    /// Search the complete external directory with at most 51 rows and an explicit page window.
101    pub fn search(
102        &mut self,
103        name: &str,
104        country: &str,
105        tag: &str,
106        offset: u32,
107        secure_only: bool,
108    ) -> Result<Value, String> {
109        if [name, country, tag]
110            .iter()
111            .any(|s| s.len() > 256 || s.chars().any(char::is_control))
112            || offset > 1_000_000
113        {
114            return Err("Recherche radio invalide ou trop longue.".into());
115        }
116        let started = Instant::now();
117        let hosts = self.candidates(started)?;
118        let offset = offset.to_string();
119        let (country_field, country_value) = country_filter(country);
120        let mut query = vec![
121            ("name", name.trim()),
122            (country_field, country_value.as_str()),
123            ("tag", tag.trim()),
124            ("offset", &offset),
125            ("limit", "51"),
126            ("hidebroken", "true"),
127            ("order", "clickcount"),
128            ("reverse", "true"),
129        ];
130        if secure_only {
131            query.push(("is_https", "true"));
132        }
133        let stations = retry_mirrors(
134            &hosts,
135            offset == "0"
136                && name.trim().is_empty()
137                && tag.trim().is_empty()
138                && !country.trim().is_empty(),
139            |host| {
140                self.read(started, host, "/json/stations/search", &query)
141                    .and_then(station_page)
142            },
143        )?;
144        Ok(stations)
145    }
146    /// Read external directory counts only after an explicit user request.
147    pub fn statistics(&mut self) -> Result<Value, String> {
148        let started = Instant::now();
149        let hosts = self.candidates(started)?;
150        retry_mirrors(&hosts, false, |host| {
151            self.read(started, host, "/json/stats", &[])
152                .and_then(station_statistics)
153        })
154    }
155    /// Resolve one station and record its requested start using the provider's documented click endpoint.
156    pub fn resolve(&mut self, uuid: &str) -> Result<Value, String> {
157        if !valid_uuid(uuid) {
158            return Err("Identifiant radio invalide.".into());
159        }
160        let started = Instant::now();
161        let hosts = self.candidates(started)?;
162        let answer = retry_mirrors(&hosts, false, |host| {
163            self.read(started, host, &format!("/json/url/{uuid}"), &[])
164                .and_then(|value| resolved_station(value, uuid))
165        })?;
166        Ok(
167            json!({"stationuuid": uuid, "url": preferred_stream(answer["url"].as_str().ok_or("Adresse du flux invalide.")?)}),
168        )
169    }
170}
171#[cfg(test)]
172#[path = "radio_tests.rs"]
173mod tests;