feat: add AniList browsing with on-demand ZokoAnime playback

This commit is contained in:
2026-10-25 15:44:41 +01:00
parent a281c6730a
commit e328ff3883
20 changed files with 1062 additions and 235 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "tubular" name = "tubular"
version = "0.1.0" version = "0.1.0"
edition = "2024" 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] [dependencies]
clap = { version = "4.6.7", features = ["derive"] } clap = { version = "4.6.7", features = ["derive"] }
+37 -12
View File
@@ -1,13 +1,13 @@
# tubular # 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 ## Requirements
- A recent stable Rust toolchain, including Cargo, to build and install. - 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. - [`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 YouTube thumbnails and Miruro cover art. - `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`; multiple videos require `--new-instance --queue-file PATH` support. Miruro also requires native episode-URL resolution and version-2 episode progress support. - 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. 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 ## Usage
```sh ```sh
tubular # Choose YouTube or Miruro tubular # Choose YouTube, AniList, or Miruro legacy
tubular "rust programming" # Choose a provider, then search this query 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 --player /path/to/terebi "japanese podcast"
tubular --help 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. 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'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. 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"`. 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:<positive decimal AniList ID>:<positive episode>`, 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. 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
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. 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.
+37
View File
@@ -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(())
}
+2 -2
View File
@@ -2,7 +2,7 @@
//! Exercises native catalog metadata and stable episode URLs; no playback requests. //! Exercises native catalog metadata and stable episode URLs; no playback requests.
use std::io; use std::io;
use tubular::miruro::{Client, Identity}; use tubular::{anime::Identity, miruro::Client};
fn invalid(message: &str) -> io::Error { fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message) io::Error::new(io::ErrorKind::InvalidData, message)
@@ -30,7 +30,7 @@ fn main() -> io::Result<()> {
"detail: {} returned episodes; selected episode {number}", "detail: {} returned episodes; selected episode {number}",
detail.episodes.len() detail.episodes.len()
); );
let identity = Identity::new(id.into(), number)?; let identity = Identity::miruro(id.into(), number)?;
println!("watch URL: {}", identity.watch_url()?); println!("watch URL: {}", identity.watch_url()?);
println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests"); println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests");
Ok(()) Ok(())
+227
View File
@@ -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<Vec<Value>> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Option<u32>> {
let number = |value: Option<&Value>| -> io::Result<Option<u32>> {
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());
}
}
+145
View File
@@ -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<Identity> {
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<Self> {
Provider::Miruro.identity(media_id, episode)
}
pub fn anilist(media_id: String, episode: u32) -> io::Result<Self> {
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<String> {
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<Self> {
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<u32> {
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::<u32>()
.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);
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ actions! {
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"]; CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"]; ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"]; VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelTags, "channel.tags", "Edit the selected 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"]; CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"]; CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"]; CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
+2
View File
@@ -1 +1,3 @@
pub mod anilist;
pub mod anime;
pub mod miruro; pub mod miruro;
+100 -66
View File
File diff suppressed because one or more lines are too long
+2 -63
View File
@@ -9,10 +9,10 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use serde::{Deserialize, Serialize}; #[cfg(test)]
use crate::anime::Identity;
use serde_json::{Map, Value}; 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. // Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy.
const MIRRORS: &[&str] = &[ const MIRRORS: &[&str] = &[
"https://miruro.cx", "https://miruro.cx",
@@ -36,67 +36,6 @@ pub struct Detail {
pub episodes: Vec<Value>, pub episodes: Vec<Value>,
} }
/// 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<Self> {
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<String> {
self.validate()?;
Ok(format!(
"{BASE}/watch/{}/episode?ep={}",
self.media_id, self.episode
))
}
pub fn from_key(key: &str) -> io::Result<Self> {
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 { impl Default for Client {
fn default() -> Self { fn default() -> Self {
Self { Self {
+68 -12
View File
@@ -5,7 +5,10 @@ use std::{
sync::mpsc::{self, Receiver, SyncSender}, sync::mpsc::{self, Receiver, SyncSender},
thread, thread,
}; };
use tubular::miruro::{Client, Detail, Identity}; use tubular::{
anime::{Identity, Provider},
miruro::{Client, Detail},
};
use unicode_normalization::UnicodeNormalization; use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -28,7 +31,7 @@ pub struct Episode {
impl Episode { impl Episode {
pub fn snapshot(&self, media: &Media) -> Video { pub fn snapshot(&self, media: &Media) -> Video {
video( let mut snapshot = video(
self.identity.key(), self.identity.key(),
format!( format!(
"{} · Episode {}{}", "{} · Episode {}{}",
@@ -40,7 +43,11 @@ impl Episode {
.unwrap_or_default() .unwrap_or_default()
), ),
self.aired_on.clone(), self.aired_on.clone(),
) );
if self.identity.provider == "anilist" {
snapshot.channel = Some("AniList".into());
}
snapshot
} }
} }
@@ -67,21 +74,30 @@ pub struct Worker {
generation: u64, generation: u64,
} }
impl Worker { impl Worker {
pub fn new() -> Self { pub fn new(provider: Provider) -> Self {
let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1); let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1);
let (results, receiver) = mpsc::sync_channel(1); let (results, receiver) = mpsc::sync_channel(1);
thread::spawn(move || { thread::spawn(move || {
let client = Client::default(); let client = Client::default();
let anilist = tubular::anilist::Client::default();
while let Ok((generation, request)) = requests.recv() { while let Ok((generation, request)) = requests.recv() {
let result = match request { let result = match (provider, request) {
Request::Search(query) => client (Provider::Miruro, Request::Search(query)) => client
.search(&query) .search(&query)
.and_then(catalog) .and_then(catalog)
.map(Response::Catalog), .map(Response::Catalog),
Request::Episodes { media_id, slug } => client (Provider::Miruro, Request::Episodes { media_id, slug }) => client
.detail(&media_id, &slug) .detail(&media_id, &slug)
.and_then(|detail| series(&media_id, detail)) .and_then(|detail| series(&media_id, detail))
.map(Response::Series), .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() { if results.send((generation, result)).is_err() {
break; break;
@@ -97,11 +113,11 @@ impl Worker {
} }
pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> { pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> {
if self.busy { if self.busy {
return Err(io::Error::other("Miruro worker is busy")); return Err(io::Error::other("Anime catalog worker is busy"));
} }
self.sender self.sender
.try_send((generation, request)) .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.busy = true;
self.generation = generation; self.generation = generation;
Ok(()) Ok(())
@@ -117,7 +133,7 @@ impl Worker {
self.busy = false; self.busy = false;
Some(( Some((
self.generation, self.generation,
Err(io::Error::other("Miruro worker stopped")), Err(io::Error::other("Anime catalog worker stopped")),
)) ))
} }
Err(_) => None, Err(_) => None,
@@ -203,7 +219,7 @@ fn media(metadata: Value) -> io::Result<Media> {
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| invalid("Miruro catalog ID is missing"))? .ok_or_else(|| invalid("Miruro catalog ID is missing"))?
.to_owned(); .to_owned();
Identity::new(id.clone(), 1)?; Identity::miruro(id.clone(), 1)?;
Ok(Media { Ok(Media {
id, id,
title: title(&metadata)?, title: title(&metadata)?,
@@ -243,7 +259,7 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
.and_then(Value::as_u64) .and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok()) .and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| invalid("Miruro episode number is invalid"))?; .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) { if !seen.insert(number) {
return Err(invalid("Miruro returned duplicate episode numbers")); return Err(invalid("Miruro returned duplicate episode numbers"));
} }
@@ -266,6 +282,46 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
Ok(Series { media, episodes }) 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<Episode> {
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<Series> {
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::<io::Result<Vec<_>>>()?;
Ok(Series { media, episodes })
}
#[cfg(test)] #[cfg(test)]
#[path = "../tests/unit/miruro_browser.rs"] #[path = "../tests/unit/miruro_browser.rs"]
mod tests; mod tests;
+204 -39
View File
@@ -21,7 +21,7 @@ use ratatui::{
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
}; };
use serde_json::Value; use serde_json::Value;
use tubular::miruro::Identity; use tubular::anime::{Identity, Provider};
use crate::{ use crate::{
Options, PreviewMode, config, Options, PreviewMode, config,
@@ -92,6 +92,7 @@ struct DisplayRow {
} }
struct Screen { struct Screen {
content: Content, content: Content,
provider: Provider,
selection: ListState, selection: ListState,
checked: Vec<String>, checked: Vec<String>,
description_scroll: u16, description_scroll: u16,
@@ -105,6 +106,7 @@ impl Screen {
fn new(content: Content) -> Self { fn new(content: Content) -> Self {
let mut screen = Self { let mut screen = Self {
content, content,
provider: Provider::Miruro,
selection: ListState::default(), selection: ListState::default(),
checked: Vec::new(), checked: Vec::new(),
description_scroll: 0, description_scroll: 0,
@@ -223,7 +225,7 @@ impl Screen {
self.overview.clear(); self.overview.clear();
match &self.content { match &self.content {
Content::Catalog { media, .. } => { 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 self.rows = media
.iter() .iter()
.map(|media| DisplayRow { .map(|media| DisplayRow {
@@ -235,13 +237,30 @@ impl Screen {
.collect(); .collect();
} }
Content::Series { media, data } => { 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); self.overview = media_details(media);
if let Some(data) = data { if let Some(data) = data {
self.overview.push_str(&format!( if self.provider == Provider::AniList {
"\n\n{} returned episodes (source availability is checked on play).", self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory.");
data.len() 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 self.rows = data
.iter() .iter()
.map(|episode| { .map(|episode| {
@@ -261,6 +280,9 @@ impl Screen {
if let Some(seconds) = episode.duration_seconds { if let Some(seconds) = episode.duration_seconds {
details.push(format!("{}:{:02}", seconds / 60, seconds % 60)); details.push(format!("{}:{:02}", seconds / 60, seconds % 60));
} }
if self.provider == Provider::AniList {
details.push("UNVERIFIED · sub".into());
}
let details = details.join(" · "); let details = details.join(" · ");
let mut metadata = title.clone(); let mut metadata = title.clone();
if !details.is_empty() { if !details.is_empty() {
@@ -297,7 +319,8 @@ impl Screen {
videos, videos,
} => { } => {
self.title = format!( self.title = format!(
"Miruro · {} episodes{}", "{} · {} episodes{}",
self.provider.name(),
collection_name(*kind), collection_name(*kind),
if matches!(kind, CollectionKind::WatchLater) { if matches!(kind, CollectionKind::WatchLater) {
format!(" · {}", filter.label()) format!(" · {}", filter.label())
@@ -308,13 +331,14 @@ impl Screen {
self.rows = videos.iter().map(|video| { self.rows = videos.iter().map(|video| {
let identity = Identity::from_key(&video.id).expect("provider-scoped collection"); let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
let title = safe(&video.title); 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 } DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
}).collect(); }).collect();
} }
Content::Library { categories, shows } => { Content::Library { categories, shows } => {
self.title = format!( self.title = format!(
"Miruro · Saved shows · {}", "{} · Saved shows · {}",
self.provider.name(),
if categories.is_empty() { if categories.is_empty() {
"All saved shows".into() "All saved shows".into()
} else { } else {
@@ -347,6 +371,8 @@ impl Screen {
// History owns the actual screens, so back/forward never has to refetch a catalog. // History owns the actual screens, so back/forward never has to refetch a catalog.
pub(crate) struct Browser { pub(crate) struct Browser {
options: Options, options: Options,
provider: Provider,
episode_input: bool,
config_path: PathBuf, config_path: PathBuf,
config: config::Config, config: config::Config,
pub(crate) state: State, pub(crate) state: State,
@@ -435,11 +461,19 @@ impl Browser {
options: Options, options: Options,
config_path: PathBuf, config_path: PathBuf,
config: config::Config, config: config::Config,
state: State, mut state: State,
) -> Self { ) -> Self {
let provider = if options.anilist {
Provider::AniList
} else {
Provider::Miruro
};
state.select_catalog(provider);
let mut query = String::new(); let mut query = String::new();
crate::paste_input(&mut query, options.query.as_deref().unwrap_or("")); crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
let mut browser = Self { let mut browser = Self {
provider,
episode_input: false,
options, options,
config_path, config_path,
config, config,
@@ -463,6 +497,8 @@ impl Browser {
intent: None, intent: None,
player: 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()) if matches!(&browser.screen().content, Content::Catalog { query, .. } if !query.trim().is_empty())
{ {
browser.search(); browser.search();
@@ -486,7 +522,9 @@ impl Browser {
self.intent = Some(intent); self.intent = Some(intent);
self.screen_mut().error = None; 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.invalidate();
self.screens.truncate(self.index + 1); self.screens.truncate(self.index + 1);
self.screens.push(screen); self.screens.push(screen);
@@ -516,6 +554,18 @@ impl Browser {
} }
} }
fn edit_search(&mut self) { 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 { .. }) { if !matches!(self.screen().content, Content::Catalog { .. }) {
self.push(Screen::new(Content::Catalog { self.push(Screen::new(Content::Catalog {
query: String::new(), query: String::new(),
@@ -529,6 +579,36 @@ impl Browser {
} }
self.preview = None; 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) { fn search(&mut self) {
let Content::Catalog { query, .. } = &self.screen().content else { let Content::Catalog { query, .. } = &self.screen().content else {
return; return;
@@ -550,14 +630,18 @@ impl Browser {
return; return;
} }
self.request(Request::Search(query), Intent::Catalog); 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) { fn load_series(&mut self) {
let Content::Series { media, .. } = &self.screen().content else { let Content::Series { media, .. } = &self.screen().content else {
return; return;
}; };
let id = media.id.clone(); 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(); self.screen_mut().checked.clear();
match slug { match slug {
Ok(slug) => { Ok(slug) => {
@@ -568,7 +652,7 @@ impl Browser {
}, },
Intent::Series(id), Intent::Series(id),
); );
self.status = "Loading returned episodes…".into(); self.status = "Loading catalog episode metadata…".into();
} }
Err(error) => { Err(error) => {
self.invalidate(); self.invalidate();
@@ -676,7 +760,7 @@ impl Browser {
} }
(_, Err(error)) => self.fail(error), (_, Err(error)) => self.fail(error),
_ => self.fail(io::Error::other( _ => 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) { if self.pending.is_some() && !self.worker.as_ref().is_some_and(|worker| worker.busy) {
let request = self.pending.take().expect("pending request"); 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) { if let Err(error) = worker.submit(self.generation, request) {
self.intent = None; self.intent = None;
self.fail(error); self.fail(error);
@@ -709,7 +794,7 @@ impl Browser {
} }
} }
fn collection(&mut self, kind: CollectionKind) { 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 { let screen = Screen::new(Content::Collection {
kind, kind,
filter: WatchFilter::All, filter: WatchFilter::All,
@@ -952,7 +1037,7 @@ impl Browser {
.selected() .selected()
.and_then(|index| videos.get(index)) .and_then(|index| videos.get(index))
.map(|video| video.id.clone()); .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 let next = selected
.and_then(|id| replacement.iter().position(|video| video.id == id)) .and_then(|id| replacement.iter().position(|video| video.id == id))
.or(screen.selection.selected()); .or(screen.selection.selected());
@@ -1032,7 +1117,7 @@ impl Browser {
// Even the confirmation snapshot is provider-scoped at the mutation seam. // Even the confirmation snapshot is provider-scoped at the mutation seam.
let ids: Vec<_> = ids let ids: Vec<_> = ids
.into_iter() .into_iter()
.filter(|id| Identity::from_key(id).is_ok()) .filter(|id| self.provider.includes(id))
.collect(); .collect();
match self.state.remove(kind, &ids) { match self.state.remove(kind, &ids) {
Ok(()) => { Ok(()) => {
@@ -1197,12 +1282,20 @@ impl Browser {
match action { match action {
Some(Action::InputSubmit) => { Some(Action::InputSubmit) => {
let input = self.input.take().expect("input active"); let input = self.input.take().expect("input active");
if let Content::Catalog { query, .. } = &mut self.screen_mut().content { if self.episode_input {
*query = 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. // Typed controls are rejected too, not merely bracketed paste.
if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) { if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
@@ -1216,7 +1309,17 @@ impl Browser {
Some(Action::ChannelTags) => self.tags(), Some(Action::ChannelTags) => self.tags(),
Some(Action::CategoryBrowse) => self.category_picker(), Some(Action::CategoryBrowse) => self.category_picker(),
Some(Action::Help) => self.help = Some(0), Some(Action::Help) => self.help = Some(0),
Some(Action::SearchEdit | Action::ViewSearch) => self.edit_search(), Some(Action::SearchEdit) => 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::ViewPrevious) => self.history(false),
Some(Action::ViewNext) => self.history(true), Some(Action::ViewNext) => self.history(true),
Some(Action::ViewRefresh) => self.refresh(), Some(Action::ViewRefresh) => self.refresh(),
@@ -1323,7 +1426,14 @@ impl Browser {
} }
if self.input.is_some() { if self.input.is_some() {
return vec![ return vec![
(Action::InputSubmit, "Search catalog"), (
Action::InputSubmit,
if self.episode_input {
"Select explicit episode (UNVERIFIED)"
} else {
"Search catalog"
},
),
(Action::InputCancel, "Cancel input"), (Action::InputCancel, "Cancel input"),
(Action::DeleteCharacter, "Delete character"), (Action::DeleteCharacter, "Delete character"),
(Action::DeleteWord, "Delete word"), (Action::DeleteWord, "Delete word"),
@@ -1442,7 +1552,16 @@ impl Browser {
{ {
let mut actions = vec![ let mut actions = vec![
(Action::Help, "help"), (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::InfoOverview, "info"),
( (
Action::VideoPlay, Action::VideoPlay,
@@ -1717,7 +1836,8 @@ impl Browser {
if area.width < 24 || area.height < 10 { if area.width < 24 || area.height < 10 {
frame.render_widget( frame.render_widget(
Paragraph::new(format!( 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) self.config.keys.label(Action::Quit)
)) ))
.wrap(Wrap { trim: true }), .wrap(Wrap { trim: true }),
@@ -1749,7 +1869,13 @@ impl Browser {
ui::search( ui::search(
frame, frame,
query_area, 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, query,
self.input.is_some() && self.help.is_none() && self.removal.is_none(), self.input.is_some() && self.help.is_none() && self.removal.is_none(),
); );
@@ -1771,13 +1897,15 @@ impl Browser {
self.config.keys.label(Action::SearchEdit) self.config.keys.label(Action::SearchEdit)
) )
} else if self.intent.is_some() { } else if self.intent.is_some() {
"Loading Miruro metadata…".into() format!("Loading {} metadata…", self.provider.name())
} else { } else {
match &self.screen().content { 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::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::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(), 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 { if let Some((kind, ids)) = &self.removal {
frame.render_widget(Clear, popup); frame.render_widget(Clear, popup);
frame.render_widget( 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 }) .wrap(Wrap { trim: true })
.block( .block(
Block::bordered() Block::bordered()
@@ -1906,7 +2034,11 @@ impl Browser {
); );
} else if let Some(scroll) = self.help { } else if let Some(scroll) = self.help {
let mut lines = vec![ 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( Line::from(
"Terebi resolves episode URLs, sources, subtitles and playback metadata.", "Terebi resolves episode URLs, sources, subtitles and playback metadata.",
), ),
@@ -1918,7 +2050,7 @@ impl Browser {
})); }));
frame.render_widget(Clear, popup); frame.render_widget(Clear, popup);
let block = Block::bordered() let block = Block::bordered()
.title(" Miruro keyboard help ") .title(format!(" {} keyboard help ", self.provider.name()))
.border_style(ui::ACCENT); .border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0)); let inner = block.inner(popup).inner(Margin::new(1, 0));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
@@ -2035,12 +2167,17 @@ fn library_shows(
shows shows
} }
fn collection_videos(state: &State, kind: CollectionKind, filter: WatchFilter) -> Vec<Video> { fn collection_videos(
state: &State,
kind: CollectionKind,
filter: WatchFilter,
provider: Provider,
) -> Vec<Video> {
state state
.videos(kind) .videos(kind)
.iter() .iter()
.filter(|video| { .filter(|video| {
Identity::from_key(&video.id).is_ok() provider.includes(&video.id)
&& (!matches!(kind, CollectionKind::WatchLater) && (!matches!(kind, CollectionKind::WatchLater)
|| filter.includes(state.is_watched(&video.id))) || filter.includes(state.is_watched(&video.id)))
}) })
@@ -2106,6 +2243,22 @@ fn media_summary(media: &Media) -> String {
if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) { if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) {
parts.push(format!("{count} planned")); parts.push(format!("{count} planned"));
} }
if miruro_browser::media_provider(media) == Provider::AniList {
parts.push(
match media.metadata.get("catalog_count").and_then(Value::as_u64) {
Some(count) => format!("{count} catalog/airing numbers"),
None => "catalog/airing count unknown".into(),
},
);
if media
.metadata
.get("episode_count")
.is_none_or(Value::is_null)
{
parts.push("total unknown".into());
}
parts.push("provider availability UNVERIFIED · sub".into());
}
for (field, label) in [ for (field, label) in [
("raw", "raw listed"), ("raw", "raw listed"),
("sub", "sub listed"), ("sub", "sub listed"),
@@ -2176,7 +2329,11 @@ fn media_details(media: &Media) -> String {
} }
impl Browser { impl Browser {
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> { pub(crate) fn run(
&mut self,
terminal: &mut DefaultTerminal,
) -> io::Result<Option<crate::BrowseProvider>> {
self.state.select_catalog(self.provider);
struct RestoreTerminalInput; struct RestoreTerminalInput;
impl Drop for RestoreTerminalInput { impl Drop for RestoreTerminalInput {
fn drop(&mut self) { fn drop(&mut self) {
@@ -2204,7 +2361,15 @@ impl Browser {
if self.config.keys.resolve(self.context(), key) if self.config.keys.resolve(self.context(), key)
== Some(Action::ProviderChoose) == Some(Action::ProviderChoose)
{ {
return crate::choose_provider(terminal, &self.config.keys, Some(true)); return crate::choose_provider(
terminal,
&self.config.keys,
Some(if self.provider == Provider::AniList {
crate::BrowseProvider::AniList
} else {
crate::BrowseProvider::Miruro
}),
);
} }
if self.key(key) { if self.key(key) {
return Ok(None); return Ok(None);
+54 -20
View File
@@ -10,7 +10,7 @@ use serde::Deserialize;
use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value}; use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value};
use crate::youtube::{Video, valid_video_id, validate_video}; use crate::youtube::{Video, valid_video_id, validate_video};
use tubular::miruro::Identity; use tubular::anime::{Identity, Provider};
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024; const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_LINE: usize = 16 * 1024; const MAX_LINE: usize = 16 * 1024;
@@ -134,6 +134,9 @@ pub struct State {
watch_state: StoredFile, watch_state: StoredFile,
library: StoredFile, library: StoredFile,
saved_shows: Vec<SavedShow>, saved_shows: Vec<SavedShow>,
other_library: StoredFile,
other_shows: Vec<SavedShow>,
catalog: Provider,
watched: BTreeSet<String>, watched: BTreeSet<String>,
pending: BTreeSet<String>, pending: BTreeSet<String>,
sessions: PathBuf, sessions: PathBuf,
@@ -158,6 +161,20 @@ impl State {
let (library, document) = StoredFile::open(parent.join("miruro-library.toml"))?; let (library, document) = StoredFile::open(parent.join("miruro-library.toml"))?;
let saved_shows = load_shows(&document) let saved_shows = load_shows(&document)
.map_err(|e| invalid(format!("{}: {e}", library.path.display())))?; .map_err(|e| invalid(format!("{}: {e}", library.path.display())))?;
let (other_library, document) = StoredFile::open(parent.join("anilist-library.toml"))?;
let other_shows = load_shows(&document)
.map_err(|e| invalid(format!("{}: {e}", other_library.path.display())))?;
if saved_shows
.iter()
.any(|show| crate::miruro_browser::media_provider(&show.media) != Provider::Miruro)
|| other_shows
.iter()
.any(|show| crate::miruro_browser::media_provider(&show.media) != Provider::AniList)
{
return Err(invalid(
"Saved-show library contains a different catalog provider",
));
}
let completed: Vec<_> = watching.ids.intersection(&watched).cloned().collect(); let completed: Vec<_> = watching.ids.intersection(&watched).cloned().collect();
if !completed.is_empty() { if !completed.is_empty() {
watching.remove(&completed)?; watching.remove(&completed)?;
@@ -169,6 +186,9 @@ impl State {
watch_state, watch_state,
library, library,
saved_shows, saved_shows,
other_library,
other_shows,
catalog: Provider::Miruro,
watched, watched,
pending: BTreeSet::new(), pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"), sessions: parent.join("playback-sessions"),
@@ -179,6 +199,14 @@ impl State {
}) })
} }
pub fn select_catalog(&mut self, provider: Provider) {
if self.catalog != provider {
std::mem::swap(&mut self.library, &mut self.other_library);
std::mem::swap(&mut self.saved_shows, &mut self.other_shows);
self.catalog = provider;
}
}
fn collection(&self, kind: CollectionKind) -> &Collection { fn collection(&self, kind: CollectionKind) -> &Collection {
match kind { match kind {
CollectionKind::WatchLater => &self.watch_later, CollectionKind::WatchLater => &self.watch_later,
@@ -215,6 +243,9 @@ impl State {
tags: &BTreeSet<String>, tags: &BTreeSet<String>,
) -> io::Result<()> { ) -> io::Result<()> {
validate_show(media)?; validate_show(media)?;
if crate::miruro_browser::media_provider(media) != self.catalog {
return Err(invalid("Cannot save a show into another catalog's library"));
}
if tags.len() > MAX_SHOW_TAGS { if tags.len() > MAX_SHOW_TAGS {
return Err(invalid("A saved show cannot have more than 256 tags")); return Err(invalid("A saved show cannot have more than 256 tags"));
} }
@@ -227,7 +258,7 @@ impl State {
.iter() .iter()
.position(|show| show.media.id == media.id); .position(|show| show.media.id == media.id);
if index.is_none() && self.saved_shows.len() >= MAX_SHOWS { if index.is_none() && self.saved_shows.len() >= MAX_SHOWS {
return Err(invalid("Miruro library exceeds the 4096 show limit")); return Err(invalid("Anime library exceeds the 4096 show limit"));
} }
let show = SavedShow { let show = SavedShow {
media: media.clone(), media: media.clone(),
@@ -254,7 +285,7 @@ impl State {
} }
pub fn remove_show(&mut self, id: &str) -> io::Result<()> { pub fn remove_show(&mut self, id: &str) -> io::Result<()> {
Identity::new(id.into(), 1)?; self.catalog.identity(id.into(), 1)?;
if !self.saved_shows.iter().any(|show| show.media.id == id) { if !self.saved_shows.iter().any(|show| show.media.id == id) {
return check_original(&self.library.path, self.library.original.as_deref()); return check_original(&self.library.path, self.library.original.as_deref());
} }
@@ -502,7 +533,7 @@ impl State {
} }
fn validate_show(media: &crate::miruro_browser::Media) -> io::Result<()> { fn validate_show(media: &crate::miruro_browser::Media) -> io::Result<()> {
Identity::new(media.id.clone(), 1)?; crate::miruro_browser::media_provider(media).identity(media.id.clone(), 1)?;
if media.title.len() > MAX_LINE if media.title.len() > MAX_LINE
|| media.title.trim().is_empty() || media.title.trim().is_empty()
|| media.title.chars().any(char::is_control) || media.title.chars().any(char::is_control)
@@ -528,7 +559,7 @@ fn load_shows(document: &DocumentMut) -> io::Result<Vec<SavedShow>> {
.iter() .iter()
.any(|(key, _)| !matches!(key, "version" | "shows")) .any(|(key, _)| !matches!(key, "version" | "shows"))
{ {
return Err(invalid("Unknown Miruro library field")); return Err(invalid("Unknown anime library field"));
} }
let Some(item) = document.get("shows") else { let Some(item) = document.get("shows") else {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -540,7 +571,7 @@ fn load_shows(document: &DocumentMut) -> io::Result<Vec<SavedShow>> {
.as_array_of_tables() .as_array_of_tables()
.ok_or_else(|| invalid("shows must be an array of tables"))?; .ok_or_else(|| invalid("shows must be an array of tables"))?;
if tables.len() > MAX_SHOWS { if tables.len() > MAX_SHOWS {
return Err(invalid("Miruro library exceeds the 4096 show limit")); return Err(invalid("Anime library exceeds the 4096 show limit"));
} }
let mut shows = Vec::new(); let mut shows = Vec::new();
let mut ids = BTreeSet::new(); let mut ids = BTreeSet::new();
@@ -648,19 +679,24 @@ fn validate_snapshot(video: &mut Video, require_channel: bool) -> io::Result<()>
)); ));
} }
} }
if video.id.starts_with("miruro:") { if video.id.starts_with("miruro:") || video.id.starts_with("anilist:") {
Identity::from_key(&video.id)?; let identity = Identity::from_key(&video.id)?;
let expected_channel = if identity.provider == "anilist" {
"AniList"
} else {
"Miruro"
};
if video.title.trim().is_empty() || video.title.chars().any(char::is_control) { if video.title.trim().is_empty() || video.title.chars().any(char::is_control) {
return Err(invalid( return Err(invalid(
"Miruro episode title must be nonempty and contain no controls", "Anime episode title must be nonempty and contain no controls",
)); ));
} }
if video.channel.as_deref() != Some("Miruro") if video.channel.as_deref() != Some(expected_channel)
|| video.channel_id.is_some() || video.channel_id.is_some()
|| video.uploader_id.is_some() || video.uploader_id.is_some()
{ {
return Err(invalid( return Err(invalid(
"Miruro episodes must not contain YouTube channel identities", "Anime episode channel must match its catalog, without YouTube channel identities",
)); ));
} }
return Ok(()); return Ok(());
@@ -983,7 +1019,7 @@ pub(crate) fn register_identity(progress_file: &Path, id: &str) -> io::Result<()
#[serde(untagged)] #[serde(untagged)]
enum ProgressRecord { enum ProgressRecord {
YouTube(YouTubeProgress), YouTube(YouTubeProgress),
Miruro(MiruroProgress), Anime(AnimeProgress),
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -998,7 +1034,7 @@ struct YouTubeProgress {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct MiruroProgress { struct AnimeProgress {
version: u32, version: u32,
event: String, event: String,
media_identity: Identity, media_identity: Identity,
@@ -1067,9 +1103,7 @@ impl ProgressStream {
} }
Err(e) if e.kind() == io::ErrorKind::NotFound && valid_video_id(id) => None, Err(e) if e.kind() == io::ErrorKind::NotFound && valid_video_id(id) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => { Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(invalid( return Err(invalid("Anime sessions require their bound video metadata"));
"Miruro sessions require their bound video metadata",
));
} }
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
@@ -1198,7 +1232,7 @@ impl ProgressStream {
) -> io::Result<()> { ) -> io::Result<()> {
let record: ProgressRecord = let record: ProgressRecord =
serde_json::from_slice(&self.partial).map_err(|e| self.record_error(e))?; serde_json::from_slice(&self.partial).map_err(|e| self.record_error(e))?;
let miruro_key; let anime_key;
let (id, event, position, duration, eligible) = match &record { let (id, event, position, duration, eligible) = match &record {
ProgressRecord::YouTube(record) if record.version == 1 => { ProgressRecord::YouTube(record) if record.version == 1 => {
let id = record let id = record
@@ -1214,14 +1248,14 @@ impl ProgressStream {
record.completion_eligible, record.completion_eligible,
) )
} }
ProgressRecord::Miruro(record) if record.version == 2 => { ProgressRecord::Anime(record) if record.version == 2 => {
record record
.media_identity .media_identity
.validate() .validate()
.map_err(|e| self.record_error(e))?; .map_err(|e| self.record_error(e))?;
miruro_key = record.media_identity.key(); anime_key = record.media_identity.key();
( (
miruro_key.as_str(), anime_key.as_str(),
record.event.as_str(), record.event.as_str(),
record.position_seconds, record.position_seconds,
record.duration_seconds, record.duration_seconds,
+1 -1
View File
@@ -873,7 +873,7 @@ pub fn video_url(id: &str) -> io::Result<String> {
if valid_video_id(id) { if valid_video_id(id) {
Ok(format!("https://www.youtube.com/watch?v={id}")) Ok(format!("https://www.youtube.com/watch?v={id}"))
} else { } else {
tubular::miruro::Identity::from_key(id) tubular::anime::Identity::from_key(id)
.and_then(|identity| identity.watch_url()) .and_then(|identity| identity.watch_url())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error)) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
} }
+19 -15
View File
@@ -27,32 +27,36 @@ fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel(
provider_selection(&mut selected, Some(Action::SelectorPrevious)), provider_selection(&mut selected, Some(Action::SelectorPrevious)),
Continue(()) Continue(())
); );
for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] { for (index, provider) in [
if action == Action::SelectorNext { BrowseProvider::YouTube,
assert_eq!( BrowseProvider::AniList,
provider_selection(&mut selected, Some(action)), BrowseProvider::Miruro,
Continue(()) ]
); .into_iter()
assert_eq!( .enumerate()
provider_selection(&mut selected, Some(action)), {
Continue(()) selected = index;
);
}
let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen)) let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen))
else { else {
panic!("Selected provider must open"); panic!("Selected provider must open");
}; };
assert_eq!(choice, miruro); assert_eq!(choice, provider);
let mut options = Options::parse_from(["tubular", "initial query"]); let mut options = Options::parse_from(["tubular", "initial query"]);
options.miruro = choice; options.miruro = choice == BrowseProvider::Miruro;
options.anilist = choice == BrowseProvider::AniList;
assert_eq!(options.query.as_deref(), Some("initial query")); assert_eq!(options.query.as_deref(), Some("initial query"));
assert_eq!(options.miruro, miruro); if choice == BrowseProvider::YouTube {
if !miruro {
let app = App::new(options, PathBuf::new(), config::Config::default()); let app = App::new(options, PathBuf::new(), config::Config::default());
assert_eq!(app.views[0].query, "initial query"); assert_eq!(app.views[0].query, "initial query");
assert_eq!(app.views[app.view_index].source, ViewSource::Search); assert_eq!(app.views[app.view_index].source, ViewSource::Search);
} }
} }
assert_eq!(
provider_selection(&mut selected, Some(Action::SelectorNext)),
Continue(())
);
assert_eq!(selected, PROVIDERS.len() - 1);
assert!(Options::try_parse_from(["tubular", "--miruro", "--anilist"]).is_err());
for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] { for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] {
assert_eq!(provider_selection(&mut selected, Some(action)), Break(None)); assert_eq!(provider_selection(&mut selected, Some(action)), Break(None));
} }
+3 -1
View File
@@ -10,7 +10,9 @@ fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
); );
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity); assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!( assert_ne!(
Identity::new(identity.media_id.clone(), 2).unwrap().key(), Identity::miruro(identity.media_id.clone(), 2)
.unwrap()
.key(),
identity.key() identity.key()
); );
for key in [ for key in [
+28
View File
@@ -67,3 +67,31 @@ fn catalog_and_actual_episode_numbers_remain_distinct() {
"" ""
); );
} }
#[test]
fn anilist_catalog_numbers_are_not_planned_totals_or_stream_claims() {
let metadata = serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Cowboy Bebop"},"status":"FINISHED","episode_count":26,"catalog_count":26});
let series = anilist_series(metadata).unwrap();
assert_eq!(series.episodes.len(), 26);
assert_eq!(
series.episodes.last().unwrap().identity.key(),
"anilist:1:26"
);
let snapshot = series.episodes[0].snapshot(&series.media);
assert_eq!(snapshot.channel.as_deref(), Some("AniList"));
assert_eq!(
crate::youtube::video_url(&snapshot.id).unwrap(),
"https://zokoanime.video/stream/ani/1/1/sub"
);
let ongoing = anilist_series(serde_json::json!({"id":"2","catalog_provider":"anilist","title":{"romaji":"Ongoing"},"status":"RELEASING","episode_count":24,"nextAiringEpisode":{"episode":4},"catalog_count":3})).unwrap();
assert_eq!(ongoing.episodes.len(), 3);
let unknown = anilist_series(serde_json::json!({"id":"3","catalog_provider":"anilist","title":{"romaji":"Unknown"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
assert!(unknown.episodes.is_empty());
assert!(unknown.media.metadata["catalog_count"].is_null());
assert_eq!(
anilist_episode(&unknown.media, 42).unwrap().identity.key(),
"anilist:3:42"
);
assert!(anilist_episode(&unknown.media, 0).is_err());
assert!(anilist_episode(&unknown.media, i32::MAX as u32 + 1).is_err());
}
+60
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use clap::Parser;
use ratatui::crossterm::event::KeyModifiers; use ratatui::crossterm::event::KeyModifiers;
use std::{ use std::{
fs, fs,
@@ -26,6 +27,7 @@ impl Directory {
player: self.0.join("missing-player").into_os_string(), player: self.0.join("missing-player").into_os_string(),
default_config: false, default_config: false,
miruro: true, miruro: true,
anilist: false,
query: query.map(str::to_owned), query: query.map(str::to_owned),
}; };
Browser::new( Browser::new(
@@ -764,3 +766,61 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id); assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
} }
} }
#[test]
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
let options = Options::parse_from(["tubular", "--anilist"]);
let mut browser = Browser::new(
options,
path.clone(),
config::Config::default(),
State::open(&path).unwrap(),
);
let series = miruro_browser::anilist_series(serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Unknown total"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
browser.push(Screen::new(Content::Series {
media: series.media,
data: Some(series.episodes),
}));
browser.edit_search();
assert!(browser.episode_input);
browser.input = Some("7".into());
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let selected = browser.screen().selected_video().unwrap();
assert_eq!(selected.id, "anilist:1:7");
browser.save(CollectionKind::Saved);
let mut legacy = selected.clone();
legacy.id = "miruro:1:7".into();
legacy.channel = Some("Miruro".into());
browser.state.save(CollectionKind::Saved, &legacy).unwrap();
browser.collection(CollectionKind::Saved);
assert_eq!(browser.screen().len(), 1);
assert_eq!(browser.screen().selected_video().unwrap().id, selected.id);
browser.history(false);
for invalid in ["0", "01", "-1", "2147483648"] {
browser.select_episode(invalid);
assert!(browser.screen().error.is_some());
assert_eq!(browser.screen().len(), 1);
}
browser.select_episode("8");
assert!(browser.screen().error.is_none());
browser.screen_mut().toggle_checked();
browser.select_episode("7");
browser.screen_mut().toggle_checked();
let queue = browser.screen().playback_videos();
assert_eq!(
queue
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["anilist:1:8", "anilist:1:7"]
);
assert_eq!(
crate::youtube::video_url(&queue[0].id).unwrap(),
"https://zokoanime.video/stream/ani/1/8/sub"
);
browser.key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
assert!(!browser.episode_input);
}
+63 -2
View File
@@ -59,7 +59,7 @@ fn record(id: &str, event: &str, eligible: bool, position: f64) -> String {
fn miruro_video(episode: u32) -> Video { fn miruro_video(episode: u32) -> Video {
Video { Video {
id: Identity::new("opaque_Show-1".into(), episode) id: Identity::miruro("opaque_Show-1".into(), episode)
.unwrap() .unwrap()
.key(), .key(),
title: format!("Example show · Episode {episode}"), title: format!("Example show · Episode {episode}"),
@@ -1313,7 +1313,7 @@ fn miruro_sessions_recover_the_maximum_identity_marker() {
let directory = TestDirectory::new(); let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap(); let mut state = State::open(&directory.config()).unwrap();
let mut video = miruro_video(i32::MAX as u32); let mut video = miruro_video(i32::MAX as u32);
video.id = Identity::new("a".repeat(512), i32::MAX as u32) video.id = Identity::miruro("a".repeat(512), i32::MAX as u32)
.unwrap() .unwrap()
.key(); .key();
let report = state.progress_file(&video).unwrap(); let report = state.progress_file(&video).unwrap();
@@ -1449,3 +1449,64 @@ fn progress_batch_prevalidates_snapshots_and_session_capacity() {
assert!(!state.sessions.exists()); assert!(!state.sessions.exists());
assert_eq!(state.streams.len(), MAX_STREAMS - 1); assert_eq!(state.streams.len(), MAX_STREAMS - 1);
} }
#[test]
fn anilist_library_and_progress_are_isolated_from_opaque_miruro_ids() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut legacy_show = show();
legacy_show.id = "1".into();
legacy_show.metadata["id"] = serde_json::json!("1");
state
.save_show(&legacy_show, &["Legacy".into()].into())
.unwrap();
let legacy_bytes = fs::read(directory.0.join("miruro-library.toml")).unwrap();
state.select_catalog(Provider::AniList);
assert!(state.shows().is_empty());
assert!(state.save_show(&legacy_show, &BTreeSet::new()).is_err());
let mut ani_show = legacy_show.clone();
ani_show.metadata["catalog_provider"] = serde_json::json!("anilist");
state.save_show(&ani_show, &["New".into()].into()).unwrap();
let mut ani = miruro_video(1);
ani.id = Identity::anilist("1".into(), 1).unwrap().key();
ani.channel = Some("AniList".into());
let mut legacy = miruro_video(1);
legacy.id = Identity::miruro("1".into(), 1).unwrap().key();
for item in [&ani, &legacy, &video()] {
state.save(CollectionKind::Saved, item).unwrap();
}
let report = state.progress_file(&ani).unwrap();
register_identity(&report, &ani.id).unwrap();
append(
&report,
miruro_record(&legacy, "ended", true, 100.0, Some(100.0)).as_bytes(),
);
append(
&report,
miruro_record(&ani, "progress", false, 20.0, Some(100.0)).as_bytes(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &ani.id));
assert!(!state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
append(
&report,
miruro_record(&ani, "ended", true, 95.0, Some(100.0)).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
assert!(state.contains(CollectionKind::Saved, &legacy.id));
assert_eq!(state.shows()[0].tags, ["Legacy".into()].into());
state.select_catalog(Provider::AniList);
assert_eq!(state.shows()[0].tags, ["New".into()].into());
assert_eq!(
fs::read(directory.0.join("miruro-library.toml")).unwrap(),
legacy_bytes
);
let mut forged = ani.clone();
forged.channel = Some("Miruro".into());
assert!(state.progress_file(&forged).is_err());
}
+8
View File
@@ -542,6 +542,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
video_url("miruro:opaque_ID-7:3").unwrap(), video_url("miruro:opaque_ID-7:3").unwrap(),
"https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3" "https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3"
); );
assert_eq!(
video_url("anilist:1:3").unwrap(),
"https://zokoanime.video/stream/ani/1/3/sub"
);
for id in [ for id in [
"", "",
"--help", "--help",
@@ -549,6 +553,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
"miruro:id:0", "miruro:id:0",
"miruro:id:03", "miruro:id:03",
"miruro:../id:3", "miruro:../id:3",
"anilist:01:3",
"anilist:1:03",
"anilist:1:0",
"anilist:1:2147483648",
] { ] {
assert_eq!( assert_eq!(
video_url(id).unwrap_err().kind(), video_url(id).unwrap_err().kind(),