feat: add configurable status mappings for metadata providers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s

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>
This commit is contained in:
2026-03-19 12:44:22 +01:00
parent bfc1c76fe2
commit cfc98819ab
25 changed files with 706 additions and 129 deletions

View File

@@ -693,10 +693,11 @@ pub(crate) async fn sync_series_metadata(
.get("start_year")
.and_then(|y| y.as_i64())
.map(|y| y as i32);
let status = metadata_json
.get("status")
.and_then(|s| s.as_str())
.map(normalize_series_status);
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
Some(normalize_series_status(&state.pool, raw).await)
} else {
None
};
// Fetch existing state before upsert
let existing = sqlx::query(
@@ -775,7 +776,7 @@ pub(crate) async fn sync_series_metadata(
let fields = vec![
FieldDef {
name: "description",
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("description")).map(|s| serde_json::Value::String(s)),
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("description")).map(serde_json::Value::String),
new: description.map(|s| serde_json::Value::String(s.to_string())),
},
FieldDef {
@@ -800,7 +801,7 @@ pub(crate) async fn sync_series_metadata(
},
FieldDef {
name: "status",
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(|s| serde_json::Value::String(s)),
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(serde_json::Value::String),
new: status.as_ref().map(|s| serde_json::Value::String(s.clone())),
},
];
@@ -825,25 +826,35 @@ pub(crate) async fn sync_series_metadata(
Ok(report)
}
/// Normalize provider-specific status strings to a standard set:
/// "ongoing", "ended", "hiatus", "cancelled", or the original lowercase value
fn normalize_series_status(raw: &str) -> String {
/// Normalize provider-specific status strings using the status_mappings table.
/// Falls back to the original lowercase value if no mapping is found.
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> String {
let lower = raw.to_lowercase();
match lower.as_str() {
// AniList
"finished" => "ended".to_string(),
"releasing" => "ongoing".to_string(),
"not_yet_released" => "upcoming".to_string(),
"cancelled" => "cancelled".to_string(),
"hiatus" => "hiatus".to_string(),
// Bédéthèque
_ if lower.contains("finie") || lower.contains("terminée") => "ended".to_string(),
_ if lower.contains("en cours") => "ongoing".to_string(),
_ if lower.contains("hiatus") || lower.contains("suspendue") => "hiatus".to_string(),
_ if lower.contains("annulée") || lower.contains("arrêtée") => "cancelled".to_string(),
// Fallback
_ => lower,
// Try exact match first
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return row;
}
// Try substring match (for Bédéthèque-style statuses like "Série finie")
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' LIMIT 1",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return row;
}
// No mapping found — return lowercase original
lower
}
pub(crate) async fn sync_books_metadata(