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
+1 -1
View File
@@ -129,7 +129,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"];
+2 -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.
+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,