Files
tubular/src/miruro.rs
T

321 lines
11 KiB
Rust

//! 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<Value>,
}
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<Vec<Value>> {
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<Detail> {
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<String> {
self.get_from(MIRRORS, path, query, types)
}
fn get_from(
&self,
mirrors: &[&str],
path: &str,
query: &[(&str, &str)],
types: &[&str],
) -> io::Result<String> {
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<usize> {
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<usize> {
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<Detail> {
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<Value> {
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>() + 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::<io::Result<Vec<_>>>()
.map(Value::Array)
}
primitive => Ok(primitive.clone()),
}
}
#[cfg(test)]
#[path = "../tests/unit/miruro.rs"]
mod tests;