feat: add verified native Miruro API wrapper

This commit is contained in:
2026-10-12 18:26:14 +02:00
parent af282e2a81
commit 0fe24ff302
7 changed files with 713 additions and 8 deletions
Generated
+182
View File
@@ -79,6 +79,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64-simd"
version = "0.8.0"
@@ -139,6 +145,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
@@ -148,6 +160,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -361,6 +383,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -423,6 +451,22 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icy_sixel"
version = "0.5.1"
@@ -654,6 +698,12 @@ dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -732,6 +782,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "png"
version = "0.18.1"
@@ -981,6 +1037,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1016,6 +1086,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1098,6 +1203,12 @@ dependencies = [
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1174,6 +1285,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -1313,6 +1430,7 @@ dependencies = [
"serde_json",
"time",
"toml_edit",
"ureq",
]
[[package]]
@@ -1344,6 +1462,46 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1362,6 +1520,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wide"
version = "1.7.1"
@@ -1464,6 +1631,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -1584,6 +1760,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zlib-rs"
version = "0.6.8"
+1
View File
@@ -13,6 +13,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
toml_edit = "0.25.17"
time = "0.3.55"
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
[target."cfg(unix)".dependencies]
libc = "0.2.190"
+9 -1
View File
@@ -322,10 +322,18 @@ Video info shows a thumbnail, title, author/publication details, and a clipped d
## Development
Research for a possible future streaming-service integration: [Miruro API notes](docs/miruro-api.md). Miruro support is not implemented.
[Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper and verified transport behavior. Miruro is not yet integrated into the TUI or Terebi playback.
```sh
cargo fmt --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
```
Read-only native Miruro smoke (search → detail/episodes → fresh sources → HLS master/subtitle):
```sh
cargo run --locked --example miruro_probe
```
This uses the documented research title and direct Sun provider, stops on errors/protection pages, and does not download media segments or save tokenized URLs. The sample can become unavailable; passing it proves resource retrieval, not full player playback.
+47 -7
View File
@@ -1,14 +1,14 @@
# Miruro API research
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**. These are reverse-engineering notes for a possible in-repository wrapper, not an official API specification. Tubular does not yet implement Miruro support.
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**. These are reverse-engineering notes, not an official API specification. Tubular now has a native API module and runnable smoke example, but no Miruro TUI or Terebi integration.
Evidence labels:
- **Verified:** an actual HTTP response obtained through the research JavaScript `fetch` transport, with bounded timeouts and no supplied authentication, cookies, or protection bypass.
- **Verified:** an actual HTTP response obtained through the research JavaScript `fetch` transport, or the explicitly identified local Rust probe below, with bounded timeouts and no supplied authentication, cookies, or protection bypass.
- **Frontend:** behavior read from deployed first-party JavaScript; not necessarily a server-side guarantee.
- **Unknown:** not established by those sources or requests.
**Transport limitation:** research requests returned JSON, but a local `curl` request to the same source-validation URL returned **403 `text/html`**, a Cloudflare block page. Native-wrapper access is therefore unproven. Inspect HTTP status and Content-Type before decoding; stop on protection pages rather than treating them as empty results or adding bypass machinery. Successful research requests do not establish cross-client accessibility.
**Transport distinction:** research requests returned JSON, while a local `curl` source-validation request returned **403 `text/html`**, a Cloudflare block page. A subsequent local Rust `ureq` probe passed the search → detail/episodes → sources → HLS master/subtitle path without bypass machinery. Native access is proven for that client and sample, not every transport, network, provider or future request. Inspect HTTP status and Content-Type before decoding; stop on protection pages rather than treating them as empty results.
## Endpoint map
@@ -348,7 +348,7 @@ All research data GETs below used `fetch` with 20–25-second timeouts, no suppl
| [strm client][strm-client] | 200 / text/javascript | Source inspected, not executed |
| Local shell curl, known source ID with n=0 | 403 / text/html | Cloudflare block page |
One worker's homepage fetch without a trailing slash returned nginx 404, while the trailing-slash HTML read succeeded. This unresolved transport/URL discrepancy is not evidence of an API rule. No browser testing, playback, media-segment downloads, account mutations, protection bypass, or application tests were performed for this documentation-only change.
One worker's homepage fetch without a trailing slash returned nginx 404, while the trailing-slash HTML read succeeded. This unresolved transport/URL discrepancy is not evidence of an API rule. The initial documentation research did not run application tests; the later native-wrapper verification is recorded below. Neither investigation used a browser, playback, media-segment downloads, account mutations or protection bypass.
## Bounded reproduction requests
@@ -387,10 +387,50 @@ console.log(response.status, response.headers.get('content-type'));
console.log(await response.text());
```
## Open questions before implementation
## Native Rust wrapper and verified probe
- Can the real native HTTP client access this deployment without being blocked? Local curl failed; no workaround has been implemented or tested.
- Is there a stable plain metadata/episode API, or must the wrapper own a SvelteKit/devalue decoder tied to this deployment?
The local probe succeeded using Rust 1.90.0, `ureq` 3.4.2, rustls 0.23.45 and the `ring` TLS provider. Its isolated scratch project was removed after the exercised path was extracted into [src/miruro.rs](../src/miruro.rs). The client uses ordinary TLS verification, no cookies, no browser impersonation, no clearance tokens and no retry/alternate transport.
Library interface, exposed as `tubular::miruro`:
| Call | Result / contract |
| --- | --- |
| `Client::default()` | Reusable blocking HTTP agent; 25-second global request timeout; no automatic redirects |
| `client.search(query)` | `io::Result<Vec<serde_json::Value>>`; trimmed, nonempty autocomplete query; no cursor pagination |
| `client.detail(id, slug)` | `io::Result<Detail>`; decoded `media` JSON and `episodes: Vec<Value>`; validates returned media ID |
| `client.sources(id, episode)` | `io::Result<Value>`; fresh full response preserving headers/config/subtitles; validates positive episode and returned episodeNumber/tracks |
The wrapper does not choose providers/languages or implement a player. It preserves JSON metadata rather than freezing every deployment-specific field into a second schema. IDs/slugs are validated as nonempty ASCII alphanumeric/hyphen/underscore path segments. HTTP failures remain errors (`PermissionDenied` for 401/403); HTML and unexpected Content-Types are rejected, not converted to empty results. No tokenized URLs appear in its error messages. Successful bodies are limited to 2 MiB.
Its private route decoder resolves only the needed detail value and episode promise, using separate value tables and promise IDs. Unrelated sidebar promises are not returned. It supports the JSON-shaped tables verified on this path, **not arbitrary devalue**: tagged special types and negative sentinels are explicit errors, never silently coerced to null. Out-of-range references, rejected/missing/duplicate episode chunks, cycles/excessive depth and decoded-size amplification are rejected. Depth is bounded to 64 and expanded values share a 2 MiB size budget. These are wrapper safety limits, not Miruro server limits.
Run the retained live smoke:
```sh
cargo run --locked --example miruro_probe
```
Actual output from the extracted wrapper:
```text
search: 8 records; selected Miruro ID OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
detail: 2 episodes; selected episode 1
sources: 2 tracks; selected ssub/sun; 2 server headers retained
hls_master: HTTP 200; Content-Type application/vnd.apple.mpegurl
subtitle: HTTP 200; Content-Type text/vtt
PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no media segments or tokens saved
```
The preceding scratch probe also reported HTTP 200 for search (`application/json`), detail (`text/sveltekit-data`, seven records), and sources (`application/json`). Detail decoding found two episodes and selected `episode_number:1`; source decoding found two tracks. The master and default subtitle were fetched using the actual returned `Origin: https://ultracloud.cc` and `Referer: https://ultracloud.cc/`. URL/header necessity and full playback were not tested.
The [example](../examples/miruro_probe.rs) deliberately uses the documented title and `ssub/sun` provider, and limits resource requests to HTTPS `*.keeply.top` without redirects. It fails explicitly if that sample disappears or changes instead of silently choosing a different provider. It fetches no nested playlist, media segment, key, sprite or download page, and saves no tokens/bodies. This is an explicit network smoke command, not a network-dependent unit test.
Verification: `cargo test --locked` passed all 164 tests, including the new episode-promise regression; `cargo clippy --locked --all-targets -- -D warnings` passed; `rustfmt --edition 2024 --check src/lib.rs src/miruro.rs examples/miruro_probe.rs` passed. The decoder regression covers independent tables, out-of-order unrelated chunks, preserved episode/absolute numbers and nulls, unresolved/rejected promises, bad references/cycles, unsupported tags and route redirects.
## Open questions before TUI/player integration
- Will this same native client remain accessible on other networks/deployments? Its sampled path passed; local curl previously failed, and no workaround is implemented.
- Is there a stable plain metadata/episode API? The wrapper currently owns the bounded JSON-shaped SvelteKit decoder for the observed deployment.
- What are server-side filter/enum constraints, multi-value semantics, cursor lifetime, page-size-cookie limits, numeric-ID lookups and slug behavior?
- What happens for unavailable episodes, empty tracks, provider failures, bad cursors, rejected promise chunks and syntactically valid missing IDs?
- Which headers/config transforms are required per provider, and can Terebi propagate them to HLS playlists/segments/keys and external subtitles?
+159
View File
@@ -0,0 +1,159 @@
//! Live read-only smoke: cargo run --locked --example miruro_probe
//! Uses the documented research title and direct Sun provider; no media segments.
use std::{io, time::Duration};
use serde_json::{Map, Value};
use tubular::miruro::Client;
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn main() -> io::Result<()> {
let client = Client::default();
let results = client.search("apothecary")?;
let media = results
.iter()
.find(|media| media["id"] == "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw")
.ok_or_else(|| invalid("Research title no longer appears in search"))?;
let id = media["id"]
.as_str()
.ok_or_else(|| invalid("Missing media ID"))?;
println!("search: {} records; selected Miruro ID {id}", results.len());
let detail = client.detail(id, "the-apothecary-diaries-season-3")?;
let number = detail
.episodes
.first()
.and_then(|episode| episode["episode_number"].as_u64())
.and_then(|number| u32::try_from(number).ok())
.ok_or_else(|| invalid("No episode metadata available"))?;
println!(
"detail: {} episodes; selected episode {number}",
detail.episodes.len()
);
let sources = client.sources(id, number)?;
let tracks = sources["tracks"]
.as_array()
.ok_or_else(|| invalid("Missing tracks"))?;
let provider = 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 provider not returned"))?;
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("No direct Sun stream returned"))?;
let headers = server["headers"]
.as_object()
.ok_or_else(|| invalid("Missing server headers"))?;
println!(
"sources: {} tracks; selected ssub/sun; {} server headers retained",
tracks.len(),
headers.len()
);
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(25)))
.http_status_as_error(false)
.max_redirects(0)
.build()
.new_agent();
let master = resource(
&agent,
"hls_master",
server["streams"][0]["url"]
.as_str()
.ok_or_else(|| invalid("Missing stream URL"))?,
headers,
)?;
if !master.starts_with("#EXTM3U") || !master.contains("#EXT-X-STREAM-INF:") {
return Err(invalid("Expected an HLS master playlist"));
}
let subtitle = provider["subtitles"]
.as_array()
.and_then(|subtitles| {
subtitles
.iter()
.find(|subtitle| subtitle["default"] == true)
})
.ok_or_else(|| invalid("No default subtitle returned"))?;
let subtitle = resource(
&agent,
"subtitle",
subtitle["file"]
.as_str()
.ok_or_else(|| invalid("Missing subtitle URL"))?,
headers,
)?;
if !subtitle.starts_with("WEBVTT") || !subtitle.contains(" --> ") {
return Err(invalid("Expected timed VTT subtitles"));
}
println!(
"PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no media segments or tokens saved"
);
Ok(())
}
fn resource(
agent: &ureq::Agent,
label: &str,
url: &str,
headers: &Map<String, Value>,
) -> io::Result<String> {
let uri: ureq::http::Uri = url
.parse()
.map_err(|_| invalid("Invalid Sun resource URL"))?;
if uri.scheme_str() != Some("https")
|| !uri.host().is_some_and(|host| host.ends_with(".keeply.top"))
|| uri
.authority()
.is_some_and(|authority| authority.as_str().contains('@'))
|| uri.port_u16().is_some_and(|port| port != 443)
{
return Err(invalid(
"Smoke only permits the verified Sun HTTPS CDN; no redirects",
));
}
let mut request = agent.get(url);
for (name, value) in headers {
request = request.header(
name.as_str(),
value
.as_str()
.ok_or_else(|| invalid("Invalid server header"))?,
);
}
let mut response = request
.call()
.map_err(|_| io::Error::other("Sun resource HTTP transport failed"))?;
let status = response.status().as_u16();
let mime = response.body().mime_type().unwrap_or("missing");
println!("{label}: HTTP {status}; Content-Type {mime}");
let expected = if label == "subtitle" {
"text/vtt"
} else {
"application/vnd.apple.mpegurl"
};
if status != 200 || mime != expected {
return Err(invalid(
"Resource failed or returned unexpected content; stopping without retry",
));
}
response
.body_mut()
.with_config()
.limit(2 * 1024 * 1024)
.read_to_string()
.map_err(|_| invalid("Resource response unreadable, timed out, or exceeded 2 MiB"))
}
+1
View File
@@ -0,0 +1 @@
pub mod miruro;
+314
View File
@@ -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
);
}
}