feat: edit watching and watched status
This commit is contained in:
+10
@@ -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
@@ -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
@@ -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
@@ -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