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
+10
View File
@@ -143,6 +143,8 @@ actions! {
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"];
SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -477,6 +479,14 @@ impl Bindings {
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"),
(
&[WatchStatusWatching],
"Mark selected as watching (clear watched)",
),
(
&[WatchStatusWatched],
"Mark selected as watched (leave watching)",
),
(&[SearchEdit], "Edit general or current-category search"),
(&[ViewSearch], "Go to general YouTube Search"),
(&[ViewFeed], "Go to the combined subscription Feed"),
+89 -13
View File
@@ -109,10 +109,10 @@
//! Quitting cancels pending metadata/launch processes, not the separately running player.
//! w/v save to watch later/saved videos; W/V choose combined or category scopes in searchable selectors.
//! Alt-w/Alt-v browse the current explicit category's collection, including from collection previews.
//! C opens combined currently watching: acknowledged started videos leave automatically once watched.
//! C opens combined currently watching; m/M mark the selected video watching/watched, including previews.
//! Collection views allow x/X selected/bulk removal, including previews; removal never marks watched.
//! All three collections store offline metadata beside config.toml; channel tags determine category lists.
//! Saved titles are green before watch-later magenta; ✓ marks acknowledged eligible playback reaching 95%.
//! Saved titles are green before watch-later magenta; ✓ marks watched status, set manually or by eligible playback reaching 95%.
//! In watch-later, f cycles All/Unwatched/Watched. X confirms removal of the current filtered/category subset.
//! In saved-video views, X confirms removal of the current category/list subset; other collections and watched state stay unchanged.
//! Configure view.collection.remove.selected and view.collection.remove.bulk; y/Enter confirms bulk, n/Esc cancels.
@@ -151,7 +151,7 @@ use ratatui::{
text::{Line, Span, Text},
widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap},
};
use video_state::CollectionKind;
use video_state::{CollectionKind, WatchStatus};
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
#[derive(Parser)]
@@ -598,12 +598,17 @@ struct KeyHints {
impl KeyHints {
fn new(bindings: &keys::Bindings) -> Self {
let key = |action| bindings.label(action);
let status = format!(
"{} watching · {} watched",
key(Action::WatchStatusWatching),
key(Action::WatchStatusWatched)
);
Self {
footers: [
(
Context::Normal,
format!(
"NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::VideoPlay),
@@ -619,7 +624,7 @@ impl KeyHints {
(
Context::Info,
format!(
"INFO · {} description · {} later · {} save · {} close · {} help",
"INFO · {} description · {status} · {} later · {} save · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -630,7 +635,7 @@ impl KeyHints {
(
Context::Description,
format!(
"DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} close · {} help",
"DESCRIPTION · {}/{} scroll · {} info · {status} · {} later · {} save · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -643,7 +648,7 @@ impl KeyHints {
(
Context::CollectionNormal,
format!(
"NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit",
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::VideoPlay),
@@ -661,7 +666,7 @@ impl KeyHints {
(
Context::CollectionInfo,
format!(
"INFO · {} description · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
"INFO · {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -674,7 +679,7 @@ impl KeyHints {
(
Context::CollectionDescription,
format!(
"DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
"DESCRIPTION · {}/{} scroll · {} info · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -772,14 +777,14 @@ impl KeyHints {
],
preview: [
format!(
"· {} description · {} later · {} save · {} close ",
"· {} description · {status} · {} later · {} save · {} close ",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
format!(
"· {}/{} scroll · {} info · {} later · {} save · {} close ",
"· {}/{} scroll · {} info · {status} · {} later · {} save · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -788,7 +793,7 @@ impl KeyHints {
key(Action::InfoHide)
),
format!(
"· {} description · {} later · {} save · {} remove selected · {} remove list · {} close ",
"· {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close ",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
@@ -797,7 +802,7 @@ impl KeyHints {
key(Action::InfoHide)
),
format!(
"· {}/{} scroll · {} info · {} later · {} save · {} remove selected · {} remove list · {} close ",
"· {}/{} scroll · {} info · {status} · {} later · {} save · {} remove selected · {} remove list · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
@@ -1714,6 +1719,27 @@ impl App {
}
}
fn set_watch_status(&mut self, status: WatchStatus) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|row| view.videos.get(row))
else {
self.status = "Select a video first.".into();
return;
};
let result = self.video_state.set_watch_status(video, status);
self.rebuild_collections();
self.status = match result {
Ok(()) => match status {
WatchStatus::Watching => "Video marked as watching.".into(),
WatchStatus::Watched => "Video marked as watched.".into(),
},
Err(error) => error.to_string(),
};
}
fn filter_watch_later(&mut self) {
let view = &mut self.views[self.view_index];
if !matches!(view.source, ViewSource::WatchLater(_)) {
@@ -2837,6 +2863,8 @@ impl App {
Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved),
Some(Action::SavedVideoBrowse) => self.open_browser(BrowserKind::SavedVideos),
Some(Action::WatchingBrowse) => self.browse_collection(CollectionKind::Watching),
Some(Action::WatchStatusWatching) => self.set_watch_status(WatchStatus::Watching),
Some(Action::WatchStatusWatched) => self.set_watch_status(WatchStatus::Watched),
Some(Action::SavedVideoBrowseCategory) => {
self.browse_category_collection(CollectionKind::Saved)
}
@@ -5739,6 +5767,54 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn manual_status_updates_filtered_lists_and_closes_removed_previews() {
let mut app = app(&["tubular"]);
let document = "[keys]\nvideo.status.watching = 'f6'\nvideo.status.watched = 'f7'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#,
).unwrap().videos;
app.views[0].loaded = true;
for row in 0..2 {
app.views[0].selection.select(Some(row));
app.save_collection(CollectionKind::WatchLater);
}
app.browse_collection(CollectionKind::WatchLater);
app.filter_watch_later();
app.views[app.view_index].selection.select(Some(0));
app.preview = Some(PreviewMode::Info);
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk");
assert_eq!(app.preview, None);
app.filter_watch_later();
assert_eq!(app.views[app.view_index].videos[0].id, "jNQXAC9IVRw");
app.preview = Some(PreviewMode::Description);
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(!app.video_state.is_watched("jNQXAC9IVRw"));
assert!(app.views[app.view_index].videos.is_empty());
assert_eq!(app.views[app.view_index].selection.selected(), None);
assert_eq!(app.preview, None);
assert!(
app.video_state
.contains(CollectionKind::Watching, "jNQXAC9IVRw")
);
assert!(
app.video_state
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
);
app.browse_collection(CollectionKind::Watching);
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
assert!(app.views[app.view_index].videos.is_empty());
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
}
#[test]
fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() {
let root = std::env::temp_dir().join(format!(
+101 -1
View File
@@ -27,7 +27,7 @@ use crate::{
keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker},
ui,
video_state::{CollectionKind, State},
video_state::{CollectionKind, State, WatchStatus},
youtube::{PlayerLaunch, Video},
};
@@ -615,6 +615,23 @@ impl Browser {
Err(error) => self.fail(error),
}
}
fn set_watch_status(&mut self, status: WatchStatus) {
let Some(video) = self.screen().selected_video() else {
return;
};
let result = self.state.set_watch_status(&video, status);
self.rebuild_collections();
match result {
Ok(()) => {
self.status = match status {
WatchStatus::Watching => "Marked episode as watching.",
WatchStatus::Watched => "Marked episode as watched.",
}
.into();
}
Err(error) => self.fail(error),
}
}
fn remove(&mut self, bulk: bool) {
let Content::Collection { kind, videos, .. } = &self.screen().content else {
return;
@@ -811,6 +828,8 @@ impl Browser {
Some(Action::WatchingBrowse) => self.collection(CollectionKind::Watching),
Some(Action::WatchLaterSave) => self.save(CollectionKind::WatchLater),
Some(Action::SavedVideoSave) => self.save(CollectionKind::Saved),
Some(Action::WatchStatusWatching) => self.set_watch_status(WatchStatus::Watching),
Some(Action::WatchStatusWatched) => self.set_watch_status(WatchStatus::Watched),
Some(Action::CollectionRemoveSelected) => self.remove(false),
Some(Action::CollectionRemoveBulk) => self.remove(true),
Some(Action::WatchLaterFilter) => {
@@ -875,6 +894,8 @@ impl Browser {
bindings.extend([
(Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"),
(Action::WatchStatusWatching, "Mark episode as watching"),
(Action::WatchStatusWatched, "Mark episode as watched"),
]);
}
if let Content::Collection { kind, .. } = self.screen().content {
@@ -934,6 +955,8 @@ impl Browser {
&[
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::DescriptionNext, "down"),
(Action::DescriptionPrevious, "up"),
(Action::DescriptionPageNext, "page down"),
@@ -946,6 +969,8 @@ impl Browser {
(Action::Help, "help"),
(Action::VideoPlay, "play"),
(Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::CollectionRemoveSelected, "remove"),
(Action::CollectionRemoveBulk, "remove all"),
(Action::WatchLaterFilter, "filter"),
@@ -968,6 +993,8 @@ impl Browser {
},
),
(Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::ViewPrevious, "back"),
(Action::WatchLaterBrowse, "later"),
(Action::SavedVideoBrowse, "saved"),
@@ -1636,6 +1663,79 @@ mod tests {
assert!(sources(&directory).is_empty());
}
#[test]
fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(browser.state.videos(CollectionKind::Watching).is_empty());
assert!(!browser.bindings().iter().any(|(action, _)| {
matches!(
action,
Action::WatchStatusWatching | Action::WatchStatusWatched
)
}));
enter_series(&mut browser);
let episode = browser.screen().selected_video().unwrap();
let mut youtube = episode.clone();
youtube.id = "jNQXAC9IVRw".into();
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
youtube.uploader_id = Some("@jawed".into());
browser
.state
.save(CollectionKind::Watching, &youtube)
.unwrap();
browser.save(CollectionKind::WatchLater);
browser.description_focus = true;
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(
browser
.state
.contains(CollectionKind::Watching, &episode.id)
);
assert!(!browser.state.is_watched(&episode.id));
browser.description_focus = false;
browser.collection(CollectionKind::Watching);
browser.open();
assert!(browser.pending.is_some());
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
assert!(browser.state.is_watched(&episode.id));
assert_eq!(browser.screen().len(), 0);
assert!(browser.pending.is_none());
browser.collection(CollectionKind::WatchLater);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 1);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
assert!(!browser.state.is_watched(&episode.id));
assert!(
browser
.state
.contains(CollectionKind::Watching, &episode.id)
);
assert!(
browser
.state
.contains(CollectionKind::Watching, &youtube.id)
);
assert!(!browser.state.is_watched(&youtube.id));
let state = State::open(&browser.config_path).unwrap();
assert!(state.contains(CollectionKind::Watching, &episode.id));
assert!(state.contains(CollectionKind::WatchLater, &episode.id));
let overrides: toml_edit::DocumentMut =
"[keys.video.status]\nwatched = 'alt+m'\n".parse().unwrap();
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
browser.collection(CollectionKind::Watching);
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
assert!(!browser.state.is_watched(&episode.id));
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT));
assert!(browser.state.is_watched(&episode.id));
assert!(state.contains(CollectionKind::Watching, &youtube.id));
}
#[test]
fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
let directory = Directory::new();
+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();