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

@@ -412,8 +412,7 @@ pub async fn list_series(
None => String::new(),
};
let missing_cte = format!(
r#"
let missing_cte = r#"
missing_counts AS (
SELECT eml.series_name,
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
@@ -422,8 +421,7 @@ pub async fn list_series(
WHERE eml.library_id = $1 AND eml.status = 'approved'
GROUP BY eml.series_name
)
"#
);
"#.to_string();
let metadata_links_cte = r#"
metadata_links AS (
@@ -673,8 +671,7 @@ pub async fn list_all_series(
// Missing counts CTE — needs library_id filter when filtering by library
let missing_cte = if query.library_id.is_some() {
format!(
r#"
r#"
missing_counts AS (
SELECT eml.series_name, eml.library_id,
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
@@ -683,8 +680,7 @@ pub async fn list_all_series(
WHERE eml.library_id = $1 AND eml.status = 'approved'
GROUP BY eml.series_name, eml.library_id
)
"#
)
"#.to_string()
} else {
r#"
missing_counts AS (
@@ -871,7 +867,37 @@ pub async fn series_statuses(
State(state): State<AppState>,
) -> Result<Json<Vec<String>>, ApiError> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT status FROM series_metadata WHERE status IS NOT NULL ORDER BY status",
r#"SELECT DISTINCT s FROM (
SELECT status AS s FROM series_metadata WHERE status IS NOT NULL
UNION
SELECT mapped_status AS s FROM status_mappings
) t ORDER BY s"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
/// List distinct raw provider statuses from external metadata links
#[utoipa::path(
get,
path = "/series/provider-statuses",
tag = "books",
responses(
(status = 200, body = Vec<String>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn provider_statuses(
State(state): State<AppState>,
) -> Result<Json<Vec<String>>, ApiError> {
let rows: Vec<String> = sqlx::query_scalar(
r#"SELECT DISTINCT lower(metadata_json->>'status') AS s
FROM external_metadata_links
WHERE metadata_json->>'status' IS NOT NULL
AND metadata_json->>'status' != ''
ORDER BY s"#,
)
.fetch_all(&state.pool)
.await?;

View File

@@ -154,10 +154,11 @@ pub async fn sync_komga_read_books(
.fetch_all(&state.pool)
.await?;
type BookEntry = (Uuid, String, String);
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
let mut primary_map: HashMap<(String, String), Vec<(Uuid, String, String)>> = HashMap::new();
let mut primary_map: HashMap<(String, String), Vec<BookEntry>> = HashMap::new();
// Secondary: title_lower -> Vec<(Uuid, title, series)>
let mut secondary_map: HashMap<String, Vec<(Uuid, String, String)>> = HashMap::new();
let mut secondary_map: HashMap<String, Vec<BookEntry>> = HashMap::new();
for row in &rows {
let id: Uuid = row.get("id");

View File

@@ -137,6 +137,7 @@ async fn main() -> anyhow::Result<()> {
.route("/series", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series))
.route("/series/statuses", get(books::series_statuses))
.route("/series/provider-statuses", get(books::provider_statuses))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
.route("/stats", get(stats::get_stats))
.route("/search", get(search::search_books))

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(

View File

@@ -389,17 +389,19 @@ async fn process_metadata_batch(
update_progress(pool, job_id, processed, total, series_name).await;
insert_result(
pool,
job_id,
library_id,
series_name,
"already_linked",
None,
false,
0,
None,
None,
None,
Some("Unclassified series skipped"),
&InsertResultParams {
job_id,
library_id,
series_name,
status: "already_linked",
provider_used: None,
fallback_used: false,
candidates_count: 0,
best_confidence: None,
best_candidate_json: None,
link_id: None,
error_message: Some("Unclassified series skipped"),
},
)
.await;
continue;
@@ -411,17 +413,19 @@ async fn process_metadata_batch(
update_progress(pool, job_id, processed, total, series_name).await;
insert_result(
pool,
job_id,
library_id,
series_name,
"already_linked",
None,
false,
0,
None,
None,
None,
None,
&InsertResultParams {
job_id,
library_id,
series_name,
status: "already_linked",
provider_used: None,
fallback_used: false,
candidates_count: 0,
best_confidence: None,
best_candidate_json: None,
link_id: None,
error_message: None,
},
)
.await;
continue;
@@ -577,17 +581,19 @@ async fn process_metadata_batch(
insert_result(
pool,
job_id,
library_id,
series_name,
result_status,
provider_used.as_deref(),
fallback_used,
candidates_count,
best_confidence,
best_candidate.as_ref(),
link_id,
error_msg.as_deref(),
&InsertResultParams {
job_id,
library_id,
series_name,
status: result_status,
provider_used: provider_used.as_deref(),
fallback_used,
candidates_count,
best_confidence,
best_candidate_json: best_candidate.as_ref(),
link_id,
error_message: error_msg.as_deref(),
},
)
.await;
@@ -765,9 +771,12 @@ async fn sync_series_from_candidate(
let publishers = &candidate.publishers;
let start_year = candidate.start_year;
let total_volumes = candidate.total_volumes;
let status = candidate.metadata_json
.get("status")
.and_then(|s| s.as_str());
let status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
Some(crate::metadata::normalize_series_status(pool, raw).await)
} else {
None
};
let status = status.as_deref();
sqlx::query(
r#"
@@ -1070,20 +1079,21 @@ pub(crate) async fn update_progress(pool: &PgPool, job_id: Uuid, processed: i32,
.await;
}
async fn insert_result(
pool: &PgPool,
struct InsertResultParams<'a> {
job_id: Uuid,
library_id: Uuid,
series_name: &str,
status: &str,
provider_used: Option<&str>,
series_name: &'a str,
status: &'a str,
provider_used: Option<&'a str>,
fallback_used: bool,
candidates_count: i32,
best_confidence: Option<f32>,
best_candidate_json: Option<&serde_json::Value>,
best_candidate_json: Option<&'a serde_json::Value>,
link_id: Option<Uuid>,
error_message: Option<&str>,
) {
error_message: Option<&'a str>,
}
async fn insert_result(pool: &PgPool, params: &InsertResultParams<'_>) {
let _ = sqlx::query(
r#"
INSERT INTO metadata_batch_results
@@ -1091,17 +1101,17 @@ async fn insert_result(
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"#,
)
.bind(job_id)
.bind(library_id)
.bind(series_name)
.bind(status)
.bind(provider_used)
.bind(fallback_used)
.bind(candidates_count)
.bind(best_confidence)
.bind(best_candidate_json)
.bind(link_id)
.bind(error_message)
.bind(params.job_id)
.bind(params.library_id)
.bind(params.series_name)
.bind(params.status)
.bind(params.provider_used)
.bind(params.fallback_used)
.bind(params.candidates_count)
.bind(params.best_confidence)
.bind(params.best_candidate_json)
.bind(params.link_id)
.bind(params.error_message)
.execute(pool)
.await;
}

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;

View File

@@ -574,9 +574,12 @@ async fn sync_series_with_diff(
let new_publishers = &candidate.publishers;
let new_start_year = candidate.start_year;
let new_total_volumes = candidate.total_volumes;
let new_status = candidate.metadata_json
.get("status")
.and_then(|s| s.as_str());
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
Some(crate::metadata::normalize_series_status(pool, raw).await)
} else {
None
};
let new_status = new_status.as_deref();
// Fetch existing series metadata for diffing
let existing = sqlx::query(

View File

@@ -53,6 +53,11 @@ use utoipa::OpenApi;
crate::metadata::get_metadata_links,
crate::metadata::get_missing_books,
crate::metadata::delete_metadata_link,
crate::books::series_statuses,
crate::books::provider_statuses,
crate::settings::list_status_mappings,
crate::settings::upsert_status_mapping,
crate::settings::delete_status_mapping,
),
components(
schemas(
@@ -93,6 +98,8 @@ use utoipa::OpenApi;
crate::settings::ClearCacheResponse,
crate::settings::CacheStats,
crate::settings::ThumbnailStats,
crate::settings::StatusMappingDto,
crate::settings::UpsertStatusMappingRequest,
crate::stats::StatsResponse,
crate::stats::StatsOverview,
crate::stats::ReadingStatusStats,
@@ -101,6 +108,8 @@ use utoipa::OpenApi;
crate::stats::LibraryStats,
crate::stats::TopSeries,
crate::stats::MonthlyAdditions,
crate::stats::MetadataStats,
crate::stats::ProviderCount,
crate::metadata::ApproveRequest,
crate::metadata::ApproveResponse,
crate::metadata::SyncReport,

View File

@@ -277,7 +277,17 @@ pub async fn get_page(
let cache_dir2 = cache_dir_path.clone();
let format2 = format;
tokio::spawn(async move {
prefetch_page(state2, book_id, &abs_path2, next_page, format2, quality, width, filter, timeout_secs, &cache_dir2).await;
prefetch_page(state2, &PrefetchParams {
book_id,
abs_path: &abs_path2,
page: next_page,
format: format2,
quality,
width,
filter,
timeout_secs,
cache_dir: &cache_dir2,
}).await;
});
}
@@ -290,19 +300,30 @@ pub async fn get_page(
}
}
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
async fn prefetch_page(
state: AppState,
struct PrefetchParams<'a> {
book_id: Uuid,
abs_path: &str,
abs_path: &'a str,
page: u32,
format: OutputFormat,
quality: u8,
width: u32,
filter: image::imageops::FilterType,
timeout_secs: u64,
cache_dir: &Path,
) {
cache_dir: &'a Path,
}
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
async fn prefetch_page(state: AppState, params: &PrefetchParams<'_>) {
let book_id = params.book_id;
let page = params.page;
let format = params.format;
let quality = params.quality;
let width = params.width;
let filter = params.filter;
let timeout_secs = params.timeout_secs;
let abs_path = params.abs_path;
let cache_dir = params.cache_dir;
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
// Already in memory cache?
if state.page_cache.lock().await.contains(&mem_key) {

View File

@@ -1,11 +1,12 @@
use axum::{
extract::State,
routing::{get, post},
extract::{Path as AxumPath, State},
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
@@ -42,6 +43,14 @@ pub fn settings_routes() -> Router<AppState> {
.route("/settings/cache/clear", post(clear_cache))
.route("/settings/cache/stats", get(get_cache_stats))
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
.route(
"/settings/status-mappings",
get(list_status_mappings).post(upsert_status_mapping),
)
.route(
"/settings/status-mappings/:id",
delete(delete_status_mapping),
)
}
/// List all settings
@@ -324,3 +333,120 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
Ok(Json(stats))
}
// ---------------------------------------------------------------------------
// Status Mappings
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct StatusMappingDto {
pub id: String,
pub provider_status: String,
pub mapped_status: String,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpsertStatusMappingRequest {
pub provider_status: String,
pub mapped_status: String,
}
/// List all status mappings
#[utoipa::path(
get,
path = "/settings/status-mappings",
tag = "settings",
responses(
(status = 200, body = Vec<StatusMappingDto>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn list_status_mappings(
State(state): State<AppState>,
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
let rows = sqlx::query(
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status, provider_status",
)
.fetch_all(&state.pool)
.await?;
let mappings = rows
.iter()
.map(|row| StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"),
})
.collect();
Ok(Json(mappings))
}
/// Create or update a status mapping
#[utoipa::path(
post,
path = "/settings/status-mappings",
tag = "settings",
request_body = UpsertStatusMappingRequest,
responses(
(status = 200, body = StatusMappingDto),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn upsert_status_mapping(
State(state): State<AppState>,
Json(body): Json<UpsertStatusMappingRequest>,
) -> Result<Json<StatusMappingDto>, ApiError> {
let provider_status = body.provider_status.to_lowercase();
let row = sqlx::query(
r#"
INSERT INTO status_mappings (provider_status, mapped_status)
VALUES ($1, $2)
ON CONFLICT (provider_status)
DO UPDATE SET mapped_status = $2, updated_at = NOW()
RETURNING id, provider_status, mapped_status
"#,
)
.bind(&provider_status)
.bind(&body.mapped_status)
.fetch_one(&state.pool)
.await?;
Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"),
}))
}
/// Delete a status mapping
#[utoipa::path(
delete,
path = "/settings/status-mappings/{id}",
tag = "settings",
params(("id" = String, Path, description = "Mapping UUID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("Bearer" = []))
)]
pub async fn delete_status_mapping(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<Value>, ApiError> {
let result = sqlx::query("DELETE FROM status_mappings WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("status mapping not found"));
}
Ok(Json(serde_json::json!({"deleted": true})))
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<string[]>("/series/provider-statuses");
return NextResponse.json(data);
} catch {
return NextResponse.json([], { status: 200 });
}
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<string[]>("/series/statuses");
return NextResponse.json(data);
} catch {
return NextResponse.json([], { status: 200 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const data = await apiFetch<unknown>(`/settings/status-mappings/${id}`, {
method: "DELETE",
});
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to delete status mapping" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET() {
try {
const data = await apiFetch<unknown>("/settings/status-mappings");
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to fetch status mappings" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<unknown>("/settings/status-mappings", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to save status mapping" }, { status: 500 });
}
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
import type { Locale } from "../../lib/i18n/types";
@@ -577,6 +577,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
{/* Metadata Providers */}
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
{/* Status Mappings */}
<StatusMappingsCard />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -988,3 +991,212 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
</Card>
);
}
// ---------------------------------------------------------------------------
// Status Mappings sub-component
// ---------------------------------------------------------------------------
function StatusMappingsCard() {
const { t } = useTranslation();
const [mappings, setMappings] = useState<StatusMappingDto[]>([]);
const [targetStatuses, setTargetStatuses] = useState<string[]>([]);
const [providerStatuses, setProviderStatuses] = useState<string[]>([]);
const [newTargetName, setNewTargetName] = useState("");
const [loading, setLoading] = useState(true);
const loadData = useCallback(async () => {
try {
const [mRes, sRes, pRes] = await Promise.all([
fetch("/api/settings/status-mappings").then((r) => r.ok ? r.json() : []),
fetch("/api/series/statuses").then((r) => r.ok ? r.json() : []),
fetch("/api/series/provider-statuses").then((r) => r.ok ? r.json() : []),
]);
setMappings(mRes);
setTargetStatuses(sRes);
setProviderStatuses(pRes);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// Group mappings by target status
const grouped = useMemo(() => {
const map = new Map<string, StatusMappingDto[]>();
for (const m of mappings) {
const list = map.get(m.mapped_status) || [];
list.push(m);
map.set(m.mapped_status, list);
}
return map;
}, [mappings]);
// Provider statuses not yet mapped
const mappedProviderStatuses = useMemo(
() => new Set(mappings.map((m) => m.provider_status)),
[mappings],
);
const unmappedProviderStatuses = useMemo(
() => providerStatuses.filter((ps) => !mappedProviderStatuses.has(ps)),
[providerStatuses, mappedProviderStatuses],
);
async function handleAssign(providerStatus: string, targetStatus: string) {
if (!providerStatus || !targetStatus) return;
try {
const res = await fetch("/api/settings/status-mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider_status: providerStatus, mapped_status: targetStatus }),
});
if (res.ok) {
const created: StatusMappingDto = await res.json();
setMappings((prev) => [...prev.filter((m) => m.provider_status !== created.provider_status), created]);
}
} catch {
// ignore
}
}
async function handleDelete(id: string) {
try {
const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" });
if (res.ok) {
setMappings((prev) => prev.filter((m) => m.id !== id));
}
} catch {
// ignore
}
}
async function handleChangeTarget(mapping: StatusMappingDto, newTarget: string) {
try {
const res = await fetch("/api/settings/status-mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider_status: mapping.provider_status, mapped_status: newTarget }),
});
if (res.ok) {
const updated: StatusMappingDto = await res.json();
setMappings((prev) => prev.map((m) => (m.id === mapping.id ? updated : m)));
}
} catch {
// ignore
}
}
function handleCreateTarget() {
const name = newTargetName.trim().toLowerCase();
if (!name || targetStatuses.includes(name)) return;
setTargetStatuses((prev) => [...prev, name].sort());
setNewTargetName("");
}
function statusLabel(status: string) {
const key = `seriesStatus.${status}` as Parameters<typeof t>[0];
const translated = t(key);
return translated !== key ? translated : status;
}
if (loading) {
return (
<Card className="mb-6">
<CardContent><p className="text-muted-foreground py-4">{t("common.loading")}</p></CardContent>
</Card>
);
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="settings" size="md" />
{t("settings.statusMappings")}
</CardTitle>
<CardDescription>{t("settings.statusMappingsDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Create new target status */}
<div className="flex gap-2 items-center">
<FormInput
placeholder={t("settings.newTargetPlaceholder")}
value={newTargetName}
onChange={(e) => setNewTargetName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreateTarget(); }}
className="max-w-[250px]"
/>
<Button
onClick={handleCreateTarget}
disabled={!newTargetName.trim() || targetStatuses.includes(newTargetName.trim().toLowerCase())}
>
<Icon name="plus" size="sm" />
{t("settings.createTargetStatus")}
</Button>
</div>
{/* Grouped by target status */}
{targetStatuses.map((target) => {
const items = grouped.get(target) || [];
return (
<div key={target} className="border border-border/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-foreground">
{statusLabel(target)}
</span>
<span className="text-xs text-muted-foreground font-mono">({target})</span>
</div>
<div className="flex flex-wrap gap-2">
{items.map((m) => (
<span
key={m.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-muted/50 text-sm font-mono"
>
{m.provider_status}
<button
type="button"
onClick={() => handleDelete(m.id)}
className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
title={t("common.delete")}
>
<Icon name="x" size="sm" />
</button>
</span>
))}
</div>
</div>
);
})}
{/* Unmapped provider statuses */}
{unmappedProviderStatuses.length > 0 && (
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4>
<div className="space-y-2">
{unmappedProviderStatuses.map((ps) => (
<div key={ps} className="flex items-center gap-2">
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{ps}</span>
<Icon name="chevronRight" size="sm" />
<FormSelect
className="w-auto"
value=""
onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }}
>
<option value="">{t("settings.selectTargetStatus")}</option>
{targetStatuses.map((s) => (
<option key={s} value={s}>{statusLabel(s)}</option>
))}
</FormSelect>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -429,6 +429,28 @@ export async function getThumbnailStats() {
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
}
// Status mappings
export type StatusMappingDto = {
id: string;
provider_status: string;
mapped_status: string;
};
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
return apiFetch<StatusMappingDto[]>("/settings/status-mappings");
}
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
return apiFetch<StatusMappingDto>("/settings/status-mappings", {
method: "POST",
body: JSON.stringify({ provider_status, mapped_status }),
});
}
export async function deleteStatusMapping(id: string): Promise<void> {
await apiFetch<unknown>(`/settings/status-mappings/${id}`, { method: "DELETE" });
}
export async function convertBook(bookId: string) {
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
}

View File

@@ -445,6 +445,19 @@ const en: Record<TranslationKey, string> = {
"settings.comicvineHelp": "Get your key at",
"settings.freeProviders": "are free and do not require an API key.",
// Settings - Status Mappings
"settings.statusMappings": "Status mappings",
"settings.statusMappingsDesc": "Configure the mapping between provider statuses and database statuses. Multiple provider statuses can map to a single target status.",
"settings.targetStatus": "Target status",
"settings.providerStatuses": "Provider statuses",
"settings.addProviderStatus": "Add a provider status…",
"settings.noMappings": "No mappings configured",
"settings.unmappedSection": "Unmapped",
"settings.addMapping": "Add a mapping",
"settings.selectTargetStatus": "Select a target status",
"settings.newTargetPlaceholder": "New target status (e.g. hiatus)",
"settings.createTargetStatus": "Create status",
// Settings - Language
"settings.language": "Language",
"settings.languageDesc": "Choose the interface language",

View File

@@ -443,6 +443,19 @@ const fr = {
"settings.comicvineHelp": "Obtenez votre clé sur",
"settings.freeProviders": "sont gratuits et ne nécessitent pas de clé API.",
// Settings - Status Mappings
"settings.statusMappings": "Correspondance de statuts",
"settings.statusMappingsDesc": "Configurer la correspondance entre les statuts des fournisseurs et les statuts en base de données. Plusieurs statuts fournisseurs peuvent pointer vers un même statut cible.",
"settings.targetStatus": "Statut cible",
"settings.providerStatuses": "Statuts fournisseurs",
"settings.addProviderStatus": "Ajouter un statut fournisseur…",
"settings.noMappings": "Aucune correspondance configurée",
"settings.unmappedSection": "Non mappés",
"settings.addMapping": "Ajouter une correspondance",
"settings.selectTargetStatus": "Sélectionner un statut cible",
"settings.newTargetPlaceholder": "Nouveau statut cible (ex: hiatus)",
"settings.createTargetStatus": "Créer un statut",
// Settings - Language
"settings.language": "Langue",
"settings.languageDesc": "Choisir la langue de l'interface",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
-- Status mappings: many provider statuses → one target status (existing in series_metadata.status)
CREATE TABLE status_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_status TEXT NOT NULL UNIQUE,
mapped_status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Pre-populate with current hardcoded mappings from normalize_series_status
INSERT INTO status_mappings (provider_status, mapped_status) VALUES
-- AniList
('finished', 'ended'),
('releasing', 'ongoing'),
('not_yet_released', 'upcoming'),
('cancelled', 'cancelled'),
('hiatus', 'hiatus'),
-- Bédéthèque (French)
('finie', 'ended'),
('terminée', 'ended'),
('en cours', 'ongoing'),
('suspendue', 'hiatus'),
('annulée', 'cancelled'),
('arrêtée', 'cancelled');

View File

@@ -0,0 +1,19 @@
-- Re-normalize series_metadata.status using the status_mappings table.
-- Batch sync was not calling normalize_series_status before, so raw provider
-- values like "Série en cours" ended up in the DB alongside "ongoing".
-- Exact match
UPDATE series_metadata sm
SET status = m.mapped_status, updated_at = NOW()
FROM status_mappings m
WHERE LOWER(sm.status) = m.provider_status
AND sm.status IS NOT NULL
AND LOWER(sm.status) != m.mapped_status;
-- Substring match (for values like "Série en cours" containing "en cours")
UPDATE series_metadata sm
SET status = m.mapped_status, updated_at = NOW()
FROM status_mappings m
WHERE LOWER(sm.status) LIKE '%' || m.provider_status || '%'
AND sm.status IS NOT NULL
AND LOWER(sm.status) != m.mapped_status;