feat: edit watching and watched status
This commit is contained in:
+264
-22
@@ -65,6 +65,12 @@ pub enum CollectionKind {
|
||||
Watching,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum WatchStatus {
|
||||
Watching,
|
||||
Watched,
|
||||
}
|
||||
|
||||
struct Collection {
|
||||
file: StoredFile,
|
||||
videos: Vec<Video>,
|
||||
@@ -192,27 +198,78 @@ impl State {
|
||||
|
||||
pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> {
|
||||
if matches!(kind, CollectionKind::Watching) {
|
||||
self.discovery = None;
|
||||
loop {
|
||||
self.discover()?;
|
||||
if self.discovery.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for stream in &mut self.streams {
|
||||
match stream.prepare() {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
if stream.id.as_ref().is_some_and(|id| ids.contains(id)) {
|
||||
stream.mark_seen()?;
|
||||
}
|
||||
}
|
||||
self.suppress_sessions(ids, false)?;
|
||||
}
|
||||
self.collection_mut(kind).remove(ids)
|
||||
}
|
||||
|
||||
pub fn set_watch_status(&mut self, video: &Video, status: WatchStatus) -> io::Result<()> {
|
||||
let mut video = video.clone();
|
||||
validate_snapshot(&mut video, false)?;
|
||||
check_original(&self.watch_state.path, self.watch_state.original.as_deref())?;
|
||||
check_original(
|
||||
&self.watching.file.path,
|
||||
self.watching.file.original.as_deref(),
|
||||
)?;
|
||||
let result = (|| {
|
||||
self.suppress_sessions(std::slice::from_ref(&video.id), true)?;
|
||||
self.pending.remove(&video.id);
|
||||
match status {
|
||||
WatchStatus::Watching => {
|
||||
self.watching.save(&video)?;
|
||||
self.notify_changed = true;
|
||||
if self.watched.contains(&video.id) {
|
||||
self.watch_state.save(watched_document(
|
||||
self.watched.iter().filter(|id| *id != &video.id),
|
||||
))?;
|
||||
self.watched.remove(&video.id);
|
||||
}
|
||||
}
|
||||
WatchStatus::Watched => {
|
||||
if !self.watched.contains(&video.id) {
|
||||
self.watch_state.save(watched_document(
|
||||
self.watched.iter().chain(std::iter::once(&video.id)),
|
||||
))?;
|
||||
self.watched.insert(video.id.clone());
|
||||
self.notify_changed = true;
|
||||
}
|
||||
self.watching.remove(std::slice::from_ref(&video.id))?;
|
||||
self.notify_changed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
result.map_err(|e: io::Error| {
|
||||
io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"Could not set watch status; some session/state files may already have been updated: {e}"
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn suppress_sessions(&mut self, ids: &[String], status_edited: bool) -> io::Result<()> {
|
||||
self.discovery = None;
|
||||
loop {
|
||||
self.discover()?;
|
||||
if self.discovery.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for stream in &mut self.streams {
|
||||
match stream.prepare() {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
if stream.id.as_ref().is_some_and(|id| ids.contains(id)) {
|
||||
stream.mark_seen(status_edited)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
|
||||
if self.streams.len() >= MAX_STREAMS {
|
||||
return Err(invalid(
|
||||
@@ -313,7 +370,7 @@ impl State {
|
||||
self.watching.save(video)?;
|
||||
self.notify_changed |= added;
|
||||
}
|
||||
stream.mark_seen()?;
|
||||
stream.mark_seen(false)?;
|
||||
}
|
||||
stream.started = false;
|
||||
}
|
||||
@@ -751,6 +808,7 @@ struct ProgressStream {
|
||||
video: Option<Video>,
|
||||
seen_file: Option<StoredFile>,
|
||||
seen: bool,
|
||||
status_edited: bool,
|
||||
started: bool,
|
||||
file: Option<File>,
|
||||
partial: Vec<u8>,
|
||||
@@ -767,6 +825,7 @@ impl ProgressStream {
|
||||
video: None,
|
||||
seen_file: None,
|
||||
seen: false,
|
||||
status_edited: false,
|
||||
started: false,
|
||||
file: None,
|
||||
partial: Vec::new(),
|
||||
@@ -820,22 +879,29 @@ impl ProgressStream {
|
||||
if seen
|
||||
&& (document
|
||||
.iter()
|
||||
.any(|(key, _)| !matches!(key, "version" | "id"))
|
||||
.any(|(key, _)| !matches!(key, "version" | "id" | "status_edited"))
|
||||
|| document.get("id").and_then(|item| item.as_str()) != Some(id))
|
||||
{
|
||||
return Err(invalid(
|
||||
"Session watching admission must match its video-id marker",
|
||||
));
|
||||
}
|
||||
let status_edited = match document.get("status_edited") {
|
||||
Some(item) => item
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid("Session status_edited must be a boolean"))?,
|
||||
None => false,
|
||||
};
|
||||
self.id = Some(id.into());
|
||||
self.video = video;
|
||||
self.seen_file = Some(seen_file);
|
||||
self.seen = seen;
|
||||
self.status_edited = status_edited;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_seen(&mut self) -> io::Result<()> {
|
||||
if self.seen {
|
||||
fn mark_seen(&mut self, status_edited: bool) -> io::Result<()> {
|
||||
if self.seen && (!status_edited || self.status_edited) {
|
||||
let file = self.seen_file.as_ref().unwrap();
|
||||
return check_original(&file.path, file.original.as_deref());
|
||||
}
|
||||
@@ -843,8 +909,12 @@ impl ProgressStream {
|
||||
let mut document = DocumentMut::new();
|
||||
document["version"] = value(1);
|
||||
document["id"] = value(self.id.as_deref().unwrap());
|
||||
if status_edited || self.status_edited {
|
||||
document["status_edited"] = value(true);
|
||||
}
|
||||
self.seen_file.as_mut().unwrap().save(document)?;
|
||||
self.seen = true;
|
||||
self.status_edited |= status_edited;
|
||||
self.started = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -970,7 +1040,7 @@ impl ProgressStream {
|
||||
"position must be finite/nonnegative and duration positive/finite or null",
|
||||
));
|
||||
}
|
||||
if self.id.as_deref() != Some(id) {
|
||||
if self.id.as_deref() != Some(id) || self.status_edited {
|
||||
return Ok(());
|
||||
}
|
||||
if position > 0.0 && !self.seen && !watched.contains(id) {
|
||||
@@ -1090,6 +1160,178 @@ mod tests {
|
||||
+ "\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();
|
||||
|
||||
Reference in New Issue
Block a user