1397 lines
55 KiB
Rust
1397 lines
55 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::new("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());
|
|
}
|
|
|
|
#[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::new("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));
|
|
}
|