use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::{
config::AniListConfig,
error::AniListError,
query,
response::{
GraphQlResponse, MediaDetailResponse, MediaPageResponse, StaffDetailResponse,
StaffPageResponse,
},
};
use crate::{
core::{config::TimeWindow, pagination::Page},
unified::{
conversions::anilist::{anilist_media_to_movie, anilist_media_to_tv},
models::{
UnifiedMovie, UnifiedMovieDetails, UnifiedPerson, UnifiedPersonDetails,
UnifiedSearchResult, UnifiedTvShow, UnifiedTvShowDetails,
},
},
};
const MOVIE_FORMATS: &[&str] = &["MOVIE"];
const TV_FORMATS: &[&str] = &["TV", "TV_SHORT", "ONA", "OVA", "SPECIAL"];
#[derive(Clone, Debug)]
pub struct AniListClient {
http: reqwest::Client,
config: AniListConfig,
rate_limiter: crate::providers::rate_limit::RateLimiter,
}
impl AniListClient {
pub fn new(config: AniListConfig) -> Result<Self, AniListError> {
config
.rate_limit
.validate()
.map_err(AniListError::InvalidConfig)?;
let http = crate::providers::http::build_client(
config.connect_timeout,
config.request_timeout,
reqwest::header::HeaderMap::new(),
)?;
let rate_limiter = crate::providers::rate_limit::RateLimiter::new(config.rate_limit);
Ok(Self {
http,
config,
rate_limiter,
})
}
pub fn config(&self) -> &AniListConfig {
&self.config
}
async fn graphql<T: DeserializeOwned>(
&self,
query: &str,
variables: Value,
) -> Result<T, AniListError> {
let body = json!({
"query": query,
"variables": variables,
});
let policy = self.config.retry;
let mut retry_index = 0u32;
loop {
self.rate_limiter.acquire().await;
match self.graphql_attempt::<T>(&body).await {
Ok(data) => return Ok(data),
Err(err) => {
if policy.should_retry(retry_index) && err.is_retryable() {
let delay = policy.backoff(retry_index, err.retry_after());
tracing::debug!(
retry = retry_index,
?delay,
error = %err,
"anilist: retrying transient error"
);
tokio::time::sleep(delay).await;
retry_index += 1;
continue;
}
return Err(err);
}
}
}
}
async fn graphql_attempt<T: DeserializeOwned>(&self, body: &Value) -> Result<T, AniListError> {
let resp = self
.http
.post(&self.config.base_url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json")
.json(body)
.send()
.await?;
let status = resp.status();
let retry_after = super::error::parse_retry_after(resp.headers());
let text = resp.text().await?;
let gql_resp: GraphQlResponse<T> = match serde_json::from_str(&text) {
Ok(parsed) => parsed,
Err(parse_err) => {
if !status.is_success() {
return Err(AniListError::Status {
status: status.as_u16(),
retry_after,
message: truncate_body(&text),
});
}
return Err(AniListError::Deserialization(parse_err));
}
};
if let Some(errors) = gql_resp.errors.filter(|e| !e.is_empty()) {
if errors.iter().any(|e| e.status == Some(404)) {
return Err(AniListError::NotFound);
}
if let Some(data) = gql_resp.data {
tracing::warn!(?errors, "anilist: returning partial data alongside errors");
return Ok(data);
}
tracing::warn!(?errors, "anilist: graphql errors");
return Err(AniListError::GraphQL {
errors,
retry_after,
});
}
if let Some(data) = gql_resp.data {
return Ok(data);
}
if !status.is_success() {
return Err(AniListError::Status {
status: status.as_u16(),
retry_after,
message: truncate_body(&text),
});
}
Err(AniListError::NoData)
}
fn format_in_value(formats: &[&str]) -> Value {
Value::Array(
formats
.iter()
.map(|s| Value::String(s.to_string()))
.collect(),
)
}
fn page_vars(page: Option<u32>, per_page: u32) -> (i64, i64) {
(page.unwrap_or(1) as i64, per_page as i64)
}
pub async fn search_movies(
&self,
query: &str,
page: Option<u32>,
) -> Result<Page<UnifiedMovie>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"query": query,
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(MOVIE_FORMATS),
});
tracing::debug!(
query,
page = page_num,
"anilist: graphql SEARCH_ANIME (search_movies)"
);
let resp: MediaPageResponse = self.graphql(query::SEARCH_ANIME, vars).await?;
media_page_to_movies(resp)
}
pub async fn search_tv_shows(
&self,
query: &str,
page: Option<u32>,
) -> Result<Page<UnifiedTvShow>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"query": query,
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(TV_FORMATS),
});
tracing::debug!(
query,
page = page_num,
"anilist: graphql SEARCH_ANIME (search_tv_shows)"
);
let resp: MediaPageResponse = self.graphql(query::SEARCH_ANIME, vars).await?;
media_page_to_tv(resp)
}
pub async fn search_people(
&self,
query: &str,
page: Option<u32>,
) -> Result<Page<UnifiedPerson>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"query": query,
"page": page_num,
"perPage": per_page,
});
tracing::debug!(query, page = page_num, "anilist: graphql SEARCH_STAFF");
let resp: StaffPageResponse = self.graphql(query::SEARCH_STAFF, vars).await?;
let page_data = resp.page.ok_or(AniListError::NoData)?;
let pi = &page_data.page_info;
Ok(Page::with_has_next(
pi.current_page.unwrap_or(1) as u32,
page_data
.staff
.into_iter()
.map(crate::unified::conversions::anilist::staff_to_person)
.collect(),
pi.has_next_page,
pi.last_page.unwrap_or(1) as u32,
pi.total.unwrap_or(0) as u32,
))
}
pub async fn search_multi(
&self,
query: &str,
page: Option<u32>,
) -> Result<Page<UnifiedSearchResult>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"query": query,
"page": page_num,
"perPage": per_page,
});
tracing::debug!(
query,
page = page_num,
"anilist: graphql SEARCH_ANIME (search_multi)"
);
let resp: MediaPageResponse = self.graphql(query::SEARCH_ANIME, vars).await?;
let page_data = resp.page.ok_or(AniListError::NoData)?;
let pi = &page_data.page_info;
let results = page_data
.media
.into_iter()
.filter_map(crate::unified::conversions::anilist::anilist_media_to_search_result)
.collect();
Ok(Page::with_has_next(
pi.current_page.unwrap_or(1) as u32,
results,
pi.has_next_page,
pi.last_page.unwrap_or(1) as u32,
pi.total.unwrap_or(0) as u32,
))
}
pub async fn movie_details(&self, id: i32) -> Result<UnifiedMovieDetails, AniListError> {
let vars = json!({ "id": id });
tracing::debug!(id, "anilist: graphql MEDIA_DETAILS (movie_details)");
let resp: MediaDetailResponse = self.graphql(query::MEDIA_DETAILS, vars).await?;
let media = resp.media.ok_or(AniListError::NotFound)?;
Ok(crate::unified::conversions::anilist::anilist_media_detail_to_movie_details(media))
}
pub async fn tv_show_details(&self, id: i32) -> Result<UnifiedTvShowDetails, AniListError> {
let vars = json!({ "id": id });
tracing::debug!(id, "anilist: graphql MEDIA_DETAILS (tv_show_details)");
let resp: MediaDetailResponse = self.graphql(query::MEDIA_DETAILS, vars).await?;
let media = resp.media.ok_or(AniListError::NotFound)?;
Ok(crate::unified::conversions::anilist::anilist_media_detail_to_tv_details(media))
}
pub async fn person_details(&self, id: i32) -> Result<UnifiedPersonDetails, AniListError> {
let vars = json!({ "id": id });
tracing::debug!(id, "anilist: graphql STAFF_DETAILS (person_details)");
let resp: StaffDetailResponse = self.graphql(query::STAFF_DETAILS, vars).await?;
let staff = resp.staff.ok_or(AniListError::NotFound)?;
Ok(crate::unified::conversions::anilist::staff_detail_to_person_details(staff))
}
pub async fn trending_movies(
&self,
_time_window: TimeWindow,
page: Option<u32>,
) -> Result<Page<UnifiedMovie>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(MOVIE_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_TRENDING_ANIME (trending_movies)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_TRENDING_ANIME, vars).await?;
media_page_to_movies(resp)
}
pub async fn trending_tv(
&self,
_time_window: TimeWindow,
page: Option<u32>,
) -> Result<Page<UnifiedTvShow>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(TV_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_TRENDING_ANIME (trending_tv)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_TRENDING_ANIME, vars).await?;
media_page_to_tv(resp)
}
pub async fn popular_movies(
&self,
page: Option<u32>,
) -> Result<Page<UnifiedMovie>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(MOVIE_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_POPULAR_ANIME (popular_movies)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_POPULAR_ANIME, vars).await?;
media_page_to_movies(resp)
}
pub async fn top_rated_movies(
&self,
page: Option<u32>,
) -> Result<Page<UnifiedMovie>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(MOVIE_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_TOP_SCORED_ANIME (top_rated_movies)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_TOP_SCORED_ANIME, vars).await?;
media_page_to_movies(resp)
}
pub async fn popular_tv_shows(
&self,
page: Option<u32>,
) -> Result<Page<UnifiedTvShow>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(TV_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_POPULAR_ANIME (popular_tv_shows)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_POPULAR_ANIME, vars).await?;
media_page_to_tv(resp)
}
pub async fn top_rated_tv_shows(
&self,
page: Option<u32>,
) -> Result<Page<UnifiedTvShow>, AniListError> {
let (page_num, per_page) = Self::page_vars(page, self.config.per_page);
let vars = json!({
"page": page_num,
"perPage": per_page,
"formatIn": Self::format_in_value(TV_FORMATS),
});
tracing::debug!(
page = page_num,
"anilist: graphql LIST_TOP_SCORED_ANIME (top_rated_tv_shows)"
);
let resp: MediaPageResponse = self.graphql(query::LIST_TOP_SCORED_ANIME, vars).await?;
media_page_to_tv(resp)
}
}
fn truncate_body(body: &str) -> String {
const MAX_CHARS: usize = 200;
let trimmed = body.trim();
if trimmed.is_empty() {
return "empty response body".to_string();
}
let truncated: String = trimmed.chars().take(MAX_CHARS).collect();
if truncated.len() < trimmed.len() {
format!("{truncated}…")
} else {
truncated
}
}
fn media_page_to_movies(
resp: MediaPageResponse,
) -> Result<Page<UnifiedMovie>, super::error::AniListError> {
let page = resp.page.ok_or(super::error::AniListError::NoData)?;
let pi = &page.page_info;
Ok(Page::with_has_next(
pi.current_page.unwrap_or(1) as u32,
page.media.into_iter().map(anilist_media_to_movie).collect(),
pi.has_next_page,
pi.last_page.unwrap_or(1) as u32,
pi.total.unwrap_or(0) as u32,
))
}
fn media_page_to_tv(
resp: MediaPageResponse,
) -> Result<Page<UnifiedTvShow>, super::error::AniListError> {
let page = resp.page.ok_or(super::error::AniListError::NoData)?;
let pi = &page.page_info;
Ok(Page::with_has_next(
pi.current_page.unwrap_or(1) as u32,
page.media.into_iter().map(anilist_media_to_tv).collect(),
pi.has_next_page,
pi.last_page.unwrap_or(1) as u32,
pi.total.unwrap_or(0) as u32,
))
}