224 lines
8.8 KiB
Rust
224 lines
8.8 KiB
Rust
//! 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 = 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<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());
|
||
}
|
||
}
|