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(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let missing_cte = format!(
|
let missing_cte = r#"
|
||||||
r#"
|
|
||||||
missing_counts AS (
|
missing_counts AS (
|
||||||
SELECT eml.series_name,
|
SELECT eml.series_name,
|
||||||
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
|
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'
|
WHERE eml.library_id = $1 AND eml.status = 'approved'
|
||||||
GROUP BY eml.series_name
|
GROUP BY eml.series_name
|
||||||
)
|
)
|
||||||
"#
|
"#.to_string();
|
||||||
);
|
|
||||||
|
|
||||||
let metadata_links_cte = r#"
|
let metadata_links_cte = r#"
|
||||||
metadata_links AS (
|
metadata_links AS (
|
||||||
@@ -673,8 +671,7 @@ pub async fn list_all_series(
|
|||||||
|
|
||||||
// Missing counts CTE — needs library_id filter when filtering by library
|
// Missing counts CTE — needs library_id filter when filtering by library
|
||||||
let missing_cte = if query.library_id.is_some() {
|
let missing_cte = if query.library_id.is_some() {
|
||||||
format!(
|
r#"
|
||||||
r#"
|
|
||||||
missing_counts AS (
|
missing_counts AS (
|
||||||
SELECT eml.series_name, eml.library_id,
|
SELECT eml.series_name, eml.library_id,
|
||||||
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
|
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'
|
WHERE eml.library_id = $1 AND eml.status = 'approved'
|
||||||
GROUP BY eml.series_name, eml.library_id
|
GROUP BY eml.series_name, eml.library_id
|
||||||
)
|
)
|
||||||
"#
|
"#.to_string()
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
r#"
|
r#"
|
||||||
missing_counts AS (
|
missing_counts AS (
|
||||||
@@ -871,7 +867,37 @@ pub async fn series_statuses(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<String>>, ApiError> {
|
) -> Result<Json<Vec<String>>, ApiError> {
|
||||||
let rows: Vec<String> = sqlx::query_scalar(
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -154,10 +154,11 @@ pub async fn sync_komga_read_books(
|
|||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
type BookEntry = (Uuid, String, String);
|
||||||
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
|
// 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)>
|
// 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 {
|
for row in &rows {
|
||||||
let id: Uuid = row.get("id");
|
let id: Uuid = row.get("id");
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(books::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(books::ongoing_series))
|
||||||
.route("/series/statuses", get(books::series_statuses))
|
.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("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
.route("/stats", get(stats::get_stats))
|
.route("/stats", get(stats::get_stats))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
|
|||||||
@@ -693,10 +693,11 @@ pub(crate) async fn sync_series_metadata(
|
|||||||
.get("start_year")
|
.get("start_year")
|
||||||
.and_then(|y| y.as_i64())
|
.and_then(|y| y.as_i64())
|
||||||
.map(|y| y as i32);
|
.map(|y| y as i32);
|
||||||
let status = metadata_json
|
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
|
||||||
.get("status")
|
Some(normalize_series_status(&state.pool, raw).await)
|
||||||
.and_then(|s| s.as_str())
|
} else {
|
||||||
.map(normalize_series_status);
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch existing state before upsert
|
// Fetch existing state before upsert
|
||||||
let existing = sqlx::query(
|
let existing = sqlx::query(
|
||||||
@@ -775,7 +776,7 @@ pub(crate) async fn sync_series_metadata(
|
|||||||
let fields = vec![
|
let fields = vec![
|
||||||
FieldDef {
|
FieldDef {
|
||||||
name: "description",
|
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())),
|
new: description.map(|s| serde_json::Value::String(s.to_string())),
|
||||||
},
|
},
|
||||||
FieldDef {
|
FieldDef {
|
||||||
@@ -800,7 +801,7 @@ pub(crate) async fn sync_series_metadata(
|
|||||||
},
|
},
|
||||||
FieldDef {
|
FieldDef {
|
||||||
name: "status",
|
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())),
|
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)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize provider-specific status strings to a standard set:
|
/// Normalize provider-specific status strings using the status_mappings table.
|
||||||
/// "ongoing", "ended", "hiatus", "cancelled", or the original lowercase value
|
/// Falls back to the original lowercase value if no mapping is found.
|
||||||
fn normalize_series_status(raw: &str) -> String {
|
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> String {
|
||||||
let lower = raw.to_lowercase();
|
let lower = raw.to_lowercase();
|
||||||
match lower.as_str() {
|
|
||||||
// AniList
|
// Try exact match first
|
||||||
"finished" => "ended".to_string(),
|
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
|
||||||
"releasing" => "ongoing".to_string(),
|
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1",
|
||||||
"not_yet_released" => "upcoming".to_string(),
|
)
|
||||||
"cancelled" => "cancelled".to_string(),
|
.bind(&lower)
|
||||||
"hiatus" => "hiatus".to_string(),
|
.fetch_optional(pool)
|
||||||
// Bédéthèque
|
.await
|
||||||
_ if lower.contains("finie") || lower.contains("terminée") => "ended".to_string(),
|
{
|
||||||
_ if lower.contains("en cours") => "ongoing".to_string(),
|
return row;
|
||||||
_ 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 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(
|
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;
|
update_progress(pool, job_id, processed, total, series_name).await;
|
||||||
insert_result(
|
insert_result(
|
||||||
pool,
|
pool,
|
||||||
job_id,
|
&InsertResultParams {
|
||||||
library_id,
|
job_id,
|
||||||
series_name,
|
library_id,
|
||||||
"already_linked",
|
series_name,
|
||||||
None,
|
status: "already_linked",
|
||||||
false,
|
provider_used: None,
|
||||||
0,
|
fallback_used: false,
|
||||||
None,
|
candidates_count: 0,
|
||||||
None,
|
best_confidence: None,
|
||||||
None,
|
best_candidate_json: None,
|
||||||
Some("Unclassified series skipped"),
|
link_id: None,
|
||||||
|
error_message: Some("Unclassified series skipped"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
continue;
|
continue;
|
||||||
@@ -411,17 +413,19 @@ async fn process_metadata_batch(
|
|||||||
update_progress(pool, job_id, processed, total, series_name).await;
|
update_progress(pool, job_id, processed, total, series_name).await;
|
||||||
insert_result(
|
insert_result(
|
||||||
pool,
|
pool,
|
||||||
job_id,
|
&InsertResultParams {
|
||||||
library_id,
|
job_id,
|
||||||
series_name,
|
library_id,
|
||||||
"already_linked",
|
series_name,
|
||||||
None,
|
status: "already_linked",
|
||||||
false,
|
provider_used: None,
|
||||||
0,
|
fallback_used: false,
|
||||||
None,
|
candidates_count: 0,
|
||||||
None,
|
best_confidence: None,
|
||||||
None,
|
best_candidate_json: None,
|
||||||
None,
|
link_id: None,
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
continue;
|
continue;
|
||||||
@@ -577,17 +581,19 @@ async fn process_metadata_batch(
|
|||||||
|
|
||||||
insert_result(
|
insert_result(
|
||||||
pool,
|
pool,
|
||||||
job_id,
|
&InsertResultParams {
|
||||||
library_id,
|
job_id,
|
||||||
series_name,
|
library_id,
|
||||||
result_status,
|
series_name,
|
||||||
provider_used.as_deref(),
|
status: result_status,
|
||||||
fallback_used,
|
provider_used: provider_used.as_deref(),
|
||||||
candidates_count,
|
fallback_used,
|
||||||
best_confidence,
|
candidates_count,
|
||||||
best_candidate.as_ref(),
|
best_confidence,
|
||||||
link_id,
|
best_candidate_json: best_candidate.as_ref(),
|
||||||
error_msg.as_deref(),
|
link_id,
|
||||||
|
error_message: error_msg.as_deref(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -765,9 +771,12 @@ async fn sync_series_from_candidate(
|
|||||||
let publishers = &candidate.publishers;
|
let publishers = &candidate.publishers;
|
||||||
let start_year = candidate.start_year;
|
let start_year = candidate.start_year;
|
||||||
let total_volumes = candidate.total_volumes;
|
let total_volumes = candidate.total_volumes;
|
||||||
let status = candidate.metadata_json
|
let status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
|
||||||
.get("status")
|
Some(crate::metadata::normalize_series_status(pool, raw).await)
|
||||||
.and_then(|s| s.as_str());
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let status = status.as_deref();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -1070,20 +1079,21 @@ pub(crate) async fn update_progress(pool: &PgPool, job_id: Uuid, processed: i32,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_result(
|
struct InsertResultParams<'a> {
|
||||||
pool: &PgPool,
|
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
series_name: &str,
|
series_name: &'a str,
|
||||||
status: &str,
|
status: &'a str,
|
||||||
provider_used: Option<&str>,
|
provider_used: Option<&'a str>,
|
||||||
fallback_used: bool,
|
fallback_used: bool,
|
||||||
candidates_count: i32,
|
candidates_count: i32,
|
||||||
best_confidence: Option<f32>,
|
best_confidence: Option<f32>,
|
||||||
best_candidate_json: Option<&serde_json::Value>,
|
best_candidate_json: Option<&'a serde_json::Value>,
|
||||||
link_id: Option<Uuid>,
|
link_id: Option<Uuid>,
|
||||||
error_message: Option<&str>,
|
error_message: Option<&'a str>,
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
async fn insert_result(pool: &PgPool, params: &InsertResultParams<'_>) {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO metadata_batch_results
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(params.job_id)
|
||||||
.bind(library_id)
|
.bind(params.library_id)
|
||||||
.bind(series_name)
|
.bind(params.series_name)
|
||||||
.bind(status)
|
.bind(params.status)
|
||||||
.bind(provider_used)
|
.bind(params.provider_used)
|
||||||
.bind(fallback_used)
|
.bind(params.fallback_used)
|
||||||
.bind(candidates_count)
|
.bind(params.candidates_count)
|
||||||
.bind(best_confidence)
|
.bind(params.best_confidence)
|
||||||
.bind(best_candidate_json)
|
.bind(params.best_candidate_json)
|
||||||
.bind(link_id)
|
.bind(params.link_id)
|
||||||
.bind(error_message)
|
.bind(params.error_message)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async fn search_series_impl(
|
|||||||
let mut candidates: Vec<SeriesCandidate> = media
|
let mut candidates: Vec<SeriesCandidate> = media
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|m| {
|
.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_obj = m.get("title")?;
|
||||||
let title = title_obj
|
let title = title_obj
|
||||||
.get("english")
|
.get("english")
|
||||||
|
|||||||
@@ -497,6 +497,13 @@ async fn get_series_books_impl(
|
|||||||
}))
|
}))
|
||||||
.collect();
|
.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() {
|
for (idx, album_el) in doc.select(&album_sel).enumerate() {
|
||||||
// Title from <a class="titre" title="..."> — the title attribute is clean
|
// Title from <a class="titre" title="..."> — the title attribute is clean
|
||||||
let title_sel = Selector::parse("a.titre").ok();
|
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}-"
|
// Only keep main tomes — their URLs contain "Tome-{N}-"
|
||||||
// Skip hors-série (HS), intégrales (INT/INTFL), romans, coffrets, etc.
|
// 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_TOME.is_match(album_url) {
|
||||||
if !re.is_match(album_url) {
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let external_book_id = regex::Regex::new(r"-(\d+)\.html")
|
let external_book_id = RE_BOOK_ID
|
||||||
.ok()
|
.captures(album_url)
|
||||||
.and_then(|re| re.captures(album_url))
|
|
||||||
.map(|c| c[1].to_string())
|
.map(|c| c[1].to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
|
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
|
||||||
let volume_number = regex::Regex::new(r"(?i)Tome-(\d+)-")
|
let volume_number = RE_VOLUME
|
||||||
.ok()
|
.captures(album_url)
|
||||||
.and_then(|re| re.captures(album_url))
|
|
||||||
.and_then(|c| c[1].parse::<i32>().ok())
|
.and_then(|c| c[1].parse::<i32>().ok())
|
||||||
.or_else(|| extract_volume_from_title(&title));
|
.or_else(|| extract_volume_from_title(&title));
|
||||||
|
|
||||||
@@ -649,13 +652,13 @@ fn compute_confidence(title: &str, query: &str) -> f32 {
|
|||||||
return 1.0;
|
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
|
0.85
|
||||||
} else if title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm) {
|
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower)
|
||||||
0.85
|
|| title_norm.contains(&query_norm) || query_norm.contains(&title_norm)
|
||||||
} 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) {
|
|
||||||
0.7
|
0.7
|
||||||
} else {
|
} else {
|
||||||
let common: usize = query_lower
|
let common: usize = query_lower
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ async fn search_series_impl(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|vol| {
|
.filter_map(|vol| {
|
||||||
let name = vol.get("name").and_then(|n| n.as_str())?.to_string();
|
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
|
let description = vol
|
||||||
.get("description")
|
.get("description")
|
||||||
.and_then(|d| d.as_str())
|
.and_then(|d| d.as_str())
|
||||||
.map(|d| strip_html(d));
|
.map(strip_html);
|
||||||
let publisher = vol
|
let publisher = vol
|
||||||
.get("publisher")
|
.get("publisher")
|
||||||
.and_then(|p| p.get("name"))
|
.and_then(|p| p.get("name"))
|
||||||
@@ -180,7 +180,7 @@ async fn get_series_books_impl(
|
|||||||
let books: Vec<BookCandidate> = results
|
let books: Vec<BookCandidate> = results
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|issue| {
|
.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
|
let name = issue
|
||||||
.get("name")
|
.get("name")
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
@@ -194,7 +194,7 @@ async fn get_series_books_impl(
|
|||||||
let description = issue
|
let description = issue
|
||||||
.get("description")
|
.get("description")
|
||||||
.and_then(|d| d.as_str())
|
.and_then(|d| d.as_str())
|
||||||
.map(|d| strip_html(d));
|
.map(strip_html);
|
||||||
let cover_url = issue
|
let cover_url = issue
|
||||||
.get("image")
|
.get("image")
|
||||||
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
|
.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
|
let mut books: Vec<BookCandidate> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| volume_to_book_candidate(item))
|
.map(volume_to_book_candidate)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by volume number
|
// Sort by volume number
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ async fn search_series_impl(
|
|||||||
entry.publishers.push(p.clone());
|
entry.publishers.push(p.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entry.start_year.is_none() || first_publish_year.map_or(false, |y| entry.start_year.unwrap() > y) {
|
if (entry.start_year.is_none() || first_publish_year.is_some_and(|y| entry.start_year.unwrap() > y))
|
||||||
if first_publish_year.is_some() {
|
&& first_publish_year.is_some()
|
||||||
entry.start_year = first_publish_year;
|
{
|
||||||
}
|
entry.start_year = first_publish_year;
|
||||||
}
|
}
|
||||||
if entry.cover_url.is_none() {
|
if entry.cover_url.is_none() {
|
||||||
entry.cover_url = cover_url;
|
entry.cover_url = cover_url;
|
||||||
|
|||||||
@@ -574,9 +574,12 @@ async fn sync_series_with_diff(
|
|||||||
let new_publishers = &candidate.publishers;
|
let new_publishers = &candidate.publishers;
|
||||||
let new_start_year = candidate.start_year;
|
let new_start_year = candidate.start_year;
|
||||||
let new_total_volumes = candidate.total_volumes;
|
let new_total_volumes = candidate.total_volumes;
|
||||||
let new_status = candidate.metadata_json
|
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
|
||||||
.get("status")
|
Some(crate::metadata::normalize_series_status(pool, raw).await)
|
||||||
.and_then(|s| s.as_str());
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let new_status = new_status.as_deref();
|
||||||
|
|
||||||
// Fetch existing series metadata for diffing
|
// Fetch existing series metadata for diffing
|
||||||
let existing = sqlx::query(
|
let existing = sqlx::query(
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ use utoipa::OpenApi;
|
|||||||
crate::metadata::get_metadata_links,
|
crate::metadata::get_metadata_links,
|
||||||
crate::metadata::get_missing_books,
|
crate::metadata::get_missing_books,
|
||||||
crate::metadata::delete_metadata_link,
|
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(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -93,6 +98,8 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::ClearCacheResponse,
|
crate::settings::ClearCacheResponse,
|
||||||
crate::settings::CacheStats,
|
crate::settings::CacheStats,
|
||||||
crate::settings::ThumbnailStats,
|
crate::settings::ThumbnailStats,
|
||||||
|
crate::settings::StatusMappingDto,
|
||||||
|
crate::settings::UpsertStatusMappingRequest,
|
||||||
crate::stats::StatsResponse,
|
crate::stats::StatsResponse,
|
||||||
crate::stats::StatsOverview,
|
crate::stats::StatsOverview,
|
||||||
crate::stats::ReadingStatusStats,
|
crate::stats::ReadingStatusStats,
|
||||||
@@ -101,6 +108,8 @@ use utoipa::OpenApi;
|
|||||||
crate::stats::LibraryStats,
|
crate::stats::LibraryStats,
|
||||||
crate::stats::TopSeries,
|
crate::stats::TopSeries,
|
||||||
crate::stats::MonthlyAdditions,
|
crate::stats::MonthlyAdditions,
|
||||||
|
crate::stats::MetadataStats,
|
||||||
|
crate::stats::ProviderCount,
|
||||||
crate::metadata::ApproveRequest,
|
crate::metadata::ApproveRequest,
|
||||||
crate::metadata::ApproveResponse,
|
crate::metadata::ApproveResponse,
|
||||||
crate::metadata::SyncReport,
|
crate::metadata::SyncReport,
|
||||||
|
|||||||
@@ -277,7 +277,17 @@ pub async fn get_page(
|
|||||||
let cache_dir2 = cache_dir_path.clone();
|
let cache_dir2 = cache_dir_path.clone();
|
||||||
let format2 = format;
|
let format2 = format;
|
||||||
tokio::spawn(async move {
|
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).
|
struct PrefetchParams<'a> {
|
||||||
async fn prefetch_page(
|
|
||||||
state: AppState,
|
|
||||||
book_id: Uuid,
|
book_id: Uuid,
|
||||||
abs_path: &str,
|
abs_path: &'a str,
|
||||||
page: u32,
|
page: u32,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
quality: u8,
|
quality: u8,
|
||||||
width: u32,
|
width: u32,
|
||||||
filter: image::imageops::FilterType,
|
filter: image::imageops::FilterType,
|
||||||
timeout_secs: u64,
|
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());
|
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
|
||||||
// Already in memory cache?
|
// Already in memory cache?
|
||||||
if state.page_cache.lock().await.contains(&mem_key) {
|
if state.page_cache.lock().await.contains(&mem_key) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path as AxumPath, State},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
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/clear", post(clear_cache))
|
||||||
.route("/settings/cache/stats", get(get_cache_stats))
|
.route("/settings/cache/stats", get(get_cache_stats))
|
||||||
.route("/settings/thumbnail/stats", get(get_thumbnail_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
|
/// List all settings
|
||||||
@@ -324,3 +333,120 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
|
|||||||
|
|
||||||
Ok(Json(stats))
|
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 { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
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 { useTranslation } from "../../lib/i18n/context";
|
||||||
import type { Locale } from "../../lib/i18n/types";
|
import type { Locale } from "../../lib/i18n/types";
|
||||||
|
|
||||||
@@ -577,6 +577,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
{/* Metadata Providers */}
|
{/* Metadata Providers */}
|
||||||
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
|
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
|
||||||
|
{/* Status Mappings */}
|
||||||
|
<StatusMappingsCard />
|
||||||
|
|
||||||
{/* Komga Sync */}
|
{/* Komga Sync */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -988,3 +991,212 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
|||||||
</Card>
|
</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");
|
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) {
|
export async function convertBook(bookId: string) {
|
||||||
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,6 +445,19 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"settings.comicvineHelp": "Get your key at",
|
"settings.comicvineHelp": "Get your key at",
|
||||||
"settings.freeProviders": "are free and do not require an API key.",
|
"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
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.languageDesc": "Choose the interface language",
|
"settings.languageDesc": "Choose the interface language",
|
||||||
|
|||||||
@@ -443,6 +443,19 @@ const fr = {
|
|||||||
"settings.comicvineHelp": "Obtenez votre clé sur",
|
"settings.comicvineHelp": "Obtenez votre clé sur",
|
||||||
"settings.freeProviders": "sont gratuits et ne nécessitent pas de clé API.",
|
"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
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
"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