Files
stripstream-librarian/apps/api/src/metadata_providers/comicvine.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

268 lines
8.3 KiB
Rust

use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
pub struct ComicVineProvider;
impl MetadataProvider for ComicVineProvider {
fn name(&self) -> &str {
"comicvine"
}
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 })
}
}
fn build_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent("StripstreamLibrarian/1.0")
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}
async fn search_series_impl(
query: &str,
config: &ProviderConfig,
) -> Result<Vec<SeriesCandidate>, String> {
let api_key = config
.api_key
.as_deref()
.filter(|k| !k.is_empty())
.ok_or_else(|| "ComicVine requires an API key. Configure it in Settings > Integrations.".to_string())?;
let client = build_client()?;
let url = format!(
"https://comicvine.gamespot.com/api/search/?api_key={}&format=json&resources=volume&query={}&limit=20",
api_key,
urlencoded(query)
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("ComicVine request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("ComicVine returned {status}: {text}"));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
let results = match data.get("results").and_then(|r| r.as_array()) {
Some(results) => results,
None => return Ok(vec![]),
};
let query_lower = query.to_lowercase();
let mut candidates: Vec<SeriesCandidate> = results
.iter()
.filter_map(|vol| {
let name = vol.get("name").and_then(|n| n.as_str())?.to_string();
let id = vol.get("id").and_then(|id| id.as_i64())?;
let description = vol
.get("description")
.and_then(|d| d.as_str())
.map(strip_html);
let publisher = vol
.get("publisher")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(String::from);
let start_year = vol
.get("start_year")
.and_then(|y| y.as_str())
.and_then(|y| y.parse::<i32>().ok());
let count_of_issues = vol
.get("count_of_issues")
.and_then(|c| c.as_i64())
.map(|c| c as i32);
let cover_url = vol
.get("image")
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
.and_then(|u| u.as_str())
.map(String::from);
let site_url = vol
.get("site_detail_url")
.and_then(|u| u.as_str())
.map(String::from);
let confidence = compute_confidence(&name, &query_lower);
Some(SeriesCandidate {
external_id: id.to_string(),
title: name,
authors: vec![],
description,
publishers: publisher.into_iter().collect(),
start_year,
total_volumes: count_of_issues,
cover_url,
external_url: site_url,
confidence,
metadata_json: serde_json::json!({}),
})
})
.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 api_key = config
.api_key
.as_deref()
.filter(|k| !k.is_empty())
.ok_or_else(|| "ComicVine requires an API key".to_string())?;
let client = build_client()?;
let url = format!(
"https://comicvine.gamespot.com/api/issues/?api_key={}&format=json&filter=volume:{}&sort=issue_number:asc&limit=100&field_list=id,name,issue_number,description,image,cover_date,site_detail_url",
api_key,
external_id
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("ComicVine request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("ComicVine returned {status}: {text}"));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
let results = match data.get("results").and_then(|r| r.as_array()) {
Some(results) => results,
None => return Ok(vec![]),
};
let books: Vec<BookCandidate> = results
.iter()
.filter_map(|issue| {
let id = issue.get("id").and_then(|id| id.as_i64())?;
let name = issue
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let issue_number = issue
.get("issue_number")
.and_then(|n| n.as_str())
.and_then(|n| n.parse::<f64>().ok())
.map(|n| n as i32);
let description = issue
.get("description")
.and_then(|d| d.as_str())
.map(strip_html);
let cover_url = issue
.get("image")
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
.and_then(|u| u.as_str())
.map(String::from);
let cover_date = issue
.get("cover_date")
.and_then(|d| d.as_str())
.map(String::from);
Some(BookCandidate {
external_book_id: id.to_string(),
title: name,
volume_number: issue_number,
authors: vec![],
isbn: None,
summary: description,
cover_url,
page_count: None,
language: None,
publish_date: cover_date,
metadata_json: serde_json::json!({}),
})
})
.collect();
Ok(books)
}
fn strip_html(s: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
result.trim().to_string()
}
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)
}
}
fn urlencoded(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
_ => result.push_str(&format!("%{:02X}", byte)),
}
}
result
}