//! 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> { 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 { 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 { 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 = crate::read_catalog_body(response.body_mut(), BODY_LIMIT) .map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?; decode(&body) } } fn decode(body: &str) -> io::Result { 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 { 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> { let number = |value: Option<&Value>| -> io::Result> { 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()); } }