diff --git a/README.md b/README.md index 5baeca4..74ea0d2 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit | Enter | Open a series, or resolve a fresh direct **ssub / sun** source and play the episode | | `-` / `+` | Back / forward through cached screens, preserving query and selection | | `w` / `v` | Save the selected episode to watch later / saved episodes | +| `m` / `M` | Mark the selected episode as watching / watched (also in metadata) | | `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes | | `f` | Cycle All / Unwatched / Watched in watch later | | `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection | @@ -75,7 +76,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit | `r` | Refresh the active screen and reload configured keys | | `?` / `q` | Keyboard help / quit | -These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Only episodes—not catalog titles—can be saved. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and acknowledged watched status; Terebi owns resume positions. +These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Only episodes—not catalog titles—can be saved or have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions. Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed. @@ -115,6 +116,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `V` | Choose combined or category saved videos from any video view or preview | | `Alt-v` | Open the current category's saved videos | | `C` (Shift-C) | Open the combined Currently watching list | +| `m` / `M` | Mark the selected video as watching / watched (also works in previews) | | Tab / Shift-Tab | Switch between visible views | | `r` | Reload configuration and refresh the current view | | `?` | Open / close the scrollable keybinding reference | @@ -253,19 +255,23 @@ Moving between watch-later scopes carries the current All/Unwatched/Watched filt Watch-later-only titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight. -### Watched status +### Watch status Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later. +Press `m` to mark the selected video/episode **watching**: clear its watched flag and add it to Currently watching with offline metadata. Press `M` to mark it **watched**: set the watched flag and remove it from Currently watching. Both work in lists and previews/metadata, persist across restarts, and immediately update watch-later filters and markers without changing watch-later or saved membership. Miruro catalog titles are not editable. Configure `video.status.watching` and `video.status.watched` to remap or disable these actions. + +Manual edits take precedence over reports from existing bound playback sessions, including reports recovered after restart. Start a new playback session to resume automatic status updates. Editing watch status does not change Terebi's resume position. + ### Storage Files are siblings of `config.toml`: - `watch-later.toml`: versioned saved-video metadata. - `saved-videos.toml`: independent versioned saved-video metadata. -- `currently-watching.toml`: versioned metadata for started, unfinished videos. +- `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually. - `watch-state.toml`: versioned global watched video IDs. -- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission markers, and Miruro source documents. +- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and Miruro source documents. State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB. @@ -285,9 +291,9 @@ The collection is persisted independently in `saved-videos.toml` beside `config. ## Currently watching -`C` opens an offline list of the current provider's started but unfinished videos/episodes. YouTube uses its shared temporary tab; Miruro uses a cached episode-list screen. Both support back/forward navigation; configure the shortcut with `view.watching.browse`. +`C` opens an offline list of the current provider's unfinished videos/episodes, started through playback or marked watching with `m`. YouTube uses its shared temporary tab; Miruro uses a cached episode-list screen. Both support back/forward navigation; configure the shortcut with `view.watching.browse`. -A validated, acknowledged **positive playback position** adds the video—not merely launching Terebi or receiving position zero. Reaching the existing eligible **95% watched threshold** automatically removes it. Already-watched videos never enter. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched or you remove it. +A validated, acknowledged **positive playback position** automatically adds the video—not merely launching Terebi or receiving position zero. Reaching the eligible **95% watched threshold**, or marking it watched with `M`, removes it. Already-watched videos do not enter automatically; `m` explicitly clears watched status and adds them. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched, you mark it watched, or you remove it. Entries and their title/creator/publication metadata survive restarts. Reports written while Tubular is closed are recovered on the next launch. Older sessions without video snapshots can populate the list when matching watch-later/saved metadata exists; otherwise they continue updating watched status without inventing missing metadata. diff --git a/src/keys.rs b/src/keys.rs index 08bcb0c..a23b95b 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -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"), diff --git a/src/main.rs b/src/main.rs index 99466f0..4dd1485 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::() + .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!( diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index 9c3cb4d..17d3785 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -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(); diff --git a/src/video_state.rs b/src/video_state.rs index dd84496..bcfe6c7 100644 --- a/src/video_state.rs +++ b/src/video_state.rs @@ -65,6 +65,12 @@ pub enum CollectionKind { Watching, } +#[derive(Clone, Copy)] +pub enum WatchStatus { + Watching, + Watched, +} + struct Collection { file: StoredFile, videos: Vec