//! Blocking access to the deployed Miruro catalog and source API. //! //! See `docs/miruro-api.md`. Source URLs are ephemeral. Playback projection is //! limited to direct Sun soft-subtitle sources; no proxy, iframe or retry fallback. use std::{ collections::{BTreeMap, HashSet}, 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) } 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"))?, ) } } #[derive(Clone, Debug, Serialize)] pub struct Subtitle { pub url: String, pub format: String, pub language: String, pub label: String, pub default: bool, pub headers: BTreeMap, } #[derive(Clone, Debug, Serialize)] pub struct SourceDocument { pub version: u32, pub url: String, pub headers: BTreeMap, pub identity: Identity, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, pub subtitles: Vec, } impl SourceDocument { pub fn validate(&self) -> io::Result<()> { if self.version != 1 || self.subtitles.len() > 32 || self.subtitles.iter().filter(|track| track.default).count() > 1 { return Err(invalid( "Invalid source document version or subtitle limits", )); } if self .title .as_ref() .is_some_and(|title| title.chars().any(char::is_control)) { return Err(invalid("Invalid source title")); } self.identity.validate()?; validate_url(&self.url)?; validate_headers(&self.headers)?; for track in &self.subtitles { if track.format != "vtt" { return Err(invalid("Only external VTT subtitles are supported")); } if track.label.chars().any(char::is_control) || track.language.chars().any(char::is_control) { return Err(invalid("Invalid subtitle label or language")); } validate_url(&track.url)?; validate_headers(&track.headers)?; } Ok(()) } /// No provider/language fallback: take the first direct ssub/Sun server. pub fn from_sources(sources: &Value, identity: Identity, title: &str) -> io::Result { identity.validate()?; if sources["episodeNumber"].as_u64() != Some(u64::from(identity.episode)) { return Err(invalid( "Resolved source episode does not match its identity", )); } let config = &sources["config"]["sun"]; if config["player"] != "native" || config["proxy"] != false || config["hls"]["segments"]["enabled"] == true || config["hls"]["query"]["enabled"] == true { return Err(invalid("Sun requires unsupported proxy or HLS rewriting")); } let provider = sources["tracks"] .as_array() .and_then(|tracks| tracks.iter().find(|track| track["track"] == "ssub")) .and_then(|track| track["providers"].as_array()) .and_then(|providers| { providers .iter() .find(|provider| provider["provider"] == "sun") }) .ok_or_else(|| invalid("Direct Sun soft-subtitle source is unavailable"))?; let server = provider["servers"] .as_array() .and_then(|servers| { servers.iter().find(|server| { server["streams"] .as_array() .is_some_and(|streams| !streams.is_empty()) }) }) .ok_or_else(|| invalid("Direct Sun stream is unavailable"))?; let stream = &server["streams"][0]; if !matches!( stream["format"].as_str(), Some("hls" | "m3u8" | "application/vnd.apple.mpegurl" | "application/x-mpegurl") ) || !stream["embed"].is_null() { return Err(invalid("Selected Sun stream is not direct HLS")); } let headers = source_headers(&server["headers"])?; let mut subtitles = Vec::new(); for subtitle in provider["subtitles"] .as_array() .ok_or_else(|| invalid("Missing Sun subtitle array"))? { if subtitle["format"] != "vtt" { continue; } if subtitles.len() == 32 { return Err(invalid("Source subtitle limit exceeded")); } subtitles.push(Subtitle { url: source_string(subtitle, "file")?, format: "vtt".into(), language: source_string(subtitle, "language")?, label: source_string(subtitle, "label")?, default: subtitle["default"] .as_bool() .ok_or_else(|| invalid("Invalid subtitle default flag"))?, // Verified for direct Sun only. These are explicit resource headers, // not an inheritance rule for other providers. headers: headers.clone(), }); } let document = Self { version: 1, url: source_string(stream, "url")?, headers, identity, title: Some(title.into()), subtitles, }; document.validate()?; Ok(document) } } fn source_string(value: &Value, field: &str) -> io::Result { value .get(field) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| invalid("Missing or invalid resolved source field")) } fn source_headers(value: &Value) -> io::Result> { if value.is_null() { return Ok(BTreeMap::new()); } let headers = value .as_object() .ok_or_else(|| invalid("Invalid source header map"))?; if headers.len() > 64 { return Err(invalid("Source header limit exceeded")); } headers .iter() .map(|(key, value)| { Ok(( key.clone(), value .as_str() .ok_or_else(|| invalid("Invalid source header value"))? .into(), )) }) .collect() } fn validate_url(url: &str) -> io::Result<()> { let uri: ureq::http::Uri = url.parse().map_err(|_| invalid("Invalid source URL"))?; if !matches!(uri.scheme_str(), Some("http" | "https")) || uri.host().is_none_or(str::is_empty) || uri .authority() .is_some_and(|authority| authority.as_str().contains('@')) || url.chars().any(char::is_control) { return Err(invalid( "Source URLs must be absolute HTTP(S) without credentials", )); } Ok(()) } fn validate_headers(headers: &BTreeMap) -> io::Result<()> { if headers.len() > 64 { return Err(invalid("Source header limit exceeded")); } let mut names = HashSet::with_capacity(headers.len()); for (name, value) in headers { let name = ureq::http::HeaderName::from_bytes(name.as_bytes()) .map_err(|_| invalid("Invalid source header name"))?; if !names.insert(name) || value.chars().any(char::is_control) || ureq::http::HeaderValue::from_str(value).is_err() { return Err(invalid("Invalid or duplicate source header")); } } Ok(()) } 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) } /// Fresh source data, preserving tracks, headers, subtitles and runtime config. /// `episode` is the returned episode_number, not absolute_number or array index. pub fn sources(&self, id: &str, episode: u32) -> io::Result { validate_segment(id)?; if episode == 0 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Episode must be positive", )); } let body = self.get( "/api/sources", &[("id", id), ("n", &episode.to_string())], &["application/json"], )?; let value: Value = serde_json::from_str(&body).map_err(invalid)?; if value.get("episodeNumber").and_then(Value::as_u64) != Some(u64::from(episode)) || !value.get("tracks").is_some_and(Value::is_array) { return Err(invalid( "Miruro sources did not match the requested episode", )); } Ok(value) } pub fn source_document( &self, id: &str, episode: u32, title: &str, ) -> io::Result { let identity = Identity::new(id.into(), episode)?; SourceDocument::from_sources(&self.sources(id, episode)?, identity, title) } 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)] mod tests { use super::*; #[test] fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() { let sources = serde_json::json!({ "episodeNumber": 1, "config": { "sun": { "player": "native", "proxy": false } }, "tracks": [{ "track": "ssub", "providers": [{ "provider": "sun", "servers": [{ "headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" }, "streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }] }], "subtitles": [{ "file": "https://vault.keeply.top/english.jpeg", "format": "vtt", "language": "en", "label": "English", "default": true }] }] }] }); let identity = Identity::new("catalog".into(), 1).unwrap(); let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap(); assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity); assert_ne!( Identity::new("catalog".into(), 2).unwrap().key(), source.identity.key() ); for key in [ "miruro:catalog:01", "miruro:catalog:0", "miruro:catalog:2147483648", "miruro:catalog/:1", ] { assert!(Identity::from_key(key).is_err()); } for (pointer, value) in [ ("/episodeNumber", serde_json::json!(49)), ("/config/sun/proxy", serde_json::json!({"segments": true})), ( "/tracks/0/providers/0/servers/0/streams/0/url", serde_json::json!("file:///secret"), ), ( "/tracks/0/providers/0/servers/0/streams/0/embed", serde_json::json!({"url": "https://embed.example"}), ), ( "/tracks/0/providers/0/servers/0/headers/origin", serde_json::json!("secret"), ), ( "/tracks/0/providers/0/servers/0/headers/Referer", serde_json::json!("secret\r\nX-Injected: true"), ), ] { let mut invalid_sources = sources.clone(); if let Some(field) = invalid_sources.pointer_mut(pointer) { *field = value; } else { invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = value; } let error = SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1") .unwrap_err(); assert!(!error.to_string().contains("secret")); } let mut invalid_source = source; invalid_source .subtitles .push(invalid_source.subtitles[0].clone()); assert!(invalid_source.validate().is_err()); invalid_source.subtitles[1].default = false; invalid_source.subtitles[1].format = "srt".into(); assert!(invalid_source.validate().is_err()); } #[test] fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() { let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#; let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#; let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#; let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap(); assert_eq!(detail.media["episode_count"], 12); assert_eq!(detail.episodes[0]["episode_number"], 1); assert_eq!(detail.episodes[0]["absolute_number"], 49); assert_eq!(detail.episodes[0]["title"], "Locusts"); assert!(detail.episodes[0]["canon_type"].is_null()); for chunk in [ "", // The promised episodes never arrive. r#"{"type":"chunk","id":9,"error":["failed"]}"#, r#"{"type":"chunk","id":9,"data":[[999]]}"#, r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle. r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#, ] { assert_eq!( decode_detail(&format!("{initial}\n{chunk}")) .err() .unwrap() .kind(), io::ErrorKind::InvalidData ); } assert_eq!( decode_detail(r#"{"type":"redirect","location":"/"}"#) .err() .unwrap() .kind(), io::ErrorKind::NotFound ); } }