Files
tubular/src/anilist.rs
T

224 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}