From e328ff3883ac28c1550e05c1cf74cf4311c69ff0 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 15:44:41 +0100 Subject: [PATCH] feat: add AniList browsing with on-demand ZokoAnime playback --- Cargo.toml | 2 +- README.md | 49 +++++-- examples/anilist_probe.rs | 37 ++++++ examples/miruro_probe.rs | 4 +- src/anilist.rs | 227 ++++++++++++++++++++++++++++++++ src/anime.rs | 145 +++++++++++++++++++++ src/keys.rs | 2 +- src/lib.rs | 2 + src/main.rs | 166 ++++++++++++++---------- src/miruro.rs | 65 +--------- src/miruro_browser.rs | 80 ++++++++++-- src/miruro_ui.rs | 243 +++++++++++++++++++++++++++++------ src/video_state.rs | 74 ++++++++--- src/youtube.rs | 2 +- tests/unit/app.rs | 34 ++--- tests/unit/miruro.rs | 4 +- tests/unit/miruro_browser.rs | 28 ++++ tests/unit/miruro_ui.rs | 60 +++++++++ tests/unit/video_state.rs | 65 +++++++++- tests/unit/youtube.rs | 8 ++ 20 files changed, 1062 insertions(+), 235 deletions(-) create mode 100644 examples/anilist_probe.rs create mode 100644 src/anilist.rs create mode 100644 src/anime.rs diff --git a/Cargo.toml b/Cargo.toml index 8b886bf..db28d77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "tubular" version = "0.1.0" edition = "2024" -description = "A terminal YouTube and Miruro browser that opens selected videos in Terebi" +description = "A terminal YouTube and AniList browser that opens selected videos in Terebi" [dependencies] clap = { version = "4.6.7", features = ["derive"] } diff --git a/README.md b/README.md index 1e1cef1..ba4df99 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # tubular -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. +A modal terminal YouTube and AniList browser built with Rust and [Ratatui](https://ratatui.rs/). Search videos or anime catalog titles, organize channels and saved shows with overlapping tags, and open a selection in Terebi. The explicitly separate Miruro legacy catalog and library remain accessible. ## Requirements - A recent stable Rust toolchain, including Cargo, to build and install. -- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; Miruro uses the native API client. -- `curl` on `PATH` for YouTube thumbnails and Miruro cover art. -- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; multiple videos require `--new-instance --queue-file PATH` support. Miruro also requires native episode-URL resolution and version-2 episode progress support. +- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; AniList and legacy Miruro catalog requests use native HTTPS. +- `curl` on `PATH` for thumbnails and anime cover art. +- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; queues require `--new-instance --queue-file PATH`. AniList playback requires the ZokoAnime sub resolver and version-2 `anilist` identity progress support; legacy Miruro retains its own resolver and identities. The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player. @@ -34,20 +34,21 @@ cargo run --locked -- "rust programming" ## Usage ```sh -tubular # Choose YouTube or Miruro +tubular # Choose YouTube, AniList, or Miruro legacy tubular "rust programming" # Choose a provider, then search this query -tubular --miruro "apothecary" # Skip the chooser and search Miruro directly +tubular --anilist "Cowboy Bebop" # Native AniList catalog; resolve sub on selection +tubular --miruro "apothecary" # Explicit legacy Miruro browsing tubular --player /path/to/terebi "japanese podcast" tubular --help ``` -Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows to select and Enter to open that provider's separate interface; Esc, `q`, or Ctrl-C quits without opening a browser. Navigation, confirmation and Esc use the configured `selector.select.next`, `selector.select.previous`, `selector.open` and `selector.close` bindings; quit/interrupt bindings also apply. An initial CLI query searches whichever provider you select. `--miruro` bypasses the chooser; `--help` and `--default-config` remain noninteractive. +Startup shows a centered **YouTube / AniList / Miruro legacy** selector. Use `j` / `k` or arrows to select and Enter to open; Esc, `q`, or Ctrl-C quits. Configured selector/quit keys apply. An initial CLI query searches only the chosen catalog. `--anilist` and `--miruro` bypass the chooser and are mutually exclusive; `--help` and `--default-config` remain noninteractive. After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting. -In either provider's normal mode, press `p` (`app.provider`) to reopen the chooser with the current provider selected, then Enter to switch. Esc cancels and returns to browsing (unlike at startup); `q` or Ctrl-C quits. Both browser sessions stay in memory, preserving their queries, results, selection and navigation history when you switch back. Switching leaves running players unaffected and never reapplies the CLI query: it is used only for the initially opened provider. `--miruro` only skips the startup chooser; you can still switch providers with `p`. +In normal mode, `p` (`app.provider`) reopens the chooser with the current selection. Enter switches; Esc cancels; `q` or Ctrl-C quits. All three browser sessions retain their own queries, results, selection and history. Shared persisted state and pending progress follow the active browser, without mixing provider-scoped collections or show libraries. Switching never interrupts players or reapplies the initial CLI query. -Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata. +The browsers share a cyan view heading, bordered search/results/status, and a dim key hint bar. Rows place the title above metadata; YouTube channel/publication/duration behavior is unchanged. Saved titles are green, watch-later titles magenta, and `✓` marks watched entries. AniList and legacy Miruro reuse the anime catalog/series/episode interface, cover-art previews, overlapping show tags, checked queues and offline episode collections. YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable. YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right at 20 ms per terminal column, using the smallest groups of whole user-perceived characters with matching column widths. Two narrow characters and one wide character therefore swap together after 40 ms, without shifting the untouched suffix or inserting temporary blanks. Only an exhausted shorter title is padded with trailing spaces; that padding disappears when the animation finishes. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. “Original-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text. @@ -77,9 +78,33 @@ Two or more checked videos launch `terebi --new-instance --queue-file '/private/ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for its entire terminal session and restores the previous mode on exit. Supporting terminals can then distinguish Ctrl+Return from plain Return. A terminal or multiplexer that does not support/forward this protocol may still send identical bytes for both; Tubular cannot distinguish them. In that case, configure another key, for example `[keys.video]` with `enqueue = "alt+e"`. -### Miruro browser +### AniList catalog and on-demand sub playback -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. +Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: a 20-second timeout, no redirects, 2 MiB response limit, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values. + +**Provider availability is UNVERIFIED.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The initial direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons. + +Rows, metadata and selection prompts label availability UNVERIFIED. Terebi resolves the current selection fresh; unavailable episodes, malformed/protected pages and network failures appear as distinct player errors, while launch/catalog errors remain visible in Tubular. A successful handoff does not mark an episode watched. + +Identities are `anilist::`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`. + +`anilist-library.toml` stores AniList shows/tags separately from the untouched `miruro-library.toml`. Shared episode collection/state files use exact provider-qualified keys and filter each catalog independently; `miruro:1:1` is not `anilist:1:1`. No opaque Miruro IDs, saved show metadata or old playback sessions are mapped or reinterpreted. + +The controls in the legacy table below also apply to AniList, except unknown-count series use `i` for explicit episode selection. Saved AniList shows reopen current native detail by ID. The display limit is 10,000 known catalog numbers; oversized counts report an error instead of truncating silently. + +Read-only native smoke (does not launch playback): + +```sh +cargo run --locked --example anilist_probe +cargo test --locked +cargo run --locked -- --anilist "Cowboy Bebop" +``` + +The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the UNVERIFIED episode-1 identity/stable URL. It does not prove playback. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution and provider-availability caveats. + +### Miruro legacy browser + +Choose **Miruro legacy** or use `--miruro`. This explicitly retained catalog has its own **catalog → series → returned episodes** session and saved-show library; it is never consulted for AniList browsing or playback. Catalog and episode-metadata requests use the official Miruro 2.0 mirrors (`miruro.cx`, `.pw`, `.ws`, `.to`, `.tv`, `.bz`), not their legacy `www.` versions. Each session starts with `.cx`, remembers the last working mirror, and tries each mirror at most once when a host is unreachable, blocked, redirected, unavailable, or returns an unexpected Content-Type/unreadable body. Requests have a shared 25-second budget with at most five seconds per mirror; rate limits and other invalid/unauthorized requests stop immediately. No per-request randomization, directory scraping, cookies, or Cloudflare bypass is used. If every mirror is blocked, browsing still fails with the number of mirrors attempted and the last failure. Playback retains Terebi's existing `barelystarted.miruro.tv` URL contract; catalog failover does not add playback-mirror support. @@ -291,7 +316,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr ## Watch later -Watch later is **one stored collection**, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. In YouTube, category lists are derived from saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. Its combined list contains only YouTube videos. Miruro uses the flat episode lists described above. No category memberships are stored on individual videos. +Watch later is **one stored collection**, deduplicated by YouTube video ID or exact provider-qualified AniList/Miruro episode identity, with separate provider views. YouTube category lists still derive from channel identities and current subscription categories; anime uses flat provider-local episode lists. No category memberships are stored on individual videos. In YouTube, `W` opens a searchable collection selector from any video view or preview. The All categories (combined) entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly. diff --git a/examples/anilist_probe.rs b/examples/anilist_probe.rs new file mode 100644 index 0000000..f98fa2a --- /dev/null +++ b/examples/anilist_probe.rs @@ -0,0 +1,37 @@ +//! Read-only native smoke: cargo run --locked --example anilist_probe +use std::io; +use tubular::{ + anilist::{Client, catalog_count}, + anime::Identity, +}; + +fn main() -> io::Result<()> { + let client = Client::default(); + let results = client.search("Cowboy Bebop")?; + let media = results + .iter() + .find(|media| media["id"] == "1") + .ok_or_else(|| io::Error::other("Exact Cowboy Bebop AniList ID 1 absent from search"))?; + let id = media["id"] + .as_str() + .ok_or_else(|| io::Error::other("Missing catalog ID"))?; + let detail = client.detail(id)?; + let count = catalog_count(&detail)?.ok_or_else(|| io::Error::other("Catalog count unknown"))?; + if count == 0 { + return Err(io::Error::other("No catalog episode number 1")); + } + let identity = Identity::anilist(id.into(), 1)?; + println!( + "native search: {} records; exact AniList ID {id}", + results.len() + ); + println!( + "detail: {count} catalog episode numbers; selected episode 1; provider availability UNVERIFIED" + ); + println!("identity: {}", identity.key()); + println!("watch URL: {}", identity.watch_url()?); + println!( + "PASS: native AniList search -> exact detail -> UNVERIFIED catalog episode -> stable sub URL (no playback request)" + ); + Ok(()) +} diff --git a/examples/miruro_probe.rs b/examples/miruro_probe.rs index d2a0502..85fa64b 100644 --- a/examples/miruro_probe.rs +++ b/examples/miruro_probe.rs @@ -2,7 +2,7 @@ //! Exercises native catalog metadata and stable episode URLs; no playback requests. use std::io; -use tubular::miruro::{Client, Identity}; +use tubular::{anime::Identity, miruro::Client}; fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) @@ -30,7 +30,7 @@ fn main() -> io::Result<()> { "detail: {} returned episodes; selected episode {number}", detail.episodes.len() ); - let identity = Identity::new(id.into(), number)?; + let identity = Identity::miruro(id.into(), number)?; println!("watch URL: {}", identity.watch_url()?); println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests"); Ok(()) diff --git a/src/anilist.rs b/src/anilist.rs new file mode 100644 index 0000000..2cb014e --- /dev/null +++ b/src/anilist.rs @@ -0,0 +1,227 @@ +//! AniList metadata only. Episode numbers are catalog information, never stream availability. +use crate::anime::positive_decimal; +use serde_json::{Value, json}; +use std::{io, time::Duration}; + +const FIELDS: &str = "id title { english romaji native } format status episodes season seasonYear description(asHtml: false) coverImage { extraLarge large medium } bannerImage duration averageScore genres source countryOfOrigin nextAiringEpisode { episode }"; +const BODY_LIMIT: u64 = 2 * 1024 * 1024; + +pub struct Client { + agent: ureq::Agent, +} +impl Default for Client { + fn default() -> Self { + Self { + agent: ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(20))) + .http_status_as_error(false) + .max_redirects(0) + .build() + .new_agent(), + } + } +} +impl Client { + pub fn search(&self, query: &str) -> io::Result> { + let query = query.trim(); + if query.is_empty() || query.len() > 4096 { + return Err(invalid("AniList query must contain 1–4096 bytes")); + } + let mut response = self.query(&format!("query ($search: String!) {{ Page(page: 1, perPage: 8) {{ media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {FIELDS} }} }} }}"), json!({"search": query}))?; + let Some(Value::Array(items)) = response.pointer_mut("/Page/media").map(Value::take) else { + return Err(invalid("AniList search is missing its media array")); + }; + if items.len() > 8 { + return Err(invalid("AniList search exceeded eight results")); + } + let mut ids = std::collections::HashSet::new(); + items + .into_iter() + .map(|item| { + let media = normalize(item)?; + if !ids.insert(media["id"].as_str().unwrap().to_owned()) { + return Err(invalid("AniList returned duplicate IDs")); + } + Ok(media) + }) + .collect() + } + pub fn detail(&self, id: &str) -> io::Result { + let id_number = positive_decimal(id)?; + let mut response = self.query( + &format!("query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {FIELDS} }} }}"), + json!({"id": id_number}), + )?; + let media = response + .get_mut("Media") + .filter(|v| !v.is_null()) + .map(Value::take) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "AniList catalog title not found") + })?; + let media = normalize(media)?; + if media["id"].as_str() != Some(id) { + return Err(invalid("AniList detail ID does not match request")); + } + Ok(media) + } + fn query(&self, query: &str, variables: Value) -> io::Result { + let body = json!({"query": query, "variables": variables}).to_string(); + let mut response = self + .agent + .post("https://graphql.anilist.co") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .send(body.as_str()) + .map_err(|_| io::Error::other("AniList network request failed"))?; + let status = response.status().as_u16(); + if status != 200 { + return Err(io::Error::new( + match status { + 401 | 403 => io::ErrorKind::PermissionDenied, + 404 => io::ErrorKind::NotFound, + _ => io::ErrorKind::Other, + }, + format!("AniList HTTP {status}"), + )); + } + if response.body().mime_type() != Some("application/json") { + return Err(invalid("AniList returned non-JSON content")); + } + let body = response + .body_mut() + .with_config() + .limit(BODY_LIMIT) + .read_to_string() + .map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?; + decode(&body) + } +} +fn decode(body: &str) -> io::Result { + let mut value: Value = + serde_json::from_str(body).map_err(|_| invalid("Malformed AniList JSON"))?; + if let Some(errors) = value.get("errors").filter(|v| !v.is_null()) { + let errors = errors + .as_array() + .ok_or_else(|| invalid("Malformed AniList GraphQL errors"))?; + if !errors.is_empty() { + return Err(io::Error::other( + "AniList GraphQL request failed (catalog data not accepted)", + )); + } + } + value + .get_mut("data") + .filter(|v| v.is_object()) + .map(Value::take) + .ok_or_else(|| invalid("AniList response is missing data")) +} +/// Normalize metadata field names to the existing anime UI, retaining explicit nulls. +fn normalize(mut media: Value) -> io::Result { + let id = media + .get("id") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| *n > 0 && *n <= i32::MAX as u32) + .ok_or_else(|| invalid("Invalid AniList catalog ID"))?; + let object = media + .as_object_mut() + .ok_or_else(|| invalid("Invalid AniList media"))?; + object.insert("id".into(), Value::String(id.to_string())); + object.insert("catalog_provider".into(), Value::String("anilist".into())); + for (source, target) in [ + ("episodes", "episode_count"), + ("seasonYear", "season_year"), + ("duration", "episode_duration_minutes"), + ("averageScore", "average_score"), + ("countryOfOrigin", "country_of_origin"), + ] { + let value = object.get(source).cloned().unwrap_or(Value::Null); + object.insert(target.into(), value); + } + let cover = object + .get("coverImage") + .and_then(|v| { + v.get("extraLarge") + .and_then(Value::as_str) + .or_else(|| v.get("large").and_then(Value::as_str)) + }) + .map(str::to_owned); + object.insert( + "cover_url".into(), + cover.map(Value::String).unwrap_or(Value::Null), + ); + let count = catalog_count(&media)?; + media.as_object_mut().unwrap().insert( + "catalog_count".into(), + count.map(|n| json!(n)).unwrap_or(Value::Null), + ); + Ok(media) +} +/// Finished totals are catalog numbers; releasing totals remain plans, not aired episodes. +pub fn catalog_count(media: &Value) -> io::Result> { + let number = |value: Option<&Value>| -> io::Result> { + match value { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| *n <= i32::MAX as u32) + .map(Some) + .ok_or_else(|| invalid("Invalid AniList episode count")), + } + }; + let total = number(media.get("episode_count"))?; + match media.get("status").and_then(Value::as_str) { + Some("FINISHED") => Ok(total), + Some("NOT_YET_RELEASED") => Ok(Some(0)), + Some("RELEASING") => { + let next = number(media.pointer("/nextAiringEpisode/episode"))?; + if next == Some(0) { + return Err(invalid("Invalid AniList next airing episode")); + } + let aired = next.map(|n| n - 1); + if aired.zip(total).is_some_and(|(aired, total)| aired > total) { + return Err(invalid("AniList aired count exceeds planned total")); + } + Ok(aired) + } + _ => Ok(None), + } +} +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn catalog_boundaries_and_graphql_errors() { + let finished = normalize(json!({"id":1,"status":"FINISHED","episodes":26})).unwrap(); + assert_eq!(catalog_count(&finished).unwrap(), Some(26)); + let ongoing = normalize( + json!({"id":2,"status":"RELEASING","episodes":24,"nextAiringEpisode":{"episode":5}}), + ) + .unwrap(); + assert_eq!(catalog_count(&ongoing).unwrap(), Some(4)); + for status in ["RELEASING", "HIATUS", "CANCELLED", "FINISHED"] { + assert_eq!( + catalog_count(&normalize(json!({"id":2,"status":status,"episodes":null})).unwrap()) + .unwrap(), + None + ); + } + assert!( + normalize( + json!({"id":2,"status":"RELEASING","episodes":2,"nextAiringEpisode":{"episode":5}}) + ) + .is_err() + ); + assert!(normalize(json!({"id":0})).is_err()); + assert!(decode(r#"{"data":{"Media":{"id":1}},"errors":[{"message":"bad"}]}"#).is_err()); + assert!(decode(r#"{"data":null}"#).is_err()); + assert!(decode(r#"{"errors":{},"data":{}}"#).is_err()); + assert!(decode(r#"{"data":{"Media":null}}"#).unwrap()["Media"].is_null()); + } +} diff --git a/src/anime.rs b/src/anime.rs new file mode 100644 index 0000000..a1a51ac --- /dev/null +++ b/src/anime.rs @@ -0,0 +1,145 @@ +//! Stable identities for the two explicitly separate anime catalogs. +use serde::{Deserialize, Serialize}; +use std::io; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Provider { + AniList, + #[default] + Miruro, +} +impl Provider { + pub fn name(self) -> &'static str { + match self { + Self::AniList => "AniList", + Self::Miruro => "Miruro legacy", + } + } + pub fn key(self) -> &'static str { + match self { + Self::AniList => "anilist", + Self::Miruro => "miruro", + } + } + pub fn identity(self, media_id: String, episode: u32) -> io::Result { + let identity = Identity { + provider: self.key().into(), + media_id, + episode, + }; + identity.validate()?; + Ok(identity) + } + pub fn includes(self, key: &str) -> bool { + Identity::from_key(key).is_ok_and(|identity| identity.provider == self.key()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub provider: String, + pub media_id: String, + pub episode: u32, +} +impl Identity { + pub fn miruro(media_id: String, episode: u32) -> io::Result { + Provider::Miruro.identity(media_id, episode) + } + pub fn anilist(media_id: String, episode: u32) -> io::Result { + Provider::AniList.identity(media_id, episode) + } + pub fn validate(&self) -> io::Result<()> { + if self.episode == 0 || self.episode > i32::MAX as u32 { + return Err(invalid("Invalid anime episode number")); + } + match self.provider.as_str() { + "anilist" => { + positive_decimal(&self.media_id)?; + } + "miruro" + if !self.media_id.is_empty() + && self.media_id.len() <= 512 + && self + .media_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) => {} + _ => return Err(invalid("Invalid anime catalog identity")), + } + Ok(()) + } + pub fn key(&self) -> String { + format!("{}:{}:{}", self.provider, self.media_id, self.episode) + } + pub fn watch_url(&self) -> io::Result { + self.validate()?; + Ok(match self.provider.as_str() { + "anilist" => format!( + "https://zokoanime.video/stream/ani/{}/{}/sub", + self.media_id, self.episode + ), + _ => format!( + "https://barelystarted.miruro.tv/watch/{}/episode?ep={}", + self.media_id, self.episode + ), + }) + } + pub fn from_key(key: &str) -> io::Result { + let mut parts = key.split(':'); + let provider = parts.next().unwrap_or(""); + let media_id = parts.next().unwrap_or(""); + let episode = positive_decimal(parts.next().unwrap_or(""))?; + if parts.next().is_some() { + return Err(invalid("Invalid anime episode key")); + } + let identity = Self { + provider: provider.into(), + media_id: media_id.into(), + episode, + }; + identity.validate()?; + Ok(identity) + } +} +pub(crate) fn positive_decimal(text: &str) -> io::Result { + if text.starts_with('0') || text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid("Expected canonical positive decimal Int32")); + } + text.parse::() + .ok() + .filter(|n| *n <= i32::MAX as u32) + .ok_or_else(|| invalid("Expected canonical positive decimal Int32")) +} +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn canonical_provider_isolation() { + let ani = Identity::anilist("1".into(), 1).unwrap(); + assert_eq!(ani.key(), "anilist:1:1"); + assert_eq!( + ani.watch_url().unwrap(), + "https://zokoanime.video/stream/ani/1/1/sub" + ); + assert_ne!(ani.key(), Identity::miruro("1".into(), 1).unwrap().key()); + assert!(!Provider::Miruro.includes(&ani.key())); + for key in [ + "anilist:01:1", + "anilist:0:1", + "anilist:+1:1", + "anilist:1:01", + "anilist:1:0", + "anilist:2147483648:1", + "anilist:1:2147483648", + "anilist:1:1:2", + "other:1:1", + ] { + assert!(Identity::from_key(key).is_err(), "{key}"); + } + assert_eq!(Identity::from_key(&ani.key()).unwrap(), ani); + } +} diff --git a/src/keys.rs b/src/keys.rs index 648c271..ee04bfd 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -135,7 +135,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 YouTube channel's or Miruro show's category tags", VIEW, ["s"]; + ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime 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"]; diff --git a/src/lib.rs b/src/lib.rs index 7ed3b39..acc0b64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,3 @@ +pub mod anilist; +pub mod anime; pub mod miruro; diff --git a/src/main.rs b/src/main.rs index 2b9ddf7..1db13a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,13 @@ -//! Independent Ratatui YouTube and Miruro browsers with playback handed to Terebi. +//! Terminal YouTube and AniList browsing with playback handed to Terebi. //! -//! Run `cargo run -- "rust programming"`; default to the installed Terebi CLI on PATH. -//! Key instructions below describe defaults. `[keys]` configures all actions; live hints use overrides. -//! `--default-config` prints complete TOML without loading config or starting the terminal. -//! 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; s saves shows with overlapping tags. -//! Terebi resolves the current canonical Miruro episode URL on demand, including fresh sources and subtitles. -//! 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. +//! Startup offers YouTube, AniList and the explicitly separate Miruro legacy catalog. +//! `--anilist` opens AniList directly; `--miruro` retains legacy catalog access. +//! AniList episode numbers are catalog metadata, with provider availability UNVERIFIED. +//! Unknown counts allow explicit positive episode selection; Terebi resolves sub on demand. +//! Stable provider-scoped identities persist, never resolved sources or subtitle URLs. +//! AniList and Miruro show libraries remain separate; existing Miruro IDs are never mapped. +//! `--default-config` prints complete TOML without starting the terminal. +//! Key instructions below describe defaults; live hints reflect `[keys]` overrides. //! 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. //! Esc keeps the draft without submitting; Search and each category retain independent drafts and pages. @@ -165,8 +163,8 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[derive(Clone, Parser)] #[command( version, - about = "Browse YouTube or Miruro and open selected videos in Terebi", - after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nYouTube browsing requires yt-dlp; YouTube thumbnails also use curl. Miruro browsing uses the native catalog client; Terebi must support Miruro episode URLs and version-2 progress. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then plays checked episodes in check order, or the hovered episode. One episode uses --new-instance --url URL --progress-file PATH; multiple episodes use --new-instance --queue-file PATH. Terebi resolves each current episode on demand, including sources, subtitles and playback metadata. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor one YouTube video, Terebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file. Multiple videos use --new-instance --queue-file with an absolute private version-1 JSON manifest containing ordered canonical URLs and independent private progress_file paths. Terebi resolves only the current item on demand; plain Enter opens one independent player without replacing existing playback. Ctrl+Enter (video.enqueue) sends either one video or checked videos using --enqueue --queue-file with the same private manifest format, appending to a running player without interrupting playback, or launching the queue if none is running.\nStartup selects independent YouTube or Miruro interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --miruro skips the chooser. The chosen browser starts in NORMAL and searches any CLI query automatically; otherwise press i to enter a query.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits the trimmed query if changed and returns to NORMAL; Esc keeps the draft without submitting. In category and combined-category views, queries search only those channels' full histories, not just loaded rows; clearing the query restores uploads. Each category keeps its own draft, submitted query and pagination, independent of general Search. In the independent Miruro interface, input searches its catalog; YouTube views submit to general YouTube Search. Repeating a submitted query reuses its cache or pending request.\nTEXT EDITING (search, category/channel filters, new tag, and category name prompts): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nYOUTUBE NORMAL: j/k or arrows select; Space toggles a playback check; Esc discards checks (Ctrl-Space also clears); Enter plays checked videos in check order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle, video.select.clear and video.enqueue. Checks belong to each view, survive pagination and reordering by video ID, clear on replacement refresh/query and successful launcher spawn, and are pruned when filters or collections hide/remove rows. Failed launches retain checks. Saves, removals and status actions still affect only the hovered video; i edits the query; / jumps to Search; F jumps to Feed; d opens video info (again for full description); c browses saved categories; u browses saved channels; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; R renames the current explicit category; P saves the active combined category feed as a new category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, combined category feeds, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCURRENTLY WATCHING: C (Shift-C) directly opens the provider-local combined offline list of acknowledged started, unfinished videos. Positive playback progress admits a video, not launcher success; watched videos leave automatically. x removes selected and X confirms removing the list, without marking watched. Removal persists and old session reports never re-add it; a new playback session can. currently-watching.toml sits beside config.toml. This view shares collection previews, temporary tabs and navigation history; it has no category selector or watched filter.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nYouTube Search and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Space checks categories; Enter browses checked categories, or the hovered match if none are checked; p immediately saves pin/unpin; R renames the hovered category; Esc cancels checks and closes. Checkboxes are separate from the dim p pin marker. One checked category opens its ordinary view; multiple checked categories open their combined newest-first, deduplicated feed. Configure selector.toggle to change Space. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses checked categories, or the selected match if none are checked; Esc returns to NORMAL with the filter retained. Space remains text while filtering. Hidden checked categories stay checked. With no checks and no matches, Enter/p do nothing. Reopening clears the filter and prechecks the active combined feed members.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels, combined category feeds and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category, combined category and channel views retain loaded rows, selection, and pagination for this session. Combined feeds retain their cache when member assignments change but the resolved channel union stays equal; deleting a member invalidates the combined view rather than widening it. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 60 results per page; Feed, categories, and channel views share a 60-row budget across up to four concurrent extractors. Category queries request matching channel-history results instead of uploads.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views sort all loaded rows newest-first using approximate upload dates, including undated rows. Selection follows video identity when later pages reorder rows.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nSAVE COMBINED CATEGORY (P / Shift-P): only an active combined category feed can be saved, including from its previews. The floating name prompt uses shared input editing controls; Enter saves a new flat category containing the currently viewed channel union, using mapped handles where available; Esc cancels without writes. Invalid or duplicate names keep the prompt open and current view unchanged. Saving copies memberships, not dynamic source-category definitions; later source-category edits do not affect the saved category. The new category becomes the active unpinned temporary tab, preserving matching cached rows, selection, pending pages, query draft and submitted query, and pagination without refetching. Source categories and existing pins stay unchanged. Configure category.save; P remains text in inputs.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\nNEW TAG: type a name; Enter stages it checked, clears the filter, selects the new tag, and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nPlain Enter opens a new Terebi player, leaving existing playback untouched; Ctrl+Enter appends to the running queue instead. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" + about = "Browse YouTube or AniList and open selected videos in Terebi", + after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nAniList browsing uses native GraphQL with no Miruro dependency. Episode numbers have provider availability UNVERIFIED; sub resolves on selection. Unknown-count series accept an explicit positive episode using i. --anilist opens this catalog directly; --miruro is explicitly legacy. Show libraries and episode identities remain provider-isolated. YouTube browsing requires yt-dlp; thumbnails/cover art use curl. Legacy Miruro browsing uses the native catalog client; Terebi must support Miruro episode URLs and version-2 progress. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then plays checked episodes in check order, or the hovered episode. One episode uses --new-instance --url URL --progress-file PATH; multiple episodes use --new-instance --queue-file PATH. Terebi resolves each current episode on demand, including sources, subtitles and playback metadata. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor one YouTube video, Terebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file. Multiple videos use --new-instance --queue-file with an absolute private version-1 JSON manifest containing ordered canonical URLs and independent private progress_file paths. Terebi resolves only the current item on demand; plain Enter opens one independent player without replacing existing playback. Ctrl+Enter (video.enqueue) sends either one video or checked videos using --enqueue --queue-file with the same private manifest format, appending to a running player without interrupting playback, or launching the queue if none is running.\nStartup selects independent YouTube, AniList or Miruro legacy interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --anilist or --miruro skips the chooser. The chosen browser starts in NORMAL and searches any CLI query automatically; otherwise press i to enter a query.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits the trimmed query if changed and returns to NORMAL; Esc keeps the draft without submitting. In category and combined-category views, queries search only those channels' full histories, not just loaded rows; clearing the query restores uploads. Each category keeps its own draft, submitted query and pagination, independent of general Search. In the independent anime interfaces, input searches the selected catalog (or selects an explicit episode for unknown AniList counts); YouTube views submit to general YouTube Search. Repeating a submitted query reuses its cache or pending request.\nTEXT EDITING (search, category/channel filters, new tag, and category name prompts): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nYOUTUBE NORMAL: j/k or arrows select; Space toggles a playback check; Esc discards checks (Ctrl-Space also clears); Enter plays checked videos in check order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle, video.select.clear and video.enqueue. Checks belong to each view, survive pagination and reordering by video ID, clear on replacement refresh/query and successful launcher spawn, and are pruned when filters or collections hide/remove rows. Failed launches retain checks. Saves, removals and status actions still affect only the hovered video; i edits the query; / jumps to Search; F jumps to Feed; d opens video info (again for full description); c browses saved categories; u browses saved channels; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; R renames the current explicit category; P saves the active combined category feed as a new category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, combined category feeds, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCURRENTLY WATCHING: C (Shift-C) directly opens the provider-local combined offline list of acknowledged started, unfinished videos. Positive playback progress admits a video, not launcher success; watched videos leave automatically. x removes selected and X confirms removing the list, without marking watched. Removal persists and old session reports never re-add it; a new playback session can. currently-watching.toml sits beside config.toml. This view shares collection previews, temporary tabs and navigation history; it has no category selector or watched filter.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nYouTube Search and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Space checks categories; Enter browses checked categories, or the hovered match if none are checked; p immediately saves pin/unpin; R renames the hovered category; Esc cancels checks and closes. Checkboxes are separate from the dim p pin marker. One checked category opens its ordinary view; multiple checked categories open their combined newest-first, deduplicated feed. Configure selector.toggle to change Space. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses checked categories, or the selected match if none are checked; Esc returns to NORMAL with the filter retained. Space remains text while filtering. Hidden checked categories stay checked. With no checks and no matches, Enter/p do nothing. Reopening clears the filter and prechecks the active combined feed members.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels, combined category feeds and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category, combined category and channel views retain loaded rows, selection, and pagination for this session. Combined feeds retain their cache when member assignments change but the resolved channel union stays equal; deleting a member invalidates the combined view rather than widening it. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 60 results per page; Feed, categories, and channel views share a 60-row budget across up to four concurrent extractors. Category queries request matching channel-history results instead of uploads.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views sort all loaded rows newest-first using approximate upload dates, including undated rows. Selection follows video identity when later pages reorder rows.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nSAVE COMBINED CATEGORY (P / Shift-P): only an active combined category feed can be saved, including from its previews. The floating name prompt uses shared input editing controls; Enter saves a new flat category containing the currently viewed channel union, using mapped handles where available; Esc cancels without writes. Invalid or duplicate names keep the prompt open and current view unchanged. Saving copies memberships, not dynamic source-category definitions; later source-category edits do not affect the saved category. The new category becomes the active unpinned temporary tab, preserving matching cached rows, selection, pending pages, query draft and submitted query, and pagination without refetching. Source categories and existing pins stay unchanged. Configure category.save; P remains text in inputs.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\nNEW TAG: type a name; Enter stages it checked, clears the filter, selects the new tag, and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nPlain Enter opens a new Terebi player, leaving existing playback untouched; Ctrl+Enter appends to the running queue instead. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable with URL, queue-file and per-item progress support. @@ -181,6 +179,10 @@ struct Options { #[arg(long)] miruro: bool, + /// Open AniList catalog numbers (provider availability UNVERIFIED; sub resolved on play). + #[arg(long, conflicts_with = "miruro")] + anilist: bool, + /// Optional initial search for the selected provider. query: Option, } @@ -3646,7 +3648,11 @@ impl App { ); } - fn run(&mut self, terminal: &mut DefaultTerminal, initial: bool) -> io::Result> { + fn run( + &mut self, + terminal: &mut DefaultTerminal, + initial: bool, + ) -> io::Result> { struct RestoreTerminalInput; impl Drop for RestoreTerminalInput { fn drop(&mut self) { @@ -3678,7 +3684,11 @@ impl App { if self.config.keys.resolve(self.key_context(), key) == Some(Action::ProviderChoose) { - return choose_provider(terminal, &self.config.keys, Some(false)); + return choose_provider( + terminal, + &self.config.keys, + Some(BrowseProvider::YouTube), + ); } if self.key(key) { return Ok(None); @@ -3764,12 +3774,23 @@ fn paste_input(input: &mut String, text: &str) { } } -const PROVIDERS: [(&str, bool); 2] = [("YouTube", false), ("Miruro", true)]; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BrowseProvider { + YouTube, + AniList, + Miruro, +} + +const PROVIDERS: [(&str, BrowseProvider); 3] = [ + ("YouTube", BrowseProvider::YouTube), + ("AniList · on-demand sub", BrowseProvider::AniList), + ("Miruro legacy · separate library", BrowseProvider::Miruro), +]; fn provider_selection( selected: &mut usize, action: Option, -) -> std::ops::ControlFlow> { +) -> std::ops::ControlFlow> { match action { Some(Action::SelectorNext) => *selected = (*selected + 1).min(PROVIDERS.len() - 1), Some(Action::SelectorPrevious) => *selected = selected.saturating_sub(1), @@ -3787,9 +3808,15 @@ fn provider_selection( fn choose_provider( terminal: &mut DefaultTerminal, bindings: &keys::Bindings, - current: Option, -) -> io::Result> { - let mut selected = usize::from(current.unwrap_or(false)); + current: Option, +) -> io::Result> { + let mut selected = current + .and_then(|current| { + PROVIDERS + .iter() + .position(|(_, provider)| *provider == current) + }) + .unwrap_or(0); let mut selection = ListState::default(); let hint = format!( "{}/{} select · {} open · {} {}", @@ -3859,29 +3886,26 @@ fn main() -> io::Result<()> { let config = config::load(&config_path)?; ratatui::run(|terminal| { let _keyboard = ui::KeyboardProtocol::enable()?; - if !options.miruro { - let Some(miruro) = choose_provider(terminal, &config.keys, None)? else { + let mut provider = if options.anilist { + BrowseProvider::AniList + } else if options.miruro { + BrowseProvider::Miruro + } else { + let Some(provider) = choose_provider(terminal, &config.keys, None)? else { return Ok(()); }; - options.miruro = miruro; - } + provider + }; let mut youtube: Option = None; - let mut miruro: Option = None; - let mut initial = true; + let mut anime: [Option; 2] = [None, None]; + let mut previous = None; loop { - let next = if options.miruro { - if miruro.is_none() { - miruro = Some(miruro_ui::Browser::new( - options.clone(), - config_path.clone(), - config::load(&config_path)?, - video_state::State::open(&config_path)?, - )); - } - miruro.as_mut().unwrap().run(terminal)? - } else { - let first = youtube.is_none(); - if first { + options.anilist = provider == BrowseProvider::AniList; + options.miruro = provider == BrowseProvider::Miruro; + let anime_index = usize::from(provider == BrowseProvider::Miruro); + let first_youtube = youtube.is_none(); + if provider == BrowseProvider::YouTube { + if youtube.is_none() { youtube = Some(App::with_state( options.clone(), config_path.clone(), @@ -3889,37 +3913,47 @@ fn main() -> io::Result<()> { video_state::State::open(&config_path)?, )); } - youtube.as_mut().unwrap().run(terminal, first)? + } else if anime[anime_index].is_none() { + anime[anime_index] = Some(miruro_ui::Browser::new( + options.clone(), + config_path.clone(), + config::load(&config_path)?, + video_state::State::open(&config_path)?, + )); + } + if let Some(previous) = previous.filter(|previous| *previous != provider) { + match (previous, provider) { + (BrowseProvider::YouTube, _) => std::mem::swap( + &mut youtube.as_mut().unwrap().video_state, + &mut anime[anime_index].as_mut().unwrap().state, + ), + (_, BrowseProvider::YouTube) => std::mem::swap( + &mut anime[usize::from(previous == BrowseProvider::Miruro)] + .as_mut() + .unwrap() + .state, + &mut youtube.as_mut().unwrap().video_state, + ), + _ => { + let (anilist, miruro) = anime.split_at_mut(1); + std::mem::swap( + &mut anilist[0].as_mut().unwrap().state, + &mut miruro[0].as_mut().unwrap().state, + ); + } + } + } + let next = if provider == BrowseProvider::YouTube { + youtube.as_mut().unwrap().run(terminal, first_youtube)? + } else { + anime[anime_index].as_mut().unwrap().run(terminal)? }; let Some(next) = next else { return Ok(()); }; - if initial { - options.query = None; - initial = false; - } - if next != options.miruro { - if next && miruro.is_none() { - miruro = Some(miruro_ui::Browser::new( - options.clone(), - config_path.clone(), - config::load(&config_path)?, - video_state::State::open(&config_path)?, - )); - } else if !next && youtube.is_none() { - youtube = Some(App::with_state( - options.clone(), - config_path.clone(), - config::load(&config_path)?, - video_state::State::open(&config_path)?, - )); - } - std::mem::swap( - &mut youtube.as_mut().unwrap().video_state, - &mut miruro.as_mut().unwrap().state, - ); - } - options.miruro = next; + options.query = None; + previous = Some(provider); + provider = next; } }) } diff --git a/src/miruro.rs b/src/miruro.rs index 70a43a9..3ed7222 100644 --- a/src/miruro.rs +++ b/src/miruro.rs @@ -9,10 +9,10 @@ use std::{ time::{Duration, Instant}, }; -use serde::{Deserialize, Serialize}; +#[cfg(test)] +use crate::anime::Identity; use serde_json::{Map, Value}; -const BASE: &str = "https://barelystarted.miruro.tv"; // Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy. const MIRRORS: &[&str] = &[ "https://miruro.cx", @@ -36,67 +36,6 @@ pub struct Detail { pub episodes: Vec, } -/// Stable catalog identity, never a CDN address or playback-provider name. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct Identity { - pub provider: String, - pub media_id: String, - pub episode: u32, -} - -impl Identity { - pub fn new(media_id: String, episode: u32) -> io::Result { - let identity = Self { - provider: "miruro".into(), - media_id, - episode, - }; - identity.validate()?; - Ok(identity) - } - - pub fn validate(&self) -> io::Result<()> { - if self.provider != "miruro" - || self.media_id.len() > 512 - || self.episode == 0 - || self.episode > i32::MAX as u32 - { - return Err(invalid("Invalid Miruro episode identity")); - } - validate_segment(&self.media_id) - } - - pub fn key(&self) -> String { - format!("miruro:{}:{}", self.media_id, self.episode) - } - - /// The slug is non-identifying; metadata readers must resolve the canonical route. - pub fn watch_url(&self) -> io::Result { - self.validate()?; - Ok(format!( - "{BASE}/watch/{}/episode?ep={}", - self.media_id, self.episode - )) - } - - pub fn from_key(key: &str) -> io::Result { - let (media_id, episode) = key - .strip_prefix("miruro:") - .and_then(|rest| rest.split_once(':')) - .ok_or_else(|| invalid("Invalid Miruro episode key"))?; - if episode.starts_with('0') || !episode.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(invalid("Noncanonical Miruro episode key")); - } - Self::new( - media_id.into(), - episode - .parse() - .map_err(|_| invalid("Invalid Miruro episode number"))?, - ) - } -} - impl Default for Client { fn default() -> Self { Self { diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs index 6ea63f9..9ae8a37 100644 --- a/src/miruro_browser.rs +++ b/src/miruro_browser.rs @@ -5,7 +5,10 @@ use std::{ sync::mpsc::{self, Receiver, SyncSender}, thread, }; -use tubular::miruro::{Client, Detail, Identity}; +use tubular::{ + anime::{Identity, Provider}, + miruro::{Client, Detail}, +}; use unicode_normalization::UnicodeNormalization; #[derive(Clone, Debug)] @@ -28,7 +31,7 @@ pub struct Episode { impl Episode { pub fn snapshot(&self, media: &Media) -> Video { - video( + let mut snapshot = video( self.identity.key(), format!( "{} · Episode {}{}", @@ -40,7 +43,11 @@ impl Episode { .unwrap_or_default() ), self.aired_on.clone(), - ) + ); + if self.identity.provider == "anilist" { + snapshot.channel = Some("AniList".into()); + } + snapshot } } @@ -67,21 +74,30 @@ pub struct Worker { generation: u64, } impl Worker { - pub fn new() -> Self { + pub fn new(provider: Provider) -> Self { let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1); let (results, receiver) = mpsc::sync_channel(1); thread::spawn(move || { let client = Client::default(); + let anilist = tubular::anilist::Client::default(); while let Ok((generation, request)) = requests.recv() { - let result = match request { - Request::Search(query) => client + let result = match (provider, request) { + (Provider::Miruro, Request::Search(query)) => client .search(&query) .and_then(catalog) .map(Response::Catalog), - Request::Episodes { media_id, slug } => client + (Provider::Miruro, Request::Episodes { media_id, slug }) => client .detail(&media_id, &slug) .and_then(|detail| series(&media_id, detail)) .map(Response::Series), + (Provider::AniList, Request::Search(query)) => anilist + .search(&query) + .and_then(catalog) + .map(Response::Catalog), + (Provider::AniList, Request::Episodes { media_id, .. }) => anilist + .detail(&media_id) + .and_then(anilist_series) + .map(Response::Series), }; if results.send((generation, result)).is_err() { break; @@ -97,11 +113,11 @@ impl Worker { } pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> { if self.busy { - return Err(io::Error::other("Miruro worker is busy")); + return Err(io::Error::other("Anime catalog worker is busy")); } self.sender .try_send((generation, request)) - .map_err(|_| io::Error::other("Miruro worker unavailable"))?; + .map_err(|_| io::Error::other("Anime catalog worker unavailable"))?; self.busy = true; self.generation = generation; Ok(()) @@ -117,7 +133,7 @@ impl Worker { self.busy = false; Some(( self.generation, - Err(io::Error::other("Miruro worker stopped")), + Err(io::Error::other("Anime catalog worker stopped")), )) } Err(_) => None, @@ -203,7 +219,7 @@ fn media(metadata: Value) -> io::Result { .and_then(Value::as_str) .ok_or_else(|| invalid("Miruro catalog ID is missing"))? .to_owned(); - Identity::new(id.clone(), 1)?; + Identity::miruro(id.clone(), 1)?; Ok(Media { id, title: title(&metadata)?, @@ -243,7 +259,7 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result { .and_then(Value::as_u64) .and_then(|n| u32::try_from(n).ok()) .ok_or_else(|| invalid("Miruro episode number is invalid"))?; - let identity = Identity::new(media_id.to_owned(), number)?; + let identity = Identity::miruro(media_id.to_owned(), number)?; if !seen.insert(number) { return Err(invalid("Miruro returned duplicate episode numbers")); } @@ -266,6 +282,46 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result { Ok(Series { media, episodes }) } +pub fn media_provider(media: &Media) -> Provider { + if media + .metadata + .get("catalog_provider") + .and_then(Value::as_str) + == Some("anilist") + { + Provider::AniList + } else { + Provider::Miruro + } +} + +pub fn anilist_episode(media: &Media, number: u32) -> io::Result { + let identity = Identity::anilist(media.id.clone(), number)?; + Ok(Episode { + identity, + title: None, + synopsis: Some("Provider availability UNVERIFIED. Terebi resolves this sub episode on selection; catalog numbering does not guarantee a stream.".into()), + aired_on: None, + duration_seconds: None, + metadata: Value::Null, + }) +} + +pub fn anilist_series(metadata: Value) -> io::Result { + let media = media(metadata)?; + Identity::anilist(media.id.clone(), 1)?; + let count = tubular::anilist::catalog_count(&media.metadata)?; + if count.is_some_and(|count| count > 10000) { + return Err(invalid( + "AniList catalog exceeds the 10000 episode display limit", + )); + } + let episodes = (1..=count.unwrap_or(0)) + .map(|number| anilist_episode(&media, number)) + .collect::>>()?; + Ok(Series { media, episodes }) +} + #[cfg(test)] #[path = "../tests/unit/miruro_browser.rs"] mod tests; diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index 59b2d25..5adfc48 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -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, 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