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

@@ -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