feat: conversion CBR → CBZ via job asynchrone

Ajoute la possibilité de convertir un livre CBR en CBZ depuis le backoffice.
La conversion est sécurisée : le CBR original n'est supprimé qu'après vérification
du CBZ généré et mise à jour de la base de données.

- parsers: nouvelle fn `convert_cbr_to_cbz` (unar extract → zip pack → vérification → rename atomique)
- api: `POST /books/:id/convert` crée un job `cbr_to_cbz` (vérifie format CBR, détecte collision)
- indexer: nouveau `converter.rs` dispatché depuis `job.rs`
- backoffice: bouton "Convert to CBZ" sur la page détail (visible si CBR), label dans JobRow
- migrations: colonne `book_id` sur `index_jobs` + type `cbr_to_cbz` dans le check constraint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 23:02:08 +01:00
parent e8bb014874
commit e0b80cae38
21 changed files with 821 additions and 16 deletions

View File

@@ -4,7 +4,7 @@ use sqlx::{PgPool, Row};
use tracing::{error, info};
use uuid::Uuid;
use crate::{analyzer, meili, scanner, AppState};
use crate::{analyzer, converter, meili, scanner, AppState};
pub async fn cleanup_stale_jobs(pool: &PgPool) -> Result<()> {
let result = sqlx::query(
@@ -137,10 +137,22 @@ pub async fn process_job(
) -> Result<()> {
info!("[JOB] Processing {} library={:?}", job_id, target_library_id);
let job_type: String = sqlx::query_scalar("SELECT type FROM index_jobs WHERE id = $1")
.bind(job_id)
.fetch_one(&state.pool)
.await?;
let (job_type, book_id): (String, Option<Uuid>) = {
let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1")
.bind(job_id)
.fetch_one(&state.pool)
.await?;
(row.get("type"), row.get("book_id"))
};
// CBR to CBZ conversion
if job_type == "cbr_to_cbz" {
let book_id = book_id.ok_or_else(|| {
anyhow::anyhow!("cbr_to_cbz job {} has no book_id", job_id)
})?;
converter::convert_book(state, job_id, book_id).await?;
return Ok(());
}
// Thumbnail rebuild: generate thumbnails for books missing them
if job_type == "thumbnail_rebuild" {