refactor: delegate Miruro playback resolution to Terebi
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+13
-249
@@ -1,13 +1,8 @@
|
||||
//! Blocking access to the deployed Miruro catalog and source API.
|
||||
//! Blocking access to the deployed Miruro catalog and stable episode identities.
|
||||
//!
|
||||
//! 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,
|
||||
};
|
||||
//! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers,
|
||||
//! subtitles and playback metadata.
|
||||
use std::{io, mem::size_of, time::Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -60,6 +55,15 @@ impl Identity {
|
||||
format!("miruro:{}:{}", self.media_id, self.episode)
|
||||
}
|
||||
|
||||
/// The slug is non-identifying; metadata readers must resolve the canonical route.
|
||||
pub fn watch_url(&self) -> io::Result<String> {
|
||||
self.validate()?;
|
||||
Ok(format!(
|
||||
"{BASE}/watch/{}/episode?ep={}",
|
||||
self.media_id, self.episode
|
||||
))
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> io::Result<Self> {
|
||||
let (media_id, episode) = key
|
||||
.strip_prefix("miruro:")
|
||||
@@ -77,210 +81,6 @@ impl Identity {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -333,42 +133,6 @@ impl Client {
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// Fresh source data, preserving tracks, headers, subtitles and runtime config.
|
||||
/// `episode` is the returned episode_number, not absolute_number or array index.
|
||||
pub fn sources(&self, id: &str, episode: u32) -> io::Result<Value> {
|
||||
validate_segment(id)?;
|
||||
if episode == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Episode must be positive",
|
||||
));
|
||||
}
|
||||
let body = self.get(
|
||||
"/api/sources",
|
||||
&[("id", id), ("n", &episode.to_string())],
|
||||
&["application/json"],
|
||||
)?;
|
||||
let value: Value = serde_json::from_str(&body).map_err(invalid)?;
|
||||
if value.get("episodeNumber").and_then(Value::as_u64) != Some(u64::from(episode))
|
||||
|| !value.get("tracks").is_some_and(Value::is_array)
|
||||
{
|
||||
return Err(invalid(
|
||||
"Miruro sources did not match the requested episode",
|
||||
));
|
||||
}
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
sync::mpsc::{self, Receiver, SyncSender},
|
||||
thread,
|
||||
};
|
||||
use tubular::miruro::{Client, Detail, Identity, SourceDocument};
|
||||
use tubular::miruro::{Client, Detail, Identity};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -53,12 +53,10 @@ pub struct Series {
|
||||
pub enum Request {
|
||||
Search(String),
|
||||
Episodes { media_id: String, slug: String },
|
||||
Source(Video),
|
||||
}
|
||||
pub enum Response {
|
||||
Catalog(Vec<Media>),
|
||||
Series(Series),
|
||||
Source(Video, Box<SourceDocument>),
|
||||
}
|
||||
|
||||
// One running request, no queued network requests. The UI retains only its newest intent.
|
||||
@@ -84,11 +82,6 @@ impl Worker {
|
||||
.detail(&media_id, &slug)
|
||||
.and_then(|detail| series(&media_id, detail))
|
||||
.map(Response::Series),
|
||||
Request::Source(video) => Identity::from_key(&video.id)
|
||||
.and_then(|id| {
|
||||
client.source_document(&id.media_id, id.episode, &video.title)
|
||||
})
|
||||
.map(|source| Response::Source(video, Box::new(source))),
|
||||
};
|
||||
if results.send((generation, result)).is_err() {
|
||||
break;
|
||||
|
||||
+16
-47
@@ -265,7 +265,7 @@ impl Screen {
|
||||
self.rows = videos.iter().map(|video| {
|
||||
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
||||
let title = safe(&video.title);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter resolves a fresh direct Sun source; no catalog slug is reconstructed from this title.", identity.episode, identity.media_id);
|
||||
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter sends the episode URL to Terebi, which resolves sources and playback metadata.", identity.episode, identity.media_id);
|
||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||
}).collect();
|
||||
}
|
||||
@@ -317,7 +317,6 @@ struct Browser {
|
||||
enum Intent {
|
||||
Catalog,
|
||||
Series(String),
|
||||
Source(String),
|
||||
}
|
||||
|
||||
enum LibraryModal {
|
||||
@@ -526,12 +525,20 @@ impl Browser {
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
self.load_series();
|
||||
} else if let Some(video) = self.screen().selected_video() {
|
||||
if Identity::from_key(&video.id).is_err() {
|
||||
return;
|
||||
self.invalidate();
|
||||
self.screen_mut().error = None;
|
||||
let result = self.state.progress_file(&video).and_then(|progress_file| {
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
PlayerLaunch::start(&video.id, &self.options.player, &progress_file)
|
||||
});
|
||||
match result {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.status = format!("Opening {} in Terebi…", safe(&video.title));
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
let id = video.id.clone();
|
||||
self.request(Request::Source(video), Intent::Source(id));
|
||||
self.status = "Resolving fresh direct Sun source…".into();
|
||||
}
|
||||
}
|
||||
fn fail(&mut self, error: impl std::fmt::Display) {
|
||||
@@ -595,34 +602,6 @@ impl Browser {
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
(Intent::Source(id), Ok(Response::Source(video, source))) => {
|
||||
if video.id != id || self.screen().selected_key() != Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
if source.identity.key() != id {
|
||||
self.fail(io::Error::other(
|
||||
"Miruro source identity did not match the selected episode",
|
||||
));
|
||||
return;
|
||||
}
|
||||
match self.state.miruro_session(&video, &source).and_then(
|
||||
|(source_file, progress_file)| {
|
||||
// Dropping the launcher cancels only its CLI child, not the native player.
|
||||
self.player = None;
|
||||
PlayerLaunch::start_source(
|
||||
&source_file,
|
||||
&self.options.player,
|
||||
&progress_file,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Ok(player) => {
|
||||
self.player = Some(player);
|
||||
self.status = format!("Opening {} in Terebi…", safe(&video.title));
|
||||
}
|
||||
Err(error) => self.fail(error),
|
||||
}
|
||||
}
|
||||
(_, Err(error)) => self.fail(error),
|
||||
_ => self.fail(io::Error::other(
|
||||
"Miruro response did not match the current request",
|
||||
@@ -910,12 +889,6 @@ impl Browser {
|
||||
screen.clamp();
|
||||
screen.rebuild_display();
|
||||
}
|
||||
// Progress may remove the originating currently-watching row during resolution.
|
||||
if let Some(Intent::Source(id)) = &self.intent
|
||||
&& self.screen().selected_key() != Some(id.as_str())
|
||||
{
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
fn save(&mut self, kind: CollectionKind) {
|
||||
let Some(video) = self.screen().selected_video() else {
|
||||
@@ -1064,10 +1037,6 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn select(&mut self, action: Action) {
|
||||
if matches!(self.intent, Some(Intent::Source(_))) {
|
||||
self.invalidate();
|
||||
self.status.clear();
|
||||
}
|
||||
let screen = self.screen_mut();
|
||||
let last = screen.len().saturating_sub(1);
|
||||
let selected = screen.selection.selected().unwrap_or(0);
|
||||
@@ -1292,7 +1261,7 @@ impl Browser {
|
||||
) {
|
||||
"Open series"
|
||||
} else {
|
||||
"Play episode (fresh source)"
|
||||
"Play episode in Terebi"
|
||||
},
|
||||
),
|
||||
(Action::VideoNext, "Next title/episode"),
|
||||
@@ -1804,7 +1773,7 @@ impl Browser {
|
||||
let mut lines = vec![
|
||||
Line::from("Miruro catalog → series → actual episodes").bold(),
|
||||
Line::from(
|
||||
"No provider/track fallback; fresh direct ssub/Sun HLS + VTT on every play.",
|
||||
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
|
||||
),
|
||||
Line::from("✓ is acknowledged playback progress, never CLI completion."),
|
||||
Line::default(),
|
||||
|
||||
+1
-22
@@ -10,13 +10,12 @@ use serde::Deserialize;
|
||||
use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value};
|
||||
|
||||
use crate::youtube::{Video, valid_video_id, validate_video};
|
||||
use tubular::miruro::{Identity, SourceDocument};
|
||||
use tubular::miruro::Identity;
|
||||
|
||||
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_LINE: usize = 16 * 1024;
|
||||
const STREAM_BYTES: usize = 16 * 1024;
|
||||
const MAX_STREAMS: usize = 4096;
|
||||
const MAX_SOURCE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_SHOWS: usize = 4096;
|
||||
const MAX_SHOW_TAGS: usize = 256;
|
||||
const MAX_TAG_BYTES: usize = 256;
|
||||
@@ -376,26 +375,6 @@ impl State {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn miruro_session(
|
||||
&mut self,
|
||||
video: &Video,
|
||||
source: &SourceDocument,
|
||||
) -> io::Result<(PathBuf, PathBuf)> {
|
||||
source.validate()?;
|
||||
if Identity::from_key(&video.id)? != source.identity {
|
||||
return Err(invalid("Source identity must match the playback episode"));
|
||||
}
|
||||
let contents = serde_json::to_vec(source).map_err(|e| invalid(e.to_string()))?;
|
||||
if contents.len() > MAX_SOURCE_BYTES {
|
||||
return Err(invalid("Playback source exceeds the 1 MiB limit"));
|
||||
}
|
||||
let progress_file = self.progress_file(video)?;
|
||||
let source_file = progress_file.with_file_name("source.json");
|
||||
atomic_write(&source_file, None, &contents)?;
|
||||
register_identity(&progress_file, &video.id)?;
|
||||
Ok((source_file, progress_file))
|
||||
}
|
||||
|
||||
pub fn poll_progress(&mut self) -> io::Result<bool> {
|
||||
let mut error = self.discover().err();
|
||||
let count = self.streams.len().min(16);
|
||||
|
||||
+8
-58
@@ -915,67 +915,17 @@ pub struct PlayerLaunch {
|
||||
|
||||
impl PlayerLaunch {
|
||||
pub fn start(id: &str, player: &OsStr, progress_file: &Path) -> io::Result<Self> {
|
||||
if !valid_video_id(id) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Invalid YouTube video ID",
|
||||
));
|
||||
}
|
||||
let url = if valid_video_id(id) {
|
||||
format!("https://www.youtube.com/watch?v={id}")
|
||||
} else {
|
||||
tubular::miruro::Identity::from_key(id)
|
||||
.and_then(|identity| identity.watch_url())
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?
|
||||
};
|
||||
crate::video_state::register_identity(progress_file, id)?;
|
||||
let mut command = isolated_command(player);
|
||||
command
|
||||
.args([
|
||||
"--new-instance",
|
||||
"--url",
|
||||
&format!("https://www.youtube.com/watch?v={id}"),
|
||||
])
|
||||
.arg("--progress-file")
|
||||
.arg(progress_file);
|
||||
Self::launch(command)
|
||||
}
|
||||
|
||||
/// Session files are owned and retained by video_state, not by this launcher.
|
||||
pub fn start_source(
|
||||
source_file: &Path,
|
||||
player: &OsStr,
|
||||
progress_file: &Path,
|
||||
) -> io::Result<Self> {
|
||||
if !std::fs::symlink_metadata(source_file)?.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Source document must be a regular file",
|
||||
));
|
||||
}
|
||||
let source_file = std::fs::canonicalize(source_file)?;
|
||||
let parent = std::fs::canonicalize(progress_file.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "Missing progress directory")
|
||||
})?)?;
|
||||
if source_file.parent() != Some(parent.as_path())
|
||||
|| progress_file
|
||||
.file_name()
|
||||
.is_none_or(|name| name != "progress.jsonl")
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Source and progress must share their private session directory",
|
||||
));
|
||||
}
|
||||
let progress_file = parent.join("progress.jsonl");
|
||||
match std::fs::symlink_metadata(&progress_file) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
Ok(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"Progress destination must not exist",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut command = isolated_command(player);
|
||||
command
|
||||
.arg("--new-instance")
|
||||
.arg("--source-file")
|
||||
.arg(source_file)
|
||||
.args(["--new-instance", "--url", &url])
|
||||
.arg("--progress-file")
|
||||
.arg(progress_file);
|
||||
Self::launch(command)
|
||||
|
||||
Reference in New Issue
Block a user