feat: add verified native Miruro API wrapper
This commit is contained in:
+314
@@ -0,0 +1,314 @@
|
||||
//! Blocking access to the deployed Miruro catalog and source API.
|
||||
//!
|
||||
//! See `docs/miruro-api.md`. Source URLs are ephemeral; retain server headers and
|
||||
//! provider configuration for playback. This module does not select a provider,
|
||||
//! bypass protection, decode iframe embeds, or integrate with a player.
|
||||
use std::{io, mem::size_of, time::Duration};
|
||||
|
||||
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<Value>,
|
||||
}
|
||||
|
||||
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<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)
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result<String> {
|
||||
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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user