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,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é