From 0fe24ff30227afc5cfa12f3c20f0ea4d8c4f5b51 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 18:26:14 +0200 Subject: [PATCH] feat: add verified native Miruro API wrapper --- Cargo.lock | 182 +++++++++++++++++++++++ Cargo.toml | 1 + README.md | 10 +- docs/miruro-api.md | 54 ++++++- examples/miruro_probe.rs | 159 ++++++++++++++++++++ src/lib.rs | 1 + src/miruro.rs | 314 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 examples/miruro_probe.rs create mode 100644 src/lib.rs create mode 100644 src/miruro.rs diff --git a/Cargo.lock b/Cargo.lock index 07966ba..7aec098 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 64d95ed..e829de6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index ca8c25f..00f2279 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/miruro-api.md b/docs/miruro-api.md index 8d06444..5a7fe56 100644 --- a/docs/miruro-api.md +++ b/docs/miruro-api.md @@ -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>`; trimmed, nonempty autocomplete query; no cursor pagination | +| `client.detail(id, slug)` | `io::Result`; decoded `media` JSON and `episodes: Vec`; validates returned media ID | +| `client.sources(id, episode)` | `io::Result`; 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? diff --git a/examples/miruro_probe.rs b/examples/miruro_probe.rs new file mode 100644 index 0000000..844b766 --- /dev/null +++ b/examples/miruro_probe.rs @@ -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, +) -> io::Result { + 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")) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7ed3b39 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod miruro; diff --git a/src/miruro.rs b/src/miruro.rs new file mode 100644 index 0000000..fb88e71 --- /dev/null +++ b/src/miruro.rs @@ -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, +} + +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) + } + + 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 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 + ); + } +}