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
+12 -6
View File
@@ -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 | | 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 | | `-` / `+` | Back / forward through cached screens, preserving query and selection |
| `w` / `v` | Save the selected episode to watch later / saved episodes | | `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 | | `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
| `f` | Cycle All / Unwatched / Watched in watch later | | `f` | Cycle All / Unwatched / Watched in watch later |
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection | | `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 | | `r` | Refresh the active screen and reload configured keys |
| `?` / `q` | Keyboard help / quit | | `?` / `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. 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 | | `V` | Choose combined or category saved videos from any video view or preview |
| `Alt-v` | Open the current category's saved videos | | `Alt-v` | Open the current category's saved videos |
| `C` (Shift-C) | Open the combined Currently watching list | | `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 | | Tab / Shift-Tab | Switch between visible views |
| `r` | Reload configuration and refresh the current view | | `r` | Reload configuration and refresh the current view |
| `?` | Open / close the scrollable keybinding reference | | `?` | 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. 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. 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 ### Storage
Files are siblings of `config.toml`: Files are siblings of `config.toml`:
- `watch-later.toml`: versioned saved-video metadata. - `watch-later.toml`: versioned saved-video metadata.
- `saved-videos.toml`: independent 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. - `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. 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 ## 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. 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.
+10
View File
@@ -143,6 +143,8 @@ actions! {
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"]; 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"]; SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"]; 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"]; 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"]; 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"]; 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"), (&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"), (&[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"), (&[SearchEdit], "Edit general or current-category search"),
(&[ViewSearch], "Go to general YouTube Search"), (&[ViewSearch], "Go to general YouTube Search"),
(&[ViewFeed], "Go to the combined subscription Feed"), (&[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. //! 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. //! 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. //! 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. //! 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. //! 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 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. //! 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. //! 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}, text::{Line, Span, Text},
widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap}, 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}; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
#[derive(Parser)] #[derive(Parser)]
@@ -598,12 +598,17 @@ struct KeyHints {
impl KeyHints { impl KeyHints {
fn new(bindings: &keys::Bindings) -> Self { fn new(bindings: &keys::Bindings) -> Self {
let key = |action| bindings.label(action); let key = |action| bindings.label(action);
let status = format!(
"{} watching · {} watched",
key(Action::WatchStatusWatching),
key(Action::WatchStatusWatched)
);
Self { Self {
footers: [ footers: [
( (
Context::Normal, Context::Normal,
format!( format!(
"NORMAL · {}/{} select · {} play · {}/{} later · {}/{} saved · {} search · {} help · {} quit", "NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
key(Action::VideoNext), key(Action::VideoNext),
key(Action::VideoPrevious), key(Action::VideoPrevious),
key(Action::VideoPlay), key(Action::VideoPlay),
@@ -619,7 +624,7 @@ impl KeyHints {
( (
Context::Info, Context::Info,
format!( format!(
"INFO · {} description · {} later · {} save · {} close · {} help", "INFO · {} description · {status} · {} later · {} save · {} close · {} help",
key(Action::InfoDescription), key(Action::InfoDescription),
key(Action::WatchLaterSave), key(Action::WatchLaterSave),
key(Action::SavedVideoSave), key(Action::SavedVideoSave),
@@ -630,7 +635,7 @@ impl KeyHints {
( (
Context::Description, Context::Description,
format!( format!(
"DESCRIPTION · {}/{} scroll · {} info · {} later · {} save · {} close · {} help", "DESCRIPTION · {}/{} scroll · {} info · {status} · {} later · {} save · {} close · {} help",
key(Action::DescriptionNext), key(Action::DescriptionNext),
key(Action::DescriptionPrevious), key(Action::DescriptionPrevious),
key(Action::InfoOverview), key(Action::InfoOverview),
@@ -643,7 +648,7 @@ impl KeyHints {
( (
Context::CollectionNormal, Context::CollectionNormal,
format!( 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::VideoNext),
key(Action::VideoPrevious), key(Action::VideoPrevious),
key(Action::VideoPlay), key(Action::VideoPlay),
@@ -661,7 +666,7 @@ impl KeyHints {
( (
Context::CollectionInfo, Context::CollectionInfo,
format!( 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::InfoDescription),
key(Action::WatchLaterSave), key(Action::WatchLaterSave),
key(Action::SavedVideoSave), key(Action::SavedVideoSave),
@@ -674,7 +679,7 @@ impl KeyHints {
( (
Context::CollectionDescription, Context::CollectionDescription,
format!( 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::DescriptionNext),
key(Action::DescriptionPrevious), key(Action::DescriptionPrevious),
key(Action::InfoOverview), key(Action::InfoOverview),
@@ -772,14 +777,14 @@ impl KeyHints {
], ],
preview: [ preview: [
format!( format!(
"· {} description · {} later · {} save · {} close ", "· {} description · {status} · {} later · {} save · {} close ",
key(Action::InfoDescription), key(Action::InfoDescription),
key(Action::WatchLaterSave), key(Action::WatchLaterSave),
key(Action::SavedVideoSave), key(Action::SavedVideoSave),
key(Action::InfoHide) key(Action::InfoHide)
), ),
format!( format!(
"· {}/{} scroll · {} info · {} later · {} save · {} close ", "· {}/{} scroll · {} info · {status} · {} later · {} save · {} close ",
key(Action::DescriptionNext), key(Action::DescriptionNext),
key(Action::DescriptionPrevious), key(Action::DescriptionPrevious),
key(Action::InfoOverview), key(Action::InfoOverview),
@@ -788,7 +793,7 @@ impl KeyHints {
key(Action::InfoHide) key(Action::InfoHide)
), ),
format!( format!(
"· {} description · {} later · {} save · {} remove selected · {} remove list · {} close ", "· {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close ",
key(Action::InfoDescription), key(Action::InfoDescription),
key(Action::WatchLaterSave), key(Action::WatchLaterSave),
key(Action::SavedVideoSave), key(Action::SavedVideoSave),
@@ -797,7 +802,7 @@ impl KeyHints {
key(Action::InfoHide) key(Action::InfoHide)
), ),
format!( 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::DescriptionNext),
key(Action::DescriptionPrevious), key(Action::DescriptionPrevious),
key(Action::InfoOverview), 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) { fn filter_watch_later(&mut self) {
let view = &mut self.views[self.view_index]; let view = &mut self.views[self.view_index];
if !matches!(view.source, ViewSource::WatchLater(_)) { if !matches!(view.source, ViewSource::WatchLater(_)) {
@@ -2837,6 +2863,8 @@ impl App {
Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved), Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved),
Some(Action::SavedVideoBrowse) => self.open_browser(BrowserKind::SavedVideos), Some(Action::SavedVideoBrowse) => self.open_browser(BrowserKind::SavedVideos),
Some(Action::WatchingBrowse) => self.browse_collection(CollectionKind::Watching), 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) => { Some(Action::SavedVideoBrowseCategory) => {
self.browse_category_collection(CollectionKind::Saved) self.browse_category_collection(CollectionKind::Saved)
} }
@@ -5739,6 +5767,54 @@ mod tests {
std::fs::remove_dir_all(root).unwrap(); 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] #[test]
fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() { fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
+101 -1
View File
@@ -27,7 +27,7 @@ use crate::{
keys::{Action, Context}, keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker}, miruro_browser::{self, Episode, Media, Request, Response, Worker},
ui, ui,
video_state::{CollectionKind, State}, video_state::{CollectionKind, State, WatchStatus},
youtube::{PlayerLaunch, Video}, youtube::{PlayerLaunch, Video},
}; };
@@ -615,6 +615,23 @@ impl Browser {
Err(error) => self.fail(error), 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) { fn remove(&mut self, bulk: bool) {
let Content::Collection { kind, videos, .. } = &self.screen().content else { let Content::Collection { kind, videos, .. } = &self.screen().content else {
return; return;
@@ -811,6 +828,8 @@ impl Browser {
Some(Action::WatchingBrowse) => self.collection(CollectionKind::Watching), Some(Action::WatchingBrowse) => self.collection(CollectionKind::Watching),
Some(Action::WatchLaterSave) => self.save(CollectionKind::WatchLater), Some(Action::WatchLaterSave) => self.save(CollectionKind::WatchLater),
Some(Action::SavedVideoSave) => self.save(CollectionKind::Saved), 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::CollectionRemoveSelected) => self.remove(false),
Some(Action::CollectionRemoveBulk) => self.remove(true), Some(Action::CollectionRemoveBulk) => self.remove(true),
Some(Action::WatchLaterFilter) => { Some(Action::WatchLaterFilter) => {
@@ -875,6 +894,8 @@ impl Browser {
bindings.extend([ bindings.extend([
(Action::WatchLaterSave, "Save episode to watch later"), (Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"), (Action::SavedVideoSave, "Save episode"),
(Action::WatchStatusWatching, "Mark episode as watching"),
(Action::WatchStatusWatched, "Mark episode as watched"),
]); ]);
} }
if let Content::Collection { kind, .. } = self.screen().content { if let Content::Collection { kind, .. } = self.screen().content {
@@ -934,6 +955,8 @@ impl Browser {
&[ &[
(Action::InfoHide, "close"), (Action::InfoHide, "close"),
(Action::Help, "help"), (Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::DescriptionNext, "down"), (Action::DescriptionNext, "down"),
(Action::DescriptionPrevious, "up"), (Action::DescriptionPrevious, "up"),
(Action::DescriptionPageNext, "page down"), (Action::DescriptionPageNext, "page down"),
@@ -946,6 +969,8 @@ impl Browser {
(Action::Help, "help"), (Action::Help, "help"),
(Action::VideoPlay, "play"), (Action::VideoPlay, "play"),
(Action::InfoOverview, "info"), (Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::CollectionRemoveSelected, "remove"), (Action::CollectionRemoveSelected, "remove"),
(Action::CollectionRemoveBulk, "remove all"), (Action::CollectionRemoveBulk, "remove all"),
(Action::WatchLaterFilter, "filter"), (Action::WatchLaterFilter, "filter"),
@@ -968,6 +993,8 @@ impl Browser {
}, },
), ),
(Action::InfoOverview, "info"), (Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::ViewPrevious, "back"), (Action::ViewPrevious, "back"),
(Action::WatchLaterBrowse, "later"), (Action::WatchLaterBrowse, "later"),
(Action::SavedVideoBrowse, "saved"), (Action::SavedVideoBrowse, "saved"),
@@ -1636,6 +1663,79 @@ mod tests {
assert!(sources(&directory).is_empty()); 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] #[test]
fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() { fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
let directory = Directory::new(); let directory = Directory::new();
+250 -8
View File
@@ -65,6 +65,12 @@ pub enum CollectionKind {
Watching, Watching,
} }
#[derive(Clone, Copy)]
pub enum WatchStatus {
Watching,
Watched,
}
struct Collection { struct Collection {
file: StoredFile, file: StoredFile,
videos: Vec<Video>, videos: Vec<Video>,
@@ -192,6 +198,58 @@ impl State {
pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> { pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> {
if matches!(kind, CollectionKind::Watching) { if matches!(kind, CollectionKind::Watching) {
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; self.discovery = None;
loop { loop {
self.discover()?; self.discover()?;
@@ -206,11 +264,10 @@ impl State {
Err(e) => return Err(e), Err(e) => return Err(e),
} }
if stream.id.as_ref().is_some_and(|id| ids.contains(id)) { if stream.id.as_ref().is_some_and(|id| ids.contains(id)) {
stream.mark_seen()?; stream.mark_seen(status_edited)?;
} }
} }
} Ok(())
self.collection_mut(kind).remove(ids)
} }
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> { pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
@@ -313,7 +370,7 @@ impl State {
self.watching.save(video)?; self.watching.save(video)?;
self.notify_changed |= added; self.notify_changed |= added;
} }
stream.mark_seen()?; stream.mark_seen(false)?;
} }
stream.started = false; stream.started = false;
} }
@@ -751,6 +808,7 @@ struct ProgressStream {
video: Option<Video>, video: Option<Video>,
seen_file: Option<StoredFile>, seen_file: Option<StoredFile>,
seen: bool, seen: bool,
status_edited: bool,
started: bool, started: bool,
file: Option<File>, file: Option<File>,
partial: Vec<u8>, partial: Vec<u8>,
@@ -767,6 +825,7 @@ impl ProgressStream {
video: None, video: None,
seen_file: None, seen_file: None,
seen: false, seen: false,
status_edited: false,
started: false, started: false,
file: None, file: None,
partial: Vec::new(), partial: Vec::new(),
@@ -820,22 +879,29 @@ impl ProgressStream {
if seen if seen
&& (document && (document
.iter() .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)) || document.get("id").and_then(|item| item.as_str()) != Some(id))
{ {
return Err(invalid( return Err(invalid(
"Session watching admission must match its video-id marker", "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.id = Some(id.into());
self.video = video; self.video = video;
self.seen_file = Some(seen_file); self.seen_file = Some(seen_file);
self.seen = seen; self.seen = seen;
self.status_edited = status_edited;
Ok(()) Ok(())
} }
fn mark_seen(&mut self) -> io::Result<()> { fn mark_seen(&mut self, status_edited: bool) -> io::Result<()> {
if self.seen { if self.seen && (!status_edited || self.status_edited) {
let file = self.seen_file.as_ref().unwrap(); let file = self.seen_file.as_ref().unwrap();
return check_original(&file.path, file.original.as_deref()); return check_original(&file.path, file.original.as_deref());
} }
@@ -843,8 +909,12 @@ impl ProgressStream {
let mut document = DocumentMut::new(); let mut document = DocumentMut::new();
document["version"] = value(1); document["version"] = value(1);
document["id"] = value(self.id.as_deref().unwrap()); 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_file.as_mut().unwrap().save(document)?;
self.seen = true; self.seen = true;
self.status_edited |= status_edited;
self.started = false; self.started = false;
Ok(()) Ok(())
} }
@@ -970,7 +1040,7 @@ impl ProgressStream {
"position must be finite/nonnegative and duration positive/finite or null", "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(()); return Ok(());
} }
if position > 0.0 && !self.seen && !watched.contains(id) { if position > 0.0 && !self.seen && !watched.contains(id) {
@@ -1090,6 +1160,178 @@ mod tests {
+ "\n" + "\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] #[test]
fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() { fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() {
let directory = TestDirectory::new(); let directory = TestDirectory::new();