Compare commits

...

2 Commits

Author SHA1 Message Date
hl-valdemar 08e4a62228 feat: edit watching and watched status 2026-10-17 01:50:39 +02:00
hl-valdemar bb0a3e58ce feat: unify provider interface design 2026-10-17 01:28:14 +02:00
8 changed files with 825 additions and 279 deletions
+3
View File
@@ -0,0 +1,3 @@
# Revision descriptions
Use concise revision descriptions without a `(TODO.md)` suffix. Include a ticket ID only when the task has a real ticket.
+17 -9
View File
@@ -43,6 +43,8 @@ Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows
After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting.
Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and two-line title/episode rows rather than YouTube's video table.
Selecting a YouTube video launches:
```sh
@@ -56,7 +58,7 @@ Each selection opens an independent player instance without replacing existing p
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, without YouTube's Search/Feed tabs, channel controls or category selectors.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; the metadata pane shows the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata pane, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
| Key | Miruro action |
| --- | --- |
@@ -65,15 +67,16 @@ 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 |
| `d` | Focus/unfocus the synopsis/metadata pane; `j` / `k` and page keys scroll while focused |
| Esc | Cancel input/help or return focus to the episode/title list |
| `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection |
| Esc | Cancel input or close help/metadata and return to the episode/title list |
| `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.
@@ -113,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 |
@@ -251,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.
@@ -283,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.
+2
View File
@@ -433,6 +433,8 @@ Wrapper-extraction verification, before TUI integration: `cargo test --locked` p
`tubular` offers a centered YouTube/Miruro startup selector; an initial query follows the chosen provider. `tubular --miruro "apothecary"` bypasses it and starts the dedicated Miruro interface directly. `src/miruro_ui.rs` owns catalog → series → episodes navigation, synopsis/metadata presentation, cached screen history and flat episode collections; YouTube retains its separate Search/Feed/category interface. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. `src/miruro_browser.rs` supplies native catalog/series/episode models and one worker for bounded network work; changing views/queries or episode selections invalidates stale source responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization.
Both browsers share `src/ui.rs` for layout, search editing/cursor scrolling, selection styling and loading animation. Miruro uses the same full-width results/status chrome and centered metadata/help/removal popups as YouTube, while preserving its own catalog/episode rows and navigation. Press `d` for metadata; arrows/Ctrl-j/k scroll it, while plain j/k change the selected row.
Titles normalizing to an empty slug fail explicitly; the wrapper does not guess an alternative detail route. This does not reject accented Latin titles, which use NFD decomposition.
### Source document and launch
+10
View File
@@ -143,6 +143,8 @@ actions! {
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"];
SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -477,6 +479,14 @@ impl Bindings {
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"),
(
&[WatchStatusWatching],
"Mark selected as watching (clear watched)",
),
(
&[WatchStatusWatched],
"Mark selected as watched (leave watching)",
),
(&[SearchEdit], "Edit general or current-category search"),
(&[ViewSearch], "Go to general YouTube Search"),
(&[ViewFeed], "Go to the combined subscription Feed"),
+114 -71
View File
@@ -109,10 +109,10 @@
//! Quitting cancels pending metadata/launch processes, not the separately running player.
//! w/v save to watch later/saved videos; W/V choose combined or category scopes in searchable selectors.
//! Alt-w/Alt-v browse the current explicit category's collection, including from collection previews.
//! C opens combined currently watching: acknowledged started videos leave automatically once watched.
//! C opens combined currently watching; m/M mark the selected video watching/watched, including previews.
//! Collection views allow x/X selected/bulk removal, including previews; removal never marks watched.
//! All three collections store offline metadata beside config.toml; channel tags determine category lists.
//! Saved titles are green before watch-later magenta; ✓ marks acknowledged eligible playback reaching 95%.
//! Saved titles are green before watch-later magenta; ✓ marks watched status, set manually or by eligible playback reaching 95%.
//! In watch-later, f cycles All/Unwatched/Watched. X confirms removal of the current filtered/category subset.
//! In saved-video views, X confirms removal of the current category/list subset; other collections and watched state stay unchanged.
//! Configure view.collection.remove.selected and view.collection.remove.bulk; y/Enter confirms bulk, n/Esc cancels.
@@ -122,6 +122,7 @@ mod keys;
mod miruro_browser;
mod miruro_ui;
mod thumbnail;
mod ui;
mod video_state;
mod youtube;
@@ -150,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)]
@@ -597,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),
@@ -618,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),
@@ -629,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),
@@ -642,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),
@@ -660,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),
@@ -673,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),
@@ -771,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),
@@ -787,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),
@@ -796,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),
@@ -1713,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(_)) {
@@ -2836,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)
}
@@ -2883,8 +2912,7 @@ impl App {
}
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
const SPINNER: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
let spinner = SPINNER[(elapsed.as_millis() / 100 % SPINNER.len() as u128) as usize];
let spinner = ui::spinner(elapsed);
let area = frame.area();
let mut tab_lines = vec![Line::default()];
let mut row_width = 0;
@@ -2919,54 +2947,23 @@ impl App {
.min(usize::from(area.height.saturating_sub(10).max(1)))
as u16;
let first_row = selected_row.saturating_sub(usize::from(tab_height).saturating_sub(1));
let [tabs, search, results, status, help] = Layout::vertical([
Constraint::Length(tab_height),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(area);
let [tabs, search, results, status, help] = ui::browser_layout(area, tab_height);
frame.render_widget(
Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)),
tabs,
);
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
Style::new()
};
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query_view = &self.views[self.query_view_index()];
let query = ratatui::text::Line::raw(query_view.query.as_str());
let query_width = query.width();
let search_title: Line<'_> = match query_view.source {
ViewSource::Category(_) | ViewSource::CategoryGroup(_) => {
" tubular / Category search ".into()
}
_ => " tubular / YouTube search ".into(),
let search_title = match query_view.source {
ViewSource::Category(_) | ViewSource::CategoryGroup(_) => " tubular / Category search ",
_ => " tubular / YouTube search ",
};
let search_block = Block::bordered()
.title(search_title)
.border_style(search_style);
let input = search_block.inner(search);
let scroll = query_width.saturating_sub(input.width.saturating_sub(1) as usize);
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(search_block),
ui::search(
frame,
search,
search_title,
query_view.query.as_str(),
self.mode == Mode::Insert && self.category_dialog.is_none(),
);
if self.mode == Mode::Insert
&& self.category_dialog.is_none()
&& input.width != 0
&& input.height != 0
{
let cursor = query_width
.saturating_sub(scroll)
.min(usize::from(input.width - 1));
frame.set_cursor_position((input.x + cursor as u16, input.y));
}
let view = &mut self.views[self.view_index];
let table = Table::new(
view.videos.iter().map(|video| {
@@ -3023,8 +3020,8 @@ impl App {
},
self.job.is_some().then_some(spinner),
)))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
.row_highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
@@ -3046,7 +3043,7 @@ impl App {
{
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
let block = Block::bordered().border_style(ui::ACCENT);
let inner = block.inner(popup);
let cached = cached_description(&self.descriptions, &video.id);
let text = match cached {
@@ -3209,7 +3206,7 @@ impl App {
let popup = preview_area(frame.area());
let block = Block::bordered()
.title(self.key_hints.help.as_str())
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(ratatui::layout::Margin::new(1, 0));
let viewport = (inner.width, inner.height);
if help.viewport != Some(viewport) {
@@ -3243,7 +3240,7 @@ impl App {
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(),
})
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [input, list, error] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
@@ -3324,8 +3321,8 @@ impl App {
});
frame.render_stateful_widget(
List::new(items)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
list,
&mut browser.selection,
);
@@ -3374,9 +3371,7 @@ impl App {
"Submitting saves this rename immediately; existing memberships and pins are preserved.",
),
};
let mut block = Block::bordered()
.title(title)
.border_style(Style::new().fg(Color::Cyan));
let mut block = Block::bordered().title(title).border_style(ui::ACCENT);
if let CategoryNameTarget::Rename { old, .. } = &input.target {
block = block.title_bottom(format!(" {old} "));
}
@@ -3450,7 +3445,7 @@ impl App {
" Tags "
})
.title_bottom(dialog.label.as_str())
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [input, body, warning, error, help] = Layout::vertical([
Constraint::Length(if dialog.name.is_none() { 3 } else { 0 }),
Constraint::Min(1),
@@ -3522,8 +3517,8 @@ impl App {
])
}),
)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
body,
&mut dialog.selection,
);
@@ -3695,8 +3690,8 @@ fn choose_provider(
bindings.label(Action::SelectorClose),
);
let providers = List::new(PROVIDERS.map(|(name, _)| name))
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
.highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
loop {
selection.select(Some(selected));
terminal.draw(|frame| {
@@ -3711,7 +3706,7 @@ fn choose_provider(
);
let block = Block::bordered()
.title(" Browse provider ")
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [list, hints] = Layout::vertical([Constraint::Min(0), Constraint::Length(2)])
.areas(block.inner(popup));
frame.render_widget(block, popup);
@@ -5772,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!(
+363 -177
View File
@@ -1,4 +1,8 @@
use std::{io, path::PathBuf, time::Duration};
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use ratatui::{
DefaultTerminal, Frame,
@@ -10,10 +14,10 @@ use ratatui::{
},
execute,
},
layout::{Constraint, Layout},
layout::Margin,
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use serde_json::Value;
use tubular::miruro::Identity;
@@ -22,7 +26,8 @@ use crate::{
Options, config,
keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker},
video_state::{CollectionKind, State},
ui,
video_state::{CollectionKind, State, WatchStatus},
youtube::{PlayerLaunch, Video},
};
@@ -85,7 +90,6 @@ struct Screen {
description_scroll: u16,
error: Option<String>,
title: String,
summary: String,
overview: String,
description: String,
rows: Vec<DisplayRow>,
@@ -98,7 +102,6 @@ impl Screen {
description_scroll: 0,
error: None,
title: String::new(),
summary: String::new(),
overview: String::new(),
description: String::new(),
rows: Vec::new(),
@@ -157,7 +160,6 @@ impl Screen {
fn rebuild_display(&mut self) {
self.rows.clear();
self.overview.clear();
self.summary.clear();
match &self.content {
Content::Catalog { media, .. } => {
self.title = "Miruro · Catalog (autocomplete, up to 8 titles)".into();
@@ -173,7 +175,6 @@ impl Screen {
}
Content::Series { media, data } => {
self.title = format!("Miruro · {} · Episodes", safe(&media.title));
self.summary = media_summary(media);
self.overview = media_details(media);
if let Some(data) = data {
self.overview.push_str(&format!(
@@ -243,9 +244,6 @@ impl Screen {
String::new()
}
);
self.summary =
"Offline episode collections · ✓ watched · watch later · saved · watching"
.into();
self.rows = videos.iter().map(|video| {
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
let title = safe(&video.title);
@@ -617,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;
@@ -813,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) => {
@@ -863,7 +880,7 @@ impl Browser {
(Action::WatchLaterBrowse, "Watch later episodes"),
(Action::SavedVideoBrowse, "Saved episodes"),
(Action::WatchingBrowse, "Currently watching episodes"),
(Action::InfoOverview, "Focus/unfocus metadata"),
(Action::InfoOverview, "Show/hide metadata"),
(Action::Help, "Keyboard help"),
(Action::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"),
@@ -871,12 +888,14 @@ impl Browser {
if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
{
bindings.push((Action::InfoDescription, "Focus/unfocus metadata"));
bindings.push((Action::InfoDescription, "Show/hide metadata"));
}
if !matches!(self.screen().content, Content::Catalog { .. }) {
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 {
@@ -893,7 +912,7 @@ impl Browser {
}
if self.description_focus {
bindings.extend([
(Action::InfoHide, "Return focus to list"),
(Action::InfoHide, "Close metadata"),
(Action::DescriptionNext, "Scroll metadata down"),
(Action::DescriptionPrevious, "Scroll metadata up"),
(Action::DescriptionPageNext, "Metadata page down"),
@@ -909,61 +928,92 @@ impl Browser {
self.config.keys.label(Action::Quit)
);
}
if self.help.is_some() {
return [
(Action::HelpClose, "close"),
(Action::HelpNext, "down"),
(Action::HelpPrevious, "up"),
(Action::HelpPageNext, "page down"),
(Action::HelpPagePrevious, "page up"),
(Action::Quit, "quit"),
]
.iter()
.map(|(action, label)| format!("{} {label}", self.config.keys.label(*action)))
.collect::<Vec<_>>()
.join(" · ");
}
let actions: &[Action] = if self.input.is_some() {
&[Action::InputSubmit, Action::InputCancel, Action::DeleteWord]
let (mode, actions): (&str, &[(Action, &str)]) = if self.help.is_some() {
(
"HELP",
&[
(Action::HelpClose, "close"),
(Action::HelpNext, "down"),
(Action::HelpPrevious, "up"),
(Action::HelpPageNext, "page down"),
(Action::HelpPagePrevious, "page up"),
(Action::Quit, "quit"),
],
)
} else if self.input.is_some() {
(
"INSERT",
&[
(Action::InputSubmit, "search"),
(Action::InputCancel, "cancel"),
(Action::DeleteWord, "delete word"),
],
)
} else if self.description_focus {
&[
Action::InfoHide,
Action::DescriptionNext,
Action::DescriptionPrevious,
Action::DescriptionPageNext,
Action::Help,
]
(
"DESCRIPTION",
&[
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::DescriptionNext, "down"),
(Action::DescriptionPrevious, "up"),
(Action::DescriptionPageNext, "page down"),
],
)
} else if matches!(self.screen().content, Content::Collection { .. }) {
&[
Action::VideoPlay,
Action::CollectionRemoveSelected,
Action::CollectionRemoveBulk,
Action::WatchLaterFilter,
Action::InfoOverview,
Action::ViewPrevious,
Action::SearchEdit,
Action::Help,
Action::Quit,
]
(
"NORMAL",
&[
(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"),
(Action::ViewPrevious, "back"),
(Action::Quit, "quit"),
],
)
} else {
&[
Action::VideoPlay,
Action::SearchEdit,
Action::ViewPrevious,
Action::WatchLaterBrowse,
Action::SavedVideoBrowse,
Action::WatchingBrowse,
Action::InfoOverview,
Action::Help,
Action::Quit,
]
(
"NORMAL",
&[
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(
Action::VideoPlay,
if matches!(self.screen().content, Content::Catalog { .. }) {
"open"
} else {
"play"
},
),
(Action::InfoOverview, "info"),
(Action::WatchStatusWatching, "mark watching"),
(Action::WatchStatusWatched, "mark watched"),
(Action::ViewPrevious, "back"),
(Action::WatchLaterBrowse, "later"),
(Action::SavedVideoBrowse, "saved"),
(Action::WatchingBrowse, "watching"),
(Action::Quit, "quit"),
],
)
};
self.bindings()
.into_iter()
.filter(|(action, _)| actions.contains(action))
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
.collect::<Vec<_>>()
.join(" · ")
let supported = self.bindings();
let mut hints = mode.to_owned();
for (action, label) in actions {
if self.help.is_some() || supported.iter().any(|(supported, _)| supported == action) {
hints.push_str(" · ");
hints.push_str(self.config.keys.label(*action));
hints.push(' ');
hints.push_str(label);
}
}
hints
}
fn markers(&self, id: &str) -> String {
let mut markers = String::new();
@@ -1029,132 +1079,133 @@ impl Browser {
})
.collect()
}
fn draw(&mut self, frame: &mut Frame) {
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
let area = frame.area();
if area.width < 24 || area.height < 8 {
if area.width < 24 || area.height < 10 {
frame.render_widget(
Paragraph::new("Miruro\nTerminal too small; enlarge to browse.\nq / Ctrl-C exits")
.wrap(Wrap { trim: true }),
Paragraph::new(format!(
"Miruro\nTerminal too small; enlarge to browse.\n{} exits",
self.config.keys.label(Action::Quit)
))
.wrap(Wrap { trim: true }),
area,
);
return;
}
let [header, query_area, content, status, hints] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(2),
Constraint::Length(2),
])
.areas(area);
let [header, query_area, content, status, hints] = ui::browser_layout(area, 1);
frame.render_widget(
Paragraph::new(self.screen().title.as_str()).bold().cyan(),
Paragraph::new(Line::from(vec![
" ".into(),
self.screen().title.as_str().into(),
" ".into(),
]))
.style(ui::ACCENT)
.bold(),
header,
);
let input = self.input.as_ref().map(|input| format!("{input}▏"));
let query = input
.as_deref()
.unwrap_or_else(|| match &self.screen().content {
Content::Catalog { query, .. } => query.as_str(),
_ => self.screen().summary.as_str(),
});
frame.render_widget(
Paragraph::new(query).block(Block::bordered().title(if self.input.is_some() {
"Catalog query · editing"
} else if matches!(self.screen().content, Content::Catalog { .. }) {
"Catalog query"
} else {
"Metadata"
})),
let query = self.input.as_deref().unwrap_or_else(|| {
self.screens[..=self.index]
.iter()
.rev()
.find_map(|screen| match &screen.content {
Content::Catalog { query, .. } => Some(query.as_str()),
_ => None,
})
.unwrap_or("")
});
ui::search(
frame,
query_area,
" tubular / Miruro search ",
query,
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
);
let (list_area, metadata_area) = if area.width >= 80 {
let [list, metadata] =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)])
.areas(content);
(Some(list), Some(metadata))
} else if self.description_focus {
(None, Some(content))
} else {
(Some(content), None)
let title = match self.screen().content {
Content::Catalog { .. } => " Titles ",
_ => " Episodes ",
};
if let Some(list_area) = list_area {
if self.screen().rows.is_empty() {
let empty = if let Some(error) = &self.screen().error {
format!(
"{error}\n\n{} refreshes; {} searches the catalog.",
self.config.keys.label(Action::ViewRefresh),
self.config.keys.label(Action::SearchEdit)
)
} else if self.intent.is_some() {
"Loading Miruro metadata…".into()
} else {
match &self.screen().content {
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
}
};
frame.render_widget(
Paragraph::new(empty)
.wrap(Wrap { trim: true })
.block(Block::bordered().title("Titles / episodes")),
list_area,
);
let block = Block::bordered().title(crate::loading_title(
title,
"",
self.intent.is_some().then(|| ui::spinner(elapsed)),
));
if self.screen().rows.is_empty() {
let empty = if let Some(error) = &self.screen().error {
format!(
"{error}\n\n{} refreshes; {} searches the catalog.",
self.config.keys.label(Action::ViewRefresh),
self.config.keys.label(Action::SearchEdit)
)
} else if self.intent.is_some() {
"Loading Miruro metadata…".into()
} else {
let mut selection = std::mem::take(&mut self.screen_mut().selection);
frame.render_stateful_widget(
List::new(self.rows())
.block(Block::bordered().title(if self.description_focus {
"Titles / episodes"
} else {
"Titles / episodes · focused"
}))
.highlight_symbol("› ")
.highlight_style(Style::new().fg(Color::Cyan).bold()),
list_area,
&mut selection,
);
self.screen_mut().selection = selection;
}
match &self.screen().content {
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
}
};
frame.render_widget(
Paragraph::new(empty).wrap(Wrap { trim: true }).block(block),
content,
);
} else {
let mut selection = std::mem::take(&mut self.screen_mut().selection);
frame.render_stateful_widget(
List::new(self.rows())
.block(block)
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
content,
&mut selection,
);
self.screen_mut().selection = selection;
}
if let Some(metadata_area) = metadata_area {
let block = Block::bordered().title(if self.description_focus {
"Synopsis / metadata · focused"
} else {
"Synopsis / metadata"
});
let inner = block.inner(metadata_area);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
let popup = crate::preview_area(area);
if self.description_focus {
let block = Block::bordered()
.title(" Description ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
if let Some(key) = self.screen().selected_key() {
metadata.lines.push(Line::from(self.markers(key)));
}
let paragraph = Paragraph::new(metadata).wrap(Wrap { trim: true });
let max_scroll = paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16;
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = self.screen().description_scroll.min(max_scroll);
frame.render_widget(paragraph.scroll((scroll, 0)).block(block), metadata_area);
frame.render_widget(Clear, popup);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup);
self.screen_mut().description_scroll = scroll;
}
let status_text = if self.intent.is_some() {
format!("{} · loading…", self.status)
} else {
self.status.clone()
};
frame.render_widget(
Paragraph::new(status_text).wrap(Wrap { trim: true }),
status,
);
frame.render_widget(
Paragraph::new(self.hints()).wrap(Wrap { trim: true }).dim(),
hints,
);
if let Some((kind, ids)) = &self.removal {
frame.render_widget(ratatui::widgets::Clear, content);
frame.render_widget(Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind))).wrap(Wrap { trim: true }).block(Block::bordered().title("Confirm removal")), content);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind)))
.wrap(Wrap { trim: true })
.block(
Block::bordered()
.title(" Remove collection ")
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
} else if let Some(scroll) = self.help {
let mut lines = vec![
Line::from("Miruro catalog → series → actual episodes").bold(),
@@ -1167,17 +1218,24 @@ impl Browser {
lines.extend(self.bindings().into_iter().map(|(action, label)| {
Line::from(format!("{} {label}", self.config.keys.label(action)))
}));
frame.render_widget(ratatui::widgets::Clear, content);
let block = Block::bordered().title("Miruro keyboard help");
let inner = block.inner(content);
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(" Miruro keyboard help ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
let max_scroll = paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16;
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = scroll.min(max_scroll);
self.help = Some(scroll);
frame.render_widget(paragraph.scroll((scroll, 0)).block(block), content);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup);
}
}
}
@@ -1346,9 +1404,10 @@ pub fn run(
SetCursorStyle::SteadyBlock
)?;
let mut browser = Browser::new(options, config_path, config, state);
let started = Instant::now();
loop {
browser.update();
terminal.draw(|frame| browser.draw(frame))?;
terminal.draw(|frame| browser.draw(frame, started.elapsed()))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
@@ -1460,6 +1519,60 @@ mod tests {
.collect()
}
#[test]
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
use ratatui::{Terminal, backend::TestBackend};
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
browser
.screen_mut()
.overview
.push_str(&"\nMore synopsis".repeat(40));
browser.screen_mut().overview.push_str("\nTAIL");
browser.screen_mut().update_description();
let selected = browser.screen().selected_video().unwrap().id;
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
let popup = crate::preview_area(terminal.backend().buffer().area);
let popup_text = |terminal: &Terminal<TestBackend>| -> String {
(popup.y + 1..popup.bottom() - 1)
.flat_map(|y| {
(popup.x + 1..popup.right() - 1)
.map(move |x| terminal.backend().buffer()[(x, y)].symbol())
})
.collect()
};
assert!(popup_text(&terminal).contains("Episode 3"));
browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert_eq!(browser.screen().description_scroll, 1);
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = u16::MAX;
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert!(popup_text(&terminal).contains("TAIL"));
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!browser.description_focus);
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = 0;
}
browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:first:7"
);
}
#[test]
fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() {
let directory = Directory::new();
@@ -1550,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();
+52
View File
@@ -0,0 +1,52 @@
use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::Line,
widgets::{Block, Paragraph},
};
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
pub const SELECTION_MARKER: &str = "> ";
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(area)
}
pub fn search(frame: &mut Frame, area: Rect, title: &str, query: &str, editing: bool) {
let block =
Block::bordered()
.title(title)
.border_style(if editing { ACCENT } else { Style::new() });
let inner = block.inner(area);
let query = Line::raw(query);
let width = query.width();
let scroll = width.saturating_sub(usize::from(inner.width.saturating_sub(1)));
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(block),
area,
);
if editing && !inner.is_empty() {
let cursor = width
.saturating_sub(scroll)
.min(usize::from(inner.width - 1));
frame.set_cursor_position((inner.x + cursor as u16, inner.y));
}
}
pub fn spinner(elapsed: Duration) -> &'static str {
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
}
+264 -22
View File
@@ -65,6 +65,12 @@ pub enum CollectionKind {
Watching,
}
#[derive(Clone, Copy)]
pub enum WatchStatus {
Watching,
Watched,
}
struct Collection {
file: StoredFile,
videos: Vec<Video>,
@@ -192,27 +198,78 @@ impl State {
pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> {
if matches!(kind, CollectionKind::Watching) {
self.discovery = None;
loop {
self.discover()?;
if self.discovery.is_none() {
break;
}
}
for stream in &mut self.streams {
match stream.prepare() {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
}
if stream.id.as_ref().is_some_and(|id| ids.contains(id)) {
stream.mark_seen()?;
}
}
self.suppress_sessions(ids, false)?;
}
self.collection_mut(kind).remove(ids)
}
pub fn set_watch_status(&mut self, video: &Video, status: WatchStatus) -> io::Result<()> {
let mut video = video.clone();
validate_snapshot(&mut video, false)?;
check_original(&self.watch_state.path, self.watch_state.original.as_deref())?;
check_original(
&self.watching.file.path,
self.watching.file.original.as_deref(),
)?;
let result = (|| {
self.suppress_sessions(std::slice::from_ref(&video.id), true)?;
self.pending.remove(&video.id);
match status {
WatchStatus::Watching => {
self.watching.save(&video)?;
self.notify_changed = true;
if self.watched.contains(&video.id) {
self.watch_state.save(watched_document(
self.watched.iter().filter(|id| *id != &video.id),
))?;
self.watched.remove(&video.id);
}
}
WatchStatus::Watched => {
if !self.watched.contains(&video.id) {
self.watch_state.save(watched_document(
self.watched.iter().chain(std::iter::once(&video.id)),
))?;
self.watched.insert(video.id.clone());
self.notify_changed = true;
}
self.watching.remove(std::slice::from_ref(&video.id))?;
self.notify_changed = true;
}
}
Ok(())
})();
result.map_err(|e: io::Error| {
io::Error::new(
e.kind(),
format!(
"Could not set watch status; some session/state files may already have been updated: {e}"
),
)
})
}
fn suppress_sessions(&mut self, ids: &[String], status_edited: bool) -> io::Result<()> {
self.discovery = None;
loop {
self.discover()?;
if self.discovery.is_none() {
break;
}
}
for stream in &mut self.streams {
match stream.prepare() {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
}
if stream.id.as_ref().is_some_and(|id| ids.contains(id)) {
stream.mark_seen(status_edited)?;
}
}
Ok(())
}
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
if self.streams.len() >= MAX_STREAMS {
return Err(invalid(
@@ -313,7 +370,7 @@ impl State {
self.watching.save(video)?;
self.notify_changed |= added;
}
stream.mark_seen()?;
stream.mark_seen(false)?;
}
stream.started = false;
}
@@ -751,6 +808,7 @@ struct ProgressStream {
video: Option<Video>,
seen_file: Option<StoredFile>,
seen: bool,
status_edited: bool,
started: bool,
file: Option<File>,
partial: Vec<u8>,
@@ -767,6 +825,7 @@ impl ProgressStream {
video: None,
seen_file: None,
seen: false,
status_edited: false,
started: false,
file: None,
partial: Vec::new(),
@@ -820,22 +879,29 @@ impl ProgressStream {
if seen
&& (document
.iter()
.any(|(key, _)| !matches!(key, "version" | "id"))
.any(|(key, _)| !matches!(key, "version" | "id" | "status_edited"))
|| document.get("id").and_then(|item| item.as_str()) != Some(id))
{
return Err(invalid(
"Session watching admission must match its video-id marker",
));
}
let status_edited = match document.get("status_edited") {
Some(item) => item
.as_bool()
.ok_or_else(|| invalid("Session status_edited must be a boolean"))?,
None => false,
};
self.id = Some(id.into());
self.video = video;
self.seen_file = Some(seen_file);
self.seen = seen;
self.status_edited = status_edited;
Ok(())
}
fn mark_seen(&mut self) -> io::Result<()> {
if self.seen {
fn mark_seen(&mut self, status_edited: bool) -> io::Result<()> {
if self.seen && (!status_edited || self.status_edited) {
let file = self.seen_file.as_ref().unwrap();
return check_original(&file.path, file.original.as_deref());
}
@@ -843,8 +909,12 @@ impl ProgressStream {
let mut document = DocumentMut::new();
document["version"] = value(1);
document["id"] = value(self.id.as_deref().unwrap());
if status_edited || self.status_edited {
document["status_edited"] = value(true);
}
self.seen_file.as_mut().unwrap().save(document)?;
self.seen = true;
self.status_edited |= status_edited;
self.started = false;
Ok(())
}
@@ -970,7 +1040,7 @@ impl ProgressStream {
"position must be finite/nonnegative and duration positive/finite or null",
));
}
if self.id.as_deref() != Some(id) {
if self.id.as_deref() != Some(id) || self.status_edited {
return Ok(());
}
if position > 0.0 && !self.seen && !watched.contains(id) {
@@ -1090,6 +1160,178 @@ mod tests {
+ "\n"
}
#[test]
fn manual_status_transitions_preserve_both_providers_and_other_collections() {
for video in [video(), miruro_video(1)] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let other = miruro_video(2);
state
.set_watch_status(&other, WatchStatus::Watched)
.unwrap();
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &video).unwrap();
}
let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap();
let later = fs::read(directory.0.join("watch-later.toml")).unwrap();
for status in [
WatchStatus::Watching,
WatchStatus::Watched,
WatchStatus::Watching,
] {
state.set_watch_status(&video, status).unwrap();
let watching = fs::read(directory.0.join("currently-watching.toml")).unwrap();
let watched =
read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap();
state.set_watch_status(&video, status).unwrap();
assert_eq!(
fs::read(directory.0.join("currently-watching.toml")).unwrap(),
watching
);
assert_eq!(
read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(),
watched
);
state = State::open(&directory.config()).unwrap();
assert!(state.is_watched(&other.id));
assert_eq!(
state.is_watched(&video.id),
matches!(status, WatchStatus::Watched)
);
assert_eq!(
state.contains(CollectionKind::Watching, &video.id),
matches!(status, WatchStatus::Watching)
);
if matches!(status, WatchStatus::Watching) {
assert_eq!(
serde_json::to_value(&state.videos(CollectionKind::Watching)[0]).unwrap(),
serde_json::to_value(&video).unwrap()
);
}
assert_eq!(
fs::read(directory.0.join("saved-videos.toml")).unwrap(),
saved
);
assert_eq!(
fs::read(directory.0.join("watch-later.toml")).unwrap(),
later
);
}
}
}
#[test]
fn manual_watching_ignores_old_completion_replays_but_accepts_new_sessions() {
for video in [video(), miruro_video(1)] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let completion = if video.id.starts_with("miruro:") {
miruro_record(&video, "ended", true, 95.0, Some(100.0))
} else {
record(&video.id, "ended", true, 95.0)
};
let first = state.progress_file(&video).unwrap();
register_identity(&first, &video.id).unwrap();
append(&first, completion.as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
let second = state.progress_file(&video).unwrap();
register_identity(&second, &video.id).unwrap();
// Neither the reports nor the second session have been read by this instance.
drop(state);
let mut state = State::open(&directory.config()).unwrap();
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
for report in [&first, &second] {
append(report, completion.as_bytes());
}
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
let new_session = state.progress_file(&video).unwrap();
register_identity(&new_session, &video.id).unwrap();
append(&new_session, completion.as_bytes());
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
assert!(!state.contains(CollectionKind::Watching, &video.id));
}
}
#[test]
fn manual_status_rejects_invalid_videos_and_external_edits_before_writing() {
for video in [video(), miruro_video(1)] {
for status in [WatchStatus::Watching, WatchStatus::Watched] {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut invalid_video = video.clone();
invalid_video.id = "--invalid".into();
assert!(state.set_watch_status(&invalid_video, status).is_err());
assert!(!directory.0.join("watch-state.toml").exists());
assert!(!directory.0.join("currently-watching.toml").exists());
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
state
.set_watch_status(&video, WatchStatus::Watched)
.unwrap();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
for name in ["watch-state.toml", "currently-watching.toml"] {
let path = directory.0.join(name);
let original = fs::read(&path).unwrap();
append(&path, b"\n# external edit\n");
let changed = fs::read(&path).unwrap();
assert!(state.set_watch_status(&video, status).is_err());
assert_eq!(fs::read(&path).unwrap(), changed);
assert!(state.is_watched(&video.id));
assert!(!state.contains(CollectionKind::Watching, &video.id));
assert!(!report.with_file_name("watching-seen.toml").exists());
fs::write(path, original).unwrap();
}
}
}
}
#[test]
fn manual_watching_clears_consumed_pending_completion_and_checks_session_edits() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let report = state.progress_file(&video).unwrap();
register_identity(&report, &video.id).unwrap();
append(&report, record(&video.id, "ended", true, 95.0).as_bytes());
let watched = directory.0.join("watch-state.toml");
fs::write(&watched, "external edit").unwrap();
assert!(state.poll_progress().is_err());
assert!(state.pending.contains(&video.id));
fs::remove_file(watched).unwrap();
let marker = report.with_file_name("watching-seen.toml");
fs::write(&marker, "external edit").unwrap();
assert!(
state
.set_watch_status(&video, WatchStatus::Watching)
.is_err()
);
assert!(state.videos(CollectionKind::Watching).is_empty());
assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit");
fs::remove_file(&marker).unwrap();
state
.set_watch_status(&video, WatchStatus::Watching)
.unwrap();
assert!(!state.pending.contains(&video.id));
state.poll_progress().unwrap();
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video.id));
assert!(state.contains(CollectionKind::Watching, &video.id));
}
#[test]
fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() {
let directory = TestDirectory::new();