fix: support current official Miruro mirrors
This commit is contained in:
@@ -81,6 +81,8 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it
|
||||
|
||||
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
|
||||
|
||||
Catalog and episode-metadata requests use the official Miruro 2.0 mirrors (`miruro.cx`, `.pw`, `.ws`, `.to`, `.tv`, `.bz`), not their legacy `www.` versions. Each session starts with `.cx`, remembers the last working mirror, and tries each mirror at most once when a host is unreachable, blocked, redirected, unavailable, or returns an unexpected Content-Type/unreadable body. Requests have a shared 25-second budget with at most five seconds per mirror; rate limits and other invalid/unauthorized requests stop immediately. No per-request randomization, directory scraping, cookies, or Cloudflare bypass is used. If every mirror is blocked, browsing still fails with the number of mirrors attempted and the last failure. Playback retains Terebi's existing `barelystarted.miruro.tv` URL contract; catalog failover does not add playback-mirror support.
|
||||
|
||||
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee a playable source; Terebi resolves playback.
|
||||
|
||||
| Key | Miruro action |
|
||||
|
||||
+6
-2
@@ -2,13 +2,15 @@
|
||||
|
||||
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**; ownership split updated **2026-10-22**. These are reverse-engineering notes, not an official API specification. Tubular now owns catalog/detail retrieval and stable identity; Terebi resolves episode URLs. Source/CDN investigations below are historical evidence, not the current launch path.
|
||||
|
||||
**Mirror update (2026-10-25):** the [official directory](https://www.miruro.com/#domains) links Miruro 2.0 at `https://miruro.cx`, `https://miruro.pw`, `https://miruro.ws`, `https://miruro.to`, `https://miruro.tv`, and `https://miruro.bz`; `www.` hosts are labeled Legacy. Tubular now uses these six explicit origins for catalog/detail requests. Local native `ureq` and curl probes received **403 `text/html`** from all six autocomplete endpoints; curl returned Cloudflare block pages. Thus live schema/ID compatibility and successful browsing on these mirrors remain unverified. The original `cargo run --locked --example miruro_probe` now exercises all six and reports `Miruro request failed on 6/6 mirrors`, rather than stopping at the obsolete browsing origin. Endpoint/schema evidence below remains the historical snapshot, not a claim of current mirror access.
|
||||
|
||||
Evidence labels:
|
||||
|
||||
- **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 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.
|
||||
**Transport distinction (historical):** 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 was proven for that client and sample, not every transport, network, provider or future request. Inspect HTTP status and Content-Type before decoding; never treat protection pages as empty results. The current client can try the next explicitly listed official mirror, but cannot restore access when every mirror blocks it.
|
||||
|
||||
## Endpoint map
|
||||
|
||||
@@ -395,7 +397,7 @@ Current library interface:
|
||||
|
||||
| Call | Result / contract |
|
||||
| --- | --- |
|
||||
| `Client::default()` | Reusable blocking HTTP agent; 25-second global request timeout; no automatic redirects |
|
||||
| `Client::default()` | Reusable blocking HTTP agent; six official 2.0 mirrors; sticky successful origin; 25-second total request budget, at most five seconds per attempt; 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 |
|
||||
| `Identity::new(id, episode)`, `Identity::from_key(key)` | Validated catalog namespace/episode identity; `key()` is `miruro:<opaque ID>:<episode>` |
|
||||
@@ -403,6 +405,8 @@ Current library interface:
|
||||
|
||||
Raw catalog methods preserve JSON metadata without a second deployment-specific schema. IDs/slugs are validated path segments; HTTP/HTML/Content-Type failures remain errors, not empty results. Successful bodies are limited to 2 MiB. Source selection, resource headers and subtitles belong to Terebi.
|
||||
|
||||
The client starts at `miruro.cx` and remembers the last origin whose response passed HTTP, Content-Type and bounded-body checks. On transport/body failures, unexpected Content-Type, HTTP 301–308, 403, 404, 408 or 5xx, it tries the remaining origins once in directory order. Other HTTP errors, including 400, 401 and 429, stop immediately; rotating mirrors must not circumvent rate limits. Exhaustion reports the attempted/total mirror count and last origin/error. JSON/route decoding and detail-ID validation remain mandatory and do not trigger additional fallback. The directory is not scraped at runtime, and requests are not randomized. Playback URLs deliberately retain `barelystarted.miruro.tv`: current Terebi admits and resolves only that hostname, so playback failover requires a separate player change.
|
||||
|
||||
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.
|
||||
|
||||
The retained `cargo run --locked --example miruro_probe` smoke now covers search → detail/episodes only, not sources or playback.
|
||||
|
||||
+81
-13
@@ -2,16 +2,32 @@
|
||||
//!
|
||||
//! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers,
|
||||
//! subtitles and playback metadata.
|
||||
use std::{io, mem::size_of, time::Duration};
|
||||
use std::{
|
||||
io,
|
||||
mem::size_of,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const BASE: &str = "https://barelystarted.miruro.tv";
|
||||
// Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy.
|
||||
const MIRRORS: &[&str] = &[
|
||||
"https://miruro.cx",
|
||||
"https://miruro.pw",
|
||||
"https://miruro.ws",
|
||||
"https://miruro.to",
|
||||
"https://miruro.tv",
|
||||
"https://miruro.bz",
|
||||
];
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(25);
|
||||
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
preferred: AtomicUsize,
|
||||
}
|
||||
|
||||
/// Catalog metadata and available episode metadata, not a playback guarantee.
|
||||
@@ -85,11 +101,12 @@ impl Default for Client {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(25)))
|
||||
.timeout_global(Some(REQUEST_TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.new_agent(),
|
||||
preferred: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,12 +151,42 @@ impl Client {
|
||||
}
|
||||
|
||||
fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result<String> {
|
||||
let mut response = self
|
||||
self.get_from(MIRRORS, path, query, types)
|
||||
}
|
||||
|
||||
fn get_from(
|
||||
&self,
|
||||
mirrors: &[&str],
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
types: &[&str],
|
||||
) -> io::Result<String> {
|
||||
let started = Instant::now();
|
||||
let first = self.preferred.load(Ordering::Relaxed) % mirrors.len();
|
||||
let mut last_error = io::Error::new(io::ErrorKind::TimedOut, "Miruro request timed out");
|
||||
let mut attempts = 0;
|
||||
for offset in 0..mirrors.len() {
|
||||
let Some(remaining) = REQUEST_TIMEOUT.checked_sub(started.elapsed()) else {
|
||||
break;
|
||||
};
|
||||
let index = (first + offset) % mirrors.len();
|
||||
let base = mirrors[index];
|
||||
attempts += 1;
|
||||
let response = self
|
||||
.agent
|
||||
.get(format!("{BASE}{path}"))
|
||||
.get(format!("{base}{path}"))
|
||||
.query_pairs(query.iter().copied())
|
||||
.call()
|
||||
.map_err(|_| io::Error::other("Miruro HTTP transport failed"))?;
|
||||
.config()
|
||||
.timeout_global(Some(remaining.min(Duration::from_secs(5))))
|
||||
.build()
|
||||
.call();
|
||||
let mut response = match response {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
last_error = io::Error::other(format!("{base}: HTTP transport failed"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let mime = response.body().mime_type().unwrap_or("missing");
|
||||
if status != 200 {
|
||||
@@ -148,20 +195,41 @@ impl Client {
|
||||
404 => io::ErrorKind::NotFound,
|
||||
_ => io::ErrorKind::Other,
|
||||
};
|
||||
return Err(io::Error::new(
|
||||
kind,
|
||||
format!("Miruro HTTP {status} ({mime}); no retry attempted"),
|
||||
));
|
||||
last_error = io::Error::new(kind, format!("{base}: HTTP {status} ({mime})"));
|
||||
// Do not rotate hosts to work around rate limits or invalid requests.
|
||||
if !matches!(status, 301..=308 | 403 | 404 | 408 | 500..=599) {
|
||||
return Err(last_error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !types.contains(&mime) {
|
||||
return Err(invalid(format!("Unexpected Miruro Content-Type: {mime}")));
|
||||
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
||||
continue;
|
||||
}
|
||||
response
|
||||
match response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(BODY_LIMIT)
|
||||
.read_to_string()
|
||||
.map_err(|_| invalid("Miruro response was unreadable, timed out, or exceeded 2 MiB"))
|
||||
{
|
||||
Ok(body) => {
|
||||
self.preferred.store(index, Ordering::Relaxed);
|
||||
return Ok(body);
|
||||
}
|
||||
Err(_) => {
|
||||
last_error = invalid(format!(
|
||||
"{base}: response unreadable, timed out, or exceeded 2 MiB"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
last_error.kind(),
|
||||
format!(
|
||||
"Miruro request failed on {attempts}/{} mirrors; last error: {last_error}",
|
||||
mirrors.len()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,3 +83,105 @@ fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_failover_remembers_success_and_stops_on_rate_limits_and_bad_requests() {
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for (path, status, mime, body) in [
|
||||
("/one/catalog?q=anime", 403, "text/html", "blocked"),
|
||||
("/two/catalog?q=anime", 200, "text/html", "protection page"),
|
||||
(
|
||||
"/three/catalog?q=anime",
|
||||
200,
|
||||
"application/json",
|
||||
r#"{"data":[{"id":"series"}]}"#,
|
||||
),
|
||||
("/three/detail", 503, "text/html", "unavailable"),
|
||||
(
|
||||
"/one/detail",
|
||||
200,
|
||||
"text/sveltekit-data",
|
||||
"episode metadata",
|
||||
),
|
||||
("/one/limited", 429, "text/html", "rate limited"),
|
||||
("/one/bad-query", 400, "application/json", "{}"),
|
||||
("/one/exhausted", 403, "text/html", "blocked"),
|
||||
("/two/exhausted", 404, "text/html", "gone"),
|
||||
("/three/exhausted", 502, "text/html", "unavailable"),
|
||||
("/one/recovered", 200, "application/json", r#"{"data":[]}"#),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(line.trim_end(), format!("GET {path} HTTP/1.1"));
|
||||
loop {
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status} Response\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
).unwrap();
|
||||
}
|
||||
});
|
||||
let bases = [
|
||||
format!("{base}/one"),
|
||||
format!("{base}/two"),
|
||||
format!("{base}/three"),
|
||||
];
|
||||
let mirrors: Vec<_> = bases.iter().map(String::as_str).collect();
|
||||
let client = Client::default();
|
||||
let body = client
|
||||
.get_from(
|
||||
&mirrors,
|
||||
"/catalog",
|
||||
&[("q", "anime")],
|
||||
&["application/json"],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&body).unwrap()["data"][0]["id"],
|
||||
"series"
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.get_from(&mirrors, "/detail", &[], &["text/sveltekit-data"])
|
||||
.unwrap(),
|
||||
"episode metadata"
|
||||
);
|
||||
for (path, status) in [("/limited", 429), ("/bad-query", 400)] {
|
||||
let error = client
|
||||
.get_from(&mirrors, path, &[], &["application/json"])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(&format!("HTTP {status}")));
|
||||
}
|
||||
let error = client
|
||||
.get_from(&mirrors, "/exhausted", &[], &["application/json"])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("failed on 3/3 mirrors"));
|
||||
assert!(error.to_string().contains("HTTP 502"));
|
||||
let body = client
|
||||
.get_from(&mirrors, "/recovered", &[], &["application/json"])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&body).unwrap()["data"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user