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
+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());
}
}