160 lines
5.3 KiB
Rust
160 lines
5.3 KiB
Rust
//! 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"))
|
|
}
|