660 lines
24 KiB
Rust
660 lines
24 KiB
Rust
//! 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<Value>,
|
|
}
|
|
|
|
/// 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<Self> {
|
|
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<Self> {
|
|
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<String, String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct SourceDocument {
|
|
pub version: u32,
|
|
pub url: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
pub identity: Identity,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
pub subtitles: Vec<Subtitle>,
|
|
}
|
|
|
|
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<Self> {
|
|
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<String> {
|
|
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<BTreeMap<String, String>> {
|
|
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<String, String>) -> 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<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)
|
|
}
|
|
|
|
pub fn source_document(
|
|
&self,
|
|
id: &str,
|
|
episode: u32,
|
|
title: &str,
|
|
) -> io::Result<SourceDocument> {
|
|
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<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 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
|
|
);
|
|
}
|
|
}
|