fix: support current official Miruro mirrors
This commit is contained in:
+96
-28
@@ -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()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user