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

@@ -128,7 +128,7 @@ async fn search_series_impl(
let mut candidates: Vec<SeriesCandidate> = media
.iter()
.filter_map(|m| {
let id = m.get("id").and_then(|id| id.as_i64())? as i64;
let id = m.get("id").and_then(|id| id.as_i64())?;
let title_obj = m.get("title")?;
let title = title_obj
.get("english")

View File

@@ -497,6 +497,13 @@ async fn get_series_books_impl(
}))
.collect();
static RE_TOME: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)-Tome-\d+-").unwrap());
static RE_BOOK_ID: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"-(\d+)\.html").unwrap());
static RE_VOLUME: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)Tome-(\d+)-").unwrap());
for (idx, album_el) in doc.select(&album_sel).enumerate() {
// Title from <a class="titre" title="..."> — the title attribute is clean
let title_sel = Selector::parse("a.titre").ok();
@@ -516,22 +523,18 @@ async fn get_series_books_impl(
// Only keep main tomes — their URLs contain "Tome-{N}-"
// Skip hors-série (HS), intégrales (INT/INTFL), romans, coffrets, etc.
if let Ok(re) = regex::Regex::new(r"(?i)-Tome-\d+-") {
if !re.is_match(album_url) {
continue;
}
if !RE_TOME.is_match(album_url) {
continue;
}
let external_book_id = regex::Regex::new(r"-(\d+)\.html")
.ok()
.and_then(|re| re.captures(album_url))
let external_book_id = RE_BOOK_ID
.captures(album_url)
.map(|c| c[1].to_string())
.unwrap_or_default();
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
let volume_number = regex::Regex::new(r"(?i)Tome-(\d+)-")
.ok()
.and_then(|re| re.captures(album_url))
let volume_number = RE_VOLUME
.captures(album_url)
.and_then(|c| c[1].parse::<i32>().ok())
.or_else(|| extract_volume_from_title(&title));
@@ -649,13 +652,13 @@ fn compute_confidence(title: &str, query: &str) -> f32 {
return 1.0;
}
if title_lower.starts_with(&query_lower) || query_lower.starts_with(&title_lower) {
if title_lower.starts_with(&query_lower) || query_lower.starts_with(&title_lower)
|| title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm)
{
0.85
} else if title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm) {
0.85
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower) {
0.7
} else if title_norm.contains(&query_norm) || query_norm.contains(&title_norm) {
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower)
|| title_norm.contains(&query_norm) || query_norm.contains(&title_norm)
{
0.7
} else {
let common: usize = query_lower

View File

@@ -86,11 +86,11 @@ async fn search_series_impl(
.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 id = vol.get("id").and_then(|id| id.as_i64())?;
let description = vol
.get("description")
.and_then(|d| d.as_str())
.map(|d| strip_html(d));
.map(strip_html);
let publisher = vol
.get("publisher")
.and_then(|p| p.get("name"))
@@ -180,7 +180,7 @@ async fn get_series_books_impl(
let books: Vec<BookCandidate> = results
.iter()
.filter_map(|issue| {
let id = issue.get("id").and_then(|id| id.as_i64())? as i64;
let id = issue.get("id").and_then(|id| id.as_i64())?;
let name = issue
.get("name")
.and_then(|n| n.as_str())
@@ -194,7 +194,7 @@ async fn get_series_books_impl(
let description = issue
.get("description")
.and_then(|d| d.as_str())
.map(|d| strip_html(d));
.map(strip_html);
let cover_url = issue
.get("image")
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))

View File

@@ -295,7 +295,7 @@ async fn get_series_books_impl(
let mut books: Vec<BookCandidate> = items
.iter()
.map(|item| volume_to_book_candidate(item))
.map(volume_to_book_candidate)
.collect();
// Sort by volume number

View File

@@ -144,10 +144,10 @@ async fn search_series_impl(
entry.publishers.push(p.clone());
}
}
if entry.start_year.is_none() || first_publish_year.map_or(false, |y| entry.start_year.unwrap() > y) {
if first_publish_year.is_some() {
entry.start_year = first_publish_year;
}
if (entry.start_year.is_none() || first_publish_year.is_some_and(|y| entry.start_year.unwrap() > y))
&& first_publish_year.is_some()
{
entry.start_year = first_publish_year;
}
if entry.cover_url.is_none() {
entry.cover_url = cover_url;