feat: integrate Miruro playback and episode progress

This commit is contained in:
2026-10-12 22:23:15 +02:00
parent 0fe24ff302
commit 0e909001d0
9 changed files with 1676 additions and 92 deletions
+349 -4
View File
@@ -1,10 +1,15 @@
//! 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};
//! See `docs/miruro-api.md`. Source URLs are ephemeral. Playback projection is
//! limited to direct Sun soft-subtitle sources; no proxy, iframe or retry fallback.
use std::{
collections::{BTreeMap, HashSet},
io,
mem::size_of,
time::Duration,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
const BASE: &str = "https://barelystarted.miruro.tv";
@@ -20,6 +25,262 @@ pub struct Detail {
pub episodes: Vec<Value>,
}
/// Stable catalog identity, never a CDN address or playback-provider name.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Identity {
pub provider: String,
pub media_id: String,
pub episode: u32,
}
impl Identity {
pub fn new(media_id: String, episode: u32) -> io::Result<Self> {
let identity = Self {
provider: "miruro".into(),
media_id,
episode,
};
identity.validate()?;
Ok(identity)
}
pub fn validate(&self) -> io::Result<()> {
if self.provider != "miruro"
|| self.media_id.len() > 512
|| self.episode == 0
|| self.episode > i32::MAX as u32
{
return Err(invalid("Invalid Miruro episode identity"));
}
validate_segment(&self.media_id)
}
pub fn key(&self) -> String {
format!("miruro:{}:{}", self.media_id, self.episode)
}
pub fn from_key(key: &str) -> io::Result<Self> {
let (media_id, episode) = key
.strip_prefix("miruro:")
.and_then(|rest| rest.split_once(':'))
.ok_or_else(|| invalid("Invalid Miruro episode key"))?;
if episode.starts_with('0') || !episode.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("Noncanonical Miruro episode key"));
}
Self::new(
media_id.into(),
episode
.parse()
.map_err(|_| invalid("Invalid Miruro episode number"))?,
)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Subtitle {
pub url: String,
pub format: String,
pub language: String,
pub label: String,
pub default: bool,
pub headers: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct SourceDocument {
pub version: u32,
pub url: String,
pub headers: BTreeMap<String, String>,
pub identity: Identity,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub subtitles: Vec<Subtitle>,
}
impl SourceDocument {
pub fn validate(&self) -> io::Result<()> {
if self.version != 1
|| self.subtitles.len() > 32
|| self.subtitles.iter().filter(|track| track.default).count() > 1
{
return Err(invalid(
"Invalid source document version or subtitle limits",
));
}
if self
.title
.as_ref()
.is_some_and(|title| title.chars().any(char::is_control))
{
return Err(invalid("Invalid source title"));
}
self.identity.validate()?;
validate_url(&self.url)?;
validate_headers(&self.headers)?;
for track in &self.subtitles {
if track.format != "vtt" {
return Err(invalid("Only external VTT subtitles are supported"));
}
if track.label.chars().any(char::is_control)
|| track.language.chars().any(char::is_control)
{
return Err(invalid("Invalid subtitle label or language"));
}
validate_url(&track.url)?;
validate_headers(&track.headers)?;
}
Ok(())
}
/// No provider/language fallback: take the first direct ssub/Sun server.
pub fn from_sources(sources: &Value, identity: Identity, title: &str) -> io::Result<Self> {
identity.validate()?;
if sources["episodeNumber"].as_u64() != Some(u64::from(identity.episode)) {
return Err(invalid(
"Resolved source episode does not match its identity",
));
}
let config = &sources["config"]["sun"];
if config["player"] != "native"
|| config["proxy"] != false
|| config["hls"]["segments"]["enabled"] == true
|| config["hls"]["query"]["enabled"] == true
{
return Err(invalid("Sun requires unsupported proxy or HLS rewriting"));
}
let provider = sources["tracks"]
.as_array()
.and_then(|tracks| 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 source is unavailable"))?;
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("Direct Sun stream is unavailable"))?;
let stream = &server["streams"][0];
if !matches!(
stream["format"].as_str(),
Some("hls" | "m3u8" | "application/vnd.apple.mpegurl" | "application/x-mpegurl")
) || !stream["embed"].is_null()
{
return Err(invalid("Selected Sun stream is not direct HLS"));
}
let headers = source_headers(&server["headers"])?;
let mut subtitles = Vec::new();
for subtitle in provider["subtitles"]
.as_array()
.ok_or_else(|| invalid("Missing Sun subtitle array"))?
{
if subtitle["format"] != "vtt" {
continue;
}
if subtitles.len() == 32 {
return Err(invalid("Source subtitle limit exceeded"));
}
subtitles.push(Subtitle {
url: source_string(subtitle, "file")?,
format: "vtt".into(),
language: source_string(subtitle, "language")?,
label: source_string(subtitle, "label")?,
default: subtitle["default"]
.as_bool()
.ok_or_else(|| invalid("Invalid subtitle default flag"))?,
// Verified for direct Sun only. These are explicit resource headers,
// not an inheritance rule for other providers.
headers: headers.clone(),
});
}
let document = Self {
version: 1,
url: source_string(stream, "url")?,
headers,
identity,
title: Some(title.into()),
subtitles,
};
document.validate()?;
Ok(document)
}
}
fn source_string(value: &Value, field: &str) -> io::Result<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| invalid("Missing or invalid resolved source field"))
}
fn source_headers(value: &Value) -> io::Result<BTreeMap<String, String>> {
if value.is_null() {
return Ok(BTreeMap::new());
}
let headers = value
.as_object()
.ok_or_else(|| invalid("Invalid source header map"))?;
if headers.len() > 64 {
return Err(invalid("Source header limit exceeded"));
}
headers
.iter()
.map(|(key, value)| {
Ok((
key.clone(),
value
.as_str()
.ok_or_else(|| invalid("Invalid source header value"))?
.into(),
))
})
.collect()
}
fn validate_url(url: &str) -> io::Result<()> {
let uri: ureq::http::Uri = url.parse().map_err(|_| invalid("Invalid source URL"))?;
if !matches!(uri.scheme_str(), Some("http" | "https"))
|| uri.host().is_none_or(str::is_empty)
|| uri
.authority()
.is_some_and(|authority| authority.as_str().contains('@'))
|| url.chars().any(char::is_control)
{
return Err(invalid(
"Source URLs must be absolute HTTP(S) without credentials",
));
}
Ok(())
}
fn validate_headers(headers: &BTreeMap<String, String>) -> io::Result<()> {
if headers.len() > 64 {
return Err(invalid("Source header limit exceeded"));
}
let mut names = HashSet::with_capacity(headers.len());
for (name, value) in headers {
let name = ureq::http::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| invalid("Invalid source header name"))?;
if !names.insert(name)
|| value.chars().any(char::is_control)
|| ureq::http::HeaderValue::from_str(value).is_err()
{
return Err(invalid("Invalid or duplicate source header"));
}
}
Ok(())
}
impl Default for Client {
fn default() -> Self {
Self {
@@ -98,6 +359,16 @@ impl Client {
Ok(value)
}
pub fn source_document(
&self,
id: &str,
episode: u32,
title: &str,
) -> io::Result<SourceDocument> {
let identity = Identity::new(id.into(), episode)?;
SourceDocument::from_sources(&self.sources(id, episode)?, identity, title)
}
fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result<String> {
let mut response = self
.agent
@@ -277,6 +548,80 @@ fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io
mod tests {
use super::*;
#[test]
fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() {
let sources = serde_json::json!({
"episodeNumber": 1,
"config": { "sun": { "player": "native", "proxy": false } },
"tracks": [{ "track": "ssub", "providers": [{
"provider": "sun",
"servers": [{
"headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" },
"streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }]
}],
"subtitles": [{
"file": "https://vault.keeply.top/english.jpeg", "format": "vtt",
"language": "en", "label": "English", "default": true
}]
}] }]
});
let identity = Identity::new("catalog".into(), 1).unwrap();
let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap();
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!(
Identity::new("catalog".into(), 2).unwrap().key(),
source.identity.key()
);
for key in [
"miruro:catalog:01",
"miruro:catalog:0",
"miruro:catalog:2147483648",
"miruro:catalog/:1",
] {
assert!(Identity::from_key(key).is_err());
}
for (pointer, value) in [
("/episodeNumber", serde_json::json!(49)),
("/config/sun/proxy", serde_json::json!({"segments": true})),
(
"/tracks/0/providers/0/servers/0/streams/0/url",
serde_json::json!("file:///secret"),
),
(
"/tracks/0/providers/0/servers/0/streams/0/embed",
serde_json::json!({"url": "https://embed.example"}),
),
(
"/tracks/0/providers/0/servers/0/headers/origin",
serde_json::json!("secret"),
),
(
"/tracks/0/providers/0/servers/0/headers/Referer",
serde_json::json!("secret\r\nX-Injected: true"),
),
] {
let mut invalid_sources = sources.clone();
if let Some(field) = invalid_sources.pointer_mut(pointer) {
*field = value;
} else {
invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] =
value;
}
let error =
SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1")
.unwrap_err();
assert!(!error.to_string().contains("secret"));
}
let mut invalid_source = source;
invalid_source
.subtitles
.push(invalid_source.subtitles[0].clone());
assert!(invalid_source.validate().is_err());
invalid_source.subtitles[1].default = false;
invalid_source.subtitles[1].format = "srt".into();
assert!(invalid_source.validate().is_err());
}
#[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]}]}"#;