feat: add configurable status mappings for metadata providers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
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:
@@ -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?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
|
||||
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
11
apps/backoffice/app/api/series/statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/statuses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal file
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
24
infra/migrations/0038_add_status_mappings.sql
Normal file
24
infra/migrations/0038_add_status_mappings.sql
Normal 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');
|
||||
19
infra/migrations/0039_renormalize_series_status.sql
Normal file
19
infra/migrations/0039_renormalize_series_status.sql
Normal 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;
|
||||
Reference in New Issue
Block a user