Compare commits

...

2 Commits

Author SHA1 Message Date
hl-valdemar ba04cbe158 feat: open selected YouTube video in browser 2026-10-17 16:58:03 +02:00
hl-valdemar f8334716af feat: save and categorize Miruro shows 2026-10-17 16:53:26 +02:00
6 changed files with 1256 additions and 23 deletions
+13 -5
View File
@@ -1,6 +1,6 @@
# tubular
A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize subscribed YouTube channels with overlapping tags, and open a selection in Terebi.
A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize YouTube channels and saved Miruro shows with overlapping tags, and open a selection in Terebi.
## Requirements
@@ -56,7 +56,7 @@ Each selection opens an independent player instance without replacing existing p
### Miruro browser
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.
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
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.
@@ -67,16 +67,22 @@ 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 |
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
| `c` | Choose All saved shows or a category, then Enter opens a saved show’s 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 |
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
| `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 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.
These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Show saving/tagging uses `channel.tags`, and the library picker uses `category.browse`. From catalog results, a series screen, or the saved-show library, press `s` to edit the **show's** tags: `j` / `k` selects, Space checks/unchecks, `i` filters, and `n` enters a new tag name. Enter stages a new tag; Enter again from the tag list saves the show and all checked tags. Esc cancels without saving. A show can belong to multiple categories or remain saved without tags. The `c` picker starts in normal mode; `i` filters, Enter browses, and Esc leaves filtering before closing. Saved-show lists are alphabetical and stored offline; Enter fetches current episodes using the saved catalog ID and title. `x` removes only the show and its tags, leaving episode collections and watch status unchanged. Categories disappear when no saved shows use them. Miruro's show categories are independent of YouTube's channel tags.
Space checks categories for combined browsing; Enter opens their deduplicated union even when the filter hides them. With no checks, Enter opens the hovered category or All saved shows.
Only episodes can 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.
@@ -91,6 +97,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| --- | --- |
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
| Enter | Launch the selected video in Terebi |
| `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 |
| `i` | Edit the current category/combined feed search; other views use general YouTube Search |
| `/` | Go directly to general YouTube Search |
@@ -271,6 +278,7 @@ Files are siblings of `config.toml`:
- `saved-videos.toml`: independent versioned saved-video metadata.
- `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, 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.
@@ -301,7 +309,7 @@ Use `x` for individual removal or `X` for bulk confirmation. Removal neither mar
## Collection removal
Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, Miruro catalog/episode browsing, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
Episode removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. The Miruro saved-show library additionally supports `x` to remove a show and its tags, without removing episodes or watch status. Search, Feed, Miruro catalog/episode browsing, ordinary YouTube channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
`x` immediately removes only the selected video/episode from the active collection. `X` snapshots the current provider's visible list and opens bulk confirmation: YouTube watch later uses the current category and All/Unwatched/Watched filter, and saved videos uses the current category's entire saved list; Miruro uses its flat list and any active watch-later filter. Currently watching uses the current provider's entire list. An empty list does not open confirmation. Neither interface's removal controls touch the other provider's entries.
+6 -1
View File
@@ -119,6 +119,7 @@ actions! {
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"];
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"];
@@ -129,7 +130,7 @@ actions! {
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or Miruro show's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
@@ -479,6 +480,10 @@ impl Bindings {
&[
(&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"),
(
&[VideoOpen],
"Open the selected YouTube video in the default browser",
),
(
&[WatchStatusWatching],
"Mark selected as watching (clear watched)",
+34 -2
View File
@@ -6,9 +6,9 @@
//! Startup offers a centered YouTube/Miruro chooser; --miruro starts directly. Browsers start in normal mode.
//! A CLI query searches the selected provider after confirmation. Press i to edit.
//! `--miruro apothecary` opens the dedicated Catalog → series → episodes interface.
//! Miruro autocomplete returns at most eight catalog titles, without pagination; catalog titles cannot be saved.
//! Miruro autocomplete returns at most eight catalog titles, without pagination; s saves shows with overlapping tags.
//! Playback resolves fresh direct ssub/sun sources off-thread and passes a private source file to Terebi.
//! Miruro episode collections retain stable catalog/episode keys, never stream URLs; channel tags remain YouTube-only.
//! Miruro episode collections retain stable catalog/episode keys, never stream URLs; c browses saved shows by category.
//! Route titles follow Miruro's Unicode NFD slug helper; unrepresentable titles report an explicit error.
//! Category and combined-category queries search their channels' full histories; other views search YouTube.
//! Insert: Enter submits a trimmed changed query; clearing a category query restores uploads.
@@ -948,6 +948,7 @@ struct App {
description_viewport: Option<(u16, u16)>,
job: Option<Job>,
player_launch: Option<PlayerLaunch>,
browser_launch: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
status: String,
}
@@ -1023,6 +1024,7 @@ impl App {
description_viewport: None,
job: None,
player_launch: None,
browser_launch: None,
status: "Enter a query to search YouTube.".into(),
}
}
@@ -2553,6 +2555,24 @@ impl App {
}
}
fn open_video_in_browser(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
return;
};
match youtube::open_in_browser(&video.id) {
Ok(launch) => {
self.browser_launch = Some(launch);
self.status = format!("Opening {} in the default browser...", video.title);
}
Err(error) => self.status = error.to_string(),
}
}
fn finish_description(&mut self, id: String, result: Result<String, String>) {
match result.and_then(|output| youtube::full_description(&output, &id)) {
Ok(description) => self.remember_description(id, description),
@@ -2601,6 +2621,17 @@ impl App {
Err(error) => error,
};
}
if let Some(result) = self
.browser_launch
.as_ref()
.and_then(|launch| launch.try_recv().ok())
{
self.browser_launch = None;
self.status = match result {
Ok(()) => "Video handed to the default browser.".into(),
Err(error) => error,
};
}
match self.video_state.poll_progress() {
Ok(true) => {
self.rebuild_collections();
@@ -2869,6 +2900,7 @@ impl App {
self.browse_category_collection(CollectionKind::Saved)
}
Some(Action::VideoPlay) => self.play(),
Some(Action::VideoOpen) => self.open_video_in_browser(),
Some(Action::VideoNext) => {
self.views[self.view_index].prefetch_requested = true;
self.views[self.view_index].selection.select_next();
+746 -15
View File
@@ -1,4 +1,5 @@
use std::{
collections::BTreeSet,
io,
path::PathBuf,
time::{Duration, Instant},
@@ -77,6 +78,10 @@ enum Content {
filter: WatchFilter,
videos: Vec<Video>,
},
Library {
categories: BTreeSet<String>,
shows: Vec<crate::video_state::SavedShow>,
},
}
struct DisplayRow {
key: Option<String>,
@@ -115,6 +120,7 @@ impl Screen {
Content::Catalog { media, .. } => media.len(),
Content::Series { data, .. } => data.as_ref().map_or(0, Vec::len),
Content::Collection { videos, .. } => videos.len(),
Content::Library { shows, .. } => shows.len(),
}
}
fn clamp(&mut self) {
@@ -124,6 +130,17 @@ impl Screen {
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
});
}
fn selected_media(&self) -> Option<Media> {
let index = self.selection.selected();
match &self.content {
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
Content::Series { media, .. } => Some(media.clone()),
Content::Library { shows, .. } => index
.and_then(|i| shows.get(i))
.map(|show| show.media.clone()),
_ => None,
}
}
fn selected_video(&self) -> Option<Video> {
let index = self.selection.selected()?;
match &self.content {
@@ -166,7 +183,7 @@ impl Screen {
self.rows = media
.iter()
.map(|media| DisplayRow {
key: None,
key: Some(media.id.clone()),
title: safe(&media.title),
details: media_summary(media),
metadata: media_details(media),
@@ -251,6 +268,25 @@ impl Screen {
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
}).collect();
}
Content::Library { categories, shows } => {
self.title = format!(
"Miruro · Saved shows · {}",
if categories.is_empty() {
"All saved shows".into()
} else {
categories.iter().cloned().collect::<Vec<_>>().join(" + ")
}
);
self.rows = shows
.iter()
.map(|show| DisplayRow {
key: Some(show.media.id.clone()),
title: safe(&show.media.title),
details: media_summary(&show.media),
metadata: media_details(&show.media),
})
.collect();
}
}
self.update_description();
}
@@ -268,6 +304,7 @@ struct Browser {
description_focus: bool,
help: Option<u16>,
removal: Option<(CollectionKind, Vec<String>)>,
modal: Option<LibraryModal>,
status: String,
worker: Option<Worker>,
generation: u64,
@@ -281,6 +318,66 @@ enum Intent {
Source(String),
}
enum LibraryModal {
Tags {
media: Media,
checked: BTreeSet<String>,
names: Vec<String>,
picker: Picker,
name: Option<String>,
error: Option<String>,
},
Categories {
names: Vec<String>,
checked: BTreeSet<String>,
picker: Picker,
},
}
#[derive(Default)]
struct Picker {
query: String,
editing: bool,
matches: Vec<usize>,
selection: ListState,
}
impl Picker {
fn refilter(&mut self, names: &[String]) {
let query = self.query.to_lowercase();
self.matches = names
.iter()
.enumerate()
.filter(|(_, name)| name.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect();
self.selection.select(if self.matches.is_empty() {
None
} else {
Some(
self.selection
.selected()
.unwrap_or(0)
.min(self.matches.len() - 1),
)
});
}
fn selected<'a>(&self, names: &'a [String]) -> Option<&'a String> {
self.selection
.selected()
.and_then(|i| self.matches.get(i))
.and_then(|i| names.get(*i))
}
fn step(&mut self, down: bool) {
if !self.matches.is_empty() {
let i = self.selection.selected().unwrap_or(0);
self.selection.select(Some(if down {
(i + 1).min(self.matches.len() - 1)
} else {
i.saturating_sub(1)
}));
}
}
}
impl Browser {
fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self {
let mut query = String::new();
@@ -300,6 +397,7 @@ impl Browser {
description_focus: false,
help: None,
removal: None,
modal: None,
status: String::new(),
worker: None,
generation: 0,
@@ -419,16 +517,9 @@ impl Browser {
}
}
fn open(&mut self) {
if let Content::Catalog { media, .. } = &self.screen().content {
let Some(media) = self
.screen()
.selection
.selected()
.and_then(|index| media.get(index))
.cloned()
else {
return;
};
if let Some(media) = self.screen().selected_media()
&& !matches!(self.screen().content, Content::Series { .. })
{
self.push(Screen::new(Content::Series { media, data: None }));
self.load_series();
} else if let Some(video) = self.screen().selected_video() {
@@ -572,6 +663,226 @@ impl Browser {
});
self.push(screen);
}
fn categories(&self) -> BTreeSet<String> {
self.state
.shows()
.iter()
.flat_map(|show| show.tags.iter().cloned())
.collect()
}
fn tags(&mut self) {
let Some(media) = self.screen().selected_media() else {
return;
};
let checked = self
.state
.shows()
.iter()
.find(|show| show.media.id == media.id)
.map(|show| show.tags.clone())
.unwrap_or_default();
let names: Vec<_> = self.categories().into_iter().collect();
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.modal = Some(LibraryModal::Tags {
media,
checked,
names,
picker,
name: None,
error: None,
});
}
fn category_picker(&mut self) {
let mut names = vec!["All saved shows".to_owned()];
names.extend(self.categories());
let mut picker = Picker::default();
picker.refilter(&names);
self.description_focus = false;
self.modal = Some(LibraryModal::Categories {
names,
checked: BTreeSet::new(),
picker,
});
}
fn library(&mut self, categories: BTreeSet<String>) {
let shows = library_shows(&self.state, &categories);
self.push(Screen::new(Content::Library { categories, shows }));
}
fn rebuild_library(&mut self) {
for screen in &mut self.screens {
if let Content::Library { categories, shows } = &mut screen.content {
let selected = screen
.selection
.selected()
.and_then(|i| shows.get(i))
.map(|show| show.media.id.clone());
let replacement = library_shows(&self.state, categories);
let next = selected
.and_then(|id| replacement.iter().position(|show| show.media.id == id))
.or(screen.selection.selected());
*shows = replacement;
screen.selection.select(next);
screen.clamp();
screen.rebuild_display();
}
}
}
fn modal_key(&mut self, key: KeyEvent, action: Option<Action>) {
if matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
return;
}
let mut modal = self.modal.take().expect("modal active");
if let LibraryModal::Tags {
name: Some(name),
checked,
names,
picker,
error,
..
} = &mut modal
{
match action {
Some(Action::InputCancel) => {
if let LibraryModal::Tags { name, error, .. } = &mut modal {
*name = None;
*error = None;
}
}
Some(Action::InputSubmit) => {
let value = name.trim().to_owned();
if value.is_empty() || value.chars().any(char::is_control) {
*error = Some("Enter a nonempty tag without control characters.".into());
} else if names.contains(&value) {
*error = Some("That tag already exists.".into());
} else {
checked.insert(value.clone());
names.push(value.clone());
names.sort();
picker.query.clear();
picker.refilter(names);
picker
.selection
.select(names.iter().position(|name| name == &value));
if let LibraryModal::Tags { name, error, .. } = &mut modal {
*name = None;
*error = None;
}
}
}
_ => {
crate::edit_input(name, key, action);
}
}
self.modal = Some(modal);
return;
}
let is_tags = matches!(modal, LibraryModal::Tags { .. });
let (names, picker) = match &mut modal {
LibraryModal::Tags { names, picker, .. }
| LibraryModal::Categories { names, picker, .. } => (names, picker),
};
let action = if picker.editing {
match action {
Some(Action::InputCancel) => {
picker.editing = false;
None
}
Some(Action::InputSubmit) if is_tags => {
picker.editing = false;
None
}
Some(Action::InputSubmit) => Some(Action::SelectorOpen),
Some(Action::TagInputNext | Action::SelectorInputNext) => {
picker.step(true);
None
}
Some(Action::TagInputPrevious | Action::SelectorInputPrevious) => {
picker.step(false);
None
}
_ => {
crate::edit_input(&mut picker.query, key, action);
picker.refilter(names);
None
}
}
} else {
action
};
match action {
Some(Action::TagCancel | Action::SelectorClose) => return,
Some(Action::TagEdit | Action::SelectorEdit) => picker.editing = true,
Some(Action::TagNext | Action::SelectorNext) => picker.step(true),
Some(Action::TagPrevious | Action::SelectorPrevious) => picker.step(false),
Some(Action::TagNew) => {
if let LibraryModal::Tags { name, error, .. } = &mut modal {
*name = Some(String::new());
*error = None;
}
}
Some(Action::TagToggle | Action::SelectorToggle) => {
if let Some(selected) = picker.selected(names).cloned() {
let all = !is_tags
&& picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0);
let checked = match &mut modal {
LibraryModal::Tags { checked, .. }
| LibraryModal::Categories { checked, .. } => checked,
};
if all {
checked.clear();
} else if !checked.remove(&selected) {
checked.insert(selected);
}
}
}
Some(Action::TagSave) => {
if let LibraryModal::Tags {
media,
checked,
error,
..
} = &mut modal
{
match self.state.save_show(media, checked) {
Ok(()) => {
self.rebuild_library();
self.status = "Saved show and category tags.".into();
return;
}
Err(failure) => *error = Some(safe(&failure.to_string())),
}
}
}
Some(Action::SelectorOpen) => {
if let LibraryModal::Categories {
names,
checked,
picker,
} = &modal
{
if !checked.is_empty() {
self.library(checked.clone());
return;
}
if let Some(selected) = picker.selected(names) {
let mut categories = checked.clone();
if picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0)
{
categories.clear();
} else {
categories.insert(selected.clone());
}
self.library(categories);
return;
}
}
}
_ => {}
}
self.modal = Some(modal);
}
fn rebuild_collections(&mut self) {
for screen in &mut self.screens {
let Content::Collection {
@@ -633,6 +944,18 @@ impl Browser {
}
}
fn remove(&mut self, bulk: bool) {
if matches!(self.screen().content, Content::Library { .. }) {
if !bulk && let Some(media) = self.screen().selected_media() {
match self.state.remove_show(&media.id) {
Ok(()) => {
self.rebuild_library();
self.status = "Removed saved show; episode state unchanged.".into();
}
Err(error) => self.fail(error),
}
}
return;
}
let Content::Collection { kind, videos, .. } = &self.screen().content else {
return;
};
@@ -686,6 +1009,7 @@ impl Browser {
match self.screen().content {
Content::Catalog { .. } => self.search(),
Content::Series { .. } => self.load_series(),
Content::Library { .. } => self.rebuild_library(),
Content::Collection { .. } => {
self.rebuild_collections();
self.status = "Collection metadata refreshed.".into();
@@ -695,9 +1019,30 @@ impl Browser {
fn context(&self) -> Context {
if self.help.is_some() {
Context::Help
} else if let Some(modal) = &self.modal {
match modal {
LibraryModal::Tags { name: Some(_), .. } => Context::TagInput,
LibraryModal::Tags { picker, .. } => {
if picker.editing {
Context::TagFilter
} else {
Context::TagList
}
}
LibraryModal::Categories { picker, .. } => {
if picker.editing {
Context::SelectorInput
} else {
Context::SelectorNormal
}
}
}
} else if self.input.is_some() {
Context::SearchInput
} else if matches!(self.screen().content, Content::Collection { .. }) {
} else if matches!(
self.screen().content,
Content::Collection { .. } | Content::Library { .. }
) {
if self.description_focus {
Context::CollectionDescription
} else {
@@ -729,6 +1074,24 @@ impl Browser {
screen.update_description();
}
fn paste(&mut self, text: &str) {
if self.help.is_none()
&& let Some(modal) = &mut self.modal
{
match modal {
LibraryModal::Tags {
name: Some(name), ..
} => crate::paste_input(name, text),
LibraryModal::Tags { names, picker, .. }
| LibraryModal::Categories { names, picker, .. }
if picker.editing =>
{
crate::paste_input(&mut picker.query, text);
picker.refilter(names);
}
_ => {}
}
return;
}
if self.removal.is_none()
&& self.help.is_none()
&& let Some(input) = &mut self.input
@@ -746,6 +1109,14 @@ impl Browser {
self.invalidate();
return true;
}
if self.modal.is_some() && self.help.is_none() {
if action == Some(Action::Help) {
self.help = Some(0);
} else {
self.modal_key(key, action);
}
return false;
}
if self.removal.is_some() {
match key.code {
KeyCode::Char('y') | KeyCode::Enter if key.modifiers.is_empty() => {
@@ -791,6 +1162,8 @@ impl Browser {
return false;
}
match action {
Some(Action::ChannelTags) => self.tags(),
Some(Action::CategoryBrowse) => self.category_picker(),
Some(Action::Help) => self.help = Some(0),
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(),
Some(Action::ViewPrevious) => self.history(false),
@@ -849,6 +1222,47 @@ impl Browser {
false
}
fn bindings(&self) -> Vec<(Action, &'static str)> {
if let Some(modal) = &self.modal {
return match modal {
LibraryModal::Tags { name: Some(_), .. } => vec![
(Action::InputSubmit, "Stage checked tag"),
(Action::InputCancel, "Cancel new tag"),
(Action::DeleteWord, "Delete word"),
(Action::DeleteLine, "Clear name"),
],
LibraryModal::Tags { picker, .. } if picker.editing => vec![
(Action::InputSubmit, "Return to tags"),
(Action::InputCancel, "Return to tags"),
(Action::TagInputNext, "Next match"),
(Action::TagInputPrevious, "Previous match"),
(Action::DeleteLine, "Clear filter"),
],
LibraryModal::Tags { .. } => vec![
(Action::TagNext, "Next tag"),
(Action::TagPrevious, "Previous tag"),
(Action::TagEdit, "Filter tags"),
(Action::TagToggle, "Toggle tag"),
(Action::TagNew, "New checked tag"),
(Action::TagSave, "Save show and tags"),
(Action::TagCancel, "Cancel without saving"),
],
LibraryModal::Categories { picker, .. } if picker.editing => vec![
(Action::InputSubmit, "Browse selected categories"),
(Action::InputCancel, "Return to selector"),
(Action::SelectorInputNext, "Next match"),
(Action::SelectorInputPrevious, "Previous match"),
(Action::DeleteLine, "Clear filter"),
],
LibraryModal::Categories { .. } => vec![
(Action::SelectorNext, "Next category"),
(Action::SelectorPrevious, "Previous category"),
(Action::SelectorEdit, "Filter categories"),
(Action::SelectorToggle, "Toggle combined category"),
(Action::SelectorOpen, "Browse selected categories"),
(Action::SelectorClose, "Close"),
],
};
}
if self.input.is_some() {
return vec![
(Action::InputSubmit, "Search catalog"),
@@ -864,7 +1278,10 @@ impl Browser {
(Action::ViewSearch, "Search catalog"),
(
Action::VideoPlay,
if matches!(self.screen().content, Content::Catalog { .. }) {
if matches!(
self.screen().content,
Content::Catalog { .. } | Content::Library { .. }
) {
"Open series"
} else {
"Play episode (fresh source)"
@@ -885,12 +1302,25 @@ impl Browser {
(Action::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"),
];
bindings.push((Action::CategoryBrowse, "Browse saved shows by category"));
if !matches!(self.screen().content, Content::Collection { .. }) {
bindings.push((Action::ChannelTags, "Save show / edit category tags"));
}
if matches!(self.screen().content, Content::Library { .. }) {
bindings.push((
Action::CollectionRemoveSelected,
"Remove selected saved show only",
));
}
if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
{
bindings.push((Action::InfoDescription, "Show/hide metadata"));
}
if !matches!(self.screen().content, Content::Catalog { .. }) {
if matches!(
self.screen().content,
Content::Series { .. } | Content::Collection { .. }
) {
bindings.extend([
(Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"),
@@ -922,6 +1352,52 @@ impl Browser {
bindings
}
fn hints(&self) -> String {
if self.modal.is_some() && self.help.is_none() {
return self
.bindings()
.into_iter()
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
.collect::<Vec<_>>()
.join(" · ");
}
if self.modal.is_none()
&& self.help.is_none()
&& self.input.is_none()
&& self.removal.is_none()
&& !self.description_focus
{
let mut actions = vec![
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(
Action::VideoPlay,
if matches!(
self.screen().content,
Content::Catalog { .. } | Content::Library { .. }
) {
"open"
} else {
"play"
},
),
(Action::CategoryBrowse, "categories"),
];
if !matches!(self.screen().content, Content::Collection { .. }) {
actions.push((Action::ChannelTags, "save show/tags"));
}
if matches!(
self.screen().content,
Content::Library { .. } | Content::Collection { .. }
) {
actions.push((Action::CollectionRemoveSelected, "remove"));
}
actions.extend([(Action::ViewPrevious, "back"), (Action::Quit, "quit")]);
return actions
.into_iter()
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
.collect::<Vec<_>>()
.join(" · ");
}
if self.removal.is_some() {
return format!(
"y/Enter confirms · n/Esc cancels · {} exits",
@@ -1052,6 +1528,16 @@ impl Browser {
.rows
.iter()
.map(|row| {
let show = if matches!(
self.screen().content,
Content::Catalog { .. } | Content::Library { .. }
) {
row.key
.as_deref()
.and_then(|id| self.state.shows().iter().find(|show| show.media.id == id))
} else {
None
};
let style = row
.key
.as_deref()
@@ -1062,6 +1548,25 @@ impl Browser {
.as_deref()
.map(|id| self.markers(id))
.unwrap_or_default();
let markers = show
.map(|show| {
format!(
"saved show{}",
if show.tags.is_empty() {
String::new()
} else {
format!(
" · {}",
show.tags
.iter()
.map(|tag| safe(tag))
.collect::<Vec<_>>()
.join(" · ")
)
}
)
})
.unwrap_or(markers);
let separator = if row.details.is_empty() || markers.is_empty() {
""
} else {
@@ -1121,7 +1626,7 @@ impl Browser {
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
);
let title = match self.screen().content {
Content::Catalog { .. } => " Titles ",
Content::Catalog { .. } | Content::Library { .. } => " Titles ",
_ => " Episodes ",
};
let block = Block::bordered().title(crate::loading_title(
@@ -1144,6 +1649,7 @@ impl Browser {
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(),
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
}
};
frame.render_widget(
@@ -1237,9 +1743,106 @@ impl Browser {
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup);
}
if self.help.is_none()
&& let Some(modal) = &mut self.modal
{
let (title, names, checked, picker, name, error) = match modal {
LibraryModal::Tags {
media,
names,
checked,
picker,
name,
error,
} => (
format!(" Save show · {} ", safe(&media.title)),
names,
checked,
picker,
name.as_deref(),
error.as_deref(),
),
LibraryModal::Categories {
names,
checked,
picker,
} => (
" Saved-show categories ".into(),
names,
checked,
picker,
None,
None,
),
};
frame.render_widget(Clear, popup);
let block = Block::bordered().title(title).border_style(ui::ACCENT);
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [input, list, message] = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Length(3),
ratatui::layout::Constraint::Min(0),
ratatui::layout::Constraint::Length(2),
])
.areas(inner);
ui::search(
frame,
input,
if name.is_some() {
" New checked tag "
} else {
" Filter "
},
name.unwrap_or(&picker.query),
name.is_some() || picker.editing,
);
let items: Vec<_> = picker
.matches
.iter()
.map(|i| {
ListItem::new(format!(
"[{}] {}",
if checked.contains(&names[*i]) {
"x"
} else {
" "
},
safe(&names[*i])
))
})
.collect();
frame.render_stateful_widget(
List::new(items)
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
list,
&mut picker.selection,
);
frame.render_widget(
Paragraph::new(
error.unwrap_or("Category tags may overlap; see configured controls below."),
)
.wrap(Wrap { trim: true }),
message,
);
}
}
}
fn library_shows(
state: &State,
categories: &BTreeSet<String>,
) -> Vec<crate::video_state::SavedShow> {
let mut shows: Vec<_> = state
.shows()
.iter()
.filter(|show| categories.is_empty() || !show.tags.is_disjoint(categories))
.cloned()
.collect();
shows.sort_by_cached_key(|show| show.media.title.to_lowercase());
shows
}
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
state
.videos(kind)
@@ -1899,6 +2502,134 @@ mod tests {
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
}
#[test]
fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
let overrides: toml_edit::DocumentMut =
"[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n"
.parse()
.unwrap();
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagList);
browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagInput);
browser.paste("Safe");
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagList);
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(browser.state.shows().is_empty());
assert_eq!(browser.index, 0);
browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
assert_eq!(
browser.state.shows()[0].tags,
BTreeSet::from(["Safe".into()])
);
assert!(browser.modal.is_none());
}
#[test]
fn checked_show_categories_survive_hover_and_filter_changes() {
let directory = Directory::new();
let mut browser = directory.browser(None);
for (id, tag) in [("first", "Drama"), ("second", "Favorites")] {
browser
.state
.save_show(&media(id), &[tag.into()].into())
.unwrap();
}
for filter in [None, Some("no matching category")] {
browser.category_picker();
for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] {
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
}
if let Some(filter) = filter {
browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
browser.paste(filter);
}
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Content::Library { shows, .. } = &browser.screen().content else {
panic!("Checked categories must open even without a hovered match");
};
assert_eq!(
shows
.iter()
.map(|show| show.media.id.as_str())
.collect::<Vec<_>>(),
["first"]
);
}
}
#[test]
fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
let key = |browser: &mut Browser, code| {
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
};
key(&mut browser, KeyCode::Char('s'));
key(&mut browser, KeyCode::Char('n'));
browser.paste("Drama");
key(&mut browser, KeyCode::Enter);
key(&mut browser, KeyCode::Esc);
assert!(browser.state.shows().is_empty());
key(&mut browser, KeyCode::Char('s'));
key(&mut browser, KeyCode::Enter);
assert!(browser.state.shows()[0].tags.is_empty());
enter_series(&mut browser);
browser.select(Action::VideoNext);
browser.save(CollectionKind::Saved);
let episode = browser.screen().selected_video().unwrap().id;
key(&mut browser, KeyCode::Char('s'));
for tag in ["Drama", "Favorites"] {
key(&mut browser, KeyCode::Char('n'));
browser.paste(tag);
key(&mut browser, KeyCode::Enter);
}
key(&mut browser, KeyCode::Enter);
assert_eq!(browser.state.shows()[0].media.id, "first");
browser
.state
.save_show(&media("second"), &BTreeSet::from(["Drama".into()]))
.unwrap();
assert_eq!(
library_shows(
&browser.state,
&BTreeSet::from(["Drama".into(), "Favorites".into()])
)
.len(),
2
);
browser.state = State::open(&browser.config_path).unwrap();
key(&mut browser, KeyCode::Char('c'));
key(&mut browser, KeyCode::Char('i'));
browser.paste("Favorites");
key(&mut browser, KeyCode::Enter);
assert_eq!(browser.screen().len(), 1);
browser.open();
assert!(
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first")
);
browser.history(false);
browser.library(BTreeSet::new());
key(&mut browser, KeyCode::Char('x'));
assert_eq!(browser.screen().len(), 1);
browser.history(false);
assert_eq!(browser.screen().len(), 0);
assert!(browser.state.contains(CollectionKind::Saved, &episode));
assert_eq!(
State::open(&browser.config_path).unwrap().shows()[0]
.media
.id,
"second"
);
}
#[test]
fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
let directory = Directory::new();
+401
View File
@@ -17,6 +17,10 @@ const MAX_LINE: usize = 16 * 1024;
const STREAM_BYTES: usize = 16 * 1024;
const MAX_STREAMS: usize = 4096;
const MAX_SOURCE_BYTES: usize = 1024 * 1024;
const MAX_SHOWS: usize = 4096;
const MAX_SHOW_TAGS: usize = 256;
const MAX_TAG_BYTES: usize = 256;
const MAX_SHOW_METADATA_BYTES: usize = 1024 * 1024;
const MAX_IDENTITY_BYTES: u64 = 7 + 512 + 1 + 10;
static NEXT_NAME: AtomicU64 = AtomicU64::new(0);
@@ -118,11 +122,19 @@ impl Collection {
}
}
#[derive(Clone, Debug)]
pub struct SavedShow {
pub media: crate::miruro_browser::Media,
pub tags: BTreeSet<String>,
}
pub struct State {
watch_later: Collection,
saved_videos: Collection,
watching: Collection,
watch_state: StoredFile,
library: StoredFile,
saved_shows: Vec<SavedShow>,
watched: BTreeSet<String>,
pending: BTreeSet<String>,
sessions: PathBuf,
@@ -144,6 +156,9 @@ impl State {
let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?;
let watched = load_watched(&document)
.map_err(|e| invalid(format!("{}: {e}", watch_state.path.display())))?;
let (library, document) = StoredFile::open(parent.join("miruro-library.toml"))?;
let saved_shows = load_shows(&document)
.map_err(|e| invalid(format!("{}: {e}", library.path.display())))?;
let completed: Vec<_> = watching.ids.intersection(&watched).cloned().collect();
if !completed.is_empty() {
watching.remove(&completed)?;
@@ -153,6 +168,8 @@ impl State {
saved_videos,
watching,
watch_state,
library,
saved_shows,
watched,
pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"),
@@ -189,6 +206,66 @@ impl State {
self.watched.contains(id)
}
pub fn shows(&self) -> &[SavedShow] {
&self.saved_shows
}
pub fn save_show(
&mut self,
media: &crate::miruro_browser::Media,
tags: &BTreeSet<String>,
) -> io::Result<()> {
validate_show(media)?;
if tags.len() > MAX_SHOW_TAGS {
return Err(invalid("A saved show cannot have more than 256 tags"));
}
let tags = tags
.iter()
.map(|tag| show_tag(tag))
.collect::<io::Result<BTreeSet<_>>>()?;
let index = self
.saved_shows
.iter()
.position(|show| show.media.id == media.id);
if index.is_none() && self.saved_shows.len() >= MAX_SHOWS {
return Err(invalid("Miruro library exceeds the 4096 show limit"));
}
let show = SavedShow {
media: media.clone(),
tags,
};
self.library.save(show_document(
self.saved_shows
.iter()
.map(|saved| {
if saved.media.id == media.id {
&show
} else {
saved
}
})
.chain(index.is_none().then_some(&show)),
)?)?;
if let Some(index) = index {
self.saved_shows[index] = show;
} else {
self.saved_shows.push(show);
}
Ok(())
}
pub fn remove_show(&mut self, id: &str) -> io::Result<()> {
Identity::new(id.into(), 1)?;
if !self.saved_shows.iter().any(|show| show.media.id == id) {
return check_original(&self.library.path, self.library.original.as_deref());
}
self.library.save(show_document(
self.saved_shows.iter().filter(|show| show.media.id != id),
)?)?;
self.saved_shows.retain(|show| show.media.id != id);
Ok(())
}
pub fn save(&mut self, kind: CollectionKind, video: &Video) -> io::Result<()> {
if matches!(kind, CollectionKind::Watching) && self.is_watched(&video.id) {
return Ok(());
@@ -424,6 +501,125 @@ impl State {
}
}
fn validate_show(media: &crate::miruro_browser::Media) -> io::Result<()> {
Identity::new(media.id.clone(), 1)?;
if media.title.len() > MAX_LINE
|| media.title.trim().is_empty()
|| media.title.chars().any(char::is_control)
{
return Err(invalid(
"Saved show title must be nonempty, contain no controls, and fit in 16 KiB",
));
}
Ok(())
}
fn show_tag(name: &str) -> io::Result<String> {
if name.len() > MAX_TAG_BYTES || name.trim().is_empty() || name.chars().any(char::is_control) {
return Err(invalid(
"Show tags must be nonempty, contain no controls, and fit in 256 bytes",
));
}
Ok(name.trim().to_owned())
}
fn load_shows(document: &DocumentMut) -> io::Result<Vec<SavedShow>> {
if document
.iter()
.any(|(key, _)| !matches!(key, "version" | "shows"))
{
return Err(invalid("Unknown Miruro library field"));
}
let Some(item) = document.get("shows") else {
return Ok(Vec::new());
};
if item.as_array().is_some_and(|array| array.is_empty()) {
return Ok(Vec::new());
}
let tables = item
.as_array_of_tables()
.ok_or_else(|| invalid("shows must be an array of tables"))?;
if tables.len() > MAX_SHOWS {
return Err(invalid("Miruro library exceeds the 4096 show limit"));
}
let mut shows = Vec::new();
let mut ids = BTreeSet::new();
for table in tables {
if table
.iter()
.any(|(key, _)| !matches!(key, "id" | "title" | "metadata" | "tags"))
{
return Err(invalid("Unknown saved show field"));
}
let text = |key| {
table
.get(key)
.and_then(|item| item.as_str())
.ok_or_else(|| invalid(format!("Saved show {key} must be a string")))
};
let metadata = text("metadata")?;
if metadata.len() > MAX_SHOW_METADATA_BYTES {
return Err(invalid("Saved show metadata exceeds the 1 MiB limit"));
}
let media = crate::miruro_browser::Media {
id: text("id")?.into(),
title: text("title")?.into(),
metadata: serde_json::from_str(metadata)
.map_err(|e| invalid(format!("Invalid saved show metadata: {e}")))?,
};
validate_show(&media)?;
let tags = table
.get("tags")
.and_then(|item| item.as_array())
.ok_or_else(|| invalid("Saved show tags must be an array of names"))?;
if tags.len() > MAX_SHOW_TAGS {
return Err(invalid("A saved show cannot have more than 256 tags"));
}
let tags = tags
.iter()
.map(|tag| {
show_tag(
tag.as_str()
.ok_or_else(|| invalid("Saved show tags must be strings"))?,
)
})
.collect::<io::Result<BTreeSet<_>>>()?;
if ids.insert(media.id.clone()) {
shows.push(SavedShow { media, tags });
}
}
Ok(shows)
}
fn show_document<'a>(shows: impl Iterator<Item = &'a SavedShow>) -> io::Result<DocumentMut> {
let mut document = DocumentMut::new();
document["version"] = value(1);
let mut tables = ArrayOfTables::new();
for show in shows {
let metadata = serde_json::to_string(&show.media.metadata)
.map_err(|e| invalid(format!("Cannot serialize saved show metadata: {e}")))?;
if metadata.len() > MAX_SHOW_METADATA_BYTES {
return Err(invalid("Saved show metadata exceeds the 1 MiB limit"));
}
let mut table = Table::new();
table["id"] = value(show.media.id.as_str());
table["title"] = value(show.media.title.as_str());
table["metadata"] = value(metadata);
let mut tags = Array::new();
for tag in &show.tags {
tags.push(tag.as_str());
}
table["tags"] = value(tags);
tables.push(table);
}
document["shows"] = if tables.is_empty() {
value(Array::new())
} else {
toml_edit::Item::ArrayOfTables(tables)
};
Ok(document)
}
fn validate_snapshot(video: &mut Video, require_channel: bool) -> io::Result<()> {
let date = video.publication_date.clone();
if let Some(date) = &date {
@@ -1130,6 +1326,211 @@ mod tests {
}
}
fn show() -> crate::miruro_browser::Media {
crate::miruro_browser::Media {
id: "opaque_Show-1".into(),
title: "Example show".into(),
metadata: serde_json::json!({
"id": "opaque_Show-1",
"title": {"english": "Example show", "native": null},
"episode_counts": {"sub": 12, "dub": null},
"genres": ["Adventure", "Fantasy"],
}),
}
}
#[test]
fn miruro_library_round_trips_overlapping_tags_and_keeps_episode_state_independent() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
assert!(state.shows().is_empty());
assert!(!path.exists());
let episode = miruro_video(1);
for kind in [CollectionKind::Saved, CollectionKind::WatchLater] {
state.save(kind, &episode).unwrap();
}
state
.set_watch_status(&episode, WatchStatus::Watched)
.unwrap();
let episode_files = ["saved-videos.toml", "watch-later.toml", "watch-state.toml"]
.map(|name| (name, fs::read(directory.0.join(name)).unwrap()));
let mut first = show();
let mut second = show();
second.id = "another_Show".into();
second.title = "Another show".into();
second.metadata = serde_json::Value::Null;
state
.save_show(
&first,
&[" Alpha ".into(), "Alpha".into(), "Beta".into()].into(),
)
.unwrap();
state
.save_show(&second, &["Beta".into(), "Gamma".into()].into())
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 2);
assert_eq!(state.shows()[0].media.metadata, first.metadata);
assert_eq!(
state.shows()[0].tags,
["Alpha".into(), "Beta".into()].into()
);
assert_eq!(
state.shows()[1].tags,
["Beta".into(), "Gamma".into()].into()
);
assert!(state.shows()[1].media.metadata.is_null());
first.title = "Updated show title".into();
first.metadata["title"]["english"] = serde_json::json!("Updated show title");
state.save_show(&first, &BTreeSet::new()).unwrap();
state.save_show(&first, &BTreeSet::new()).unwrap();
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 2);
assert_eq!(state.shows()[0].media.title, first.title);
assert_eq!(state.shows()[0].media.metadata, first.metadata);
assert!(state.shows()[0].tags.is_empty());
state.remove_show(&first.id).unwrap();
state.remove_show(&first.id).unwrap();
let state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 1);
assert_eq!(state.shows()[0].media.id, second.id);
assert!(state.is_watched(&episode.id));
for (name, bytes) in episode_files {
assert_eq!(fs::read(directory.0.join(name)).unwrap(), bytes);
}
// Duplicate hand-edited records behave like the existing episode collections.
let document = fs::read_to_string(&path).unwrap();
let duplicate = document.split_once("[[shows]]").unwrap().1;
append(&path, format!("\n[[shows]]{duplicate}").as_bytes());
let mut state = State::open(&directory.config()).unwrap();
assert_eq!(state.shows().len(), 1);
state.save_show(&second, &BTreeSet::new()).unwrap();
assert_eq!(State::open(&directory.config()).unwrap().shows().len(), 1);
state.remove_show(&second.id).unwrap();
assert!(State::open(&directory.config()).unwrap().shows().is_empty());
}
#[test]
fn miruro_library_rejects_invalid_inputs_and_files_without_changing_saved_state() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
let media = show();
state
.save_show(&media, &["Original".into()].into())
.unwrap();
let original = fs::read(&path).unwrap();
for (field, value) in [
("id", "https://unsafe.example/show".to_owned()),
("title", " ".to_owned()),
("title", "bad\nshow".to_owned()),
("title", "s".repeat(MAX_LINE + 1)),
("metadata", "m".repeat(MAX_SHOW_METADATA_BYTES)),
] {
let mut invalid_media = media.clone();
match field {
"id" => invalid_media.id = value,
"title" => invalid_media.title = value,
_ => invalid_media.metadata = serde_json::Value::String(value),
}
assert!(state.save_show(&invalid_media, &BTreeSet::new()).is_err());
assert_eq!(state.shows()[0].media.title, media.title);
assert_eq!(state.shows()[0].media.metadata, media.metadata);
assert_eq!(fs::read(&path).unwrap(), original);
}
for tags in [
[" ".into()].into(),
["bad\ttag".into()].into(),
["t".repeat(MAX_TAG_BYTES + 1)].into(),
(0..=MAX_SHOW_TAGS).map(|index| index.to_string()).collect(),
] {
assert!(state.save_show(&media, &tags).is_err());
assert_eq!(state.shows()[0].tags, ["Original".into()].into());
assert_eq!(fs::read(&path).unwrap(), original);
}
assert!(state.remove_show("https://unsafe.example/show").is_err());
assert_eq!(fs::read(&path).unwrap(), original);
let valid = std::str::from_utf8(&original).unwrap();
let mut invalid_files = vec![
"not = [valid".to_owned(),
"version = 2\nshows = []".to_owned(),
"version = 1\nunexpected = true".to_owned(),
"version = 1\nshows = [1]".to_owned(),
"version = 1\n[[shows]]\nid = 'stable'\ntitle = 'Title'\nmetadata = '{'\ntags = []"
.to_owned(),
];
let mut invalid_tags = Array::new();
invalid_tags.push(1);
let mut blank_tag = Array::new();
blank_tag.push(" ");
let mut excessive_tags = Array::new();
for _ in 0..=MAX_SHOW_TAGS {
excessive_tags.push("Tag");
}
for (field, item) in [
("tags", value(invalid_tags)),
("tags", value(blank_tag)),
("tags", value(excessive_tags)),
("id", value("../unsafe")),
("title", value("")),
("title", value("s".repeat(MAX_LINE + 1))),
(
"metadata",
value(format!("\"{}\"", "m".repeat(MAX_SHOW_METADATA_BYTES))),
),
("unknown", value(true)),
] {
let mut document = valid.parse::<DocumentMut>().unwrap();
document["shows"]
.as_array_of_tables_mut()
.unwrap()
.get_mut(0)
.unwrap()[field] = item;
invalid_files.push(document.to_string());
}
for contents in invalid_files {
fs::write(&path, &contents).unwrap();
assert!(State::open(&directory.config()).is_err(), "{contents}");
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
}
}
#[test]
fn miruro_library_does_not_overwrite_external_creations_or_edits() {
let directory = TestDirectory::new();
let path = directory.0.join("miruro-library.toml");
let mut state = State::open(&directory.config()).unwrap();
let media = show();
fs::write(&path, "external creation").unwrap();
assert!(state.save_show(&media, &BTreeSet::new()).is_err());
assert!(state.shows().is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "external creation");
fs::remove_file(&path).unwrap();
state
.save_show(&media, &["Original".into()].into())
.unwrap();
append(&path, b"\n# external edit\n");
let changed = fs::read(&path).unwrap();
let mut updated = media.clone();
updated.title = "Replacement".into();
assert!(state.save_show(&updated, &["New".into()].into()).is_err());
assert!(state.remove_show(&media.id).is_err());
assert!(state.remove_show("missing_Show").is_err());
assert_eq!(state.shows().len(), 1);
assert_eq!(state.shows()[0].media.title, media.title);
assert_eq!(state.shows()[0].tags, ["Original".into()].into());
assert_eq!(fs::read(&path).unwrap(), changed);
}
fn miruro_source(video: &Video) -> SourceDocument {
SourceDocument {
version: 1,
+56
View File
@@ -805,6 +805,52 @@ fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Re
}
}
pub fn open_in_browser(id: &str) -> io::Result<Receiver<Result<(), String>>> {
if !valid_video_id(id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid YouTube video ID",
));
}
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
let child = isolated_command(OsStr::new(opener))
.arg(format!("https://www.youtube.com/watch?v={id}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
io::Error::new(
error.kind(),
format!("Cannot start browser opener: {error}"),
)
})?;
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let result = child
.wait_with_output()
.map_err(|error| format!("Cannot wait for browser opener: {error}"))
.and_then(|output| {
if output.status.success() {
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(format!(
"Browser opener exited with {}: {}",
output.status,
error.trim()
))
}
});
let _ = sender.send(result);
});
Ok(receiver)
}
pub struct PlayerLaunch {
player: Child,
player_errors: Receiver<io::Result<String>>,
@@ -1465,6 +1511,16 @@ mod tests {
}
}
#[test]
fn browser_open_rejects_unsafe_video_ids_before_spawning() {
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
assert!(matches!(
open_in_browser(id),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
}
#[test]
fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() {
for feed in [false, true] {