Files
tubular/tests/unit/video_state.rs
T

1577 lines
62 KiB
Rust

use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let path = std::env::temp_dir().join(format!(
"tubular-watch-test-{}-{}",
std::process::id(),
NEXT_NAME.fetch_add(1, Ordering::Relaxed)
));
match create_private_directory(&path) {
Ok(()) => return Self(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => panic!("{e}"),
}
}
}
fn config(&self) -> PathBuf {
self.0.join("config.toml")
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn video() -> Video {
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: Some("jawed".into()),
channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()),
uploader_id: Some("@jawed".into()),
timestamp: Some(1_114_300_000),
duration: Some(19.0),
publication_date: Some("2005-04-23".into()),
availability: None,
}
}
fn append(path: &Path, bytes: &[u8]) {
let mut options = OpenOptions::new();
options.write(true).append(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path).unwrap().write_all(bytes).unwrap();
}
fn record(id: &str, event: &str, eligible: bool, position: f64) -> String {
format!(
"{{\"version\":1,\"event\":\"{event}\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":{eligible}}}\n"
)
}
fn miruro_video(episode: u32) -> Video {
Video {
id: Identity::miruro("opaque_Show-1".into(), episode)
.unwrap()
.key(),
title: format!("Example show · Episode {episode}"),
channel: Some("Miruro".into()),
channel_id: None,
uploader_id: None,
timestamp: Some(1_114_300_000),
duration: None,
publication_date: Some("2005-04-23".into()),
availability: None,
}
}
fn show() -> crate::miruro_browser::Media {
crate::miruro_browser::Media {
id: "opaque_Show-1".into(),
title: "Example show".into(),
metadata: serde_json::json!({
"id": "opaque_Show-1",
"title": {"english": "Example show", "native": null},
"episode_counts": {"sub": 12, "dub": null},
"genres": ["Adventure", "Fantasy"],
}),
}
}
#[test]
fn miruro_library_round_trips_overlapping_tags_and_keeps_episode_state_independent() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
assert!(state.shows().is_empty());
assert!(!path.exists());
let episode = miruro_video(1);
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &episode).unwrap();
}
state
.set_watch_status(&episode, WatchStatus::Watched)
.unwrap();
let episode_files = ["saved-videos.toml", "watch-later.toml", "watch-state.toml"]
.map(|name| (name, fs::read(directory.0.join(name)).unwrap()));
let mut first = show();
let mut second = show();
second.id = "another_Show".into();
second.title = "Another show".into();
second.metadata = serde_json::Value::Null;
state
.save_show(
&first,
&[" Alpha ".into(), "Alpha".into(), "Beta".into()].into(),
)
.unwrap();
state
.save_show(&second, &["Beta".into(), "Gamma".into()].into())
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 2);
assert_eq!(state.shows()[0].media.metadata, first.metadata);
assert_eq!(
state.shows()[0].tags,
["Alpha".into(), "Beta".into()].into()
);
assert_eq!(
state.shows()[1].tags,
["Beta".into(), "Gamma".into()].into()
);
assert!(state.shows()[1].media.metadata.is_null());
first.title = "Updated show title".into();
first.metadata["title"]["english"] = serde_json::json!("Updated show title");
state.save_show(&first, &BTreeSet::new()).unwrap();
state.save_show(&first, &BTreeSet::new()).unwrap();
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 2);
assert_eq!(state.shows()[0].media.title, first.title);
assert_eq!(state.shows()[0].media.metadata, first.metadata);
assert!(state.shows()[0].tags.is_empty());
state.remove_show(&first.id).unwrap();
state.remove_show(&first.id).unwrap();
let state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 1);
assert_eq!(state.shows()[0].media.id, second.id);
assert!(state.is_watched(&episode.id));
for (name, bytes) in episode_files {
assert_eq!(fs::read(directory.0.join(name)).unwrap(), bytes);
}
// Duplicate hand-edited records behave like the existing episode collections.
let document = fs::read_to_string(&path).unwrap();
let duplicate = document.split_once("[[shows]]").unwrap().1;
append(&path, format!("\n[[shows]]{duplicate}").as_bytes());
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 1);
state.save_show(&second, &BTreeSet::new()).unwrap();
assert_eq!(State::open(&directory.config()).unwrap().shows().len(), 1);
state.remove_show(&second.id).unwrap();
assert!(State::open(&directory.config()).unwrap().shows().is_empty());
}
#[test]
fn miruro_library_rejects_invalid_inputs_and_files_without_changing_saved_state() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
let media = show();
state
.save_show(&media, &["Original".into()].into())
.unwrap();
let original = fs::read(&path).unwrap();
for (field, value) in [
("id", "https://unsafe.example/show".to_owned()),
("title", " ".to_owned()),
("title", "bad\nshow".to_owned()),
("title", "s".repeat(MAX_LINE + 1)),
("metadata", "m".repeat(MAX_SHOW_METADATA_BYTES)),
] {
let mut invalid_media = media.clone();
match field {
"id" => invalid_media.id = value,
"title" => invalid_media.title = value,
_ => invalid_media.metadata = serde_json::Value::String(value),
}
assert!(state.save_show(&invalid_media, &BTreeSet::new()).is_err());
assert_eq!(state.shows()[0].media.title, media.title);
assert_eq!(state.shows()[0].media.metadata, media.metadata);
assert_eq!(fs::read(&path).unwrap(), original);
}
for tags in [
[" ".into()].into(),
["bad\ttag".into()].into(),
["t".repeat(MAX_TAG_BYTES + 1)].into(),
(0..=MAX_SHOW_TAGS).map(|index| index.to_string()).collect(),
] {
assert!(state.save_show(&media, &tags).is_err());
assert_eq!(state.shows()[0].tags, ["Original".into()].into());
assert_eq!(fs::read(&path).unwrap(), original);
}
assert!(state.remove_show("https://unsafe.example/show").is_err());
assert_eq!(fs::read(&path).unwrap(), original);
let valid = std::str::from_utf8(&original).unwrap();
let mut invalid_files = vec![
"not = [valid".to_owned(),
"version = 2\nshows = []".to_owned(),
"version = 1\nunexpected = true".to_owned(),
"version = 1\nshows = [1]".to_owned(),
"version = 1\n[[shows]]\nid = 'stable'\ntitle = 'Title'\nmetadata = '{'\ntags = []"
.to_owned(),
];
let mut invalid_tags = Array::new();
invalid_tags.push(1);
let mut blank_tag = Array::new();
blank_tag.push(" ");
let mut excessive_tags = Array::new();
for _ in 0..=MAX_SHOW_TAGS {
excessive_tags.push("Tag");
}
for (field, item) in [
("tags", value(invalid_tags)),
("tags", value(blank_tag)),
("tags", value(excessive_tags)),
("id", value("../unsafe")),
("title", value("")),
("title", value("s".repeat(MAX_LINE + 1))),
(
"metadata",
value(format!("\"{}\"", "m".repeat(MAX_SHOW_METADATA_BYTES))),
),
("unknown", value(true)),
] {
let mut document = valid.parse::<DocumentMut>().unwrap();
document["shows"]
.as_array_of_tables_mut()
.unwrap()
.get_mut(0)
.unwrap()[field] = item;
invalid_files.push(document.to_string());
}
for contents in invalid_files {
fs::write(&path, &contents).unwrap();
assert!(State::open(&directory.config()).is_err(), "{contents}");
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
}
}
#[test]
fn miruro_library_does_not_overwrite_external_creations_or_edits() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
let media = show();
fs::write(&path, "external creation").unwrap();
assert!(state.save_show(&media, &BTreeSet::new()).is_err());
assert!(state.shows().is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "external creation");
fs::remove_file(&path).unwrap();
state
.save_show(&media, &["Original".into()].into())
.unwrap();
append(&path, b"\n# external edit\n");
let changed = fs::read(&path).unwrap();
let mut updated = media.clone();
updated.title = "Replacement".into();
assert!(state.save_show(&updated, &["New".into()].into()).is_err());
assert!(state.remove_show(&media.id).is_err());
assert!(state.remove_show("missing_Show").is_err());
assert_eq!(state.shows().len(), 1);
assert_eq!(state.shows()[0].media.title, media.title);
assert_eq!(state.shows()[0].tags, ["Original".into()].into());
assert_eq!(fs::read(&path).unwrap(), changed);
}
fn miruro_record(
video: &Video,
event: &str,
eligible: bool,
position: f64,
duration: Option<f64>,
) -> String {
serde_json::json!({
"version": 2,
"event": event,
"media_identity": Identity::from_key(&video.id).unwrap(),
"position_seconds": position,
"duration_seconds": duration,
"completion_eligible": eligible,
})
.to_string()
+ "\n"
}
#[test]
fn manual_status_transitions_preserve_both_providers_and_other_collections() {
for video in [video(), miruro_video(1)] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let other = miruro_video(2);
state
.set_watch_status(&other, WatchStatus::Watched)
.unwrap();
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &video).unwrap();
}
let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap();
let later = fs::read(directory.0.join("watch-later.toml")).unwrap();
for status in [
WatchStatus::Watching,
WatchStatus::Watched,
WatchStatus::Watching,
] {
state.set_watch_status(&video, status).unwrap();
let watching = fs::read(directory.0.join("currently-watching.toml")).unwrap();
let watched =
read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap();
state.set_watch_status(&video, status).unwrap();
assert_eq!(
fs::read(directory.0.join("currently-watching.toml")).unwrap(),
watching
);
assert_eq!(
read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(),
watched
);
state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&other.id));
assert_eq!(
state.is_watched(&video.id),
matches!(status, WatchStatus::Watched)
);
assert_eq!(
state.contains(CollectionKind::Watching, &video.id),
matches!(status, WatchStatus::Watching)
);
if matches!(status, WatchStatus::Watching) {
assert_eq!(
serde_json::to_value(&state.videos(CollectionKind::Watching)[0]).unwrap(),
serde_json::to_value(&video).unwrap()
);
}
assert_eq!(
fs::read(directory.0.join("saved-videos.toml")).unwrap(),
saved
);
assert_eq!(
fs::read(directory.0.join("watch-later.toml")).unwrap(),
later
);
}
}
}
#[test]
fn manual_watching_ignores_old_completion_replays_but_accepts_new_sessions() {
for video in [video(), miruro_video(1)] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let completion = if video.id.starts_with("miruro:") {
miruro_record(&video, "ended", true, 95.0, Some(100.0))
} else {
record(&video.id, "ended", true, 95.0)
};
let first = state.progress_file(&video).unwrap();
register_identity(&first, &video.id).unwrap();
append(&first, completion.as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
let second = state.progress_file(&video).unwrap();
register_identity(&second, &video.id).unwrap();
// Neither the reports nor the second session have been read by this instance.
drop(state);
let mut state = State::open(&directory.config()).unwrap();
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
for report in [&first, &second] {
append(report, completion.as_bytes());
}
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
let new_session = state.progress_file(&video).unwrap();
register_identity(&new_session, &video.id).unwrap();
append(&new_session, completion.as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(!state.contains(CollectionKind::Watching, &video.id));
}
}
#[test]
fn manual_status_rejects_invalid_videos_and_external_edits_before_writing() {
for video in [video(), miruro_video(1)] {
for status in [WatchStatus::Watching, WatchStatus::Watched] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut invalid_video = video.clone();
invalid_video.id = "--invalid".into();
assert!(state.set_watch_status(&invalid_video, status).is_err());
assert!(!directory.0.join("watch-state.toml").exists());
assert!(!directory.0.join("currently-watching.toml").exists());
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
state
.set_watch_status(&video, WatchStatus::Watched)
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
for name in ["watch-state.toml", "currently-watching.toml"] {
let path = directory.0.join(name);
let original = fs::read(&path).unwrap();
append(&path, b"\n# external edit\n");
let changed = fs::read(&path).unwrap();
assert!(state.set_watch_status(&video, status).is_err());
assert_eq!(fs::read(&path).unwrap(), changed);
assert!(state.is_watched(&video.id));
assert!(!state.contains(CollectionKind::Watching, &video.id));
assert!(!report.with_file_name("watching-seen.toml").exists());
fs::write(path, original).unwrap();
}
}
}
}
#[test]
fn manual_watching_clears_consumed_pending_completion_and_checks_session_edits() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(&report, record(&video.id, "ended", true, 95.0).as_bytes());
let watched = directory.0.join("watch-state.toml");
fs::write(&watched, "external edit").unwrap();
assert!(state.poll_progress().is_err());
assert!(state.pending.contains(&video.id));
fs::remove_file(watched).unwrap();
let marker = report.with_file_name("watching-seen.toml");
fs::write(&marker, "external edit").unwrap();
assert!(
state
.set_watch_status(&video, WatchStatus::Watching)
.is_err()
);
assert!(state.videos(CollectionKind::Watching).is_empty());
assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit");
fs::remove_file(&marker).unwrap();
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
assert!(!state.pending.contains(&video.id));
state.poll_progress().unwrap();
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
}
#[test]
fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
state.save(kind, &video).unwrap();
state.save(kind, &video).unwrap();
assert_eq!(state.videos(kind).len(), 1);
assert!(state.contains(kind, &video.id));
}
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(
&report,
record(&video.id, "progress", true, 95.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
let mut restarted = State::open(&directory.config()).unwrap();
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
let stored = &restarted.videos(kind)[0];
assert_eq!(stored.id, video.id);
assert_eq!(stored.title, video.title);
assert_eq!(stored.channel, video.channel);
assert_eq!(stored.channel_id, video.channel_id);
assert_eq!(stored.uploader_id, video.uploader_id);
assert_eq!(stored.timestamp, video.timestamp);
assert_eq!(stored.publication_date, video.publication_date);
}
assert!(restarted.is_watched(&video.id));
restarted
.remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id))
.unwrap();
restarted
.remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id))
.unwrap();
let mut restarted = State::open(&directory.config()).unwrap();
assert!(restarted.videos(CollectionKind::WatchLater).is_empty());
assert!(restarted.contains(CollectionKind::Saved, &video.id));
assert!(restarted.is_watched(&video.id));
restarted.save(CollectionKind::WatchLater, &video).unwrap();
restarted
.remove(CollectionKind::Saved, std::slice::from_ref(&video.id))
.unwrap();
let mut restarted = State::open(&directory.config()).unwrap();
assert!(restarted.videos(CollectionKind::Saved).is_empty());
assert!(restarted.contains(CollectionKind::WatchLater, &video.id));
assert!(restarted.is_watched(&video.id));
assert!(!restarted.poll_progress().unwrap());
}
#[test]
fn malformed_and_externally_changed_state_is_never_overwritten() {
let directory = TestDirectory::new();
let path = directory.0.join("watch-later.toml");
let mut state = State::open(&directory.config()).unwrap();
fs::write(&path, "not = [valid").unwrap();
assert!(state.save(CollectionKind::WatchLater, &video()).is_err());
assert!(state.videos(CollectionKind::WatchLater).is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid");
assert!(State::open(&directory.config()).is_err());
fs::remove_file(&path).unwrap();
let saved_path = directory.0.join("saved-videos.toml");
fs::write(&saved_path, "not = [valid").unwrap();
assert!(state.save(CollectionKind::Saved, &video()).is_err());
assert!(state.videos(CollectionKind::Saved).is_empty());
assert_eq!(fs::read_to_string(&saved_path).unwrap(), "not = [valid");
assert!(State::open(&directory.config()).is_err());
fs::remove_file(&saved_path).unwrap();
fs::write(&saved_path, "version = 1\n[[videos]]\nid = '--unsafe'\n").unwrap();
assert!(State::open(&directory.config()).is_err());
assert_eq!(
fs::read_to_string(&saved_path).unwrap(),
"version = 1\n[[videos]]\nid = '--unsafe'\n"
);
fs::remove_file(&saved_path).unwrap();
fs::write(
directory.0.join("watch-state.toml"),
"version = 1\nwatched = [\"--unsafe\"]\n",
)
.unwrap();
assert!(State::open(&directory.config()).is_err());
assert_eq!(
fs::read_to_string(directory.0.join("watch-state.toml")).unwrap(),
"version = 1\nwatched = [\"--unsafe\"]\n"
);
}
#[test]
fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file(&video()).unwrap();
register_identity(&report, &video().id).unwrap();
append(
&report,
record(&video().id, "progress", true, 95.0).as_bytes(),
);
let path = directory.0.join("watch-state.toml");
fs::write(&path, "malformed state").unwrap();
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert!(state.videos(CollectionKind::Watching).is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "malformed state");
fs::remove_file(&path).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
#[cfg(unix)]
#[test]
fn historical_progress_sessions_leave_descriptors_available_for_subprocesses() {
const CHILD: &str = "TUBULAR_PROGRESS_DESCRIPTOR_TEST";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"video_state::tests::historical_progress_sessions_leave_descriptors_available_for_subprocesses",
"--test-threads=1",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
// Isolate the process-wide limit from the rest of the test suite.
let mut limit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: limit is valid writable storage; only this child process is affected.
unsafe {
assert_eq!(libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit), 0);
limit.rlim_cur = limit.rlim_cur.min(64);
assert_eq!(libc::setrlimit(libc::RLIMIT_NOFILE, &limit), 0);
}
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let reports = state.progress_files(&vec![video.clone(); 96]).unwrap();
for report in &reports {
register_identity(report, &video.id).unwrap();
append(report, record(&video.id, "progress", false, 0.0).as_bytes());
}
let last = reports.last().unwrap();
let completion = record(&video.id, "closed", true, 95.0);
append(last, &completion.as_bytes()[..completion.len() - 1]);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
for _ in 0..12 {
state.poll_progress().unwrap();
}
assert!(!state.is_watched(&video.id));
append(last, b"\n");
for _ in 0..6 {
state.poll_progress().unwrap();
}
assert!(state.is_watched(&video.id));
let output = std::process::Command::new("/usr/bin/true")
.output()
.expect("progress monitoring must leave descriptors available for subprocess pipes");
assert!(output.status.success());
}
#[test]
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file(&video()).unwrap();
register_identity(&report, &video().id).unwrap();
let line = record(&video().id, "ended", true, 95.0);
append(&report, &line.as_bytes()[..line.len() - 1]);
assert!(!state.poll_progress().unwrap());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
let mut other = video();
other.id = "rQ_J9WH6CGk".into();
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());
assert!(state.is_watched(&video().id));
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
}
#[test]
fn wrong_identity_ineligible_positions_and_untrusted_reports_do_not_mark_watched() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file(&video()).unwrap();
register_identity(&report, &video().id).unwrap();
for line in [
record("rQ_J9WH6CGk", "progress", true, 100.0),
record(&video().id, "progress", false, 100.0),
record(&video().id, "closed", true, 94.9),
] {
append(&report, line.as_bytes());
}
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video().id));
assert!(!state.is_watched(&video().id));
assert!(!state.is_watched("rQ_J9WH6CGk"));
for line in [
record(&video().id, "unknown", true, 100.0),
record(&video().id, "progress", true, -1.0),
record(&video().id, "progress", true, 100.0).replace("\"version\":1", "\"version\":2"),
record(&video().id, "progress", true, 100.0)
.replace("\"duration_seconds\":100", "\"duration_seconds\":0"),
] {
append(&report, line.as_bytes());
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
#[cfg(unix)]
{
let symlink = state.progress_file(&video()).unwrap();
register_identity(&symlink, &video().id).unwrap();
let external = directory.0.join("external.jsonl");
append(
&external,
record(&video().id, "progress", true, 100.0).as_bytes(),
);
std::os::unix::fs::symlink(&external, &symlink).unwrap();
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
}
#[test]
fn watching_requires_a_valid_positive_bound_acknowledgement() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.poll_progress().unwrap());
register_identity(&report, &video.id).unwrap();
assert!(!state.poll_progress().unwrap());
for line in [
record("rQ_J9WH6CGk", "progress", true, 100.0),
record(&video.id, "progress", true, 0.0),
] {
append(&report, line.as_bytes());
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
}
for line in [
record(&video.id, "unknown", true, 10.0),
record(&video.id, "progress", true, -1.0),
record(&video.id, "progress", true, 10.0)
.replace("\"duration_seconds\":100", "\"duration_seconds\":0"),
record(&video.id, "progress", true, 10.0).replace("\"version\":1", "\"version\":2"),
] {
append(&report, line.as_bytes());
assert!(state.poll_progress().is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
}
append(
&report,
record(&video.id, "progress", false, 10.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.is_watched(&video.id));
}
#[test]
fn starts_arriving_while_closed_preserve_metadata_without_a_creator() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut video = video();
video.channel = None;
video.channel_id = None;
video.uploader_id = None;
assert!(state.save(CollectionKind::Saved, &video).is_err());
assert!(state.save(CollectionKind::WatchLater, &video).is_err());
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
drop(state);
append(
&report,
record(&video.id, "closed", true, 10.0)
.replace("\"duration_seconds\":100", "\"duration_seconds\":null")
.as_bytes(),
);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
let stored = &state.videos(CollectionKind::Watching)[0];
assert_eq!(stored.id, video.id);
assert_eq!(stored.title, video.title);
assert_eq!(stored.channel, None);
assert_eq!(stored.channel_id, None);
assert_eq!(stored.uploader_id, None);
assert_eq!(stored.publication_date, video.publication_date);
assert_eq!(stored.timestamp, video.timestamp);
append(&report, record(&video.id, "ended", false, 100.0).as_bytes());
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video.id));
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.poll_progress().unwrap());
}
#[test]
fn completion_wins_over_parallel_sessions_late_reports_and_replays() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &video).unwrap();
}
let first = state.progress_file(&video).unwrap();
register_identity(&first, &video.id).unwrap();
append(&first, record(&video.id, "progress", true, 94.9).as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.is_watched(&video.id));
let second = state.progress_file(&video).unwrap();
register_identity(&second, &video.id).unwrap();
append(
&second,
record(&video.id, "progress", true, 20.0).as_bytes(),
);
append(&first, record(&video.id, "ended", true, 95.0).as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(!state.contains(CollectionKind::Watching, &video.id));
for report in [&first, &second] {
append(report, record(&video.id, "progress", true, 30.0).as_bytes());
}
let replay = state.progress_file(&video).unwrap();
register_identity(&replay, &video.id).unwrap();
append(
&replay,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
state
.remove(CollectionKind::Watching, std::slice::from_ref(&video.id))
.unwrap();
assert!(state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Saved, &video.id));
assert!(state.contains(CollectionKind::WatchLater, &video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
}
#[test]
fn manual_removal_suppresses_existing_sessions_across_restart_but_not_new_sessions() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &video).unwrap();
}
let first = state.progress_file(&video).unwrap();
register_identity(&first, &video.id).unwrap();
append(&first, record(&video.id, "progress", true, 10.0).as_bytes());
assert!(state.poll_progress().unwrap());
// Simulate a crash after collection persistence but before its admission marker.
fs::remove_file(first.with_file_name("watching-seen.toml")).unwrap();
let second = state.progress_file(&video).unwrap();
register_identity(&second, &video.id).unwrap();
append(
&second,
record(&video.id, "progress", true, 20.0).as_bytes(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
state
.remove(CollectionKind::Watching, std::slice::from_ref(&video.id))
.unwrap();
for report in [&first, &second] {
append(report, record(&video.id, "progress", true, 50.0).as_bytes());
}
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Saved, &video.id));
assert!(state.contains(CollectionKind::WatchLater, &video.id));
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
let replay = state.progress_file(&video).unwrap();
register_identity(&replay, &video.id).unwrap();
append(
&replay,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.is_watched(&video.id));
}
#[test]
fn watching_write_conflicts_retry_consumed_starts() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
let path = directory.0.join("currently-watching.toml");
fs::write(&path, "external edit").unwrap();
append(
&report,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "external edit");
assert!(state.poll_progress().is_err());
fs::remove_file(&path).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video.id));
let original = fs::read(&path).unwrap();
append(&path, b"\n# external removal conflict\n");
let replay = state.progress_file(&video).unwrap();
register_identity(&replay, &video.id).unwrap();
append(
&replay,
record(&video.id, "progress", true, 20.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(!replay.with_file_name("watching-seen.toml").exists());
fs::write(&path, original).unwrap();
assert!(!state.poll_progress().unwrap());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.poll_progress().unwrap());
}
#[test]
fn failed_session_admission_retries_without_losing_starts_or_allowing_readdition() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
state.streams[0].prepare().unwrap();
let marker = report.with_file_name("watching-seen.toml");
append(&marker, b"external edit");
append(
&report,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(
state
.remove(CollectionKind::Watching, std::slice::from_ref(&video.id))
.is_err()
);
assert!(state.contains(CollectionKind::Watching, &video.id));
assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit");
fs::remove_file(&marker).unwrap();
assert!(state.poll_progress().unwrap());
state
.remove(CollectionKind::Watching, std::slice::from_ref(&video.id))
.unwrap();
append(
&report,
record(&video.id, "progress", true, 50.0).as_bytes(),
);
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
}
#[test]
fn failed_completion_cleanup_retries_and_restart_prunes_stale_watching() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(
&report,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
let path = directory.0.join("currently-watching.toml");
let original = fs::read(&path).unwrap();
append(&path, b"\n# external edit\n");
let changed = fs::read(&path).unwrap();
append(&report, record(&video.id, "ended", true, 95.0).as_bytes());
assert!(state.poll_progress().is_err());
assert!(state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
assert_eq!(fs::read(&path).unwrap(), changed);
append(
&report,
record(&video.id, "progress", true, 20.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
fs::write(&path, original).unwrap();
assert!(state.poll_progress().unwrap());
assert!(!state.contains(CollectionKind::Watching, &video.id));
drop(state);
// Crash recovery must also handle a watched write committed before collection cleanup.
fs::write(&path, video_document(std::iter::once(&video)).to_string()).unwrap();
let mut state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.poll_progress().unwrap());
}
#[test]
fn legacy_sessions_use_saved_metadata_when_available_and_still_mark_completion_without_it() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
state.save(CollectionKind::Saved, &video).unwrap();
let report = state.progress_file(&video).unwrap();
fs::remove_file(report.with_file_name("video.toml")).unwrap();
register_identity(&report, &video.id).unwrap();
append(
&report,
record(&video.id, "progress", true, 10.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &video.id));
append(&report, record(&video.id, "ended", true, 95.0).as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
let mut other = video.clone();
other.id = "rQ_J9WH6CGk".into();
let report = state.progress_file(&other).unwrap();
fs::remove_file(report.with_file_name("video.toml")).unwrap();
register_identity(&report, &other.id).unwrap();
append(
&report,
record(&other.id, "progress", true, 10.0).as_bytes(),
);
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
append(&report, record(&other.id, "closed", true, 95.0).as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&other.id));
}
#[test]
fn session_metadata_and_admission_markers_must_match_the_bound_identity() {
for mismatched_snapshot in [true, false] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
if mismatched_snapshot {
let mut other = video.clone();
other.id = "rQ_J9WH6CGk".into();
fs::write(
report.with_file_name("video.toml"),
video_document(std::iter::once(&other)).to_string(),
)
.unwrap();
} else {
append(
&report.with_file_name("watching-seen.toml"),
b"version = 1\nid = \"rQ_J9WH6CGk\"\n",
);
}
append(&report, record(&video.id, "ended", true, 100.0).as_bytes());
assert!(state.poll_progress().is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
}
}
#[cfg(unix)]
#[test]
fn session_snapshots_reject_symlinks_and_shared_permissions() {
use std::os::unix::fs::PermissionsExt;
for symlink in [true, false] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
let snapshot = report.with_file_name("video.toml");
if symlink {
let external = directory.0.join("external.toml");
fs::write(&external, fs::read(&snapshot).unwrap()).unwrap();
fs::remove_file(&snapshot).unwrap();
std::os::unix::fs::symlink(&external, &snapshot).unwrap();
} else {
fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o644)).unwrap();
}
append(
&report,
record(&video.id, "progress", true, 100.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
}
}
#[test]
fn watching_and_session_snapshots_keep_date_handle_channel_and_schema_validation() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
for invalid_field in ["date", "handle", "channel"] {
let mut video = video();
match invalid_field {
"date" => video.publication_date = Some("2005-02-30".into()),
"handle" => video.uploader_id = Some("not-a-handle".into()),
"channel" => video.channel_id = Some("not-a-channel-id".into()),
_ => unreachable!(),
}
assert!(state.progress_file(&video).is_err());
assert!(state.save(CollectionKind::Watching, &video).is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
}
let path = directory.0.join("currently-watching.toml");
let contents = "version = 1\nvideos = []\nunexpected = true\n";
fs::write(&path, contents).unwrap();
assert!(State::open(&directory.config()).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
}
#[test]
fn watching_and_session_snapshots_reject_oversized_files() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
OpenOptions::new()
.write(true)
.open(report.with_file_name("video.toml"))
.unwrap()
.set_len(MAX_STATE_BYTES + 1)
.unwrap();
append(
&report,
record(&video.id, "progress", true, 100.0).as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
File::create(directory.0.join("currently-watching.toml"))
.unwrap()
.set_len(MAX_STATE_BYTES + 1)
.unwrap();
assert!(State::open(&directory.config()).is_err());
}
#[test]
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 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(),
video.id
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for path in [
&report.with_file_name("video-id"),
&report.with_file_name("video.toml"),
] {
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
for path in [
report.parent().unwrap(),
&directory.0.join("playback-sessions"),
] {
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o700
);
}
}
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
drop(state);
// A GUI can acknowledge its final snapshot after Tubular has quit.
append(
&report,
miruro_record(&video, "closed", false, 10.0, None).as_bytes(),
);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
let stored = &state.videos(CollectionKind::Watching)[0];
assert_eq!(stored.id, video.id);
assert_eq!(stored.title, video.title);
assert_eq!(stored.channel, video.channel);
assert_eq!(stored.timestamp, video.timestamp);
assert_eq!(stored.publication_date, video.publication_date);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.contains(CollectionKind::Watching, &video.id));
assert!(!state.poll_progress().unwrap());
append(
&report,
miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&video.id));
assert!(!state.poll_progress().unwrap());
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
let stored = &state.videos(kind)[0];
assert_eq!(stored.id, video.id);
assert_eq!(stored.title, video.title);
assert_eq!(stored.channel, video.channel);
assert_eq!(stored.channel_id, None);
assert_eq!(stored.uploader_id, None);
assert_eq!(stored.timestamp, video.timestamp);
assert_eq!(stored.publication_date, video.publication_date);
}
assert!(report.exists());
}
#[test]
fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() {
for event in ["progress", "ended", "closed"] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
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(),
);
assert!(!state.poll_progress().unwrap());
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&miruro_video(2).id));
for (eligible, position, duration) in [
(true, 0.0, Some(100.0)),
(true, 94.9, Some(100.0)),
(false, 100.0, Some(100.0)),
(true, 100.0, None),
] {
append(
&report,
miruro_record(&video, event, eligible, position, duration).as_bytes(),
);
state.poll_progress().unwrap();
assert!(!state.is_watched(&video.id));
}
assert!(state.contains(CollectionKind::Watching, &video.id));
let completion = miruro_record(&video, event, true, 95.0, Some(100.0));
append(&report, completion.as_bytes());
append(&report, completion.as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.poll_progress().unwrap());
}
}
#[test]
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.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"),
valid.replace("\"version\":2", "\"version\":3"),
valid.replace("\"provider\":\"miruro\"", "\"provider\":\"youtube\""),
valid.replace("\"episode\":1", "\"episode\":0"),
valid.replace("\"episode\":1", "\"episode\":2147483648"),
valid.replace("\"position_seconds\":100.0", "\"position_seconds\":-1"),
valid.replace("\"position_seconds\":100.0", "\"position_seconds\":1e999"),
valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":0"),
valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":-1"),
valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":1e999"),
valid.replace("\"event\":\"progress\"", "\"event\":\"other\""),
valid.replacen('{', "{\"media_url\":null,", 1),
record(&self::video().id, "ended", true, 100.0).replace("\"version\":1", "\"version\":2"),
] {
append(&report, line.as_bytes());
assert!(state.poll_progress().is_err(), "{line}");
assert!(state.videos(CollectionKind::Watching).is_empty());
assert!(!state.is_watched(&video.id));
assert!(!state.poll_progress().unwrap());
}
}
#[test]
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 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());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
let other = miruro_video(2);
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());
assert!(state.is_watched(&video.id));
assert!(!state.is_watched(&other.id));
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
drop(state);
assert!(report.exists());
let state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&video.id));
}
#[test]
fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
for field in [
"series",
"noncanonical",
"empty-title",
"control-title",
"channel",
"channel-id",
"uploader",
] {
let mut video = miruro_video(1);
match field {
"series" => video.id = "miruro-series:opaque_Show-1".into(),
"noncanonical" => video.id = "miruro:opaque_Show-1:01".into(),
"empty-title" => video.title = " ".into(),
"control-title" => video.title.push('\n'),
"channel" => video.channel = Some("YouTube".into()),
"channel-id" => video.channel_id = self::video().channel_id,
"uploader" => video.uploader_id = Some("@jawed".into()),
_ => unreachable!(),
}
for kind in [
CollectionKind::WatchLater,
CollectionKind::Saved,
CollectionKind::Watching,
] {
assert!(state.save(kind, &video).is_err(), "{field}");
}
assert!(state.progress_file(&video).is_err(), "{field}");
}
for id in [
"miruro-series:opaque_Show-1",
"miruro:opaque_Show-1:01",
"other:opaque_Show-1:1",
] {
let mut watched = DocumentMut::new();
watched["version"] = value(1);
let mut ids = Array::new();
ids.push(id);
watched["watched"] = value(ids);
assert!(load_watched(&watched).is_err());
}
}
#[test]
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::miruro("a".repeat(512), i32::MAX as u32)
.unwrap()
.key();
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(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
drop(state);
let state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&video.id));
}
#[test]
fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() {
for missing_snapshot in [true, false] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = miruro_video(1);
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 {
append(
&report.with_file_name("video-id"),
&vec![b'x'; MAX_IDENTITY_BYTES as usize],
);
}
append(
&report,
miruro_record(&video, "ended", true, 100.0, Some(100.0)).as_bytes(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
}
}
#[test]
fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
let valid = record(&video.id, "ended", true, 95.0);
for url in [
format!("https://youtu.be/{}", video.id),
format!("https://www.youtube.com/watch?v={}&t=1", video.id),
format!("http://www.youtube.com/watch?v={}", video.id),
] {
append(
&report,
valid
.replace(
&format!("https://www.youtube.com/watch?v={}", video.id),
&url,
)
.as_bytes(),
);
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video.id));
assert!(state.videos(CollectionKind::Watching).is_empty());
}
append(
&report,
valid
.replacen('{', "{\"legacy_extension\":true,", 1)
.as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
}
#[test]
fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_sessions() {
use crate::youtube::PlayerLaunch;
use std::ffi::OsStr;
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
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",
] {
let mut invalid_video = video();
invalid_video.id = id.into();
assert!(matches!(
PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state, false),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
for videos in [vec![], vec![video(), video()]] {
assert!(matches!(
PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state, false),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
assert!(!state.sessions.exists());
assert!(state.streams.is_empty());
}
#[test]
fn progress_batch_prevalidates_snapshots_and_session_capacity() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut invalid_video = miruro_video(2);
invalid_video.title = "invalid\nmetadata".into();
assert!(state.progress_files(&[video(), invalid_video]).is_err());
assert!(!state.sessions.exists());
assert!(state.streams.is_empty());
for _ in 0..MAX_STREAMS - 1 {
state
.streams
.push(ProgressStream::new(directory.0.join("unused.jsonl")));
}
assert!(state.progress_files(&[video(), miruro_video(2)]).is_err());
assert!(!state.sessions.exists());
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
}
#[test]
fn anilist_library_and_progress_are_isolated_from_opaque_miruro_ids() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut legacy_show = show();
legacy_show.id = "1".into();
legacy_show.metadata["id"] = serde_json::json!("1");
state
.save_show(&legacy_show, &["Legacy".into()].into())
.unwrap();
let legacy_bytes = fs::read(directory.0.join("miruro-library.toml")).unwrap();
state.select_catalog(Provider::AniList);
assert!(state.shows().is_empty());
assert!(state.save_show(&legacy_show, &BTreeSet::new()).is_err());
let mut ani_show = legacy_show.clone();
ani_show.metadata["catalog_provider"] = serde_json::json!("anilist");
state.save_show(&ani_show, &["New".into()].into()).unwrap();
let mut ani = miruro_video(1);
ani.id = Identity::anilist("1".into(), 1).unwrap().key();
ani.channel = Some("AniList".into());
let mut legacy = miruro_video(1);
legacy.id = Identity::miruro("1".into(), 1).unwrap().key();
for item in [&ani, &legacy, &video()] {
state.save(CollectionKind::Saved, item).unwrap();
}
let report = state.progress_file(&ani).unwrap();
register_identity(&report, &ani.id).unwrap();
append(
&report,
miruro_record(&legacy, "ended", true, 100.0, Some(100.0)).as_bytes(),
);
append(
&report,
miruro_record(&ani, "progress", false, 20.0, Some(100.0)).as_bytes(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &ani.id));
assert!(!state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
append(
&report,
miruro_record(&ani, "ended", true, 95.0, Some(100.0)).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
assert!(state.contains(CollectionKind::Saved, &legacy.id));
assert_eq!(state.shows()[0].tags, ["Legacy".into()].into());
state.select_catalog(Provider::AniList);
assert_eq!(state.shows()[0].tags, ["New".into()].into());
assert_eq!(
fs::read(directory.0.join("miruro-library.toml")).unwrap(),
legacy_bytes
);
let mut forged = ani.clone();
forged.channel = Some("Miruro".into());
assert!(state.progress_file(&forged).is_err());
}