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"
|
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ toml_edit = "0.25.17"
|
|||||||
time = "0.3.55"
|
time = "0.3.55"
|
||||||
unicode-normalization = "0.1.25"
|
unicode-normalization = "0.1.25"
|
||||||
unicode-segmentation = "1.13.3"
|
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]
|
[target."cfg(unix)".dependencies]
|
||||||
libc = "0.2.190"
|
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
|
### 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.
|
**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") {
|
if response.body().mime_type() != Some("application/json") {
|
||||||
return Err(invalid("AniList returned non-JSON content"));
|
return Err(invalid("AniList returned non-JSON content"));
|
||||||
}
|
}
|
||||||
let body = response
|
let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT)
|
||||||
.body_mut()
|
|
||||||
.with_config()
|
|
||||||
.limit(BODY_LIMIT)
|
|
||||||
.read_to_string()
|
|
||||||
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
||||||
decode(&body)
|
decode(&body)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -1,3 +1,76 @@
|
|||||||
pub mod anilist;
|
pub mod anilist;
|
||||||
pub mod anime;
|
pub mod anime;
|
||||||
pub mod miruro;
|
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}"));
|
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match response
|
match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) {
|
||||||
.body_mut()
|
|
||||||
.with_config()
|
|
||||||
.limit(BODY_LIMIT)
|
|
||||||
.read_to_string()
|
|
||||||
{
|
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
self.preferred.store(index, Ordering::Relaxed);
|
self.preferred.store(index, Ordering::Relaxed);
|
||||||
return Ok(body);
|
return Ok(body);
|
||||||
|
|||||||
+15
-2
@@ -734,8 +734,21 @@ impl Browser {
|
|||||||
if let Some(media) = self.screen().selected_media().cloned()
|
if let Some(media) = self.screen().selected_media().cloned()
|
||||||
&& !matches!(self.screen().content, Content::Series { .. })
|
&& !matches!(self.screen().content, Content::Series { .. })
|
||||||
{
|
{
|
||||||
self.push(Screen::new(Content::Series { media, data: None }));
|
if self.provider == Provider::AniList
|
||||||
self.load_series();
|
&& 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 {
|
} else {
|
||||||
self.play(false);
|
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]
|
#[test]
|
||||||
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
|
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user