Compare commits

..

3 Commits

Author SHA1 Message Date
e6aa7ebed0 chore: bump version to 1.9.2
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 44s
2026-03-19 13:22:41 +01:00
c44b51d6ef fix: unmap status mappings instead of deleting, store unmapped provider statuses
- Make mapped_status nullable so unmapping (X button) sets NULL instead of
  deleting the row — provider statuses never disappear from the UI
- normalize_series_status now returns the raw provider status (lowercased)
  when no mapping exists, so all statuses are stored in series_metadata
- Fix series_statuses query crash caused by NULL mapped_status values
- Fix metadata batch/refresh server actions crashing page on 400 errors
- StatusMappingDto.mapped_status is now string | null in the backoffice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:22:31 +01:00
d4c48de780 chore: bump version to 1.9.1 2026-03-19 12:59:31 +01:00
14 changed files with 115 additions and 74 deletions

8
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api" name = "api"
version = "1.9.0" version = "1.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -1232,7 +1232,7 @@ dependencies = [
[[package]] [[package]]
name = "indexer" name = "indexer"
version = "1.9.0" version = "1.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1771,7 +1771,7 @@ dependencies = [
[[package]] [[package]]
name = "parsers" name = "parsers"
version = "1.9.0" version = "1.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flate2", "flate2",
@@ -2906,7 +2906,7 @@ dependencies = [
[[package]] [[package]]
name = "stripstream-core" name = "stripstream-core"
version = "1.9.0" version = "1.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",

View File

@@ -9,7 +9,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "1.9.0" version = "1.9.2"
license = "MIT" license = "MIT"
[workspace.dependencies] [workspace.dependencies]

View File

@@ -870,7 +870,7 @@ pub async fn series_statuses(
r#"SELECT DISTINCT s FROM ( r#"SELECT DISTINCT s FROM (
SELECT LOWER(status) AS s FROM series_metadata WHERE status IS NOT NULL SELECT LOWER(status) AS s FROM series_metadata WHERE status IS NOT NULL
UNION UNION
SELECT mapped_status AS s FROM status_mappings SELECT mapped_status AS s FROM status_mappings WHERE mapped_status IS NOT NULL
) t ORDER BY s"#, ) t ORDER BY s"#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)

View File

@@ -694,7 +694,7 @@ pub(crate) async fn sync_series_metadata(
.and_then(|y| y.as_i64()) .and_then(|y| y.as_i64())
.map(|y| y as i32); .map(|y| y as i32);
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) { let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
normalize_series_status(&state.pool, raw).await Some(normalize_series_status(&state.pool, raw).await)
} else { } else {
None None
}; };
@@ -802,7 +802,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(serde_json::Value::String), 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: &String| serde_json::Value::String(s.clone())),
}, },
]; ];
@@ -828,33 +828,33 @@ pub(crate) async fn sync_series_metadata(
/// Normalize provider-specific status strings using the status_mappings table. /// Normalize provider-specific status strings using the status_mappings table.
/// Returns None if no mapping is found — unknown statuses are not stored. /// Returns None if no mapping is found — unknown statuses are not stored.
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> Option<String> { pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> String {
let lower = raw.to_lowercase(); let lower = raw.to_lowercase();
// Try exact match first // Try exact match first (only mapped entries)
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>( if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1", "SELECT mapped_status FROM status_mappings WHERE provider_status = $1 AND mapped_status IS NOT NULL",
) )
.bind(&lower) .bind(&lower)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
{ {
return Some(row); return row;
} }
// Try substring match (for Bédéthèque-style statuses like "Série finie") // Try substring match (for Bédéthèque-style statuses like "Série finie")
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>( if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' LIMIT 1", "SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' AND mapped_status IS NOT NULL LIMIT 1",
) )
.bind(&lower) .bind(&lower)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
{ {
return Some(row); return row;
} }
// No mapping found — don't store unknown statuses // No mapping found — return the provider status as-is (lowercased)
None lower
} }
pub(crate) async fn sync_books_metadata( pub(crate) async fn sync_books_metadata(

View File

@@ -772,7 +772,7 @@ async fn sync_series_from_candidate(
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 = if let Some(raw) = 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()) {
crate::metadata::normalize_series_status(pool, raw).await Some(crate::metadata::normalize_series_status(pool, raw).await)
} else { } else {
None None
}; };

View File

@@ -575,7 +575,7 @@ async fn sync_series_with_diff(
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 = if let Some(raw) = 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()) {
crate::metadata::normalize_series_status(pool, raw).await Some(crate::metadata::normalize_series_status(pool, raw).await)
} else { } else {
None None
}; };

View File

@@ -342,7 +342,7 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
pub struct StatusMappingDto { pub struct StatusMappingDto {
pub id: String, pub id: String,
pub provider_status: String, pub provider_status: String,
pub mapped_status: String, pub mapped_status: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, ToSchema)] #[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -366,7 +366,7 @@ pub async fn list_status_mappings(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> { ) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status, provider_status", "SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -376,7 +376,7 @@ pub async fn list_status_mappings(
.map(|row| StatusMappingDto { .map(|row| StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(), id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"), provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"), mapped_status: row.get::<Option<String>, _>("mapped_status"),
}) })
.collect(); .collect();
@@ -418,18 +418,18 @@ pub async fn upsert_status_mapping(
Ok(Json(StatusMappingDto { Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(), id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"), provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"), mapped_status: row.get::<Option<String>, _>("mapped_status"),
})) }))
} }
/// Delete a status mapping /// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/settings/status-mappings/{id}", path = "/settings/status-mappings/{id}",
tag = "settings", tag = "settings",
params(("id" = String, Path, description = "Mapping UUID")), params(("id" = String, Path, description = "Mapping UUID")),
responses( responses(
(status = 204, description = "Deleted"), (status = 200, body = StatusMappingDto),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"), (status = 404, description = "Not found"),
), ),
@@ -438,15 +438,20 @@ pub async fn upsert_status_mapping(
pub async fn delete_status_mapping( pub async fn delete_status_mapping(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>, AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<StatusMappingDto>, ApiError> {
let result = sqlx::query("DELETE FROM status_mappings WHERE id = $1") let row = sqlx::query(
.bind(id) "UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
.execute(&state.pool) )
.await?; .bind(id)
.fetch_optional(&state.pool)
.await?;
if result.rows_affected() == 0 { match row {
return Err(ApiError::not_found("status mapping not found")); Some(row) => Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
})),
None => Err(ApiError::not_found("status mapping not found")),
} }
Ok(Json(serde_json::json!({"deleted": true})))
} }

View File

@@ -53,7 +53,13 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
"use server"; "use server";
const libraryId = formData.get("library_id") as string; const libraryId = formData.get("library_id") as string;
if (!libraryId) return; if (!libraryId) return;
const result = await startMetadataBatch(libraryId); let result;
try {
result = await startMetadataBatch(libraryId);
} catch {
// Library may have metadata disabled — ignore silently
return;
}
revalidatePath("/jobs"); revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`); redirect(`/jobs?highlight=${result.id}`);
} }
@@ -62,7 +68,12 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
"use server"; "use server";
const libraryId = formData.get("library_id") as string; const libraryId = formData.get("library_id") as string;
if (!libraryId) return; if (!libraryId) return;
const result = await startMetadataRefresh(libraryId); let result;
try {
result = await startMetadataRefresh(libraryId);
} catch {
return;
}
revalidatePath("/jobs"); revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`); redirect(`/jobs?highlight=${result.id}`);
} }

View File

@@ -80,7 +80,12 @@ export default async function LibrariesPage() {
async function batchMetadataAction(formData: FormData) { async function batchMetadataAction(formData: FormData) {
"use server"; "use server";
const id = formData.get("id") as string; const id = formData.get("id") as string;
await startMetadataBatch(id); try {
await startMetadataBatch(id);
} catch {
// Library may have metadata disabled — ignore silently
return;
}
revalidatePath("/libraries"); revalidatePath("/libraries");
revalidatePath("/jobs"); revalidatePath("/jobs");
} }

View File

@@ -1023,26 +1023,39 @@ function StatusMappingsCard() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
// Group mappings by target status // Group mappings by target status (only those with a non-null mapped_status)
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<string, StatusMappingDto[]>(); const map = new Map<string, StatusMappingDto[]>();
for (const m of mappings) { for (const m of mappings) {
const list = map.get(m.mapped_status) || []; if (m.mapped_status) {
list.push(m); const list = map.get(m.mapped_status) || [];
map.set(m.mapped_status, list); list.push(m);
map.set(m.mapped_status, list);
}
} }
return map; return map;
}, [mappings]); }, [mappings]);
// Provider statuses not yet mapped // Unmapped = mappings with null mapped_status + provider statuses not in status_mappings at all
const mappedProviderStatuses = useMemo( const knownProviderStatuses = useMemo(
() => new Set(mappings.map((m) => m.provider_status)), () => new Set(mappings.map((m) => m.provider_status)),
[mappings], [mappings],
); );
const unmappedProviderStatuses = useMemo( const unmappedMappings = useMemo(
() => providerStatuses.filter((ps) => !mappedProviderStatuses.has(ps)), () => mappings.filter((m) => !m.mapped_status),
[providerStatuses, mappedProviderStatuses], [mappings],
); );
const newProviderStatuses = useMemo(
() => providerStatuses.filter((ps) => !knownProviderStatuses.has(ps)),
[providerStatuses, knownProviderStatuses],
);
// All possible targets = existing statuses from DB + custom ones added locally
const [customTargets, setCustomTargets] = useState<string[]>([]);
const allTargets = useMemo(() => {
const set = new Set([...targetStatuses, ...customTargets]);
return [...set].sort();
}, [targetStatuses, customTargets]);
async function handleAssign(providerStatus: string, targetStatus: string) { async function handleAssign(providerStatus: string, targetStatus: string) {
if (!providerStatus || !targetStatus) return; if (!providerStatus || !targetStatus) return;
@@ -1061,27 +1074,12 @@ function StatusMappingsCard() {
} }
} }
async function handleDelete(id: string) { async function handleUnmap(id: string) {
try { try {
const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" }); 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) { if (res.ok) {
const updated: StatusMappingDto = await res.json(); const updated: StatusMappingDto = await res.json();
setMappings((prev) => prev.map((m) => (m.id === mapping.id ? updated : m))); setMappings((prev) => prev.map((m) => (m.id === id ? updated : m)));
} }
} catch { } catch {
// ignore // ignore
@@ -1090,8 +1088,8 @@ function StatusMappingsCard() {
function handleCreateTarget() { function handleCreateTarget() {
const name = newTargetName.trim().toLowerCase(); const name = newTargetName.trim().toLowerCase();
if (!name || targetStatuses.includes(name)) return; if (!name || allTargets.includes(name)) return;
setTargetStatuses((prev) => [...prev, name].sort()); setCustomTargets((prev) => [...prev, name]);
setNewTargetName(""); setNewTargetName("");
} }
@@ -1131,7 +1129,7 @@ function StatusMappingsCard() {
/> />
<Button <Button
onClick={handleCreateTarget} onClick={handleCreateTarget}
disabled={!newTargetName.trim() || targetStatuses.includes(newTargetName.trim().toLowerCase())} disabled={!newTargetName.trim() || allTargets.includes(newTargetName.trim().toLowerCase())}
> >
<Icon name="plus" size="sm" /> <Icon name="plus" size="sm" />
{t("settings.createTargetStatus")} {t("settings.createTargetStatus")}
@@ -1139,7 +1137,7 @@ function StatusMappingsCard() {
</div> </div>
{/* Grouped by target status */} {/* Grouped by target status */}
{targetStatuses.map((target) => { {allTargets.map((target) => {
const items = grouped.get(target) || []; const items = grouped.get(target) || [];
return ( return (
<div key={target} className="border border-border/50 rounded-lg p-3"> <div key={target} className="border border-border/50 rounded-lg p-3">
@@ -1158,7 +1156,7 @@ function StatusMappingsCard() {
{m.provider_status} {m.provider_status}
<button <button
type="button" type="button"
onClick={() => handleDelete(m.id)} onClick={() => handleUnmap(m.id)}
className="ml-1 text-muted-foreground hover:text-destructive transition-colors" className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
title={t("common.delete")} title={t("common.delete")}
> >
@@ -1166,17 +1164,36 @@ function StatusMappingsCard() {
</button> </button>
</span> </span>
))} ))}
{items.length === 0 && (
<span className="text-xs text-muted-foreground italic">{t("settings.noMappings")}</span>
)}
</div> </div>
</div> </div>
); );
})} })}
{/* Unmapped provider statuses */} {/* Unmapped provider statuses (null mapped_status + brand new from providers) */}
{unmappedProviderStatuses.length > 0 && ( {(unmappedMappings.length > 0 || newProviderStatuses.length > 0) && (
<div className="border-t border-border/50 pt-4"> <div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4> <h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4>
<div className="space-y-2"> <div className="space-y-2">
{unmappedProviderStatuses.map((ps) => ( {unmappedMappings.map((m) => (
<div key={m.id} className="flex items-center gap-2">
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{m.provider_status}</span>
<Icon name="chevronRight" size="sm" />
<FormSelect
className="w-auto"
value=""
onChange={(e) => { if (e.target.value) handleAssign(m.provider_status, e.target.value); }}
>
<option value="">{t("settings.selectTargetStatus")}</option>
{allTargets.map((s) => (
<option key={s} value={s}>{statusLabel(s)}</option>
))}
</FormSelect>
</div>
))}
{newProviderStatuses.map((ps) => (
<div key={ps} className="flex items-center gap-2"> <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> <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" /> <Icon name="chevronRight" size="sm" />
@@ -1186,7 +1203,7 @@ function StatusMappingsCard() {
onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }} onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }}
> >
<option value="">{t("settings.selectTargetStatus")}</option> <option value="">{t("settings.selectTargetStatus")}</option>
{targetStatuses.map((s) => ( {allTargets.map((s) => (
<option key={s} value={s}>{statusLabel(s)}</option> <option key={s} value={s}>{statusLabel(s)}</option>
))} ))}
</FormSelect> </FormSelect>

View File

@@ -433,7 +433,7 @@ export async function getThumbnailStats() {
export type StatusMappingDto = { export type StatusMappingDto = {
id: string; id: string;
provider_status: string; provider_status: string;
mapped_status: string; mapped_status: string | null;
}; };
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> { export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "stripstream-backoffice", "name": "stripstream-backoffice",
"version": "1.9.0", "version": "1.9.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 7082", "dev": "next dev -p 7082",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
-- Allow mapped_status to be NULL to represent "known but unmapped" provider statuses.
-- Clicking X in the UI will set mapped_status to NULL instead of deleting the row.
ALTER TABLE status_mappings ALTER COLUMN mapped_status DROP NOT NULL;