perf: compress anime requests and reuse AniList search metadata
This commit is contained in:
Generated
+1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -734,8 +734,21 @@ impl Browser {
|
||||
if let Some(media) = self.screen().selected_media().cloned()
|
||||
&& !matches!(self.screen().content, Content::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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user