From 6cc4e18f722143d9e6a1ee2d7789cb89e03ff562 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 20:11:44 +0100 Subject: [PATCH] perf: compress anime requests and reuse AniList search metadata --- Cargo.lock | 1 + Cargo.toml | 2 +- README.md | 4 ++- src/anilist.rs | 6 +--- src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++++++ src/miruro.rs | 7 +--- src/miruro_ui.rs | 17 ++++++++-- tests/unit/miruro_ui.rs | 46 ++++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa62964..68945da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1517,6 +1517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", diff --git a/Cargo.toml b/Cargo.toml index db28d77..94d64a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ toml_edit = "0.25.17" time = "0.3.55" unicode-normalization = "0.1.25" unicode-segmentation = "1.13.3" -ureq = { version = "3.4.2", default-features = false, features = ["rustls"] } +ureq = { version = "3.4.2", default-features = false, features = ["rustls", "gzip"] } [target."cfg(unix)".dependencies] libc = "0.2.190" diff --git a/README.md b/README.md index babe10a..3110cc9 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it ### AniList catalog and on-demand sub playback -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. +Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: gzip response compression, a 20-second timeout, no redirects, 2 MiB limits on both wire and decompressed response bodies, 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. + +Opening an AniList search result builds catalog episode rows immediately from the metadata already fetched, without a second detail request. `r` still fetches fresh metadata; opening a saved-library show also fetches current detail. Back/forward retains the loaded screen. **Catalog episode numbers are not source availability.** 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 direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons. diff --git a/src/anilist.rs b/src/anilist.rs index 2cb014e..fb7b5fa 100644 --- a/src/anilist.rs +++ b/src/anilist.rs @@ -88,11 +88,7 @@ impl Client { 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() + let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT) .map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?; decode(&body) } diff --git a/src/lib.rs b/src/lib.rs index acc0b64..4915b8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,76 @@ pub mod anilist; pub mod anime; pub mod miruro; + +/// Bound both wire bytes and decoded text: ureq's limit precedes decompression. +fn read_catalog_body(body: &mut ureq::Body, limit: u64) -> std::io::Result { + use std::io::Read; + + let mut text = String::new(); + body.with_config() + .limit(limit) + .reader() + .take(limit + 1) + .read_to_string(&mut text)?; + if text.len() as u64 > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Catalog response exceeded decoded body limit", + )); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compressed_catalog_bodies_keep_the_decoded_size_limit() { + use std::{io::Write, net::TcpListener, thread}; + + // Gzip of 64 spaces: fits the 32-byte wire limit, exceeds the decoded limit. + const GZIP: &[u8] = &[ + 31, 139, 8, 0, 0, 0, 0, 0, 2, 255, 83, 80, 160, 12, 0, 0, 95, 100, 117, 189, 64, 0, 0, + 0, + ]; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + use std::io::{BufRead, BufReader}; + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = BufReader::new(&mut stream); + let mut line = String::new(); + while request.read_line(&mut line).unwrap() > 0 { + if line == "\r\n" { + break; + } + line.clear(); + } + write!(stream, "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", GZIP.len()).unwrap(); + stream.write_all(GZIP).unwrap(); + } + }); + let agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build() + .new_agent(); + let mut response = agent.get(&url).call().unwrap(); + assert_eq!( + read_catalog_body(response.body_mut(), 64).unwrap(), + " ".repeat(64) + ); + let mut response = agent.get(&url).call().unwrap(); + assert_eq!( + read_catalog_body(response.body_mut(), 32) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidData + ); + server.join().unwrap(); + } +} diff --git a/src/miruro.rs b/src/miruro.rs index 3ed7222..1332b37 100644 --- a/src/miruro.rs +++ b/src/miruro.rs @@ -145,12 +145,7 @@ impl Client { last_error = invalid(format!("{base}: unexpected Content-Type {mime}")); continue; } - match response - .body_mut() - .with_config() - .limit(BODY_LIMIT) - .read_to_string() - { + match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) { Ok(body) => { self.preferred.store(index, Ordering::Relaxed); return Ok(body); diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index dacce15..8063667 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -734,8 +734,21 @@ impl Browser { if let Some(media) = self.screen().selected_media().cloned() && !matches!(self.screen().content, Content::Series { .. }) { - self.push(Screen::new(Content::Series { media, data: None })); - self.load_series(); + if self.provider == Provider::AniList + && matches!(self.screen().content, Content::Catalog { .. }) + { + // AniList search already returns the same metadata as detail. + match miruro_browser::anilist_series(media.metadata) { + Ok(series) => self.push(Screen::new(Content::Series { + media: series.media, + data: Some(series.episodes), + })), + Err(error) => self.fail(error), + } + } else { + self.push(Screen::new(Content::Series { media, data: None })); + self.load_series(); + } } else { self.play(false); } diff --git a/tests/unit/miruro_ui.rs b/tests/unit/miruro_ui.rs index d2c2572..a209ad0 100644 --- a/tests/unit/miruro_ui.rs +++ b/tests/unit/miruro_ui.rs @@ -767,6 +767,52 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() { } } +#[test] +fn anilist_search_results_open_without_waiting_for_another_catalog_request() { + let directory = Directory::new(); + let path = directory.0.join("config.toml"); + fs::write(&path, "").unwrap(); + let mut browser = Browser::new( + Options::parse_from(["tubular", "--anilist", "Cowboy Bebop"]), + path.clone(), + config::Config::default(), + State::open(&path).unwrap(), + ); + let media = miruro_browser::catalog(vec![serde_json::json!({ + "id":"1", "catalog_provider":"anilist", + "title":{"romaji":"Cowboy Bebop"}, "status":"FINISHED", + "episode_count":26, "catalog_count":26, "description":"Search synopsis" + })]) + .unwrap(); + browser.accept(browser.generation, Ok(Response::Catalog(media))); + browser.pending = None; + browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + browser.screen().selected_video().map(|video| video.id), + Some("anilist:1:1".into()), + "opening a complete search result must expose episodes immediately" + ); + assert!(browser.pending.is_none()); + assert!(browser.screen().overview.contains("Search synopsis")); + browser.select(Action::VideoLast); + assert_eq!( + browser.screen().selected_video().unwrap().id, + "anilist:1:26" + ); + + browser.refresh(); + assert!( + matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "1") + ); + let refreshed = miruro_browser::anilist_series(serde_json::json!({ + "id":"1", "catalog_provider":"anilist", "title":{"romaji":"Cowboy Bebop"}, + "status":"FINISHED", "episode_count":27 + })) + .unwrap(); + browser.accept(browser.generation, Ok(Response::Series(refreshed))); + assert_eq!(browser.screen().len(), 27); +} + #[test] fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() { let directory = Directory::new();