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
+12 -5
View File
@@ -1,6 +1,6 @@
# tubular # tubular
A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize subscribed YouTube channels with overlapping tags, and open a selection in Terebi. A modal terminal YouTube and Miruro browser built with Rust and [Ratatui](https://ratatui.rs/). Search for videos or anime episodes, organize YouTube channels and saved Miruro shows with overlapping tags, and open a selection in Terebi.
## Requirements ## Requirements
@@ -56,7 +56,7 @@ Each selection opens an independent player instance without replacing existing p
### Miruro browser ### Miruro browser
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, without YouTube's Search/Feed tabs, channel controls or category selectors. Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available. Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
@@ -67,16 +67,22 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| Enter | Open a series, or resolve a fresh direct **ssub / sun** source and play the episode | | Enter | Open a series, or resolve a fresh direct **ssub / sun** source and play the episode |
| `-` / `+` | Back / forward through cached screens, preserving query and selection | | `-` / `+` | Back / forward through cached screens, preserving query and selection |
| `w` / `v` | Save the selected episode to watch later / saved episodes | | `w` / `v` | Save the selected episode to watch later / saved episodes |
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
| `c` | Choose All saved shows or a category, then Enter opens a saved show’s episodes |
| `m` / `M` | Mark the selected episode as watching / watched (also in metadata) | | `m` / `M` | Mark the selected episode as watching / watched (also in metadata) |
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes | | `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
| `f` | Cycle All / Unwatched / Watched in watch later | | `f` | Cycle All / Unwatched / Watched in watch later |
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection | | `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
| `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection | | `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection |
| Esc | Cancel input or close help/metadata and return to the episode/title list | | Esc | Cancel input or close help/metadata and return to the episode/title list |
| `r` | Refresh the active screen and reload configured keys | | `r` | Refresh the active screen and reload configured keys |
| `?` / `q` | Keyboard help / quit | | `?` / `q` | Keyboard help / quit |
These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Only episodes—not catalog titles—can be saved or have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions. These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Show saving/tagging uses `channel.tags`, and the library picker uses `category.browse`. From catalog results, a series screen, or the saved-show library, press `s` to edit the **show's** tags: `j` / `k` selects, Space checks/unchecks, `i` filters, and `n` enters a new tag name. Enter stages a new tag; Enter again from the tag list saves the show and all checked tags. Esc cancels without saving. A show can belong to multiple categories or remain saved without tags. The `c` picker starts in normal mode; `i` filters, Enter browses, and Esc leaves filtering before closing. Saved-show lists are alphabetical and stored offline; Enter fetches current episodes using the saved catalog ID and title. `x` removes only the show and its tags, leaving episode collections and watch status unchanged. Categories disappear when no saved shows use them. Miruro's show categories are independent of YouTube's channel tags.
Space checks categories for combined browsing; Enter opens their deduplicated union even when the filter hides them. With no checks, Enter opens the hovered category or All saved shows.
Only episodes can have their watch status edited. Reopening a saved episode resolves new URLs; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed. Stream and external VTT subtitle headers are supplied explicitly; Terebi selects the source's declared default subtitle. Sources requiring media redirects, cross-origin HLS, proxies, iframe playback, or provider-specific playlist rewriting fail explicitly. No provider/language fallback or automatic expired-source refresh is performed.
@@ -271,6 +277,7 @@ Files are siblings of `config.toml`:
- `saved-videos.toml`: independent versioned saved-video metadata. - `saved-videos.toml`: independent versioned saved-video metadata.
- `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually. - `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually.
- `watch-state.toml`: versioned global watched video IDs. - `watch-state.toml`: versioned global watched video IDs.
- `miruro-library.toml`: versioned saved-show catalog metadata and overlapping category tags, independent of episode collections.
- `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and Miruro source documents. - `playback-sessions/`: private per-launch progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and Miruro source documents.
State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB. State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB.
@@ -301,7 +308,7 @@ Use `x` for individual removal or `X` for bulk confirmation. Removal neither mar
## Collection removal ## Collection removal
Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, Miruro catalog/episode browsing, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls. Episode removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. The Miruro saved-show library additionally supports `x` to remove a show and its tags, without removing episodes or watch status. Search, Feed, Miruro catalog/episode browsing, ordinary YouTube channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
`x` immediately removes only the selected video/episode from the active collection. `X` snapshots the current provider's visible list and opens bulk confirmation: YouTube watch later uses the current category and All/Unwatched/Watched filter, and saved videos uses the current category's entire saved list; Miruro uses its flat list and any active watch-later filter. Currently watching uses the current provider's entire list. An empty list does not open confirmation. Neither interface's removal controls touch the other provider's entries. `x` immediately removes only the selected video/episode from the active collection. `X` snapshots the current provider's visible list and opens bulk confirmation: YouTube watch later uses the current category and All/Unwatched/Watched filter, and saved videos uses the current category's entire saved list; Miruro uses its flat list and any active watch-later filter. Currently watching uses the current provider's entire list. An empty list does not open confirmation. Neither interface's removal controls touch the other provider's entries.
+1 -1
View File
@@ -129,7 +129,7 @@ actions! {
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"]; CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"]; ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", 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"]; 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"]; 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"]; 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. //! 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. //! A CLI query searches the selected provider after confirmation. Press i to edit.
//! `--miruro apothecary` opens the dedicated Catalog → series → episodes interface. //! `--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. //! 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. //! 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. //! 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. //! Insert: Enter submits a trimmed changed query; clearing a category query restores uploads.
+746 -15
View File
@@ -1,4 +1,5 @@
use std::{ use std::{
collections::BTreeSet,
io, io,
path::PathBuf, path::PathBuf,
time::{Duration, Instant}, time::{Duration, Instant},
@@ -77,6 +78,10 @@ enum Content {
filter: WatchFilter, filter: WatchFilter,
videos: Vec<Video>, videos: Vec<Video>,
}, },
Library {
categories: BTreeSet<String>,
shows: Vec<crate::video_state::SavedShow>,
},
} }
struct DisplayRow { struct DisplayRow {
key: Option<String>, key: Option<String>,
@@ -115,6 +120,7 @@ impl Screen {
Content::Catalog { media, .. } => media.len(), Content::Catalog { media, .. } => media.len(),
Content::Series { data, .. } => data.as_ref().map_or(0, Vec::len), Content::Series { data, .. } => data.as_ref().map_or(0, Vec::len),
Content::Collection { videos, .. } => videos.len(), Content::Collection { videos, .. } => videos.len(),
Content::Library { shows, .. } => shows.len(),
} }
} }
fn clamp(&mut self) { fn clamp(&mut self) {
@@ -124,6 +130,17 @@ impl Screen {
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1)) 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> { fn selected_video(&self) -> Option<Video> {
let index = self.selection.selected()?; let index = self.selection.selected()?;
match &self.content { match &self.content {
@@ -166,7 +183,7 @@ impl Screen {
self.rows = media self.rows = media
.iter() .iter()
.map(|media| DisplayRow { .map(|media| DisplayRow {
key: None, key: Some(media.id.clone()),
title: safe(&media.title), title: safe(&media.title),
details: media_summary(media), details: media_summary(media),
metadata: media_details(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 } DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
}).collect(); }).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(); self.update_description();
} }
@@ -268,6 +304,7 @@ struct Browser {
description_focus: bool, description_focus: bool,
help: Option<u16>, help: Option<u16>,
removal: Option<(CollectionKind, Vec<String>)>, removal: Option<(CollectionKind, Vec<String>)>,
modal: Option<LibraryModal>,
status: String, status: String,
worker: Option<Worker>, worker: Option<Worker>,
generation: u64, generation: u64,
@@ -281,6 +318,66 @@ enum Intent {
Source(String), 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 { impl Browser {
fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self { fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self {
let mut query = String::new(); let mut query = String::new();
@@ -300,6 +397,7 @@ impl Browser {
description_focus: false, description_focus: false,
help: None, help: None,
removal: None, removal: None,
modal: None,
status: String::new(), status: String::new(),
worker: None, worker: None,
generation: 0, generation: 0,
@@ -419,16 +517,9 @@ impl Browser {
} }
} }
fn open(&mut self) { fn open(&mut self) {
if let Content::Catalog { media, .. } = &self.screen().content { if let Some(media) = self.screen().selected_media()
let Some(media) = self && !matches!(self.screen().content, Content::Series { .. })
.screen() {
.selection
.selected()
.and_then(|index| media.get(index))
.cloned()
else {
return;
};
self.push(Screen::new(Content::Series { media, data: None })); self.push(Screen::new(Content::Series { media, data: None }));
self.load_series(); self.load_series();
} else if let Some(video) = self.screen().selected_video() { } else if let Some(video) = self.screen().selected_video() {
@@ -572,6 +663,226 @@ impl Browser {
}); });
self.push(screen); 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) { fn rebuild_collections(&mut self) {
for screen in &mut self.screens { for screen in &mut self.screens {
let Content::Collection { let Content::Collection {
@@ -633,6 +944,18 @@ impl Browser {
} }
} }
fn remove(&mut self, bulk: bool) { 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 { let Content::Collection { kind, videos, .. } = &self.screen().content else {
return; return;
}; };
@@ -686,6 +1009,7 @@ impl Browser {
match self.screen().content { match self.screen().content {
Content::Catalog { .. } => self.search(), Content::Catalog { .. } => self.search(),
Content::Series { .. } => self.load_series(), Content::Series { .. } => self.load_series(),
Content::Library { .. } => self.rebuild_library(),
Content::Collection { .. } => { Content::Collection { .. } => {
self.rebuild_collections(); self.rebuild_collections();
self.status = "Collection metadata refreshed.".into(); self.status = "Collection metadata refreshed.".into();
@@ -695,9 +1019,30 @@ impl Browser {
fn context(&self) -> Context { fn context(&self) -> Context {
if self.help.is_some() { if self.help.is_some() {
Context::Help 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() { } else if self.input.is_some() {
Context::SearchInput Context::SearchInput
} else if matches!(self.screen().content, Content::Collection { .. }) { } else if matches!(
self.screen().content,
Content::Collection { .. } | Content::Library { .. }
) {
if self.description_focus { if self.description_focus {
Context::CollectionDescription Context::CollectionDescription
} else { } else {
@@ -729,6 +1074,24 @@ impl Browser {
screen.update_description(); screen.update_description();
} }
fn paste(&mut self, text: &str) { 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() if self.removal.is_none()
&& self.help.is_none() && self.help.is_none()
&& let Some(input) = &mut self.input && let Some(input) = &mut self.input
@@ -746,6 +1109,14 @@ impl Browser {
self.invalidate(); self.invalidate();
return true; 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() { if self.removal.is_some() {
match key.code { match key.code {
KeyCode::Char('y') | KeyCode::Enter if key.modifiers.is_empty() => { KeyCode::Char('y') | KeyCode::Enter if key.modifiers.is_empty() => {
@@ -791,6 +1162,8 @@ impl Browser {
return false; return false;
} }
match action { match action {
Some(Action::ChannelTags) => self.tags(),
Some(Action::CategoryBrowse) => self.category_picker(),
Some(Action::Help) => self.help = Some(0), Some(Action::Help) => self.help = Some(0),
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(), Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(),
Some(Action::ViewPrevious) => self.history(false), Some(Action::ViewPrevious) => self.history(false),
@@ -849,6 +1222,47 @@ impl Browser {
false false
} }
fn bindings(&self) -> Vec<(Action, &'static str)> { 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() { if self.input.is_some() {
return vec![ return vec![
(Action::InputSubmit, "Search catalog"), (Action::InputSubmit, "Search catalog"),
@@ -864,7 +1278,10 @@ impl Browser {
(Action::ViewSearch, "Search catalog"), (Action::ViewSearch, "Search catalog"),
( (
Action::VideoPlay, Action::VideoPlay,
if matches!(self.screen().content, Content::Catalog { .. }) { if matches!(
self.screen().content,
Content::Catalog { .. } | Content::Library { .. }
) {
"Open series" "Open series"
} else { } else {
"Play episode (fresh source)" "Play episode (fresh source)"
@@ -885,12 +1302,25 @@ impl Browser {
(Action::Quit, "Exit"), (Action::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"), (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) if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview) != self.config.keys.label(Action::InfoOverview)
{ {
bindings.push((Action::InfoDescription, "Show/hide metadata")); 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([ bindings.extend([
(Action::WatchLaterSave, "Save episode to watch later"), (Action::WatchLaterSave, "Save episode to watch later"),
(Action::SavedVideoSave, "Save episode"), (Action::SavedVideoSave, "Save episode"),
@@ -922,6 +1352,52 @@ impl Browser {
bindings bindings
} }
fn hints(&self) -> String { 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() { if self.removal.is_some() {
return format!( return format!(
"y/Enter confirms · n/Esc cancels · {} exits", "y/Enter confirms · n/Esc cancels · {} exits",
@@ -1052,6 +1528,16 @@ impl Browser {
.rows .rows
.iter() .iter()
.map(|row| { .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 let style = row
.key .key
.as_deref() .as_deref()
@@ -1062,6 +1548,25 @@ impl Browser {
.as_deref() .as_deref()
.map(|id| self.markers(id)) .map(|id| self.markers(id))
.unwrap_or_default(); .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() { let separator = if row.details.is_empty() || markers.is_empty() {
"" ""
} else { } else {
@@ -1121,7 +1626,7 @@ impl Browser {
self.input.is_some() && self.help.is_none() && self.removal.is_none(), self.input.is_some() && self.help.is_none() && self.removal.is_none(),
); );
let title = match self.screen().content { let title = match self.screen().content {
Content::Catalog { .. } => " Titles ", Content::Catalog { .. } | Content::Library { .. } => " Titles ",
_ => " Episodes ", _ => " Episodes ",
}; };
let block = Block::bordered().title(crate::loading_title( 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::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::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::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( frame.render_widget(
@@ -1237,8 +1743,105 @@ impl Browser {
frame.render_widget(paragraph.scroll((scroll, 0)), inner); frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup); 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> { fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
state state
@@ -1899,6 +2502,134 @@ mod tests {
assert!(browser.screen().error.is_some()); // Native launch errors remain visible. 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] #[test]
fn empty_errors_and_query_controls_do_not_create_results_or_commands() { fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
let directory = Directory::new(); let directory = Directory::new();
+401
View File
@@ -17,6 +17,10 @@ const MAX_LINE: usize = 16 * 1024;
const STREAM_BYTES: usize = 16 * 1024; const STREAM_BYTES: usize = 16 * 1024;
const MAX_STREAMS: usize = 4096; const MAX_STREAMS: usize = 4096;
const MAX_SOURCE_BYTES: usize = 1024 * 1024; 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; const MAX_IDENTITY_BYTES: u64 = 7 + 512 + 1 + 10;
static NEXT_NAME: AtomicU64 = AtomicU64::new(0); 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 { pub struct State {
watch_later: Collection, watch_later: Collection,
saved_videos: Collection, saved_videos: Collection,
watching: Collection, watching: Collection,
watch_state: StoredFile, watch_state: StoredFile,
library: StoredFile,
saved_shows: Vec<SavedShow>,
watched: BTreeSet<String>, watched: BTreeSet<String>,
pending: BTreeSet<String>, pending: BTreeSet<String>,
sessions: PathBuf, sessions: PathBuf,
@@ -144,6 +156,9 @@ impl State {
let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?; let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?;
let watched = load_watched(&document) let watched = load_watched(&document)
.map_err(|e| invalid(format!("{}: {e}", watch_state.path.display())))?; .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(); let completed: Vec<_> = watching.ids.intersection(&watched).cloned().collect();
if !completed.is_empty() { if !completed.is_empty() {
watching.remove(&completed)?; watching.remove(&completed)?;
@@ -153,6 +168,8 @@ impl State {
saved_videos, saved_videos,
watching, watching,
watch_state, watch_state,
library,
saved_shows,
watched, watched,
pending: BTreeSet::new(), pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"), sessions: parent.join("playback-sessions"),
@@ -189,6 +206,66 @@ impl State {
self.watched.contains(id) 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<()> { pub fn save(&mut self, kind: CollectionKind, video: &Video) -> io::Result<()> {
if matches!(kind, CollectionKind::Watching) && self.is_watched(&video.id) { if matches!(kind, CollectionKind::Watching) && self.is_watched(&video.id) {
return Ok(()); 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<()> { fn validate_snapshot(video: &mut Video, require_channel: bool) -> io::Result<()> {
let date = video.publication_date.clone(); let date = video.publication_date.clone();
if let Some(date) = &date { 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 { fn miruro_source(video: &Video) -> SourceDocument {
SourceDocument { SourceDocument {
version: 1, version: 1,