feat: add external metadata sync system with multiple providers
Add a complete metadata synchronization system allowing users to search and sync series/book metadata from external providers (Google Books, Open Library, ComicVine, AniList, Bédéthèque). Each library can use a different provider. Matching requires manual approval with detailed sync reports showing what was updated or skipped (locked fields protection). Key changes: - DB migrations: external_metadata_links, external_book_metadata tables, library metadata_provider column, locked_fields, total_volumes, book metadata fields (summary, isbn, publish_date) - Rust API: MetadataProvider trait + 5 provider implementations, 7 metadata endpoints (search, match, approve, reject, links, missing, delete), sync report system, provider language preference support - Backoffice: MetadataSearchModal, ProviderIcon, SafeHtml components, settings UI for provider/language config, enriched book detail page, edit forms with locked fields support, API proxy routes - OpenAPI/Swagger documentation for all new endpoints and schemas Closes #3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
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())? as i64;
|
||||
let description = vol
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(|d| strip_html(d));
|
||||
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())? 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(|d| strip_html(d));
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user