fix: support current official Miruro mirrors

This commit is contained in:
2026-10-25 12:42:10 +01:00
parent ae87bb8b36
commit baa91d4091
4 changed files with 206 additions and 30 deletions
+96 -28
View File
@@ -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,34 +151,85 @@ impl Client {
}
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,
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;
};
return Err(io::Error::new(
kind,
format!("Miruro HTTP {status} ({mime}); no retry attempted"),
));
let index = (first + offset) % mirrors.len();
let base = mirrors[index];
attempts += 1;
let response = self
.agent
.get(format!("{base}{path}"))
.query_pairs(query.iter().copied())
.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 {
let kind = match status {
401 | 403 => io::ErrorKind::PermissionDenied,
404 => io::ErrorKind::NotFound,
_ => io::ErrorKind::Other,
};
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) {
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
continue;
}
match response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
{
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"
));
}
}
}
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"))
Err(io::Error::new(
last_error.kind(),
format!(
"Miruro request failed on {attempts}/{} mirrors; last error: {last_error}",
mirrors.len()
),
))
}
}