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

@@ -5,7 +5,7 @@ use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState};
#[derive(Deserialize, ToSchema)]
pub struct ListBooksQuery {
@@ -341,6 +341,113 @@ pub async fn list_series(
}))
}
fn remap_libraries_path(path: &str) -> String {
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
if path.starts_with("/libraries/") {
return path.replacen("/libraries", &root, 1);
}
}
path.to_string()
}
fn unmap_libraries_path(path: &str) -> String {
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
if path.starts_with(&root) {
return path.replacen(&root, "/libraries", 1);
}
}
path.to_string()
}
/// Enqueue a CBR → CBZ conversion job for a single book
#[utoipa::path(
post,
path = "/books/{id}/convert",
tag = "books",
params(
("id" = String, Path, description = "Book UUID"),
),
responses(
(status = 200, body = IndexJobResponse),
(status = 404, description = "Book not found"),
(status = 409, description = "Book is not CBR, or target CBZ already exists"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn convert_book(
State(state): State<AppState>,
Path(book_id): Path<Uuid>,
) -> Result<Json<IndexJobResponse>, ApiError> {
// Fetch book file info
let row = sqlx::query(
r#"
SELECT b.id, bf.abs_path, bf.format
FROM books b
LEFT JOIN LATERAL (
SELECT abs_path, format
FROM book_files
WHERE book_id = b.id
ORDER BY updated_at DESC
LIMIT 1
) bf ON TRUE
WHERE b.id = $1
"#,
)
.bind(book_id)
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
let abs_path: Option<String> = row.get("abs_path");
let format: Option<String> = row.get("format");
if format.as_deref() != Some("cbr") {
return Err(ApiError {
status: axum::http::StatusCode::CONFLICT,
message: "book is not in CBR format".to_string(),
});
}
let abs_path = abs_path.ok_or_else(|| ApiError::not_found("book file path not found"))?;
// Check for existing CBZ with same stem
let physical_path = remap_libraries_path(&abs_path);
let cbr_path = std::path::Path::new(&physical_path);
if let (Some(parent), Some(stem)) = (cbr_path.parent(), cbr_path.file_stem()) {
let cbz_path = parent.join(format!("{}.cbz", stem.to_string_lossy()));
if cbz_path.exists() {
return Err(ApiError {
status: axum::http::StatusCode::CONFLICT,
message: format!(
"CBZ file already exists: {}",
unmap_libraries_path(&cbz_path.to_string_lossy())
),
});
}
}
// Create the conversion job
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO index_jobs (id, book_id, type, status) VALUES ($1, $2, 'cbr_to_cbz', 'pending')",
)
.bind(job_id)
.bind(book_id)
.execute(&state.pool)
.await?;
let job_row = sqlx::query(
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs WHERE id = $1",
)
.bind(job_id)
.fetch_one(&state.pool)
.await?;
Ok(Json(crate::index_jobs::map_row(job_row)))
}
use axum::{
body::Body,
http::{header, HeaderMap, HeaderValue, StatusCode},

View File

@@ -24,6 +24,8 @@ pub struct IndexJobResponse {
pub id: Uuid,
#[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>)]
pub book_id: Option<Uuid>,
pub r#type: String,
pub status: String,
#[schema(value_type = Option<String>)]
@@ -122,7 +124,7 @@ pub async fn enqueue_rebuild(
.await?;
let row = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs WHERE id = $1",
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs WHERE id = $1",
)
.bind(id)
.fetch_one(&state.pool)
@@ -145,7 +147,7 @@ pub async fn enqueue_rebuild(
)]
pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs ORDER BY created_at DESC LIMIT 100",
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs ORDER BY created_at DESC LIMIT 100",
)
.fetch_all(&state.pool)
.await?;
@@ -185,7 +187,7 @@ pub async fn cancel_job(
}
let row = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs WHERE id = $1",
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs WHERE id = $1",
)
.bind(id.0)
.fetch_one(&state.pool)
@@ -294,6 +296,7 @@ pub fn map_row(row: sqlx::postgres::PgRow) -> IndexJobResponse {
IndexJobResponse {
id: row.get("id"),
library_id: row.get("library_id"),
book_id: row.try_get("book_id").ok().flatten(),
r#type: row.get("type"),
status: row.get("status"),
started_at: row.get("started_at"),
@@ -339,9 +342,9 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse {
)]
pub async fn get_active_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files
FROM index_jobs
WHERE status IN ('pending', 'running', 'generating_thumbnails')
"SELECT id, library_id, book_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files
FROM index_jobs
WHERE status IN ('pending', 'running', 'generating_thumbnails')
ORDER BY created_at ASC"
)
.fetch_all(&state.pool)

View File

@@ -69,6 +69,7 @@ async fn main() -> anyhow::Result<()> {
.route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
.route("/books/:id/convert", axum::routing::post(books::convert_book))
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))

View File

@@ -8,6 +8,7 @@ use utoipa::OpenApi;
crate::books::get_book,
crate::books::get_thumbnail,
crate::books::list_series,
crate::books::convert_book,
crate::pages::get_page,
crate::search::search_books,
crate::index_jobs::enqueue_rebuild,

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { convertBook } from "@/lib/api";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ bookId: string }> }
) {
const { bookId } = await params;
try {
const data = await convertBook(bookId);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start conversion";
const status = message.includes("409") ? 409 : 500;
return NextResponse.json({ error: message }, { status });
}
}

View File

@@ -1,5 +1,6 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -115,7 +116,10 @@ export default async function BookDetailPage({
{book.file_format && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">File Format:</span>
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
<div className="flex items-center gap-3">
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
</div>
</div>
)}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "./ui";
interface ConvertButtonProps {
bookId: string;
}
type ConvertState =
| { type: "idle" }
| { type: "loading" }
| { type: "success"; jobId: string }
| { type: "error"; message: string };
export function ConvertButton({ bookId }: ConvertButtonProps) {
const [state, setState] = useState<ConvertState>({ type: "idle" });
const handleConvert = async () => {
setState({ type: "loading" });
try {
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
setState({ type: "error", message: body.error || "Conversion failed" });
return;
}
const job = await res.json();
setState({ type: "success", jobId: job.id });
} catch (err) {
setState({ type: "error", message: err instanceof Error ? err.message : "Unknown error" });
}
};
if (state.type === "success") {
return (
<div className="flex items-center gap-2 text-sm text-success">
<span>Conversion started.</span>
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
View job
</Link>
</div>
);
}
if (state.type === "error") {
return (
<div className="flex flex-col gap-1">
<span className="text-sm text-destructive">{state.message}</span>
<button
className="text-xs text-muted-foreground hover:underline text-left"
onClick={() => setState({ type: "idle" })}
>
Dismiss
</button>
</div>
);
}
return (
<Button
variant="secondary"
size="sm"
onClick={handleConvert}
disabled={state.type === "loading"}
>
{state.type === "loading" ? "Converting…" : "Convert to CBZ"}
</Button>
);
}

View File

@@ -93,7 +93,9 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<td className="px-4 py-3 text-sm text-foreground">
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
</td>
<td className="px-4 py-3 text-sm text-foreground">{job.type}</td>
<td className="px-4 py-3 text-sm text-foreground">
{job.type === "cbr_to_cbz" ? "CBR → CBZ" : job.type}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={job.status} />

View File

@@ -13,6 +13,7 @@ export type LibraryDto = {
export type IndexJobDto = {
id: string;
library_id: string | null;
book_id: string | null;
type: string;
status: string;
started_at: string | null;
@@ -348,3 +349,7 @@ export async function clearCache() {
export async function getThumbnailStats() {
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
}
export async function convertBook(bookId: string) {
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
}

View File

@@ -0,0 +1,108 @@
use anyhow::Result;
use sqlx::Row;
use tracing::{info, warn};
use uuid::Uuid;
use crate::{utils, AppState};
/// Execute a `cbr_to_cbz` job for the given `book_id`.
///
/// Flow:
/// 1. Read book file info from DB
/// 2. Resolve physical path
/// 3. Convert CBR → CBZ via `parsers::convert_cbr_to_cbz`
/// 4. Update `book_files` and `books` in DB
/// 5. Delete the original CBR (failure here does not fail the job)
/// 6. Mark job as success
pub async fn convert_book(state: &AppState, job_id: Uuid, book_id: Uuid) -> Result<()> {
info!("[CONVERTER] Starting CBR→CBZ conversion for book {} (job {})", book_id, job_id);
// Fetch current file info
let row = sqlx::query(
r#"
SELECT bf.id as file_id, bf.abs_path, bf.format
FROM book_files bf
WHERE bf.book_id = $1
ORDER BY bf.updated_at DESC
LIMIT 1
"#,
)
.bind(book_id)
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or_else(|| anyhow::anyhow!("no book file found for book {}", book_id))?;
let file_id: Uuid = row.get("file_id");
let abs_path: String = row.get("abs_path");
let format: String = row.get("format");
if format != "cbr" {
return Err(anyhow::anyhow!(
"book {} is not CBR (format={}), skipping conversion",
book_id,
format
));
}
let physical_path = utils::remap_libraries_path(&abs_path);
let cbr_path = std::path::Path::new(&physical_path);
info!("[CONVERTER] Converting {} → CBZ", cbr_path.display());
// Update job status to running (already set by claim_next_job, this updates current_file)
sqlx::query(
"UPDATE index_jobs SET current_file = $2 WHERE id = $1",
)
.bind(job_id)
.bind(&abs_path)
.execute(&state.pool)
.await?;
// Do the conversion
let cbz_path = parsers::convert_cbr_to_cbz(cbr_path)?;
info!("[CONVERTER] CBZ created at {}", cbz_path.display());
// Remap physical path back to /libraries/ canonical form
let new_abs_path = utils::unmap_libraries_path(&cbz_path.to_string_lossy());
// Update book_files: abs_path + format
sqlx::query(
"UPDATE book_files SET abs_path = $2, format = 'cbz', updated_at = NOW() WHERE id = $1",
)
.bind(file_id)
.bind(&new_abs_path)
.execute(&state.pool)
.await?;
// Update books: kind stays 'comic', updated_at refreshed
sqlx::query("UPDATE books SET updated_at = NOW() WHERE id = $1")
.bind(book_id)
.execute(&state.pool)
.await?;
info!("[CONVERTER] DB updated for book {}", book_id);
// Delete the original CBR file (best-effort)
if let Err(e) = std::fs::remove_file(cbr_path) {
warn!(
"[CONVERTER] Could not delete original CBR {}: {} (non-fatal)",
cbr_path.display(),
e
);
} else {
info!("[CONVERTER] Deleted original CBR {}", cbr_path.display());
}
// Mark job success
sqlx::query(
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1",
)
.bind(job_id)
.execute(&state.pool)
.await?;
info!("[CONVERTER] Job {} completed successfully", job_id);
Ok(())
}

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" {

View File

@@ -1,6 +1,7 @@
pub mod analyzer;
pub mod api;
pub mod batch;
pub mod converter;
pub mod job;
pub mod meili;
pub mod scheduler;

View File

@@ -1,6 +1,6 @@
use anyhow::{Context, Result};
use std::io::Read;
use std::path::Path;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use uuid::Uuid;
@@ -445,6 +445,141 @@ fn extract_pdf_first_page(path: &Path) -> Result<Vec<u8>> {
Ok(data)
}
/// Convert a CBR file to CBZ in-place (same directory, same stem).
///
/// The conversion is safe: a `.cbz.tmp` file is written first, verified, then
/// atomically renamed to `.cbz`. The original CBR is **not** deleted by this
/// function — the caller is responsible for removing it after a successful DB
/// update.
///
/// Returns the path of the newly created `.cbz` file.
///
/// # Errors
/// - Returns an error if a `.cbz` file with the same stem already exists.
/// - Returns an error if extraction, packing, or verification fails.
/// - Returns an error if `cbr_path` has no parent directory or no file stem.
pub fn convert_cbr_to_cbz(cbr_path: &Path) -> Result<PathBuf> {
let parent = cbr_path
.parent()
.with_context(|| format!("no parent directory for {}", cbr_path.display()))?;
let stem = cbr_path
.file_stem()
.with_context(|| format!("no file stem for {}", cbr_path.display()))?;
let cbz_path = parent.join(format!("{}.cbz", stem.to_string_lossy()));
let tmp_path = parent.join(format!("{}.cbz.tmp", stem.to_string_lossy()));
// Refuse if target CBZ already exists
if cbz_path.exists() {
return Err(anyhow::anyhow!(
"CBZ file already exists: {}",
cbz_path.display()
));
}
// Extract CBR to a temp dir
let tmp_dir =
std::env::temp_dir().join(format!("stripstream-cbr-convert-{}", Uuid::new_v4()));
std::fs::create_dir_all(&tmp_dir).context("cannot create temp dir")?;
let output = std::process::Command::new("env")
.args(["LC_ALL=en_US.UTF-8", "LANG=en_US.UTF-8", "unar", "-o"])
.arg(&tmp_dir)
.arg(cbr_path)
.output()
.context("unar failed to start")?;
if !output.status.success() {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(anyhow::anyhow!(
"unar extraction failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// Collect and sort image files
let mut image_files: Vec<_> = WalkDir::new(&tmp_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().to_lowercase();
is_image_name(&name)
})
.collect();
image_files.sort_by_key(|e| e.path().to_string_lossy().to_lowercase());
let image_count = image_files.len();
if image_count == 0 {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(anyhow::anyhow!(
"no images found in CBR: {}",
cbr_path.display()
));
}
// Pack images into the .cbz.tmp file
let pack_result = (|| -> Result<()> {
let cbz_file = std::fs::File::create(&tmp_path)
.with_context(|| format!("cannot create {}", tmp_path.display()))?;
let mut zip = zip::ZipWriter::new(cbz_file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in &image_files {
let file_name = entry.file_name().to_string_lossy().to_string();
zip.start_file(&file_name, options)
.with_context(|| format!("cannot add file {} to zip", file_name))?;
let data = std::fs::read(entry.path())
.with_context(|| format!("cannot read {}", entry.path().display()))?;
zip.write_all(&data)
.with_context(|| format!("cannot write {} to zip", file_name))?;
}
zip.finish().context("cannot finalize zip")?;
Ok(())
})();
let _ = std::fs::remove_dir_all(&tmp_dir);
if let Err(err) = pack_result {
let _ = std::fs::remove_file(&tmp_path);
return Err(err);
}
// Verify the CBZ contains the expected number of images
let verify_result = (|| -> Result<()> {
let file = std::fs::File::open(&tmp_path)
.with_context(|| format!("cannot open {}", tmp_path.display()))?;
let archive = zip::ZipArchive::new(file).context("invalid zip archive")?;
let packed_count = (0..archive.len())
.filter(|&i| {
archive
.name_for_index(i)
.map(|n| is_image_name(&n.to_ascii_lowercase()))
.unwrap_or(false)
})
.count();
if packed_count != image_count {
return Err(anyhow::anyhow!(
"CBZ verification failed: expected {} images, found {}",
image_count,
packed_count
));
}
Ok(())
})();
if let Err(err) = verify_result {
let _ = std::fs::remove_file(&tmp_path);
return Err(err);
}
// Atomic rename .cbz.tmp → .cbz
std::fs::rename(&tmp_path, &cbz_path)
.with_context(|| format!("cannot rename {} to {}", tmp_path.display(), cbz_path.display()))?;
Ok(cbz_path)
}
#[allow(dead_code)]
fn clean_title(filename: &str) -> String {
let cleaned = regex::Regex::new(r"(?i)\s*T\d+\s*")

View File

@@ -0,0 +1,2 @@
ALTER TABLE index_jobs
ADD COLUMN book_id UUID NULL REFERENCES books(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE index_jobs
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
ADD CONSTRAINT index_jobs_type_check
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz'));

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-09

View File

@@ -0,0 +1,79 @@
## Context
Stripstream Librarian stocke des BDs/ebooks en CBR, CBZ et PDF. CBR (RAR) nécessite `unrar` (outil non-libre) pour toute opération. CBZ est un ZIP — format standard open-source, universellement supporté. Le parseur utilise déjà `unar` pour extraire les CBR et `zip` crate pour lire les CBZ. La conversion CBR→CBZ réutilise donc l'infrastructure existante.
Le système de jobs (`index_jobs`) est le mécanisme standard pour les opérations longues (rebuild, thumbnail). La conversion s'y intègre naturellement.
## Goals / Non-Goals
**Goals:**
- Convertir un livre CBR individuel en CBZ via un job asynchrone
- Garantir l'intégrité : le CBR n'est supprimé qu'après vérification du CBZ
- Exposer un endpoint API `POST /books/:id/convert`
- Afficher un bouton de conversion sur la page détail du livre (si CBR)
- Suivre la progression dans la liste des jobs existante
**Non-Goals:**
- Conversion batch (toute une bibliothèque) — déferred
- Conversion PDF→CBZ ou CBZ→CBR
- Re-indexation Meilisearch forcée après conversion
- Regénération du thumbnail après conversion (les images sont identiques)
## Decisions
### D1 : Stocker le `book_id` cible dans `index_jobs`
**Décision** : Ajouter une colonne `book_id UUID NULL` à `index_jobs` via migration.
**Alternatives considérées** :
- Stocker dans `stats_json` (champ JSON existant) → pas typé, query difficile
- Passer via un paramètre de route dans l'indexer → pas possible, l'indexer poll la DB
**Rationale** : Colonne typée, indexable, requêtable proprement. La migration est simple.
### D2 : Logique de conversion dans `crates/parsers`
**Décision** : Nouvelle fonction publique `convert_cbr_to_cbz(cbr_path: &Path) -> Result<PathBuf>` dans `crates/parsers`.
**Alternatives considérées** :
- Dans `apps/indexer/src/converter.rs` directement → moins réutilisable, difficile à tester isolément
- Appel shell `unar` + `zip` sans abstraction → identique à l'existant, cohérent
**Rationale** : Les parsers contiennent déjà toute la logique I/O archive. La fonction sera testable sans démarrer l'indexer.
### D3 : Fichier temporaire `.cbz.tmp` dans le même dossier
**Décision** : Créer `{stem}.cbz.tmp` dans le dossier parent du CBR, puis `rename()`.
**Alternatives considérées** :
- `tmp_dir` système → rename cross-device impossible (EXDEV), nécessite une copie
- Dossier temp dans le même filesystem → moins simple, idem pour le rename
**Rationale** : `rename()` sur le même filesystem est atomique. Même dossier garantit même device.
### D4 : Collision de nom → refus explicite
**Décision** : Si `{stem}.cbz` existe déjà → le job échoue avec une erreur claire, CBR intact.
**Alternatives considérées** :
- Écraser le CBZ existant → dangereux si l'utilisateur a édité le CBZ manuellement
- Renommer avec suffixe (`-converted.cbz`) → confusant pour la bibliothèque
**Rationale** : Fail-safe. L'utilisateur doit résoudre le conflit manuellement.
### D5 : Chemin DB avec LIBRARIES_ROOT_PATH remapping
**Décision** : Le chemin en DB commence par `/libraries/`. La conversion utilise le même remapping que l'indexer (`config.libraries_root_path`) pour résoudre le chemin physique. Après conversion, mettre à jour `file_path` (`.cbr``.cbz`) et `file_format` = `'cbz'`.
## Risks / Trade-offs
- **Chemin temp sur même device** → Si `LIBRARIES_ROOT_PATH` pointe vers un mount différent du `/tmp`, le rename sera cross-device. Mitigation : écrire `.cbz.tmp` dans le dossier parent du CBR, pas dans `/tmp`.
- **Processus concurrent** → Un job de rebuild lancé pendant la conversion pourrait scanner le fichier `.cbz.tmp`. Mitigation : le `.tmp` n'a pas d'extension reconnue (`detect_format` retourne `None`), il sera ignoré.
- **Fichier CBR ouvert par un lecteur** → Suppression du CBR possible sur Linux même si ouvert. Sur macOS/Windows, la suppression pourrait échouer. Mitigation : log l'erreur de suppression mais ne pas faire échouer le job (le CBZ est déjà valide et en DB).
- **Annulation du job** → Si le job est annulé pendant la conversion, le `.cbz.tmp` peut rester sur le disque. Mitigation : le worker nettoie les fichiers `.cbz.tmp` orphelins au démarrage (ou on documente le risque).
## Migration Plan
1. Appliquer `0013_add_book_id_to_index_jobs.sql`
2. Déployer API + Indexer simultanément (pas de breaking change — `book_id` est NULL pour les anciens types de jobs)
3. Rollback : la colonne `book_id` est nullable, les anciens jobs ne sont pas affectés

View File

@@ -0,0 +1,30 @@
## Why
CBR est un format RAR propriétaire qui nécessite des outils tiers non-libres (`unrar`) pour la lecture. CBZ est un simple ZIP — standard ouvert, mieux supporté par les lecteurs et les outils. Permettre la conversion en backoffice évite une manipulation manuelle des fichiers et garantit une bibliothèque homogène.
## What Changes
- Nouvelle fonction `convert_cbr_to_cbz()` dans `crates/parsers`
- Nouveau endpoint API `POST /books/:id/convert` qui crée un job de conversion
- Nouveau type de job `cbr_to_cbz` traité par l'indexer
- Bouton "Convert to CBZ" sur la page détail d'un livre (visible uniquement si `file_format == 'cbr'`)
- Affichage du type `cbr_to_cbz` dans la liste des jobs du backoffice
## Capabilities
### New Capabilities
- `cbr-conversion`: Conversion d'un fichier CBR en CBZ via un job asynchrone, avec suppression sécurisée du CBR uniquement après vérification du CBZ généré.
### Modified Capabilities
<!-- Aucune spec existante n'est modifiée -->
## Impact
- **crates/parsers** : nouvelle fonction publique `convert_cbr_to_cbz`
- **apps/api** : nouveau endpoint `POST /books/:id/convert`, nouveau type de job dans `index_jobs.rs`
- **apps/indexer** : nouveau `converter.rs`, dispatch dans `worker.rs`
- **apps/backoffice** : page détail livre + liste des jobs
- **infra/migrations** : migration `0013_add_book_id_to_index_jobs.sql` pour stocker la cible du job
- **Dépendances système** : `unar` (déjà requis) + `zip` crate (déjà présent via CBZ)

View File

@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Demande de conversion d'un livre CBR
L'API SHALL exposer un endpoint `POST /books/:id/convert` qui crée un job de type `cbr_to_cbz` pour le livre spécifié. L'endpoint SHALL retourner une erreur `409 Conflict` si le livre n'est pas au format CBR. L'endpoint SHALL retourner une erreur `409 Conflict` si un fichier `{stem}.cbz` existe déjà au même emplacement que le CBR.
#### Scenario: Conversion demandée sur un livre CBR
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` sur un livre avec `file_format = 'cbr'`
- **THEN** un job `cbr_to_cbz` est créé en statut `pending` avec `book_id` = id du livre
- **THEN** la réponse HTTP 200 contient le job créé
#### Scenario: Conversion demandée sur un livre non-CBR
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` sur un livre avec `file_format != 'cbr'`
- **THEN** l'API retourne HTTP 409 avec un message d'erreur explicite
#### Scenario: CBZ déjà présent au même emplacement
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` et qu'un fichier `{stem}.cbz` existe déjà sur le disque
- **THEN** l'API retourne HTTP 409 avec un message indiquant le conflit de fichier
### Requirement: Exécution sécurisée de la conversion
L'indexer SHALL exécuter la conversion CBR→CBZ de manière sécurisée : le CBR original SHALL être supprimé uniquement après que le CBZ a été créé, vérifié, et que la base de données a été mise à jour. En cas d'échec à n'importe quelle étape, le CBR SHALL rester intact et le job SHALL passer en statut `failed`.
#### Scenario: Conversion réussie
- **WHEN** l'indexer traite un job `cbr_to_cbz`
- **THEN** il extrait les images du CBR vers un dossier temporaire
- **THEN** il crée `{stem}.cbz.tmp` dans le même dossier que le CBR
- **THEN** il vérifie que le CBZ contient le même nombre d'images que le CBR original
- **THEN** il renomme `{stem}.cbz.tmp``{stem}.cbz`
- **THEN** il met à jour `books.file_path` et `books.file_format = 'cbz'` en DB
- **THEN** il supprime le fichier CBR original
- **THEN** le job passe en statut `success`
#### Scenario: Échec pendant la création du CBZ
- **WHEN** une erreur survient avant la mise à jour DB (extraction, pack, vérification)
- **THEN** le fichier `.cbz.tmp` est supprimé si présent
- **THEN** le CBR original reste intact
- **THEN** le job passe en statut `failed` avec un message d'erreur
#### Scenario: Échec de la suppression du CBR après conversion réussie
- **WHEN** la suppression du CBR échoue après que le CBZ est valide et la DB mise à jour
- **THEN** le job passe quand même en statut `success`
- **THEN** l'erreur de suppression est loguée en avertissement
### Requirement: Vérification du CBZ généré
Le système SHALL vérifier l'intégrité du CBZ créé avant de modifier la base de données. La vérification SHALL confirmer que le nombre d'images dans le CBZ est égal au nombre d'images dans le CBR source.
#### Scenario: CBZ valide avec le bon nombre d'images
- **WHEN** le CBZ est créé avec N images
- **THEN** l'ouverture du ZIP et le décompte des entrées image retourne N
- **THEN** la vérification passe et la conversion continue
#### Scenario: CBZ invalide (décompte incorrect)
- **WHEN** le CBZ créé contient un nombre d'images différent du CBR source
- **THEN** la vérification échoue
- **THEN** le fichier `.cbz.tmp` est supprimé
- **THEN** le job échoue avec une erreur de vérification
### Requirement: Mise à jour de la base de données après conversion
L'indexer SHALL mettre à jour le livre en base de données après une conversion réussie : `file_path` SHALL être mis à jour (`.cbr``.cbz`), `file_format` SHALL être mis à jour à `'cbz'`.
#### Scenario: Mise à jour DB réussie
- **WHEN** le CBZ est vérifié et renommé
- **THEN** `books.file_path` est mis à jour pour pointer vers le nouveau fichier `.cbz`
- **THEN** `books.file_format` est mis à jour à `'cbz'`
- **THEN** `books.updated_at` est mis à jour
### Requirement: Bouton de conversion dans le backoffice
Le backoffice SHALL afficher un bouton "Convert to CBZ" sur la page détail d'un livre, visible uniquement si `book.file_format === 'cbr'`. Le clic SHALL appeler `POST /books/:id/convert` et SHALL afficher un feedback (succès ou erreur).
#### Scenario: Affichage du bouton sur un livre CBR
- **WHEN** l'utilisateur consulte la page détail d'un livre avec `file_format = 'cbr'`
- **THEN** le bouton "Convert to CBZ" est visible
#### Scenario: Bouton absent sur un livre non-CBR
- **WHEN** l'utilisateur consulte la page détail d'un livre avec `file_format != 'cbr'`
- **THEN** aucun bouton de conversion n'est affiché
#### Scenario: Conversion lancée depuis le bouton
- **WHEN** l'utilisateur clique sur "Convert to CBZ"
- **THEN** l'API est appelée et un job est créé
- **THEN** un message de confirmation avec le lien vers le job est affiché

View File

@@ -0,0 +1,61 @@
## 1. Migration DB
- [x] 1.1 Créer `infra/migrations/0013_add_book_id_to_index_jobs.sql` : ajouter colonne `book_id UUID NULL REFERENCES books(id) ON DELETE SET NULL` à `index_jobs`
- [x] 1.2 Mettre à jour `sqlx-data.json` / préparer les queries sqlx si nécessaire
## 2. Parsers — Fonction de conversion
- [x] 2.1 Ajouter `pub fn convert_cbr_to_cbz(cbr_path: &Path) -> Result<PathBuf>` dans `crates/parsers/src/lib.rs`
- Extraire le CBR vers `tmp_dir` avec `unar`
- Lister et trier les images extraites
- Créer `{stem}.cbz.tmp` dans le dossier parent du CBR avec la `zip` crate
- Vérifier que le CBZ contient le même nombre d'images que le CBR
- Renommer `.cbz.tmp``.cbz`
- Nettoyer `tmp_dir`
- Retourner le chemin du CBZ créé
- [x] 2.2 Ajouter la gestion d'erreur : collision de fichier existant, échec unar, échec zip, décompte incorrect
## 3. API — Endpoint de conversion
- [x] 3.1 Dans `apps/api/src/books.rs`, ajouter le handler `POST /books/:id/convert`
- Vérifier que le livre existe
- Vérifier que `file_format == 'cbr'`
- Résoudre le chemin physique avec `LIBRARIES_ROOT_PATH`
- Vérifier qu'aucun fichier `{stem}.cbz` n'existe déjà
- Insérer un job `cbr_to_cbz` avec `book_id` en DB
- Retourner le job créé
- [x] 3.2 Enregistrer la route dans `apps/api/src/main.rs`
- [x] 3.3 Ajouter les annotations `#[utoipa::path]` pour OpenAPI
- [x] 3.4 Mettre à jour `IndexJobResponse` / `map_row` dans `index_jobs.rs` pour inclure `book_id` (le champ est nullable)
## 4. Indexer — Worker de conversion
- [x] 4.1 Créer `apps/indexer/src/converter.rs` avec la fonction `convert_book(job_id, book_id, pool, config)`
- Lire le livre en DB (file_path, file_format)
- Résoudre le chemin physique
- Appeler `parsers::convert_cbr_to_cbz()`
- Mettre à jour `books.file_path`, `books.file_format`, `books.updated_at` en DB
- Supprimer le CBR (log warning si la suppression échoue, ne pas faire échouer le job)
- Mettre à jour le statut du job (`success` ou `failed`)
- [x] 4.2 Dans `apps/indexer/src/worker.rs`, ajouter le dispatch pour le type `cbr_to_cbz`
- [x] 4.3 Ajouter `converter` dans `apps/indexer/src/lib.rs`
## 5. Backoffice — Bouton de conversion
- [x] 5.1 Créer `apps/backoffice/app/components/ConvertButton.tsx` (composant client)
- Appelle `POST /books/:id/convert` via `apiFetch`
- Affiche un état de chargement pendant l'appel
- En succès : affiche un message avec lien vers le job créé
- En erreur : affiche le message d'erreur de l'API
- [x] 5.2 Intégrer `ConvertButton` dans `apps/backoffice/app/books/[id]/page.tsx`
- Visible uniquement si `book.file_format === 'cbr'`
- Placer dans la section des métadonnées ou comme action en haut de page
- [x] 5.3 Mettre à jour `JobRow.tsx` pour afficher le type `cbr_to_cbz` correctement (label lisible)
## 6. Vérification
- [x] 6.1 `cargo build` passe sans erreurs ni warnings clippy
- [ ] 6.2 Tester manuellement la conversion d'un livre CBR depuis la page détail
- [ ] 6.3 Vérifier que le CBR est supprimé et que `file_format` en DB est bien `'cbz'`
- [ ] 6.4 Vérifier le cas d'erreur : tenter de convertir un livre CBZ → 409
- [ ] 6.5 Vérifier le cas d'erreur : CBZ déjà présent → 409

View File

@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Demande de conversion d'un livre CBR
L'API SHALL exposer un endpoint `POST /books/:id/convert` qui crée un job de type `cbr_to_cbz` pour le livre spécifié. L'endpoint SHALL retourner une erreur `409 Conflict` si le livre n'est pas au format CBR. L'endpoint SHALL retourner une erreur `409 Conflict` si un fichier `{stem}.cbz` existe déjà au même emplacement que le CBR.
#### Scenario: Conversion demandée sur un livre CBR
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` sur un livre avec `file_format = 'cbr'`
- **THEN** un job `cbr_to_cbz` est créé en statut `pending` avec `book_id` = id du livre
- **THEN** la réponse HTTP 200 contient le job créé
#### Scenario: Conversion demandée sur un livre non-CBR
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` sur un livre avec `file_format != 'cbr'`
- **THEN** l'API retourne HTTP 409 avec un message d'erreur explicite
#### Scenario: CBZ déjà présent au même emplacement
- **WHEN** l'utilisateur envoie `POST /books/:id/convert` et qu'un fichier `{stem}.cbz` existe déjà sur le disque
- **THEN** l'API retourne HTTP 409 avec un message indiquant le conflit de fichier
### Requirement: Exécution sécurisée de la conversion
L'indexer SHALL exécuter la conversion CBR→CBZ de manière sécurisée : le CBR original SHALL être supprimé uniquement après que le CBZ a été créé, vérifié, et que la base de données a été mise à jour. En cas d'échec à n'importe quelle étape, le CBR SHALL rester intact et le job SHALL passer en statut `failed`.
#### Scenario: Conversion réussie
- **WHEN** l'indexer traite un job `cbr_to_cbz`
- **THEN** il extrait les images du CBR vers un dossier temporaire
- **THEN** il crée `{stem}.cbz.tmp` dans le même dossier que le CBR
- **THEN** il vérifie que le CBZ contient le même nombre d'images que le CBR original
- **THEN** il renomme `{stem}.cbz.tmp``{stem}.cbz`
- **THEN** il met à jour `books.file_path` et `books.file_format = 'cbz'` en DB
- **THEN** il supprime le fichier CBR original
- **THEN** le job passe en statut `success`
#### Scenario: Échec pendant la création du CBZ
- **WHEN** une erreur survient avant la mise à jour DB (extraction, pack, vérification)
- **THEN** le fichier `.cbz.tmp` est supprimé si présent
- **THEN** le CBR original reste intact
- **THEN** le job passe en statut `failed` avec un message d'erreur
#### Scenario: Échec de la suppression du CBR après conversion réussie
- **WHEN** la suppression du CBR échoue après que le CBZ est valide et la DB mise à jour
- **THEN** le job passe quand même en statut `success`
- **THEN** l'erreur de suppression est loguée en avertissement
### Requirement: Vérification du CBZ généré
Le système SHALL vérifier l'intégrité du CBZ créé avant de modifier la base de données. La vérification SHALL confirmer que le nombre d'images dans le CBZ est égal au nombre d'images dans le CBR source.
#### Scenario: CBZ valide avec le bon nombre d'images
- **WHEN** le CBZ est créé avec N images
- **THEN** l'ouverture du ZIP et le décompte des entrées image retourne N
- **THEN** la vérification passe et la conversion continue
#### Scenario: CBZ invalide (décompte incorrect)
- **WHEN** le CBZ créé contient un nombre d'images différent du CBR source
- **THEN** la vérification échoue
- **THEN** le fichier `.cbz.tmp` est supprimé
- **THEN** le job échoue avec une erreur de vérification
### Requirement: Mise à jour de la base de données après conversion
L'indexer SHALL mettre à jour le livre en base de données après une conversion réussie : `file_path` SHALL être mis à jour (`.cbr``.cbz`), `file_format` SHALL être mis à jour à `'cbz'`.
#### Scenario: Mise à jour DB réussie
- **WHEN** le CBZ est vérifié et renommé
- **THEN** `books.file_path` est mis à jour pour pointer vers le nouveau fichier `.cbz`
- **THEN** `books.file_format` est mis à jour à `'cbz'`
- **THEN** `books.updated_at` est mis à jour
### Requirement: Bouton de conversion dans le backoffice
Le backoffice SHALL afficher un bouton "Convert to CBZ" sur la page détail d'un livre, visible uniquement si `book.file_format === 'cbr'`. Le clic SHALL appeler `POST /books/:id/convert` et SHALL afficher un feedback (succès ou erreur).
#### Scenario: Affichage du bouton sur un livre CBR
- **WHEN** l'utilisateur consulte la page détail d'un livre avec `file_format = 'cbr'`
- **THEN** le bouton "Convert to CBZ" est visible
#### Scenario: Bouton absent sur un livre non-CBR
- **WHEN** l'utilisateur consulte la page détail d'un livre avec `file_format != 'cbr'`
- **THEN** aucun bouton de conversion n'est affiché
#### Scenario: Conversion lancée depuis le bouton
- **WHEN** l'utilisateur clique sur "Convert to CBZ"
- **THEN** l'API est appelée et un job est créé
- **THEN** un message de confirmation avec le lien vers le job est affiché