feat: support multiple video selection

This commit is contained in:
2026-10-22 14:01:47 +02:00
parent acfb3be1e7
commit 2d30120072
12 changed files with 883 additions and 169 deletions
+15 -5
View File
@@ -7,7 +7,7 @@ A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https:
- A recent stable Rust toolchain, including Cargo, to build and install.
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client.
- `curl` on `PATH` for YouTube thumbnails and Miruro cover art.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Both providers use `--new-instance --url … --progress-file PATH`; Miruro requires native Miruro episode-URL resolution and version-2 episode progress support.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; multiple videos require `--new-instance --queue-file PATH` support. Miruro also requires native episode-URL resolution and version-2 episode progress support.
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
@@ -57,7 +57,15 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
--progress-file '/private/session/progress.jsonl'
```
Each selection opens an independent player instance without replacing existing playback. Tubular creates a private reporting session and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
Each submitted selection opens one independent player instance without replacing existing playback. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
### Multiple video selection
In video and episode lists, **Space** checks/unchecks the hovered row; **Ctrl-Space** clears checks. **Enter** plays checked rows in their current displayed order, regardless of the order they were checked. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
Checks belong to each cached view/screen and follow stable video identities through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff).
### Miruro browser
@@ -70,7 +78,8 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels |
| `p` | Reopen the provider chooser in normal mode |
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
| Enter | Open a series, or pass the selected episode URL to Terebi for resolution and playback |
| Space / Ctrl-Space | Check/uncheck the hovered episode / clear episode checks |
| Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) |
| `-` / `+` | Back / forward through cached screens, preserving query and selection |
| `w` / `v` | Save the selected episode to watch later / saved episodes |
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
@@ -104,7 +113,8 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| Key | Action |
| --- | --- |
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
| Enter | Launch the selected video in Terebi |
| Space / Ctrl-Space | Check/uncheck the hovered video / clear checks |
| Enter | Play checked videos in displayed order, or the hovered video when none are checked |
| `p` | Reopen the provider chooser |
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
| `g` / Home, `G` / End | Select the first / last loaded video |
@@ -288,7 +298,7 @@ Files are siblings of `config.toml`:
- `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually.
- `watch-state.toml`: versioned global watched video IDs.
- `miruro-library.toml`: versioned saved-show catalog metadata and overlapping category tags, independent of episode collections.
- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, and durable admission/manual-edit markers.
- `playback-sessions/`: private per-video progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and ordered `queue.json` manifests for multi-video launches.
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.
+42 -1
View File
@@ -11,7 +11,7 @@ Terebi already accepts native Miruro episode URLs, resolves sources and supports
## URL and canonical-route handling
Tubular uses `State::progress_file(video)` and `PlayerLaunch::start(&video.id, player, progress_file)` for both providers. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs:
Tubular uses `PlayerLaunch::start(&videos, player, &mut state)` for both providers; the launcher allocates and binds private reporting sessions through `State`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs:
```sh
terebi --new-instance \
@@ -71,3 +71,44 @@ Exercised locally on 2026-10-22:
- `cargo clippy --locked --all-targets -- -D warnings` is blocked by findings in untouched files: `src/youtube/catalog.rs:184` (`collapsible_if`) and `src/titles.rs:492` (`reversed_empty_ranges`).
The launcher fixture did not run Terebi. Native Terebi playback, title enrichment and visible timeline bands are not verified by these Tubular checks. Temporary smoke fixtures were removed; the user-facing catalog probe remains.
## Ordered stream queue handoff
Multiple-video selection extends the URL handoff without transporting resolved sources or changing progress records. Tubular sends checked rows in displayed order; it does not combine providers' browsing sessions into one selection. Terebi accepts either provider in a queue and preserves the supplied order instead of applying local-file filename sorting.
```sh
terebi --new-instance --queue-file '/private/session/queue.json'
```
The version-1 manifest is:
```json
{
"version": 1,
"items": [
{
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
"progress_file": "/private/playback-sessions/session-A/progress.jsonl"
},
{
"url": "https://barelystarted.miruro.tv/watch/opaque_Show-1/episode?ep=3",
"progress_file": "/private/playback-sessions/session-B/progress.jsonl"
}
]
}
```
The manifest contains 1–4,096 entries. Tubular uses it only for batches of two or more; a single selected item retains `--url` and `--progress-file`. `--queue-file` requires `--new-instance` and an absolute manifest path, and cannot be combined with another media argument or global `--progress-file`. Terebi validates the entire manifest before launch and again at GUI startup: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry.
Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too.
Terebi resolves only the selected queue item, through its ordinary YouTube/Miruro resolver and metadata lifecycle. Next/previous, sidebar selection, reordering/removal and natural-end advancement reuse the existing queue. Switching entries cancels stale resolution/metadata work without discarding the queue; failed playback does not mark an item watched. Local-file queues retain natural filename ordering. A submitted batch opens one independent player, not one window per checked item.
Each selected entry reports only to its own registered destination. Switching drains the previous writer; returning to an entry appends only to the original owned regular-file inode, never truncating or adopting an unrelated existing file. Resume keys and version-1 YouTube/version-2 Miruro identities remain stable. Tubular's watching admission, 95% completion rule, manual-status precedence and restart recovery consume these independent reports unchanged.
### Queue verification status
- Tubular: `cargo fmt --all --check`, `cargo test --locked` (**230 passed**), `cargo build --locked` and the built CLI's `--help` passed.
- Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi.
- Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports.
- The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation.
+12 -2
View File
@@ -119,12 +119,14 @@ actions! {
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"];
VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"];
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
VideoClear, "video.select.clear", "Clear playback checks in the current view", VIEW, ["ctrl+space"];
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
@@ -480,7 +482,15 @@ impl Bindings {
"VIEWS AND VIDEOS",
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"),
(
&[VideoPlay],
"Play checked videos in displayed order, or the selected video",
),
(
&[VideoToggle],
"Check / uncheck the selected video for playback",
),
(&[VideoClear], "Clear playback checks in the current view"),
(
&[VideoOpen],
"Open the selected YouTube video in the default browser",
+108 -41
View File
File diff suppressed because one or more lines are too long
+107 -13
View File
@@ -93,6 +93,7 @@ struct DisplayRow {
struct Screen {
content: Content,
selection: ListState,
checked: BTreeSet<String>,
description_scroll: u16,
error: Option<String>,
title: String,
@@ -105,6 +106,7 @@ impl Screen {
let mut screen = Self {
content,
selection: ListState::default(),
checked: BTreeSet::new(),
description_scroll: 0,
error: None,
title: String::new(),
@@ -153,6 +155,42 @@ impl Screen {
_ => None,
}
}
fn playable(&self) -> bool {
matches!(
self.content,
Content::Series { .. } | Content::Collection { .. }
)
}
fn toggle_checked(&mut self) {
if self.playable()
&& let Some(id) = self.selected_key().map(str::to_owned)
{
if !self.checked.remove(&id) {
self.checked.insert(id);
}
}
}
fn playback_videos(&self) -> Vec<Video> {
if self.checked.is_empty() {
return self.selected_video().into_iter().collect();
}
match &self.content {
Content::Series {
media,
data: Some(data),
} => data
.iter()
.filter(|episode| self.checked.contains(&episode.identity.key()))
.map(|episode| episode.snapshot(media))
.collect(),
Content::Collection { videos, .. } => videos
.iter()
.filter(|video| self.checked.contains(&video.id))
.cloned()
.collect(),
_ => Vec::new(),
}
}
fn selected_key(&self) -> Option<&str> {
self.selection
.selected()
@@ -289,6 +327,14 @@ impl Screen {
.collect();
}
}
if !self.checked.is_empty() {
let visible: BTreeSet<_> = self
.rows
.iter()
.filter_map(|row| row.key.as_deref())
.collect();
self.checked.retain(|id| visible.contains(id.as_str()));
}
self.update_description();
}
}
@@ -506,7 +552,9 @@ impl Browser {
return;
};
let id = media.id.clone();
match miruro_browser::slug(&media.title) {
let slug = miruro_browser::slug(&media.title);
self.screen_mut().checked.clear();
match slug {
Ok(slug) => {
self.request(
Request::Episodes {
@@ -529,18 +577,25 @@ impl Browser {
{
self.push(Screen::new(Content::Series { media, data: None }));
self.load_series();
} else if let Some(video) = self.screen().selected_video() {
} else {
let videos = self.screen().playback_videos();
if videos.is_empty() {
return;
}
self.invalidate();
self.screen_mut().error = None;
let result = self.state.progress_file(&video).and_then(|progress_file| {
// Dropping the launcher cancels only its CLI child, not the native player.
self.player = None;
PlayerLaunch::start(&video.id, &self.options.player, &progress_file)
});
let result = PlayerLaunch::start(&videos, &self.options.player, &mut self.state);
match result {
Ok(player) => {
self.player = Some(player);
self.status = format!("Opening {} in Terebi…", safe(&video.title));
self.screen_mut().checked.clear();
self.status = if videos.len() == 1 {
format!("Opening {} in Terebi…", safe(&videos[0].title))
} else {
format!("Opening {} episodes in Terebi…", videos.len())
};
}
Err(error) => self.fail(error),
}
@@ -601,6 +656,7 @@ impl Browser {
current.extend(incoming);
}
*data = Some(series.episodes);
self.screen_mut().checked.clear();
self.screen_mut().selection.select(next);
self.screen_mut().clamp();
self.screen_mut().rebuild_display();
@@ -981,6 +1037,7 @@ impl Browser {
}
fn refresh(&mut self) {
self.invalidate();
self.screen_mut().checked.clear();
match config::load(&self.config_path) {
Ok(config) => {
self.config = config;
@@ -1153,6 +1210,8 @@ impl Browser {
Some(Action::ViewNext) => self.history(true),
Some(Action::ViewRefresh) => self.refresh(),
Some(Action::VideoPlay) => self.open(),
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
Some(
action @ (Action::VideoNext
| Action::VideoPrevious
@@ -1265,8 +1324,10 @@ impl Browser {
Content::Catalog { .. } | Content::Library { .. }
) {
"Open series"
} else {
} else if self.screen().checked.is_empty() {
"Play episode in Terebi"
} else {
"Play checked episodes in Terebi"
},
),
(Action::VideoNext, "Next title/episode"),
@@ -1309,6 +1370,8 @@ impl Browser {
Content::Series { .. } | Content::Collection { .. }
) {
bindings.extend([
(Action::VideoToggle, "Toggle checked episode"),
(Action::VideoClear, "Clear checked episodes"),
(Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"),
(Action::WatchStatusWatching, "Mark episode as watching"),
@@ -1366,12 +1429,20 @@ impl Browser {
Content::Catalog { .. } | Content::Library { .. }
) {
"open"
} else {
} else if self.screen().checked.is_empty() {
"play"
} else {
"play checked"
},
),
(Action::CategoryBrowse, "categories"),
];
if self.screen().playable() {
actions.extend([
(Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"),
]);
}
if !matches!(self.screen().content, Content::Collection { .. }) {
actions.push((Action::ChannelTags, "save show/tags"));
}
@@ -1420,6 +1491,9 @@ impl Browser {
"INFO",
&[
(Action::InfoDescription, "description"),
(Action::VideoPlay, "play"),
(Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"),
(Action::InfoHide, "close"),
(Action::Help, "help"),
],
@@ -1429,6 +1503,9 @@ impl Browser {
"DESCRIPTION",
&[
(Action::InfoOverview, "info"),
(Action::VideoPlay, "play"),
(Action::VideoToggle, "check"),
(Action::VideoClear, "clear checks"),
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::WatchStatusWatching, "mark watching"),
@@ -1575,7 +1652,23 @@ impl Browser {
" · "
};
ListItem::new(vec![
Line::from(row.title.as_str()).style(style),
Line::from(vec![
ratatui::text::Span::raw(if self.screen().playable() {
if row
.key
.as_ref()
.is_some_and(|id| self.screen().checked.contains(id))
{
"[x] "
} else {
"[ ] "
}
} else {
""
}),
ratatui::text::Span::raw(row.title.as_str()),
])
.style(style),
Line::from(vec![
ratatui::text::Span::raw(row.details.as_str()),
ratatui::text::Span::raw(separator),
@@ -1627,12 +1720,13 @@ impl Browser {
query,
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
);
let title = match self.screen().content {
Content::Catalog { .. } | Content::Library { .. } => " Titles ",
_ => " Episodes ",
let title = if self.screen().playable() {
format!(" Episodes · {} checked ", self.screen().checked.len())
} else {
" Titles ".into()
};
let block = Block::bordered().title(crate::loading_title(
title,
&title,
"",
self.intent.is_some().then(|| ui::spinner(elapsed)),
));
+22 -1
View File
@@ -346,6 +346,23 @@ impl State {
Ok(())
}
pub fn progress_files(&mut self, videos: &[Video]) -> io::Result<Vec<PathBuf>> {
if videos.len() > MAX_STREAMS.saturating_sub(self.streams.len()) {
return Err(invalid(
"Playback session limit reached; remove inactive playback-sessions directories and restart",
));
}
// Validate the entire batch before creating any retained sessions.
let mut snapshots = videos.to_vec();
for video in &mut snapshots {
validate_snapshot(video, false)?;
}
snapshots
.iter()
.map(|video| self.create_progress_file(video))
.collect()
}
pub fn progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
if self.streams.len() >= MAX_STREAMS {
return Err(invalid(
@@ -354,6 +371,10 @@ impl State {
}
let mut video = video.clone();
validate_snapshot(&mut video, false)?;
self.create_progress_file(&video)
}
fn create_progress_file(&mut self, video: &Video) -> io::Result<PathBuf> {
fs::create_dir_all(self.sessions.parent().unwrap_or(Path::new(".")))?;
private_directory(&self.sessions, true)?;
let directory = loop {
@@ -369,7 +390,7 @@ impl State {
}
};
let (mut snapshot, _) = StoredFile::open(directory.join("video.toml"))?;
snapshot.save(video_document(std::iter::once(&video)))?;
snapshot.save(video_document(std::iter::once(video)))?;
let path = directory.join("progress.jsonl");
self.streams.push(ProgressStream::new(path.clone()));
Ok(path)
+77 -9
View File
@@ -1,8 +1,9 @@
use std::{
collections::VecDeque,
collections::{BTreeSet, VecDeque},
ffi::OsStr,
fs::{self, OpenOptions},
io::{self, Read},
path::Path,
path::PathBuf,
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
@@ -914,20 +915,87 @@ pub struct PlayerLaunch {
}
impl PlayerLaunch {
pub fn start(id: &str, player: &OsStr, progress_file: &Path) -> io::Result<Self> {
let url = if valid_video_id(id) {
format!("https://www.youtube.com/watch?v={id}")
pub fn start(
videos: &[Video],
player: &OsStr,
state: &mut crate::video_state::State,
) -> io::Result<Self> {
if videos.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No videos selected",
));
}
let playback_url = |id: &str| {
if valid_video_id(id) {
Ok(format!("https://www.youtube.com/watch?v={id}"))
} else {
tubular::miruro::Identity::from_key(id)
.and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
}
};
crate::video_state::register_identity(progress_file, id)?;
let mut command = isolated_command(player);
command.arg("--new-instance");
if let [video] = videos {
let url = playback_url(&video.id)?;
let report = state.progress_file(video)?;
crate::video_state::register_identity(&report, &video.id)?;
command
.args(["--new-instance", "--url", &url])
.args(["--url", &url])
.arg("--progress-file")
.arg(progress_file);
.arg(report);
return Self::launch(command);
}
let mut ids = BTreeSet::new();
let urls = videos
.iter()
.map(|video| {
if !ids.insert(video.id.as_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Duplicate queue identity",
));
}
playback_url(&video.id)
})
.collect::<io::Result<Vec<_>>>()?;
let reports = state.progress_files(videos)?;
#[derive(Serialize)]
struct QueueItem {
url: String,
progress_file: PathBuf,
}
#[derive(Serialize)]
struct Queue {
version: u8,
items: Vec<QueueItem>,
}
let items = videos
.iter()
.zip(urls)
.zip(reports)
.map(|((video, url), report)| {
crate::video_state::register_identity(&report, &video.id)?;
let parent = fs::canonicalize(report.parent().expect("session directory"))?;
Ok(QueueItem {
url,
progress_file: parent.join("progress.jsonl"),
})
})
.collect::<io::Result<Vec<_>>>()?;
let manifest = items[0].progress_file.with_file_name("queue.json");
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(&manifest)?;
serde_json::to_writer(&file, &Queue { version: 1, items })?;
file.sync_all()?;
command.arg("--queue-file").arg(manifest);
Self::launch(command)
}
+196 -56
View File
@@ -67,6 +67,202 @@ fn app(args: &[&str]) -> App {
)
}
#[test]
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
let mut app = app(&["tubular"]);
app.view_index = 1;
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"New","timestamp":200}]}"#,
true,
);
let view = &mut app.views[1];
view.selection.select(Some(1));
view.toggle_checked();
view.selection.select(Some(0));
view.toggle_checked();
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Newest","timestamp":300}]}"#,
false,
);
let view = &mut app.views[1];
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
view.reset();
assert!(view.checked.is_empty());
// Old rows remain during refresh, but checks made there cannot leak into replacement data.
view.toggle_checked();
assert!(!view.checked.is_empty());
view.accept_page(
youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#)
.unwrap(),
false,
)
.unwrap();
assert!(view.checked.is_empty());
assert_eq!(view.playback_videos()[0].title, "Replacement");
}
#[test]
fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() {
let mut app = app(&["tubular"]);
let videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#,
).unwrap().videos;
app.views[0].videos = videos.clone();
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.views[1].loaded = true;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.switch_view(1);
assert!(app.views[1].checked.is_empty());
app.switch_view(0);
assert!(app.views[0].checked.contains(&videos[0].id));
app.mode = Mode::Insert;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.views[0].checked.len(), 1);
app.mode = Mode::Normal;
for preview in [
None,
Some(PreviewMode::Info),
Some(PreviewMode::Description),
] {
app.preview = preview;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(app.views[0].checked.is_empty());
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(app.views[0].checked.contains(&videos[0].id));
}
app.preview = None;
for video in &videos {
app.video_state
.save(CollectionKind::WatchLater, video)
.unwrap();
}
app.browse_collection(CollectionKind::WatchLater);
let index = app.view_index;
app.views[index].selection.select(Some(0));
app.views[index].toggle_checked();
app.views[index].selection.select(Some(1));
app.views[index].toggle_checked();
let removed = app.views[index].videos[1].id.clone();
app.remove_collection();
assert!(!app.views[index].checked.contains(&removed));
assert_eq!(app.views[index].checked.len(), 1);
assert_eq!(app.views[index].playback_videos().len(), 1);
app.views[index].watch_filter = WatchFilter::Watched;
app.rebuild_collections();
assert!(app.views[index].videos.is_empty());
assert!(app.views[index].checked.is_empty());
app.views[index].watch_filter = WatchFilter::All;
app.rebuild_collections();
app.views[index].toggle_checked();
// Refresh is a replacement even for locally rebuilt collection views.
app.load_view();
assert!(app.views[index].checked.is_empty());
assert_eq!(app.views[0].checked.len(), 1);
// An empty category exercises query replacement entirely offline.
app.config.categories.insert("Empty".into(), Vec::new());
let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into()));
view.videos = videos;
view.selection.select(Some(0));
view.toggle_checked();
view.query = "replacement query".into();
app.views.push(view);
app.view_index = app.views.len() - 1;
app.search();
assert!(app.views[app.view_index].checked.is_empty());
assert!(app.job.is_none());
}
#[test]
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
let mut app = app(&["tubular"]);
let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
app.key_hints = KeyHints::new(&app.config.keys);
for context in [
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
] {
for key in ["F20", "F21"] {
assert!(app.key_hints.footer(context).contains(key));
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
assert!(app.config.keys.help_text().to_string().contains(key));
}
}
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#,
).unwrap().videos;
for (index, video) in app.views[0].videos.iter_mut().enumerate() {
video.id = format!("offline:{index}");
}
app.views[0].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE));
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("[x]"));
assert!(rendered.contains("[ ]"));
assert!(rendered.contains("1 checked"));
app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE));
assert!(app.views[0].checked.is_empty());
}
#[test]
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(1));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.views[0].toggle_checked();
app.views[0].selection.select(Some(0));
app.options.player = "/nonexistent/tubular-test-player".into();
app.play();
assert!(app.player_launch.is_none());
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.options.player = "/usr/bin/true".into();
app.play();
assert!(app.player_launch.is_some());
assert!(app.views[0].checked.is_empty());
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
app.player_launch = None;
app.play();
assert!(app.player_launch.is_some());
}
#[test]
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
let mut app = app(&["tubular"]);
@@ -1849,62 +2045,6 @@ fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_previ
}
}
#[test]
fn configured_removal_hints_follow_collection_views_and_previews() {
let mut app = app(&["tubular"]);
let document =
"[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\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"}]}"#,
)
.unwrap()
.videos;
app.views[0].loaded = true;
app.views[0].selection.select(Some(0));
app.save_collection(CollectionKind::WatchLater);
app.save_collection(CollectionKind::Saved);
app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into());
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap();
for kind in [
None,
Some(CollectionKind::WatchLater),
Some(CollectionKind::Saved),
] {
app.switch_view(0);
if let Some(kind) = kind {
app.browse_collection(kind);
}
for preview in [
None,
Some(PreviewMode::Info),
Some(PreviewMode::Description),
] {
app.preview = preview;
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert_eq!(rendered.contains("F8"), kind.is_some());
assert_eq!(rendered.contains("F9"), kind.is_some());
if preview == Some(PreviewMode::Info) {
let popup = preview_area(buffer.area);
let title: String = (popup.x..popup.right())
.map(|x| buffer[(x, popup.y)].symbol())
.collect();
assert_eq!(title.contains("F8"), kind.is_some());
assert_eq!(title.contains("F9"), kind.is_some());
}
}
}
}
#[test]
fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() {
let root = std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id()));
+45
View File
@@ -15,6 +15,51 @@ fn press(
bindings.resolve(context, KeyEvent::new(code, modifiers))
}
#[test]
fn playback_checks_are_configurable_in_views_without_stealing_modal_space() {
let defaults = Bindings::default();
let overrides = configured("[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n").unwrap();
for &context in ALL {
for (action, modifiers, override_code) in [
(Action::VideoToggle, KeyModifiers::NONE, KeyCode::F(20)),
(Action::VideoClear, KeyModifiers::CONTROL, KeyCode::F(21)),
] {
let expected = VIEW.contains(&context).then_some(action);
assert_eq!(
press(&defaults, context, KeyCode::Char(' '), modifiers)
.filter(|resolved| *resolved == action),
expected,
);
assert_eq!(
press(&overrides, context, override_code, KeyModifiers::NONE),
expected,
);
assert_ne!(
press(&overrides, context, KeyCode::Char(' '), modifiers),
Some(action),
);
}
}
assert_eq!(
press(
&defaults,
Context::TagList,
KeyCode::Char(' '),
KeyModifiers::NONE
),
Some(Action::TagToggle),
);
assert_eq!(
press(
&defaults,
Context::SelectorNormal,
KeyCode::Char(' '),
KeyModifiers::NONE
),
Some(Action::SelectorToggle),
);
}
#[test]
fn provider_choice_is_configurable_and_normal_only() {
let defaults = Bindings::default();
+184
View File
@@ -574,3 +574,187 @@ fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
}
#[test]
fn episode_checks_follow_returned_order_and_cached_screen_history() {
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.select(Action::VideoLast);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.select(Action::VideoFirst);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let ids = |browser: &Browser| {
browser
.screen()
.playback_videos()
.into_iter()
.map(|video| video.id)
.collect::<Vec<_>>()
};
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
assert!(browser.hints().contains("play checked"));
assert!(browser.hints().contains("clear checks"));
let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("2 checked"));
assert!(text.contains("[x] Episode 3"));
assert!(text.contains("[x] Episode 7"));
if let Content::Series {
data: Some(data), ..
} = &mut browser.screen_mut().content
{
data.reverse();
}
browser.screen_mut().rebuild_display();
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
browser.history(false);
assert!(browser.screen().checked.is_empty());
browser.history(true);
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
browser.screen_mut().selection.select(None);
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
browser.select(Action::VideoLast);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(browser.screen().checked.is_empty());
assert_eq!(ids(&browser), ["miruro:first:3"]);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.refresh();
assert!(browser.screen().checked.is_empty());
// Checks made against old metadata while loading must not survive replacement.
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let replacement = series(browser.screen().selected_media().unwrap());
browser.accept(browser.generation, Ok(Response::Series(replacement)));
assert!(browser.screen().checked.is_empty());
assert_eq!(ids(&browser), ["miruro:first:3"]);
}
#[test]
fn catalog_and_saved_show_rows_cannot_be_checked() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(browser.screen().checked.is_empty());
assert!(browser.screen().playback_videos().is_empty());
assert!(
!browser
.bindings()
.iter()
.any(|(action, _)| { matches!(action, Action::VideoToggle | Action::VideoClear) })
);
browser
.state
.save_show(&media("first"), &BTreeSet::new())
.unwrap();
browser.library(BTreeSet::new());
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(browser.screen().checked.is_empty());
assert!(browser.screen().playback_videos().is_empty());
enter_series(&mut browser);
assert_eq!(browser.screen().len(), 2);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(
browser.screen().checked,
BTreeSet::from(["miruro:first:3".into()])
);
browser.edit_search();
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(browser.input.as_deref(), Some(" "));
assert!(browser.screen().checked.is_empty());
browser.input = None;
browser.history(false);
assert_eq!(
browser.screen().checked,
BTreeSet::from(["miruro:first:3".into()])
);
}
#[test]
fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
let first = browser.screen().selected_video().unwrap();
browser.save(CollectionKind::WatchLater);
browser.select(Action::VideoNext);
let second = browser.screen().selected_video().unwrap();
browser.save(CollectionKind::WatchLater);
browser
.state
.set_watch_status(&first, WatchStatus::Watched)
.unwrap();
browser.collection(CollectionKind::WatchLater);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.select(Action::VideoNext);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(browser.screen().checked.len(), 2);
browser.rebuild_collections();
assert_eq!(browser.screen().checked.len(), 2);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(
browser.screen().checked,
BTreeSet::from([second.id.clone()])
);
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert!(browser.screen().checked.is_empty());
assert_eq!(browser.screen().selected_video().unwrap().id, first.id);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.remove(false);
assert!(browser.screen().checked.is_empty());
assert!(browser.screen().playback_videos().is_empty());
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 1);
assert!(browser.screen().checked.is_empty());
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
}
#[cfg(unix)]
#[test]
fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
let first = browser.screen().selected_video().unwrap();
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.select(Action::VideoNext);
let hovered = browser.screen().selected_video().unwrap();
browser.save(CollectionKind::Saved);
browser.set_watch_status(WatchStatus::Watching);
assert!(!browser.state.contains(CollectionKind::Saved, &first.id));
assert!(!browser.state.contains(CollectionKind::Watching, &first.id));
assert!(browser.state.contains(CollectionKind::Saved, &hovered.id));
assert!(
browser
.state
.contains(CollectionKind::Watching, &hovered.id)
);
browser.open();
assert!(browser.screen().error.is_some());
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
browser.options.player = "/usr/bin/true".into();
browser.open();
assert!(browser.screen().error.is_none());
assert!(browser.player.is_some());
assert!(browser.screen().checked.is_empty());
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
}
+55
View File
@@ -1394,3 +1394,58 @@ fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() {
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video.id));
}
#[test]
fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_sessions() {
use crate::youtube::PlayerLaunch;
use std::ffi::OsStr;
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
for id in [
"",
"--help",
"jNQXAC9IVRw&list=other",
"日本語",
"miruro:opaque_Show-1:0",
"miruro:opaque_Show-1:01",
"miruro:opaque_Show-1:2147483648",
"miruro:../other:1",
"miruro:opaque_Show-1:1&ep=2",
"miruro-series:opaque_Show-1",
] {
let mut invalid_video = video();
invalid_video.id = id.into();
assert!(matches!(
PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
for videos in [vec![], vec![video(), video()]] {
assert!(matches!(
PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
assert!(!state.sessions.exists());
assert!(state.streams.is_empty());
}
#[test]
fn progress_batch_prevalidates_snapshots_and_session_capacity() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut invalid_video = miruro_video(2);
invalid_video.title = "invalid\nmetadata".into();
assert!(state.progress_files(&[video(), invalid_video]).is_err());
assert!(!state.sessions.exists());
assert!(state.streams.is_empty());
for _ in 0..MAX_STREAMS - 1 {
state
.streams
.push(ProgressStream::new(directory.0.join("unused.jsonl")));
}
assert!(state.progress_files(&[video(), miruro_video(2)]).is_err());
assert!(!state.sessions.exists());
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
}
-21
View File
@@ -533,27 +533,6 @@ mod subprocesses {
}
}
#[test]
fn player_launch_rejects_invalid_provider_ids_before_registration_or_spawning() {
for id in [
"",
"--help",
"jNQXAC9IVRw&list=other",
"日本語",
"miruro:opaque_Show-1:0",
"miruro:opaque_Show-1:01",
"miruro:opaque_Show-1:2147483648",
"miruro:../other:1",
"miruro:opaque_Show-1:1&ep=2",
"miruro-series:opaque_Show-1",
] {
assert!(matches!(
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
}
#[test]
fn browser_open_rejects_unsafe_video_ids_before_spawning() {
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {