feat: save and categorize Miruro shows

This commit is contained in:
2026-10-17 16:43:54 +02:00
parent 08e4a62228
commit f8334716af
5 changed files with 1162 additions and 23 deletions
+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();