feat: save and categorize Miruro shows
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user