perf: compress anime requests and reuse AniList search metadata

This commit is contained in:
2026-10-25 20:11:44 +01:00
parent 19267d169b
commit 6cc4e18f72
8 changed files with 141 additions and 15 deletions
Generated
+1
View File
@@ -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",
+1 -1
View File
@@ -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"
+3 -1
View File
@@ -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.
+1 -5
View File
@@ -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)
}
+73
View File
@@ -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<String> {
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();
}
}
+1 -6
View File
@@ -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);
+15 -2
View File
@@ -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);
}
+46
View File
@@ -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();