//! 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, time::Duration}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; const BASE: &str = "https://barelystarted.miruro.tv"; const BODY_LIMIT: u64 = 2 * 1024 * 1024; pub struct Client { agent: ureq::Agent, } /// Catalog metadata and available episode metadata, not a playback guarantee. pub struct Detail { pub media: Value, pub episodes: Vec, } /// Stable catalog identity, never a CDN address or playback-provider name. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Identity { pub provider: String, pub media_id: String, pub episode: u32, } impl Identity { pub fn new(media_id: String, episode: u32) -> io::Result { let identity = Self { provider: "miruro".into(), media_id, episode, }; identity.validate()?; Ok(identity) } pub fn validate(&self) -> io::Result<()> { if self.provider != "miruro" || self.media_id.len() > 512 || self.episode == 0 || self.episode > i32::MAX as u32 { return Err(invalid("Invalid Miruro episode identity")); } validate_segment(&self.media_id) } pub fn key(&self) -> String { format!("miruro:{}:{}", self.media_id, self.episode) } /// The slug is non-identifying; metadata readers must resolve the canonical route. pub fn watch_url(&self) -> io::Result { self.validate()?; Ok(format!( "{BASE}/watch/{}/episode?ep={}", self.media_id, self.episode )) } pub fn from_key(key: &str) -> io::Result { let (media_id, episode) = key .strip_prefix("miruro:") .and_then(|rest| rest.split_once(':')) .ok_or_else(|| invalid("Invalid Miruro episode key"))?; if episode.starts_with('0') || !episode.bytes().all(|byte| byte.is_ascii_digit()) { return Err(invalid("Noncanonical Miruro episode key")); } Self::new( media_id.into(), episode .parse() .map_err(|_| invalid("Invalid Miruro episode number"))?, ) } } impl Default for Client { fn default() -> Self { Self { agent: ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(25))) .http_status_as_error(false) .max_redirects(0) .build() .new_agent(), } } } 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 { let mut response = self .agent .get(format!("{BASE}{path}")) .query_pairs(query.iter().copied()) .call() .map_err(|_| io::Error::other("Miruro HTTP transport failed"))?; 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, }; return Err(io::Error::new( kind, format!("Miruro HTTP {status} ({mime}); no retry attempted"), )); } if !types.contains(&mime) { return Err(invalid(format!("Unexpected Miruro Content-Type: {mime}"))); } response .body_mut() .with_config() .limit(BODY_LIMIT) .read_to_string() .map_err(|_| invalid("Miruro response was unreadable, timed out, or exceeded 2 MiB")) } } 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;