feat: edit watching and watched status

This commit is contained in:
2026-10-17 01:43:50 +02:00
parent bb0a3e58ce
commit 08e4a62228
5 changed files with 476 additions and 42 deletions
+264 -22
View File
@@ -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();