feat: add AniList browsing with on-demand ZokoAnime playback

This commit is contained in:
2026-10-25 15:44:41 +01:00
parent a281c6730a
commit e328ff3883
20 changed files with 1062 additions and 235 deletions
+204 -39
View File
@@ -21,7 +21,7 @@ use ratatui::{
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use serde_json::Value;
use tubular::miruro::Identity;
use tubular::anime::{Identity, Provider};
use crate::{
Options, PreviewMode, config,
@@ -92,6 +92,7 @@ struct DisplayRow {
}
struct Screen {
content: Content,
provider: Provider,
selection: ListState,
checked: Vec<String>,
description_scroll: u16,
@@ -105,6 +106,7 @@ impl Screen {
fn new(content: Content) -> Self {
let mut screen = Self {
content,
provider: Provider::Miruro,
selection: ListState::default(),
checked: Vec::new(),
description_scroll: 0,
@@ -223,7 +225,7 @@ impl Screen {
self.overview.clear();
match &self.content {
Content::Catalog { media, .. } => {
self.title = "Miruro · Catalog (autocomplete, up to 8 titles)".into();
self.title = format!("{} · Catalog (up to 8 titles)", self.provider.name());
self.rows = media
.iter()
.map(|media| DisplayRow {
@@ -235,13 +237,30 @@ impl Screen {
.collect();
}
Content::Series { media, data } => {
self.title = format!("Miruro · {} · Episodes", safe(&media.title));
self.title = format!(
"{} · {} · Episodes",
self.provider.name(),
safe(&media.title)
);
self.overview = media_details(media);
if let Some(data) = data {
self.overview.push_str(&format!(
"\n\n{} returned episodes (source availability is checked on play).",
data.len()
));
if self.provider == Provider::AniList {
self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory.");
if media
.metadata
.get("catalog_count")
.is_none_or(Value::is_null)
{
self.overview.push_str("\nCatalog/airing count unknown. Use search/edit (i by default) here to select an explicit positive episode number.");
} else {
self.overview.push_str(&format!("\n{} catalog/airing episode numbers (planned totals are not availability).", data.len()));
}
} else {
self.overview.push_str(&format!(
"\n\n{} returned episodes (source availability is checked on play).",
data.len()
));
}
self.rows = data
.iter()
.map(|episode| {
@@ -261,6 +280,9 @@ impl Screen {
if let Some(seconds) = episode.duration_seconds {
details.push(format!("{}:{:02}", seconds / 60, seconds % 60));
}
if self.provider == Provider::AniList {
details.push("UNVERIFIED · sub".into());
}
let details = details.join(" · ");
let mut metadata = title.clone();
if !details.is_empty() {
@@ -297,7 +319,8 @@ impl Screen {
videos,
} => {
self.title = format!(
"Miruro · {} episodes{}",
"{} · {} episodes{}",
self.provider.name(),
collection_name(*kind),
if matches!(kind, CollectionKind::WatchLater) {
format!(" · {}", filter.label())
@@ -308,13 +331,14 @@ impl Screen {
self.rows = videos.iter().map(|video| {
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
let title = safe(&video.title);
let metadata = format!("{title}\n\nEpisode {} · Miruro ID {}\n\nStored offline episode metadata. Enter sends the episode URL to Terebi, which resolves sources and playback metadata.", identity.episode, identity.media_id);
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Provider availability UNVERIFIED. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
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 · {}",
"{} · Saved shows · {}",
self.provider.name(),
if categories.is_empty() {
"All saved shows".into()
} else {
@@ -347,6 +371,8 @@ impl Screen {
// History owns the actual screens, so back/forward never has to refetch a catalog.
pub(crate) struct Browser {
options: Options,
provider: Provider,
episode_input: bool,
config_path: PathBuf,
config: config::Config,
pub(crate) state: State,
@@ -435,11 +461,19 @@ impl Browser {
options: Options,
config_path: PathBuf,
config: config::Config,
state: State,
mut state: State,
) -> Self {
let provider = if options.anilist {
Provider::AniList
} else {
Provider::Miruro
};
state.select_catalog(provider);
let mut query = String::new();
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
let mut browser = Self {
provider,
episode_input: false,
options,
config_path,
config,
@@ -463,6 +497,8 @@ impl Browser {
intent: None,
player: None,
};
browser.screens[0].provider = provider;
browser.screens[0].rebuild_display();
if matches!(&browser.screen().content, Content::Catalog { query, .. } if !query.trim().is_empty())
{
browser.search();
@@ -486,7 +522,9 @@ impl Browser {
self.intent = Some(intent);
self.screen_mut().error = None;
}
fn push(&mut self, screen: Screen) {
fn push(&mut self, mut screen: Screen) {
screen.provider = self.provider;
screen.rebuild_display();
self.invalidate();
self.screens.truncate(self.index + 1);
self.screens.push(screen);
@@ -516,6 +554,18 @@ impl Browser {
}
}
fn edit_search(&mut self) {
self.episode_input = false;
if self.provider == Provider::AniList
&& matches!(&self.screen().content, Content::Series { media, data: Some(_) } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
{
self.invalidate();
self.preview = None;
self.input = Some(String::new());
self.episode_input = true;
self.status =
"Enter an explicit episode number; availability UNVERIFIED (sub only).".into();
return;
}
if !matches!(self.screen().content, Content::Catalog { .. }) {
self.push(Screen::new(Content::Catalog {
query: String::new(),
@@ -529,6 +579,36 @@ impl Browser {
}
self.preview = None;
}
fn select_episode(&mut self, input: &str) {
let result = (|| -> io::Result<()> {
let Content::Series {
media,
data: Some(data),
} = &mut self.screen_mut().content
else {
return Ok(());
};
let identity = Identity::from_key(&format!("anilist:{}:{}", media.id, input.trim()))?;
let episode = miruro_browser::anilist_episode(media, identity.episode)?;
let index = data
.iter()
.position(|item| item.identity == identity)
.unwrap_or(data.len());
if index == data.len() {
data.push(episode);
}
self.screen_mut().selection.select(Some(index));
self.screen_mut().rebuild_display();
Ok(())
})();
match result {
Ok(()) => {
self.screen_mut().error = None;
self.status = "Explicit episode selected; provider availability UNVERIFIED. Enter resolves sub on demand.".into();
}
Err(error) => self.fail(error),
}
}
fn search(&mut self) {
let Content::Catalog { query, .. } = &self.screen().content else {
return;
@@ -550,14 +630,18 @@ impl Browser {
return;
}
self.request(Request::Search(query), Intent::Catalog);
self.status = "Searching Miruro catalog…".into();
self.status = format!("Searching {} catalog…", self.provider.name());
}
fn load_series(&mut self) {
let Content::Series { media, .. } = &self.screen().content else {
return;
};
let id = media.id.clone();
let slug = miruro_browser::slug(&media.title);
let slug = if self.provider == Provider::AniList {
Ok(String::new())
} else {
miruro_browser::slug(&media.title)
};
self.screen_mut().checked.clear();
match slug {
Ok(slug) => {
@@ -568,7 +652,7 @@ impl Browser {
},
Intent::Series(id),
);
self.status = "Loading returned episodes…".into();
self.status = "Loading catalog episode metadata…".into();
}
Err(error) => {
self.invalidate();
@@ -676,7 +760,7 @@ impl Browser {
}
(_, Err(error)) => self.fail(error),
_ => self.fail(io::Error::other(
"Miruro response did not match the current request",
"Anime catalog response did not match the current request",
)),
}
}
@@ -686,7 +770,8 @@ impl Browser {
}
if self.pending.is_some() && !self.worker.as_ref().is_some_and(|worker| worker.busy) {
let request = self.pending.take().expect("pending request");
let worker = self.worker.get_or_insert_with(Worker::new);
let provider = self.provider;
let worker = self.worker.get_or_insert_with(|| Worker::new(provider));
if let Err(error) = worker.submit(self.generation, request) {
self.intent = None;
self.fail(error);
@@ -709,7 +794,7 @@ impl Browser {
}
}
fn collection(&mut self, kind: CollectionKind) {
let videos = collection_videos(&self.state, kind, WatchFilter::All);
let videos = collection_videos(&self.state, kind, WatchFilter::All, self.provider);
let screen = Screen::new(Content::Collection {
kind,
filter: WatchFilter::All,
@@ -952,7 +1037,7 @@ impl Browser {
.selected()
.and_then(|index| videos.get(index))
.map(|video| video.id.clone());
let replacement = collection_videos(&self.state, *kind, *filter);
let replacement = collection_videos(&self.state, *kind, *filter, self.provider);
let next = selected
.and_then(|id| replacement.iter().position(|video| video.id == id))
.or(screen.selection.selected());
@@ -1032,7 +1117,7 @@ impl Browser {
// Even the confirmation snapshot is provider-scoped at the mutation seam.
let ids: Vec<_> = ids
.into_iter()
.filter(|id| Identity::from_key(id).is_ok())
.filter(|id| self.provider.includes(id))
.collect();
match self.state.remove(kind, &ids) {
Ok(()) => {
@@ -1197,12 +1282,20 @@ impl Browser {
match action {
Some(Action::InputSubmit) => {
let input = self.input.take().expect("input active");
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
*query = input;
if self.episode_input {
self.episode_input = false;
self.select_episode(&input);
} else {
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
*query = input;
}
self.search();
}
self.search();
}
Some(Action::InputCancel) => self.input = None,
Some(Action::InputCancel) => {
self.input = None;
self.episode_input = false;
}
_ => {
// Typed controls are rejected too, not merely bracketed paste.
if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
@@ -1216,7 +1309,17 @@ impl Browser {
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::SearchEdit) => self.edit_search(),
Some(Action::ViewSearch) => {
if !matches!(self.screen().content, Content::Catalog { .. }) {
self.push(Screen::new(Content::Catalog {
query: String::new(),
media: Vec::new(),
searched: false,
}));
}
self.edit_search();
}
Some(Action::ViewPrevious) => self.history(false),
Some(Action::ViewNext) => self.history(true),
Some(Action::ViewRefresh) => self.refresh(),
@@ -1323,7 +1426,14 @@ impl Browser {
}
if self.input.is_some() {
return vec![
(Action::InputSubmit, "Search catalog"),
(
Action::InputSubmit,
if self.episode_input {
"Select explicit episode (UNVERIFIED)"
} else {
"Search catalog"
},
),
(Action::InputCancel, "Cancel input"),
(Action::DeleteCharacter, "Delete character"),
(Action::DeleteWord, "Delete word"),
@@ -1442,7 +1552,16 @@ impl Browser {
{
let mut actions = vec![
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(
Action::SearchEdit,
if self.provider == Provider::AniList
&& matches!(&self.screen().content, Content::Series { media, .. } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
{
"select episode"
} else {
"search"
},
),
(Action::InfoOverview, "info"),
(
Action::VideoPlay,
@@ -1717,7 +1836,8 @@ impl Browser {
if area.width < 24 || area.height < 10 {
frame.render_widget(
Paragraph::new(format!(
"Miruro\nTerminal too small; enlarge to browse.\n{} exits",
"{}\nTerminal too small; enlarge to browse.\n{} exits",
self.provider.name(),
self.config.keys.label(Action::Quit)
))
.wrap(Wrap { trim: true }),
@@ -1749,7 +1869,13 @@ impl Browser {
ui::search(
frame,
query_area,
" tubular / Miruro search ",
if self.episode_input {
" Explicit episode · UNVERIFIED · sub "
} else if self.provider == Provider::AniList {
" tubular / AniList search "
} else {
" tubular / Miruro legacy search "
},
query,
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
);
@@ -1771,13 +1897,15 @@ impl Browser {
self.config.keys.label(Action::SearchEdit)
)
} else if self.intent.is_some() {
"Loading Miruro metadata…".into()
format!("Loading {} metadata…", self.provider.name())
} else {
match &self.screen().content {
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for episode metadata; AniList provider availability is UNVERIFIED.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Provider availability UNVERIFIED; sub resolved on selection.", self.config.keys.label(Action::SearchEdit)),
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".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 { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
}
};
@@ -1895,7 +2023,7 @@ impl Browser {
if let Some((kind, ids)) = &self.removal {
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind)))
Paragraph::new(format!("Remove {} {} episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other providers, collections and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), self.provider.name(), collection_name(*kind)))
.wrap(Wrap { trim: true })
.block(
Block::bordered()
@@ -1906,7 +2034,11 @@ impl Browser {
);
} else if let Some(scroll) = self.help {
let mut lines = vec![
Line::from("Miruro catalog → series → actual episodes").bold(),
Line::from(format!(
"{} catalog → series → episodes · availability UNVERIFIED",
self.provider.name()
))
.bold(),
Line::from(
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
),
@@ -1918,7 +2050,7 @@ impl Browser {
}));
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(" Miruro keyboard help ")
.title(format!(" {} keyboard help ", self.provider.name()))
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
@@ -2035,12 +2167,17 @@ fn library_shows(
shows
}
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> {
fn collection_videos(
state: &State,
kind: CollectionKind,
filter: WatchFilter,
provider: Provider,
) -> Vec<Video> {
state
.videos(kind)
.iter()
.filter(|video| {
Identity::from_key(&video.id).is_ok()
provider.includes(&video.id)
&& (!matches!(kind, CollectionKind::WatchLater)
|| filter.includes(state.is_watched(&video.id)))
})
@@ -2106,6 +2243,22 @@ fn media_summary(media: &Media) -> String {
if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) {
parts.push(format!("{count} planned"));
}
if miruro_browser::media_provider(media) == Provider::AniList {
parts.push(
match media.metadata.get("catalog_count").and_then(Value::as_u64) {
Some(count) => format!("{count} catalog/airing numbers"),
None => "catalog/airing count unknown".into(),
},
);
if media
.metadata
.get("episode_count")
.is_none_or(Value::is_null)
{
parts.push("total unknown".into());
}
parts.push("provider availability UNVERIFIED · sub".into());
}
for (field, label) in [
("raw", "raw listed"),
("sub", "sub listed"),
@@ -2176,7 +2329,11 @@ fn media_details(media: &Media) -> String {
}
impl Browser {
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> {
pub(crate) fn run(
&mut self,
terminal: &mut DefaultTerminal,
) -> io::Result<Option<crate::BrowseProvider>> {
self.state.select_catalog(self.provider);
struct RestoreTerminalInput;
impl Drop for RestoreTerminalInput {
fn drop(&mut self) {
@@ -2204,7 +2361,15 @@ impl Browser {
if self.config.keys.resolve(self.context(), key)
== Some(Action::ProviderChoose)
{
return crate::choose_provider(terminal, &self.config.keys, Some(true));
return crate::choose_provider(
terminal,
&self.config.keys,
Some(if self.provider == Provider::AniList {
crate::BrowseProvider::AniList
} else {
crate::BrowseProvider::Miruro
}),
);
}
if self.key(key) {
return Ok(None);