Files
stripstream-librarian/apps/api/src/metadata_providers/anilist.rs
Froidefond Julien cfc98819ab
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
feat: add configurable status mappings for metadata providers
Add a status_mappings table to replace hardcoded provider status
normalization. Users can now configure how provider statuses (e.g.
"releasing", "finie") map to target statuses (e.g. "ongoing", "ended")
via the Settings > Integrations page.

- Migration 0038: status_mappings table with pre-seeded mappings
- Migration 0039: re-normalize existing series_metadata.status values
- API: CRUD endpoints for status mappings, DB-based normalize function
- API: new GET /series/provider-statuses endpoint
- Backoffice: StatusMappingsCard component with create target, assign,
  and delete capabilities
- Fix all clippy warnings across the API crate
- Fix missing OpenAPI schema refs (MetadataStats, ProviderCount)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:44:22 +01:00

343 lines
10 KiB
Rust

use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
pub struct AniListProvider;
impl MetadataProvider for AniListProvider {
fn name(&self) -> &str {
"anilist"
}
fn search_series(
&self,
query: &str,
config: &ProviderConfig,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
> {
let query = query.to_string();
let config = config.clone();
Box::pin(async move { search_series_impl(&query, &config).await })
}
fn get_series_books(
&self,
external_id: &str,
config: &ProviderConfig,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
> {
let external_id = external_id.to_string();
let config = config.clone();
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
}
}
const SEARCH_QUERY: &str = r#"
query ($search: String) {
Page(perPage: 20) {
media(search: $search, type: MANGA, sort: SEARCH_MATCH) {
id
title { romaji english native }
description(asHtml: false)
coverImage { large medium }
startDate { year }
status
volumes
chapters
staff { edges { node { name { full } } role } }
siteUrl
genres
}
}
}
"#;
const DETAIL_QUERY: &str = r#"
query ($id: Int) {
Media(id: $id, type: MANGA) {
id
title { romaji english native }
description(asHtml: false)
coverImage { large medium }
startDate { year }
status
volumes
chapters
staff { edges { node { name { full } } role } }
siteUrl
genres
}
}
"#;
async fn graphql_request(
client: &reqwest::Client,
query: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value, String> {
let resp = client
.post("https://graphql.anilist.co")
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"query": query,
"variables": variables,
}))
.send()
.await
.map_err(|e| format!("AniList request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("AniList returned {status}: {text}"));
}
resp.json()
.await
.map_err(|e| format!("Failed to parse AniList response: {e}"))
}
async fn search_series_impl(
query: &str,
_config: &ProviderConfig,
) -> Result<Vec<SeriesCandidate>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
let data = graphql_request(
&client,
SEARCH_QUERY,
serde_json::json!({ "search": query }),
)
.await?;
let media = match data
.get("data")
.and_then(|d| d.get("Page"))
.and_then(|p| p.get("media"))
.and_then(|m| m.as_array())
{
Some(media) => media,
None => return Ok(vec![]),
};
let query_lower = query.to_lowercase();
let mut candidates: Vec<SeriesCandidate> = media
.iter()
.filter_map(|m| {
let id = m.get("id").and_then(|id| id.as_i64())?;
let title_obj = m.get("title")?;
let title = title_obj
.get("english")
.and_then(|t| t.as_str())
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))?
.to_string();
let description = m
.get("description")
.and_then(|d| d.as_str())
.map(|d| d.replace("\\n", "\n").trim().to_string())
.filter(|d| !d.is_empty());
let cover_url = m
.get("coverImage")
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
.and_then(|u| u.as_str())
.map(String::from);
let start_year = m
.get("startDate")
.and_then(|sd| sd.get("year"))
.and_then(|y| y.as_i64())
.map(|y| y as i32);
let volumes = m
.get("volumes")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let chapters = m
.get("chapters")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let status = m
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("UNKNOWN")
.to_string();
let site_url = m
.get("siteUrl")
.and_then(|u| u.as_str())
.map(String::from);
let authors = extract_authors(m);
let confidence = compute_confidence(&title, &query_lower);
// Use volumes if known, otherwise fall back to chapters count
let (total_volumes, volume_source) = match volumes {
Some(v) => (Some(v), "volumes"),
None => match chapters {
Some(c) => (Some(c), "chapters"),
None => (None, "unknown"),
},
};
Some(SeriesCandidate {
external_id: id.to_string(),
title,
authors,
description,
publishers: vec![],
start_year,
total_volumes,
cover_url,
external_url: site_url,
confidence,
metadata_json: serde_json::json!({
"status": status,
"chapters": chapters,
"volumes": volumes,
"volume_source": volume_source,
}),
})
})
.collect();
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
candidates.truncate(10);
Ok(candidates)
}
async fn get_series_books_impl(
external_id: &str,
_config: &ProviderConfig,
) -> Result<Vec<BookCandidate>, String> {
let id: i64 = external_id
.parse()
.map_err(|_| "invalid AniList ID".to_string())?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
let data = graphql_request(
&client,
DETAIL_QUERY,
serde_json::json!({ "id": id }),
)
.await?;
let media = match data.get("data").and_then(|d| d.get("Media")) {
Some(m) => m,
None => return Ok(vec![]),
};
let title_obj = media.get("title").cloned().unwrap_or(serde_json::json!({}));
let title = title_obj
.get("english")
.and_then(|t| t.as_str())
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))
.unwrap_or("")
.to_string();
let volumes = media
.get("volumes")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
let chapters = media
.get("chapters")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
// Use volumes if known, otherwise fall back to chapters count
let total = volumes.or(chapters);
let cover_url = media
.get("coverImage")
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
.and_then(|u| u.as_str())
.map(String::from);
let description = media
.get("description")
.and_then(|d| d.as_str())
.map(|d| d.replace("\\n", "\n").trim().to_string());
let authors = extract_authors(media);
// AniList doesn't have per-volume data — generate entries from volumes count (or chapters as fallback)
let mut books = Vec::new();
if let Some(total) = total {
for vol in 1..=total {
books.push(BookCandidate {
external_book_id: format!("{}-vol-{}", external_id, vol),
title: format!("{} Vol. {}", title, vol),
volume_number: Some(vol),
authors: authors.clone(),
isbn: None,
summary: if vol == 1 { description.clone() } else { None },
cover_url: if vol == 1 { cover_url.clone() } else { None },
page_count: None,
language: Some("ja".to_string()),
publish_date: None,
metadata_json: serde_json::json!({}),
});
}
}
Ok(books)
}
fn extract_authors(media: &serde_json::Value) -> Vec<String> {
let mut authors = Vec::new();
if let Some(edges) = media
.get("staff")
.and_then(|s| s.get("edges"))
.and_then(|e| e.as_array())
{
for edge in edges {
let role = edge
.get("role")
.and_then(|r| r.as_str())
.unwrap_or("");
let role_lower = role.to_lowercase();
if role_lower.contains("story") || role_lower.contains("art") || role_lower.contains("original") {
if let Some(name) = edge
.get("node")
.and_then(|n| n.get("name"))
.and_then(|n| n.get("full"))
.and_then(|f| f.as_str())
{
if !authors.contains(&name.to_string()) {
authors.push(name.to_string());
}
}
}
}
}
authors
}
fn compute_confidence(title: &str, query: &str) -> f32 {
let title_lower = title.to_lowercase();
if title_lower == query {
1.0
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
0.8
} else if title_lower.contains(query) || query.contains(&title_lower) {
0.7
} else {
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
let max_len = query.len().max(title_lower.len()).max(1);
(common as f32 / max_len as f32).clamp(0.1, 0.6)
}
}