//! Blocking access to the deployed Miruro catalog and stable episode identities. //! //! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers, //! subtitles and playback metadata. use std::{ io, mem::size_of, sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}, }; #[cfg(test)] use crate::anime::Identity; use serde_json::{Map, Value}; // Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy. const MIRRORS: &[&str] = &[ "https://miruro.cx", "https://miruro.pw", "https://miruro.ws", "https://miruro.to", "https://miruro.tv", "https://miruro.bz", ]; const REQUEST_TIMEOUT: Duration = Duration::from_secs(25); const BODY_LIMIT: u64 = 2 * 1024 * 1024; pub struct Client { agent: ureq::Agent, preferred: AtomicUsize, } /// Catalog metadata and available episode metadata, not a playback guarantee. pub struct Detail { pub media: Value, pub episodes: Vec, } impl Default for Client { fn default() -> Self { Self { agent: ureq::Agent::config_builder() .timeout_global(Some(REQUEST_TIMEOUT)) .http_status_as_error(false) .max_redirects(0) .build() .new_agent(), preferred: AtomicUsize::new(0), } } } impl Client { /// Autocomplete summaries only: the deployed endpoint rejects cursor pagination. pub fn search(&self, query: &str) -> io::Result> { let query = query.trim(); if query.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Empty Miruro query", )); } let body = self.get( "/api/search/browse", &[("q", query), ("limit", "8"), ("type", "ANIME")], &["application/json"], )?; let mut value: Value = serde_json::from_str(&body).map_err(invalid)?; match value.get_mut("data").map(Value::take) { Some(Value::Array(items)) => Ok(items), _ => Err(invalid("Miruro search did not return a data array")), } } /// `id` is Miruro's opaque ID; `slug` follows the deployed canonical link helper. /// Resolves the episode promise without decoding unrelated sidebar promises. pub fn detail(&self, id: &str, slug: &str) -> io::Result { validate_segment(id)?; validate_segment(slug)?; let body = self.get( &format!("/info/{id}/{slug}/__data.json"), &[], &["application/json", "text/sveltekit-data"], )?; let detail = decode_detail(&body)?; if detail.media.get("id").and_then(Value::as_str) != Some(id) { return Err(invalid("Miruro detail ID did not match the requested ID")); } Ok(detail) } fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result { self.get_from(MIRRORS, path, query, types) } fn get_from( &self, mirrors: &[&str], path: &str, query: &[(&str, &str)], types: &[&str], ) -> io::Result { let started = Instant::now(); let first = self.preferred.load(Ordering::Relaxed) % mirrors.len(); let mut last_error = io::Error::new(io::ErrorKind::TimedOut, "Miruro request timed out"); let mut attempts = 0; for offset in 0..mirrors.len() { let Some(remaining) = REQUEST_TIMEOUT.checked_sub(started.elapsed()) else { break; }; let index = (first + offset) % mirrors.len(); let base = mirrors[index]; attempts += 1; let response = self .agent .get(format!("{base}{path}")) .query_pairs(query.iter().copied()) .config() .timeout_global(Some(remaining.min(Duration::from_secs(5)))) .build() .call(); let mut response = match response { Ok(response) => response, Err(_) => { last_error = io::Error::other(format!("{base}: HTTP transport failed")); continue; } }; let status = response.status().as_u16(); let mime = response.body().mime_type().unwrap_or("missing"); if status != 200 { let kind = match status { 401 | 403 => io::ErrorKind::PermissionDenied, 404 => io::ErrorKind::NotFound, _ => io::ErrorKind::Other, }; last_error = io::Error::new(kind, format!("{base}: HTTP {status} ({mime})")); // Do not rotate hosts to work around rate limits or invalid requests. if !matches!(status, 301..=308 | 403 | 404 | 408 | 500..=599) { return Err(last_error); } continue; } if !types.contains(&mime) { last_error = invalid(format!("{base}: unexpected Content-Type {mime}")); continue; } match response .body_mut() .with_config() .limit(BODY_LIMIT) .read_to_string() { Ok(body) => { self.preferred.store(index, Ordering::Relaxed); return Ok(body); } Err(_) => { last_error = invalid(format!( "{base}: response unreadable, timed out, or exceeded 2 MiB" )); } } } Err(io::Error::new( last_error.kind(), format!( "Miruro request failed on {attempts}/{} mirrors; last error: {last_error}", mirrors.len() ), )) } } fn invalid(message: impl std::fmt::Display) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.to_string()) } fn validate_segment(segment: &str) -> io::Result<()> { if segment.is_empty() || !segment .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Invalid Miruro ID or slug path segment", )); } Ok(()) } fn reference(value: &Value) -> io::Result { value .as_u64() .and_then(|index| usize::try_from(index).ok()) .ok_or_else(|| invalid("Invalid or unsupported devalue reference")) } fn field(table: &[Value], key: &str) -> io::Result { reference( table .first() .and_then(|root| root.get(key)) .ok_or_else(|| invalid(format!("Missing Miruro route field: {key}")))?, ) } fn decode_detail(body: &str) -> io::Result { let mut lines = body.lines().filter(|line| !line.trim().is_empty()); let initial: Value = serde_json::from_str( lines .next() .ok_or_else(|| invalid("Empty Miruro route data"))?, ) .map_err(invalid)?; if initial.get("type").and_then(Value::as_str) == Some("redirect") { return Err(io::Error::new( io::ErrorKind::NotFound, "Miruro detail returned a route redirect", )); } if initial.get("type").and_then(Value::as_str) != Some("data") { return Err(invalid("Unexpected Miruro route envelope")); } let node = initial .get("nodes") .and_then(Value::as_array) .and_then(|nodes| nodes.get(1)) .filter(|node| node.get("type").and_then(Value::as_str) == Some("data")) .ok_or_else(|| invalid("Missing Miruro detail route node"))?; let table = node .get("data") .and_then(Value::as_array) .ok_or_else(|| invalid("Missing Miruro detail value table"))?; let mut budget = BODY_LIMIT as usize; let media = expand(table, field(table, "detail")?, 0, &mut budget)?; let promise = table .get(field(table, "episodes")?) .and_then(Value::as_array) .filter(|tag| tag.len() == 2 && tag[0].as_str() == Some("Promise")) .ok_or_else(|| invalid("Missing Miruro episode promise"))?; let promise_id = table .get(reference(&promise[1])?) .and_then(Value::as_u64) .ok_or_else(|| invalid("Invalid Miruro episode promise ID"))?; let mut episodes = None; for line in lines { let chunk: Value = serde_json::from_str(line).map_err(invalid)?; if chunk.get("type").and_then(Value::as_str) != Some("chunk") || chunk.get("id").and_then(Value::as_u64) != Some(promise_id) { continue; } if episodes.is_some() || chunk.get("error").is_some() { return Err(invalid("Miruro episode promise was duplicated or rejected")); } let table = chunk .get("data") .and_then(Value::as_array) .ok_or_else(|| invalid("Missing Miruro episode chunk table"))?; episodes = match expand(table, 0, 0, &mut budget)? { Value::Array(items) => Some(items), _ => { return Err(invalid( "Miruro episode promise did not resolve to an array", )); } }; } Ok(Detail { media, episodes: episodes.ok_or_else(|| invalid("Miruro episode promise never resolved"))?, }) } // ponytail: JSON-shaped detail/episode tables only; reject other devalue types // rather than silently coerce them. Add tags only if the catalog actually needs them. fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io::Result { if depth >= 64 { return Err(invalid("Miruro devalue reference cycle or excessive depth")); } let value = table .get(index) .ok_or_else(|| invalid("Miruro devalue reference out of bounds"))?; let cost = size_of::() + value.as_str().map_or(0, str::len); *budget = budget .checked_sub(cost) .ok_or_else(|| invalid("Miruro decoded data exceeded its size budget"))?; match value { Value::Object(object) => { let mut decoded = Map::new(); for (key, value) in object { *budget = budget .checked_sub(key.len()) .ok_or_else(|| invalid("Miruro decoded keys exceeded their size budget"))?; decoded.insert( key.clone(), expand(table, reference(value)?, depth + 1, budget)?, ); } Ok(Value::Object(decoded)) } Value::Array(array) => { if array.first().is_some_and(Value::is_string) { return Err(invalid("Unsupported Miruro devalue tagged type")); } array .iter() .map(|value| expand(table, reference(value)?, depth + 1, budget)) .collect::>>() .map(Value::Array) } primitive => Ok(primitive.clone()), } } #[cfg(test)] #[path = "../tests/unit/miruro.rs"] mod tests;