use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::error::{Error, Result};
use crate::model::{
CanonicalMedia, ExternalId, MediaKind, SearchOptions, SourceName, SourcePayload, SyncCursor,
SyncRequest,
};
use super::http::{HttpClient, clamp_page_size, page_to_offset};
use super::{FetchPage, Provider};
#[derive(Debug, Clone)]
pub struct KitsuProvider {
client: HttpClient,
}
impl Default for KitsuProvider {
fn default() -> Self {
Self::new()
}
}
impl KitsuProvider {
pub const DEFAULT_ENDPOINT: &'static str = "https://kitsu.io/api/edge";
pub fn new() -> Self {
Self {
client: HttpClient::new(Duration::from_secs(30), Self::DEFAULT_ENDPOINT),
}
}
pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
Self {
client: HttpClient::new(Duration::from_secs(30), Self::DEFAULT_ENDPOINT)
.with_base_url(endpoint),
}
}
}
impl Provider for KitsuProvider {
fn source(&self) -> SourceName {
SourceName::Kitsu
}
fn min_interval(&self) -> Duration {
Duration::from_millis(900)
}
fn fetch_page(&self, request: &SyncRequest, cursor: SyncCursor) -> Result<FetchPage> {
let kind = request.media_kind.unwrap_or(MediaKind::Anime);
let page_size = clamp_page_size(request.page_size, 20);
let offset = page_to_offset(cursor.page, page_size);
let path = kind_path(kind);
let resp: CollectionResponse = self
.client
.get(&format!("/{path}"))
.header("Accept", "application/vnd.api+json")
.query(&[
("page[limit]", page_size.to_string()),
("page[offset]", offset.to_string()),
("sort", "id".to_string()),
("include", "categories,mappings".to_string()),
])
.send()?
.error_for_status()?
.json()?;
let items = resp
.data
.iter()
.map(|r| into_canonical(r, &resp.included, kind))
.collect::<Result<Vec<_>>>()?;
let next_cursor = resp
.links
.next
.is_some()
.then_some(SyncCursor { page: cursor.page + 1 });
Ok(FetchPage { items, next_cursor })
}
fn search(&self, query: &str, options: SearchOptions) -> Result<Vec<CanonicalMedia>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let kind = options.media_kind.unwrap_or(MediaKind::Anime);
let limit = clamp_page_size(options.limit, 20);
let path = kind_path(kind);
let resp: CollectionResponse = self
.client
.get(&format!("/{path}"))
.header("Accept", "application/vnd.api+json")
.query(&[
("filter[text]", query.to_string()),
("page[limit]", limit.to_string()),
("page[offset]", options.offset.to_string()),
("include", "categories,mappings".to_string()),
])
.send()?
.error_for_status()?
.json()?;
let mut items = resp
.data
.iter()
.map(|r| into_canonical(r, &resp.included, kind))
.collect::<Result<Vec<_>>>()?;
if let Some(fmt) = options.format {
items.retain(|m| {
m.format
.as_ref()
.map(|v| v.eq_ignore_ascii_case(&fmt))
== Some(true)
});
}
Ok(items)
}
fn get_by_id(&self, media_kind: MediaKind, source_id: &str) -> Result<Option<CanonicalMedia>> {
let path = kind_path(media_kind);
let resp = self
.client
.get(&format!("/{path}/{source_id}"))
.header("Accept", "application/vnd.api+json")
.query(&[("include", "categories,mappings")])
.send()?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let resp: ItemResponse = resp.error_for_status()?.json()?;
match resp.data {
Some(ref r) => Ok(Some(into_canonical(r, &resp.included, media_kind)?)),
None => Ok(None),
}
}
fn fetch_trending(&self, kind: MediaKind) -> Result<Vec<CanonicalMedia>> {
let path = trending_path(kind);
let resp: CollectionResponse = self
.client
.get(&format!("/{path}"))
.header("Accept", "application/vnd.api+json")
.send()?
.error_for_status()?
.json()?;
resp.data
.iter()
.map(|r| into_canonical(r, &resp.included, kind))
.collect()
}
}
fn kind_path(kind: MediaKind) -> &'static str {
match kind {
MediaKind::Anime | MediaKind::Show => "anime",
MediaKind::Manga | MediaKind::Movie => "manga",
}
}
fn trending_path(kind: MediaKind) -> &'static str {
match kind {
MediaKind::Anime | MediaKind::Show => "trending/anime",
MediaKind::Manga | MediaKind::Movie => "trending/manga",
}
}
fn best_image(img: &KitsuImageSet) -> Option<String> {
img.original
.clone()
.or_else(|| img.large.clone())
.or_else(|| img.medium.clone())
.or_else(|| img.small.clone())
.or_else(|| img.tiny.clone())
}
fn into_canonical(
resource: &Resource,
included: &[Included],
kind: MediaKind,
) -> Result<CanonicalMedia> {
let raw = serde_json::to_value(resource)?;
let attrs = &resource.attributes;
let title_display = attrs
.canonical_title
.clone()
.or_else(|| attrs.titles.en.clone())
.or_else(|| attrs.titles.en_jp.clone())
.or_else(|| attrs.titles.ja_jp.clone())
.ok_or_else(|| {
Error::Validation(format!("Kitsu media {} has no usable title", resource.id))
})?;
let mut aliases = attrs.abbreviated_titles.clone();
for title in [&attrs.titles.en, &attrs.titles.en_jp, &attrs.titles.ja_jp].into_iter().flatten() {
aliases.push(title.clone());
}
let tags = extract_categories(resource, included);
let mappings = extract_mappings(resource, included);
let mut external_ids = vec![ExternalId {
source: SourceName::Kitsu,
source_id: resource.id.clone(),
url: Some(format!("https://kitsu.io/{}", kind_path(kind))),
}];
for m in mappings {
if let Some(ext_id) = m.external_id
&& m.external_site.to_ascii_lowercase().contains("myanimelist")
{
external_ids.push(ExternalId {
source: SourceName::MyAnimeList,
source_id: ext_id,
url: None,
});
}
}
Ok(CanonicalMedia {
media_kind: kind,
title_display,
title_romaji: attrs.titles.en_jp.clone(),
title_english: attrs.titles.en.clone(),
title_native: attrs.titles.ja_jp.clone(),
synopsis: attrs.synopsis.clone().or_else(|| attrs.description.clone()),
format: attrs.subtype.clone(),
status: attrs.status.clone(),
season: None,
season_year: attrs
.start_date
.as_deref()
.and_then(|d| d.get(0..4)?.parse().ok()),
episodes: attrs.episode_count,
chapters: attrs.chapter_count,
volumes: attrs.volume_count,
country_of_origin: None,
cover_image: attrs.poster_image.as_ref().and_then(best_image),
banner_image: attrs.cover_image.as_ref().and_then(best_image),
provider_rating: attrs
.average_rating
.as_deref()
.and_then(|r| r.parse::<f64>().ok())
.map(|r| (r / 100.0).clamp(0.0, 1.0)),
nsfw: attrs.nsfw.unwrap_or(false)
|| matches!(attrs.age_rating.as_deref(), Some("R18")),
aliases,
genres: Vec::new(),
tags,
external_ids,
source_payloads: vec![SourcePayload {
source: SourceName::Kitsu,
source_id: resource.id.clone(),
url: Some(format!("https://kitsu.io/{}", kind_path(kind))),
remote_updated_at: attrs.updated_at.clone(),
raw_json: Some(raw),
}],
field_provenance: Vec::new(),
})
}
fn extract_categories(resource: &Resource, included: &[Included]) -> Vec<String> {
let Some(rels) = resource.relationships.as_ref() else {
return Vec::new();
};
let Some(cats) = rels.categories.as_ref() else {
return Vec::new();
};
cats.data
.iter()
.filter_map(|r| {
included.iter().find_map(|inc| match inc {
Included::Category(c) if c.id == r.id && r.kind == "categories" => {
c.attributes.title.clone()
}
_ => None,
})
})
.collect()
}
fn extract_mappings(resource: &Resource, included: &[Included]) -> Vec<MappingAttributes> {
let Some(rels) = resource.relationships.as_ref() else {
return Vec::new();
};
let Some(maps) = rels.mappings.as_ref() else {
return Vec::new();
};
maps.data
.iter()
.filter_map(|r| {
included.iter().find_map(|inc| match inc {
Included::Mapping(m) if m.id == r.id && r.kind == "mappings" => {
Some(m.attributes.clone())
}
_ => None,
})
})
.collect()
}
#[derive(Debug, Deserialize)]
struct CollectionResponse {
#[serde(default)]
data: Vec<Resource>,
#[serde(default)]
included: Vec<Included>,
#[serde(default)]
links: Links,
}
#[derive(Debug, Deserialize)]
struct ItemResponse {
data: Option<Resource>,
#[serde(default)]
included: Vec<Included>,
}
#[derive(Debug, Default, Deserialize)]
struct Links {
next: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Resource {
pub id: String,
#[serde(rename = "type")]
pub _type: String,
pub attributes: MediaAttributes,
pub relationships: Option<Relationships>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MediaAttributes {
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
description: Option<String>,
synopsis: Option<String>,
#[serde(rename = "canonicalTitle")]
canonical_title: Option<String>,
titles: Titles,
#[serde(rename = "abbreviatedTitles", default)]
abbreviated_titles: Vec<String>,
#[serde(rename = "averageRating")]
average_rating: Option<String>,
#[serde(rename = "startDate")]
start_date: Option<String>,
#[serde(rename = "ageRating")]
age_rating: Option<String>,
status: Option<String>,
#[serde(rename = "episodeCount")]
episode_count: Option<i32>,
#[serde(rename = "chapterCount")]
chapter_count: Option<i32>,
#[serde(rename = "volumeCount")]
volume_count: Option<i32>,
subtype: Option<String>,
nsfw: Option<bool>,
#[serde(rename = "coverImage")]
cover_image: Option<KitsuImageSet>,
#[serde(rename = "posterImage")]
poster_image: Option<KitsuImageSet>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Titles {
en: Option<String>,
en_jp: Option<String>,
ja_jp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct KitsuImageSet {
tiny: Option<String>,
small: Option<String>,
medium: Option<String>,
large: Option<String>,
original: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Relationships {
categories: Option<RelationshipCollection>,
mappings: Option<RelationshipCollection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelationshipCollection {
#[serde(default)]
data: Vec<RelationshipRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelationshipRef {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
enum Included {
#[serde(rename = "categories")]
Category(CategoryResource),
#[serde(rename = "mappings")]
Mapping(MappingResource),
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CategoryResource {
id: String,
attributes: CategoryAttributes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CategoryAttributes {
title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MappingResource {
id: String,
attributes: MappingAttributes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MappingAttributes {
#[serde(rename = "externalSite")]
external_site: String,
#[serde(rename = "externalId")]
external_id: Option<String>,
}