refactor: delegate Miruro playback resolution to Terebi

This commit is contained in:
2026-10-22 09:45:03 +02:00
parent f21a1fbe63
commit 3fda432dbd
15 changed files with 248 additions and 825 deletions
+33 -56
View File
@@ -1,75 +1,52 @@
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();
fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
let identity = Identity::from_key("miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1").unwrap();
assert_eq!(identity.key(), "miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1");
assert_eq!(
identity.watch_url().unwrap(),
"https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1"
);
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!(
Identity::new("catalog".into(), 2).unwrap().key(),
source.identity.key()
Identity::new(identity.media_id.clone(), 2).unwrap().key(),
identity.key()
);
for key in [
"miruro:catalog:01",
"miruro:catalog:0",
"miruro:catalog:2147483648",
"miruro:catalog/:1",
"miruro:catalog:+1",
"miruro::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"),
),
for invalid in [
Identity {
provider: "youtube".into(),
..identity.clone()
},
Identity {
media_id: "catalog/".into(),
..identity.clone()
},
Identity {
media_id: "a".repeat(513),
..identity.clone()
},
Identity {
episode: 0,
..identity.clone()
},
Identity {
episode: i32::MAX as u32 + 1,
..identity.clone()
},
] {
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"));
assert!(invalid.watch_url().is_err());
}
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]
+21 -128
View File
@@ -1,12 +1,11 @@
use super::*;
use ratatui::crossterm::event::KeyModifiers;
use std::{
collections::BTreeMap,
fs,
io::Write,
sync::atomic::{AtomicU64, Ordering},
};
use tubular::miruro::{Detail, SourceDocument};
use tubular::miruro::Detail;
struct Directory(PathBuf);
impl Directory {
@@ -53,16 +52,6 @@ fn series(media: &Media) -> miruro_browser::Series {
serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}),
] }).unwrap()
}
fn source(video: &Video, token: &str) -> SourceDocument {
SourceDocument {
version: 1,
url: format!("https://vault.keeply.top/master.m3u8?token={token}"),
headers: BTreeMap::new(),
identity: Identity::from_key(&video.id).unwrap(),
title: Some(video.title.clone()),
subtitles: Vec::new(),
}
}
fn catalog_results(browser: &mut Browser) {
browser.accept(
browser.generation,
@@ -77,19 +66,6 @@ fn enter_series(browser: &mut Browser) {
let result = series(media);
browser.accept(browser.generation, Ok(Response::Series(result)));
}
fn sources(directory: &Directory) -> Vec<Value> {
let path = directory.0.join("playback-sessions");
if !path.exists() {
return Vec::new();
}
fs::read_dir(path)
.unwrap()
.map(|entry| {
serde_json::from_slice(&fs::read(entry.unwrap().path().join("source.json")).unwrap())
.unwrap()
})
.collect()
}
#[test]
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
@@ -188,7 +164,7 @@ fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes()
}
#[test]
fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
fn latest_intent_and_navigation_reject_stale_catalog_and_series() {
let directory = Directory::new();
let mut browser = directory.browser(Some("old"));
let old = browser.generation;
@@ -214,30 +190,10 @@ fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
};
let data = series(media);
browser.accept(browser.generation, Ok(Response::Series(data)));
let video = browser.screen().selected_video().unwrap();
browser.open();
let stale = browser.generation;
browser.history(false);
browser.accept(
stale,
Ok(Response::Source(
video.clone(),
Box::new(source(&video, "stale")),
)),
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:first:3"
);
assert!(sources(&directory).is_empty());
browser.history(true);
browser.open();
let stale = browser.generation;
browser.refresh();
browser.accept(
stale,
Ok(Response::Source(
video.clone(),
Box::new(source(&video, "refreshed-away")),
)),
);
assert!(sources(&directory).is_empty());
}
#[test]
@@ -274,12 +230,9 @@ fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
assert!(!browser.state.is_watched(&episode.id));
browser.preview = None;
browser.collection(CollectionKind::Watching);
browser.open();
assert!(browser.pending.is_some());
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
assert!(browser.state.is_watched(&episode.id));
assert_eq!(browser.screen().len(), 0);
assert!(browser.pending.is_none());
browser.collection(CollectionKind::WatchLater);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
@@ -336,8 +289,6 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
}
browser.collection(CollectionKind::Saved);
assert_eq!(browser.screen().len(), 1);
browser.open();
assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id));
browser.remove(true);
assert_eq!(
browser.removal.as_ref().unwrap().1.as_slice(),
@@ -351,10 +302,8 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
.state
.contains(CollectionKind::WatchLater, &episode.id)
);
let (_, report) = browser
.state
.miruro_session(&episode, &source(&episode, "progress"))
.unwrap();
let report = browser.state.progress_file(&episode).unwrap();
crate::video_state::register_identity(&report, &episode.id).unwrap();
let mut output = fs::OpenOptions::new();
output.write(true).create_new(true);
#[cfg(unix)]
@@ -391,89 +340,33 @@ fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
}
#[test]
fn selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() {
fn launcher_failure_does_not_mark_series_or_offline_episodes_as_started() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
let first = browser.screen().selected_video().unwrap();
browser.open();
let stale = browser.generation;
browser.select(Action::VideoNext);
browser.accept(
stale,
Ok(Response::Source(
first.clone(),
Box::new(source(&first, "not-selected")),
)),
);
assert!(sources(&directory).is_empty());
let selected = browser.screen().selected_video().unwrap();
assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7);
browser.save(CollectionKind::Saved);
browser.collection(CollectionKind::Saved);
browser.open();
browser.pending = None;
browser.accept(
browser.generation,
Ok(Response::Source(
selected.clone(),
Box::new(source(&first, "wrong-identity")),
)),
);
assert!(sources(&directory).is_empty());
browser.open();
browser.pending = None;
let mut invalid = source(&selected, "private-token");
invalid
.headers
.insert("X-Invalid".into(), "private-header\nvalue".into());
browser.accept(
browser.generation,
Ok(Response::Source(selected.clone(), Box::new(invalid))),
);
assert!(sources(&directory).is_empty());
assert!(!browser.status.contains("private-token"));
assert!(!browser.status.contains("private-header"));
for token in ["fresh-one", "fresh-two"] {
browser.open();
assert!(
matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title)
);
browser.pending = None; // Deliver the real source response without a network request.
browser.accept(
browser.generation,
Ok(Response::Source(
selected.clone(),
Box::new(source(&selected, token)),
)),
);
}
let written = sources(&directory);
assert_eq!(written.len(), 2);
assert!(
written
.iter()
.all(|source| source["identity"]["episode"] == 7
&& source["identity"]["media_id"] == "first")
);
assert!(
written
.iter()
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-one"))
);
assert!(
written
.iter()
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-two"))
);
assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress.
assert!(browser.screen().error.is_some());
assert!(!browser.state.is_watched(&selected.id));
assert!(
!browser
.state
.contains(CollectionKind::Watching, &selected.id)
);
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
browser.save(CollectionKind::Saved);
browser.collection(CollectionKind::Saved);
browser.open();
assert!(browser.screen().error.is_some());
assert!(!browser.state.is_watched(&selected.id));
assert!(
!browser
.state
.contains(CollectionKind::Watching, &selected.id)
);
assert!(browser.state.contains(CollectionKind::Saved, &selected.id));
}
#[test]
+17 -56
View File
@@ -278,17 +278,6 @@ fn miruro_library_does_not_overwrite_external_creations_or_edits() {
assert_eq!(fs::read(&path).unwrap(), changed);
}
fn miruro_source(video: &Video) -> SourceDocument {
SourceDocument {
version: 1,
url: "https://cdn.example/video.m3u8?token=private-token".into(),
headers: [("Referer".into(), "https://player.example/".into())].into(),
identity: Identity::from_key(&video.id).unwrap(),
title: Some(video.title.clone()),
subtitles: Vec::new(),
}
}
fn miruro_record(
video: &Video,
event: &str,
@@ -1092,17 +1081,16 @@ fn watching_and_session_snapshots_reject_oversized_files() {
}
#[test]
fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() {
fn miruro_sessions_recover_collections_and_watching_without_source_files() {
let directory = TestDirectory::new();
let video = miruro_video(1);
let mut state = State::open(&directory.config()).unwrap();
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
state.save(kind, &video).unwrap();
}
let source = miruro_source(&video);
let (source_file, report) = state.miruro_session(&video, &source).unwrap();
let source_bytes = fs::read(&source_file).unwrap();
assert_eq!(source_bytes, serde_json::to_vec(&source).unwrap());
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
assert!(!report.with_file_name("source.json").exists());
assert!(!report.exists());
assert_eq!(
fs::read_to_string(report.with_file_name("video-id")).unwrap(),
@@ -1112,7 +1100,6 @@ fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_i
{
use std::os::unix::fs::PermissionsExt;
for path in [
&source_file,
&report.with_file_name("video-id"),
&report.with_file_name("video.toml"),
] {
@@ -1172,18 +1159,6 @@ fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_i
assert_eq!(stored.timestamp, video.timestamp);
assert_eq!(stored.publication_date, video.publication_date);
}
for path in [
directory.0.join("watch-later.toml"),
directory.0.join("saved-videos.toml"),
directory.0.join("currently-watching.toml"),
directory.0.join("watch-state.toml"),
report.with_file_name("video.toml"),
] {
let contents = fs::read_to_string(path).unwrap();
assert!(!contents.contains("private-token"));
assert!(!contents.contains("cdn.example"));
}
assert_eq!(fs::read(source_file).unwrap(), source_bytes);
assert!(report.exists());
}
@@ -1193,9 +1168,8 @@ fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
let (_, report) = state
.miruro_session(&video, &miruro_source(&video))
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(
&report,
miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(),
@@ -1232,9 +1206,8 @@ fn miruro_v2_rejects_invalid_wire_schemas_numbers_and_namespaces() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
let (_, report) = state
.miruro_session(&video, &miruro_source(&video))
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0));
for line in [
valid.replace("\"version\":2", "\"version\":1"),
@@ -1264,9 +1237,8 @@ fn miruro_partial_reports_and_bad_siblings_recover_after_restart() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
let (source, report) = state
.miruro_session(&video, &miruro_source(&video))
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
let completion = miruro_record(&video, "closed", true, 95.0, Some(100.0));
append(&report, &completion.as_bytes()[..completion.len() - 1]);
assert!(!state.poll_progress().unwrap());
@@ -1274,9 +1246,8 @@ fn miruro_partial_reports_and_bad_siblings_recover_after_restart() {
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
let other = miruro_video(2);
let (_, bad) = state
.miruro_session(&other, &miruro_source(&other))
.unwrap();
let bad = state.progress_file(&other).unwrap();
register_identity(&bad, &other.id).unwrap();
append(&bad, b"not json\n");
append(&report, b"\n");
assert!(state.poll_progress().is_err());
@@ -1285,7 +1256,6 @@ fn miruro_partial_reports_and_bad_siblings_recover_after_restart() {
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
drop(state);
assert!(source.exists());
assert!(report.exists());
let state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&video.id));
@@ -1339,23 +1309,15 @@ fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() {
}
#[test]
fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() {
fn miruro_sessions_recover_the_maximum_identity_marker() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut video = miruro_video(i32::MAX as u32);
video.id = Identity::new("a".repeat(512), i32::MAX as u32)
.unwrap()
.key();
let mut source = miruro_source(&video);
source.identity.episode -= 1;
assert!(state.miruro_session(&video, &source).is_err());
assert!(state.streams.is_empty());
source.identity = Identity::from_key(&video.id).unwrap();
source.title = Some("x".repeat(MAX_SOURCE_BYTES));
assert!(state.miruro_session(&video, &source).is_err());
assert!(state.streams.is_empty());
source.title = None;
let (_, report) = state.miruro_session(&video, &source).unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(
&report,
miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(),
@@ -1375,9 +1337,8 @@ fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
let (_, report) = state
.miruro_session(&video, &miruro_source(&video))
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
if missing_snapshot {
fs::remove_file(report.with_file_name("video.toml")).unwrap();
} else {
+13 -2
View File
@@ -534,8 +534,19 @@ mod subprocesses {
}
#[test]
fn player_launch_rejects_unsafe_video_ids_before_spawning() {
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
fn player_launch_rejects_invalid_provider_ids_before_registration_or_spawning() {
for id in [
"",
"--help",
"jNQXAC9IVRw&list=other",
"日本語",
"miruro:opaque_Show-1:0",
"miruro:opaque_Show-1:01",
"miruro:opaque_Show-1:2147483648",
"miruro:../other:1",
"miruro:opaque_Show-1:1&ep=2",
"miruro-series:opaque_Show-1",
] {
assert!(matches!(
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
Err(error) if error.kind() == io::ErrorKind::InvalidInput