10 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
21a87a3eb3 feat: update readme 2026-02-08 18:17:44 +01:00
ext.jeremy.guillot@maxicoffee.domains
ffceda606f feat: commit before changing gitea 2026-02-08 17:58:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
b05bd98f63 feat: affichage des cartes lors de l'analyse 2025-10-16 15:44:01 +02:00
ext.jeremy.guillot@maxicoffee.domains
9e7f7b4cfc fix: more patterns 2025-10-16 14:35:58 +02:00
ext.jeremy.guillot@maxicoffee.domains
50b33f53d7 fix: fix search 2025-10-15 16:31:58 +02:00
ext.jeremy.guillot@maxicoffee.domains
3170a7c60e feat: analyse import + all tests fixed 2025-10-15 16:14:15 +02:00
ext.jeremy.guillot@maxicoffee.domains
fbe9619224 fix: warnings navigateur 2025-08-01 15:22:54 +02:00
ext.jeremy.guillot@maxicoffee.domains
8d14676656 feat: amélioration de la gestion des messages dans le Makefile avec la séparation des commandes et des événements. Mise à jour des services pour intégrer un nouvel EventDispatcher et réorganisation des imports dans les fichiers concernés. Gestion des exceptions ajoutée dans le provider Mangadex pour une meilleure robustesse. 2025-08-01 15:14:12 +02:00
ext.jeremy.guillot@maxicoffee.domains
bec1572fcb feat: refonte de la gestion des événements de création de mangas en remplaçant le MessageBus par un EventDispatcher. Ajout d'un écouteur d'événements MangaCreated pour gérer la récupération des chapitres après la création d'un manga. Implémentation d'un EventDispatcher basé sur Symfony Messenger. 2025-07-31 16:11:16 +02:00
ext.jeremy.guillot@maxicoffee.domains
f1eb97f156 refactor: réorganisation des imports dans AddManga.vue pour une meilleure lisibilité et mise à jour de MangaCreatedListener pour utiliser MangaId lors de la création de chapitres. Suppression de l'appel à fetchMangaChapters après la création d'un manga. 2025-07-23 16:54:11 +02:00
105 changed files with 6267 additions and 249 deletions

25
.vscode/settings.json vendored
View File

@@ -1,4 +1,25 @@
{ {
"symfony-vscode.shellExecutable": "/bin/bash", "symfony-vscode.shellExecutable": "/bin/bash",
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- " "symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- ",
} "workbench.colorCustomizations": {
"activityBar.activeBackground": "#2f7c47",
"activityBar.background": "#2f7c47",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#422c74",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#2f7c47",
"statusBar.background": "#215732",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#2f7c47",
"statusBarItem.remoteBackground": "#215732",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#215732",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#21573299",
"titleBar.inactiveForeground": "#e7e7e799",
"activityBar.activeBorder": "#422c74"
},
"peacock.color": "#215732"
}

View File

@@ -145,8 +145,11 @@ twig-extension: ## Create a new twig extension
stimulus: ## Create a new stimulus controller stimulus: ## Create a new stimulus controller
@$(SYMFONY) make:stimulus-controller @$(SYMFONY) make:stimulus-controller
consume: consume-commands: ## Consume commands messages
@$(SYMFONY) messenger:consume commands events -vv @$(SYMFONY) messenger:consume commands -vv
consume-events: ## Consume events messages
@$(SYMFONY) messenger:consume events -vv
consume-schedule: ## Consume schedule messages consume-schedule: ## Consume schedule messages
@$(SYMFONY) messenger:consume async -vv scheduler_default @$(SYMFONY) messenger:consume async -vv scheduler_default

View File

@@ -13,7 +13,7 @@ Avant de commencer, assurez-vous que les outils suivants sont installés sur vot
Pour mettre en place le projet, suivez ces étapes : Pour mettre en place le projet, suivez ces étapes :
1. Clonez le dépôt du projet : 1. Clonez le dépôt du projet :
```git clone git@bitbucket.org:tkm_rd/tkm-symfony.git``` ```git clone git@git.homelab.nestor-server.fr:2222/colgora/Mangarr.git```
2. Copiez le fichier `.env.example` en `.env` : 2. Copiez le fichier `.env.example` en `.env` :
```cp .env.example .env``` ```cp .env.example .env```

View File

@@ -1,11 +1,10 @@
<template> <template>
<router-view></router-view> <router-view></router-view>
<NotificationToast />
</template> </template>
<script> <script setup>
export default { import NotificationToast from './shared/components/ui/NotificationToast.vue';
name: 'App'
}
</script> </script>
<style> <style>
@@ -18,4 +17,4 @@ export default {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -0,0 +1,217 @@
# Domaine Import - Analyse et Import de Fichiers CBZ/CBR
## Vue d'ensemble
Ce domaine permet l'import de fichiers CBZ/CBR dans Mangarr en utilisant l'analyse intelligente de noms de fichiers pour trouver automatiquement les correspondances avec les mangas de la bibliothèque.
## Architecture
### Structure des Dossiers
```
domain/import/
├── domain/
│ └── entities/
│ └── FileImport.js # Entité représentant un fichier à importer
├── infrastructure/
│ └── api/
│ └── apiImportRepository.js # Client API
├── application/
│ └── store/
│ └── newImportStore.js # Store Pinia principal
└── presentation/
├── pages/
│ └── NewImportPage.vue # Page principale d'import
└── components/
├── FileImportCard.vue # Carte de fichier à importer
├── ImportResults.vue # Résumé des résultats
└── StatusBadge.vue # Badge de statut
```
## Fonctionnalités
### 1. Upload de Fichiers
- **Drag & Drop** : Support du glisser-déposer pour les fichiers CBZ/CBR
- **Sélection multiple** : Import de plusieurs fichiers simultanément
- **Validation** : Vérification automatique des formats acceptés
### 2. Analyse Intelligente
- **Extraction automatique** : Le système analyse le nom de fichier pour extraire :
- Le titre du manga
- Le numéro de chapitre (si présent)
- Le numéro de volume (si présent)
- **Correspondance automatique** :
- Recherche des mangas correspondants dans la bibliothèque
- Score de correspondance pour chaque résultat
- Sélection automatique du meilleur match
### 3. Sélection et Validation
- **Sélection de manga** : Dropdown avec tous les mangas correspondants et leur score
- **Prévisualisation** : Affichage de la couverture et des informations du manga sélectionné
- **Édition des numéros** : Possibilité de modifier les numéros de chapitre/volume extraits
- **Exclusivité** : Un fichier ne peut être importé que comme chapitre OU volume (pas les deux)
### 4. Import
- **Import unitaire** : Import fichier par fichier
- **Import groupé** : Import de tous les fichiers prêts en une seule fois
- **Retry** : Possibilité de réessayer en cas d'erreur
- **Suivi en temps réel** : Indicateurs de progression et statuts
### 5. Résultats
- **Statistiques** : Nombre de fichiers importés, erreurs, total
- **Détails** : Liste des fichiers importés avec leurs associations
- **Erreurs** : Affichage détaillé des erreurs pour débogage
## API Endpoints Utilisés
### Analyse de fichiers
```
GET /api/manga-matches?filename={filename}
```
Retourne :
```json
{
"matches": [
{
"id": "string",
"title": "string",
"slug": "string",
"alternativeSlugs": ["string"],
"thumbnailUrl": "string",
"matchScore": 100
}
],
"chapterNumber": 1.5,
"volumeNumber": 2.0,
"possibleTitles": ["string"]
}
```
### Import de fichier
```
POST /api/chapters/import
```
FormData :
- `file`: Le fichier CBZ à importer
- `mangaId`: ID du manga
- `chapterNumber`: Numéro de chapitre (float, optionnel)
Réponse (200) :
```json
{
"message": "Chapter imported successfully",
"mangaId": "uuid",
"chapterNumber": 1.5
}
```
Erreurs :
- `404`: Manga ou Chapitre non trouvé
- `422`: Paramètres invalides ou fichier absent
- `400`: Fichier CBZ invalide
### Import de volume (À venir)
```
POST /api/volumes/import
```
FormData :
- `file`: Le fichier CBZ à importer
- `mangaId`: ID du manga
- `volumeNumber`: Numéro de volume (int)
## Store Pinia
Le store `newImportStore` gère tout l'état de l'application :
### État
- `files`: Liste des fichiers en cours de traitement
- `analyzingFiles`: Set des IDs de fichiers en analyse
- `importingFiles`: Set des IDs de fichiers en import
- `isLoading`: État de chargement global
- `globalError`: Erreur globale éventuelle
### Getters
- `pendingFiles`: Fichiers en attente d'analyse
- `analyzedFiles`: Fichiers analysés
- `readyFiles`: Fichiers prêts pour l'import
- `importedFiles`: Fichiers importés avec succès
- `errorFiles`: Fichiers en erreur
- `hasReadyFiles`: Au moins un fichier prêt
- `allFilesProcessed`: Tous les fichiers traités
- `progressPercentage`: Pourcentage de progression
### Actions Principales
- `addFiles(fileList)`: Ajoute des fichiers et lance l'analyse automatique
- `analyzeFile(fileId)`: Analyse un fichier spécifique
- `setFileManga(fileId, manga)`: Définit le manga sélectionné
- `setFileChapterNumber(fileId, number)`: Définit le numéro de chapitre
- `setFileVolumeNumber(fileId, number)`: Définit le numéro de volume
- `importFile(fileId)`: Importe un fichier
- `importAllReadyFiles()`: Importe tous les fichiers prêts
- `autoSelectBestMatches()`: Sélection automatique des meilleurs matchs
- `retryFile(fileId)`: Réessaye l'analyse ou l'import d'un fichier
## Entité FileImport
Représente un fichier dans le processus d'import :
### Propriétés
- `file`: Objet File du navigateur
- `filename`: Nom du fichier original
- `analysis`: Résultat de l'analyse (matches, chapterNumber, volumeNumber)
- `selectedManga`: Manga sélectionné par l'utilisateur
- `selectedChapterNumber`: Numéro de chapitre (auto ou manuel)
- `selectedVolumeNumber`: Numéro de volume (auto ou manuel)
- `status`: pending | analyzed | importing | imported | error
- `errorMessage`: Message d'erreur le cas échéant
### Méthodes Utiles
- `hasMatches()`: Vérifie si des correspondances ont été trouvées
- `getMatches()`: Retourne la liste des correspondances
- `getBestMatch()`: Retourne la meilleure correspondance
- `isReadyForImport()`: Vérifie si le fichier est prêt à être importé
- `getImportData()`: Prépare les données pour l'API d'import
## Workflow Utilisateur
1. **Upload**: L'utilisateur glisse-dépose ou sélectionne des fichiers CBZ/CBR
2. **Analyse automatique**: Chaque fichier est analysé pour extraire les informations
3. **Sélection auto**: Le meilleur match est automatiquement sélectionné
4. **Validation**: L'utilisateur peut modifier le manga ou les numéros si nécessaire
5. **Import**: Import unitaire ou groupé des fichiers prêts
6. **Résultats**: Affichage du résumé avec succès et erreurs
## Gestion des Erreurs
### Erreurs d'analyse
- Aucun manga trouvé → Message informatif, possibilité de réessayer
- Erreur réseau → Message d'erreur, bouton retry disponible
### Erreurs d'import
- Échec d'upload → Fichier marqué en erreur avec message détaillé
- Erreur serveur → Fichier en erreur, possibilité de retry
## Améliorations Futures
1. **Recherche manuelle** : Permettre la recherche manuelle si aucun match
2. **Multi-sélection** : Sélectionner plusieurs fichiers pour actions groupées
3. **Historique** : Garder un historique des imports récents
4. **Validation avancée** : Vérifier si le chapitre/volume existe déjà
5. **Métadonnées** : Extraire et afficher plus de métadonnées des fichiers CBZ
## Composants Réutilisables
### Depuis Shared
- `FileUpload.vue`: Zone d'upload avec drag & drop
- `LoadingSpinner.vue`: Indicateur de chargement
### Spécifiques au Domaine
- `FileImportCard.vue`: Carte complète de gestion d'un fichier
- `StatusBadge.vue`: Badge de statut avec couleurs
- `ImportResults.vue`: Résumé des résultats d'import

View File

@@ -0,0 +1,316 @@
import { defineStore } from 'pinia';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import { FileImport } from '../../domain/entities/FileImport';
import { ApiImportRepository } from '../../infrastructure/api/apiImportRepository';
const importRepository = new ApiImportRepository();
const { showSuccess, showError, showInfo } = useNotifications();
export const useNewImportStore = defineStore('newImport', {
state: () => ({
// Files being processed
files: [], // Array of FileImport entities
// Loading states
analyzingFiles: new Set(), // File IDs being analyzed
importingFiles: new Set(), // File IDs being imported
// Global states
isLoading: false,
globalError: null,
}),
getters: {
// File status getters
pendingFiles: (state) => state.files.filter(f => f.isPending()),
analyzedFiles: (state) => state.files.filter(f => f.isAnalyzed()),
readyFiles: (state) => state.files.filter(f => f.isReadyForImport()),
importedFiles: (state) => state.files.filter(f => f.isImported()),
errorFiles: (state) => state.files.filter(f => f.hasError()),
// Counts
totalFiles: (state) => state.files.length,
readyCount: (state) => state.files.filter(f => f.isReadyForImport()).length,
importedCount: (state) => state.files.filter(f => f.isImported()).length,
errorCount: (state) => state.files.filter(f => f.hasError()).length,
// Status helpers
hasFiles: (state) => state.files.length > 0,
hasReadyFiles: (state) => state.files.some(f => f.isReadyForImport()),
allFilesProcessed: (state) => {
return state.files.length > 0 &&
state.files.every(f => f.isImported() || f.hasError());
},
// Progress
progressPercentage: (state) => {
if (state.files.length === 0) return 0;
const processed = state.files.filter(f => f.isImported() || f.hasError()).length;
return Math.round((processed / state.files.length) * 100);
},
// Specific file finders
getFileById: (state) => (id) => {
return state.files.find(f => f.id === id);
}
},
actions: {
// === FILE MANAGEMENT ===
/**
* Add files to the import queue
*/
addFiles(fileList) {
const validFiles = Array.from(fileList).filter(file => {
const extension = file.name.split('.').pop().toLowerCase();
return ['cbz', 'cbr'].includes(extension);
});
if (validFiles.length === 0) {
showError('Aucun fichier CBZ/CBR valide sélectionné');
return;
}
const newFiles = validFiles.map(file => FileImport.create(file));
this.files.push(...newFiles);
showInfo(`${newFiles.length} fichier(s) ajouté(s) à la queue d'import`);
// Auto-analyze all new files
this.analyzeAllPendingFiles();
},
/**
* Remove a file from the queue
*/
removeFile(fileId) {
const index = this.files.findIndex(f => f.id === fileId);
if (index !== -1) {
this.files.splice(index, 1);
}
},
/**
* Clear all files
*/
clearFiles() {
this.files = [];
this.analyzingFiles.clear();
this.importingFiles.clear();
this.globalError = null;
},
// === ANALYSIS ACTIONS ===
/**
* Analyze all pending files
*/
async analyzeAllPendingFiles() {
const pendingFiles = this.pendingFiles;
if (pendingFiles.length === 0) return;
this.isLoading = true;
try {
await Promise.all(
pendingFiles.map(file => this.analyzeFile(file.id))
);
showSuccess(`${pendingFiles.length} fichier(s) analysé(s) avec succès`);
} catch (error) {
console.error('Error analyzing files:', error);
this.globalError = 'Erreur lors de l\'analyse des fichiers';
} finally {
this.isLoading = false;
}
},
/**
* Analyze a specific file
*/
async analyzeFile(fileId) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex === -1) return;
const file = this.files[fileIndex];
if (!file.isPending()) return;
this.analyzingFiles.add(fileId);
try {
const analysis = await importRepository.analyzeFilename(file.filename);
file.setAnalysis(analysis);
// Force reactivity by replacing the object in the array
this.files[fileIndex] = file;
if (!file.hasMatches()) {
showError(`Aucun manga trouvé pour le fichier: ${file.filename}`);
}
} catch (error) {
console.error(`Error analyzing file ${file.filename}:`, error);
file.setError(`Erreur d'analyse: ${error.message}`);
this.files[fileIndex] = file;
showError(`Erreur lors de l'analyse de ${file.filename}`);
} finally {
this.analyzingFiles.delete(fileId);
}
},
// === SELECTION ACTIONS ===
/**
* Update manga selection for a file
*/
setFileManga(fileId, manga) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedManga(manga);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
/**
* Update chapter number for a file
*/
setFileChapterNumber(fileId, chapterNumber) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedChapterNumber(chapterNumber);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
/**
* Update volume number for a file
*/
setFileVolumeNumber(fileId, volumeNumber) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedVolumeNumber(volumeNumber);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
// === IMPORT ACTIONS ===
/**
* Import all ready files
*/
async importAllReadyFiles() {
const readyFiles = this.readyFiles;
if (readyFiles.length === 0) {
showError('Aucun fichier prêt pour l\'import');
return;
}
this.isLoading = true;
let successCount = 0;
let errorCount = 0;
try {
for (const file of readyFiles) {
try {
await this.importFile(file.id);
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to import file ${file.filename}:`, error);
}
}
if (successCount > 0) {
showSuccess(`${successCount} fichier(s) importé(s) avec succès`);
}
if (errorCount > 0) {
showError(`${errorCount} fichier(s) ont échoué lors de l'import`);
}
} finally {
this.isLoading = false;
}
},
/**
* Import a specific file
*/
async importFile(fileId) {
const file = this.getFileById(fileId);
if (!file || !file.isReadyForImport()) {
throw new Error('File is not ready for import');
}
this.importingFiles.add(fileId);
file.setImporting();
try {
const importData = file.getImportData();
await importRepository.importFile(
file.file,
importData.mangaId,
importData.chapterNumber,
importData.volumeNumber
);
file.setImported();
showSuccess(`Fichier ${file.filename} importé avec succès`);
} catch (error) {
console.error(`Error importing file ${file.filename}:`, error);
file.setError(`Erreur d'import: ${error.message}`);
throw error;
} finally {
this.importingFiles.delete(fileId);
}
},
/**
* Retry import for a failed file
*/
async retryFile(fileId) {
const file = this.getFileById(fileId);
if (!file) return;
if (file.hasError() && file.selectedManga) {
// If the file had an import error but has selections, retry import
await this.importFile(fileId);
} else {
// If the file had an analysis error, retry analysis
file.status = 'pending';
file.errorMessage = null;
await this.analyzeFile(fileId);
}
},
// === UTILITY ACTIONS ===
/**
* Auto-select best matches for all files
*/
autoSelectBestMatches() {
let selectedCount = 0;
this.analyzedFiles.forEach(file => {
const bestMatch = file.getBestMatch();
if (bestMatch) {
file.setSelectedManga(bestMatch);
selectedCount++;
}
});
if (selectedCount > 0) {
showInfo(`${selectedCount} correspondance(s) automatique(s) effectuée(s)`);
}
},
/**
* Reset global state
*/
resetGlobalState() {
this.globalError = null;
this.isLoading = false;
this.analyzingFiles.clear();
this.importingFiles.clear();
}
}
});

View File

@@ -0,0 +1,200 @@
/**
* Entité représentant un fichier en cours d'import avec ses correspondances possibles
*/
export class FileImport {
constructor({
file, // File object from browser
filename, // Original filename
analysis = null, // Result from /api/manga-matches endpoint
selectedManga = null, // Selected manga match
selectedChapterNumber = null, // Selected chapter number (extracted from filename)
selectedVolumeNumber = null, // Selected volume number (extracted from filename)
status = 'pending', // 'pending', 'analyzed', 'importing', 'imported', 'error'
errorMessage = null,
importedAt = null
}) {
this.file = file;
this.filename = filename;
this.analysis = analysis;
this.selectedManga = selectedManga;
this.selectedChapterNumber = selectedChapterNumber;
this.selectedVolumeNumber = selectedVolumeNumber;
this.status = status;
this.errorMessage = errorMessage;
this.importedAt = importedAt;
this.id = this._generateId();
}
static create(file) {
return new FileImport({
file,
filename: file.name
});
}
_generateId() {
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Status helpers
isPending() {
return this.status === 'pending';
}
isAnalyzed() {
return this.status === 'analyzed';
}
isImporting() {
return this.status === 'importing';
}
isImported() {
return this.status === 'imported';
}
hasError() {
return this.status === 'error';
}
// Analysis helpers
hasMatches() {
return this.analysis && this.analysis.matches && this.analysis.matches.length > 0;
}
getMatches() {
return this.analysis?.matches || [];
}
getBestMatch() {
const matches = this.getMatches();
// Sort by matchScore (highest first) and return the best one
return matches.length > 0 ? matches.sort((a, b) => b.matchScore - a.matchScore)[0] : null;
}
// Analysis extracted data
getExtractedChapterNumber() {
return this.analysis?.chapterNumber || null;
}
getExtractedVolumeNumber() {
return this.analysis?.volumeNumber || null;
}
// Selection helpers
isReadyForImport() {
// Ready if a manga is selected and at least chapter or volume number is set
return this.selectedManga && (this.selectedChapterNumber !== null || this.selectedVolumeNumber !== null);
}
getImportType() {
if (this.selectedChapterNumber !== null) return 'chapter';
if (this.selectedVolumeNumber !== null) return 'volume';
return null;
}
// File helpers
getFormattedSize() {
if (!this.file || !this.file.size) return 'Unknown';
const bytes = this.file.size;
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
getFileExtension() {
const extension = this.filename.split('.').pop().toLowerCase();
return extension;
}
isValidFormat() {
const validExtensions = ['cbz', 'cbr'];
return validExtensions.includes(this.getFileExtension());
}
// Update methods
setAnalysis(analysis) {
this.analysis = analysis;
this.status = 'analyzed';
// Auto-set extracted chapter/volume numbers from analysis
if (analysis.chapterNumber !== null && analysis.chapterNumber !== undefined) {
this.selectedChapterNumber = analysis.chapterNumber;
}
if (analysis.volumeNumber !== null && analysis.volumeNumber !== undefined) {
this.selectedVolumeNumber = analysis.volumeNumber;
}
// Auto-select best match if available
const bestMatch = this.getBestMatch();
if (bestMatch) {
this.selectedManga = bestMatch;
}
}
setSelectedManga(manga) {
this.selectedManga = manga;
// Keep the chapter/volume numbers from analysis
}
setSelectedChapterNumber(chapterNumber) {
this.selectedChapterNumber = chapterNumber;
// If setting chapter, clear volume
if (chapterNumber !== null) {
this.selectedVolumeNumber = null;
}
}
setSelectedVolumeNumber(volumeNumber) {
this.selectedVolumeNumber = volumeNumber;
// If setting volume, clear chapter
if (volumeNumber !== null) {
this.selectedChapterNumber = null;
}
}
setImporting() {
this.status = 'importing';
this.errorMessage = null;
}
setImported() {
this.status = 'imported';
this.importedAt = new Date().toISOString();
this.errorMessage = null;
}
setError(message) {
this.status = 'error';
this.errorMessage = message;
}
// Export selection for API
getImportData() {
if (!this.isReadyForImport()) {
throw new Error('File is not ready for import');
}
const data = {
mangaId: this.selectedManga.id
};
if (this.selectedChapterNumber !== null) {
data.chapterNumber = this.selectedChapterNumber;
}
if (this.selectedVolumeNumber !== null) {
data.volumeNumber = this.selectedVolumeNumber;
}
return data;
}
}

View File

@@ -0,0 +1,239 @@
export class ImportFile {
constructor({
id,
originalName,
fileSize,
extension,
status = 'pending',
createdAt,
metadata = null,
mangaMatches = [],
selectedMangaSlug = null,
selectedVolume = null,
selectedChapter = null,
errorMessage = null,
processedAt = null,
// New properties for simplified workflow
file = null, // Browser File object
analysis = null, // Analysis result from API
selectedManga = null, // Selected manga object
selectedChapterId = null // Selected chapter ID
}) {
this.id = id;
this.originalName = originalName;
this.fileSize = fileSize;
this.extension = extension;
this.status = status;
this.createdAt = createdAt;
this.metadata = metadata;
this.mangaMatches = mangaMatches;
this.selectedMangaSlug = selectedMangaSlug;
this.selectedVolume = selectedVolume;
this.selectedChapter = selectedChapter;
this.errorMessage = errorMessage;
this.processedAt = processedAt;
// New properties
this.file = file;
this.analysis = analysis;
this.selectedManga = selectedManga;
this.selectedChapterId = selectedChapterId;
this.mangaMatches = mangaMatches; // Store found manga matches
}
static create(data) {
return new ImportFile({
...data,
createdAt: data.createdAt || new Date().toISOString()
});
}
// Create from browser File object
static createFromFile(file) {
return new ImportFile({
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
originalName: file.name,
fileSize: file.size,
extension: file.name.split('.').pop().toLowerCase(),
file: file,
createdAt: new Date().toISOString()
});
}
isProcessed() {
return this.status === 'processed';
}
hasError() {
return this.status === 'error';
}
isPending() {
return this.status === 'pending';
}
needsConversion() {
return this.extension === 'cbr';
}
isReadyForImport() {
return this.isProcessed() && this.selectedMangaSlug && (this.selectedVolume || this.selectedChapter);
}
getFormattedSize() {
const bytes = parseInt(this.fileSize);
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
getContentType() {
if (this.metadata?.chapter) {
return `Chapter ${this.metadata.chapter}`;
}
if (this.metadata?.volume) {
return `Volume ${this.metadata.volume}`;
}
return 'Unknown';
}
// === NEW METHODS FOR SIMPLIFIED WORKFLOW ===
// Status helpers for new workflow
isAnalyzed() {
return this.status === 'analyzed';
}
isImporting() {
return this.status === 'importing';
}
isImported() {
return this.status === 'imported';
}
// Analysis helpers
hasAnalysis() {
return this.analysis && this.analysis.possibleTitles && this.analysis.possibleTitles.length > 0;
}
getPossibleTitles() {
return this.analysis?.possibleTitles || [];
}
getAnalyzedChapter() {
return this.analysis?.chapterNumber || null;
}
getAnalyzedVolume() {
return this.analysis?.volumeNumber || null;
}
// For backward compatibility with existing code
hasMatches() {
return this.mangaMatches && this.mangaMatches.length > 0;
}
getMatches() {
return this.mangaMatches || [];
}
getBestMatch() {
const matches = this.getMatches();
return matches.length > 0 ? matches[0] : null;
}
// Selection helpers
isReadyForNewImport() {
return this.selectedManga && (this.selectedChapterId || this.selectedVolume !== null);
}
getImportType() {
if (this.selectedChapterId) return 'chapter';
if (this.selectedVolume !== null) return 'volume';
return null;
}
// File validation
isValidFormat() {
const validExtensions = ['cbz', 'cbr'];
return validExtensions.includes(this.extension);
}
// Update methods for new workflow
setAnalysis(analysis) {
this.analysis = analysis;
this.status = 'analyzed';
}
setMangaMatches(matches) {
this.mangaMatches = matches;
// Auto-select best match if available
const bestMatch = this.getBestMatch();
if (bestMatch) {
this.selectedManga = bestMatch;
}
}
setSelectedManga(manga) {
this.selectedManga = manga;
// Reset chapter/volume selection when manga changes
this.selectedChapterId = null;
this.selectedVolume = null;
}
setSelectedChapterById(chapterId) {
this.selectedChapterId = chapterId;
this.selectedVolume = null; // Can't have both
}
setSelectedVolumeNumber(volumeNumber) {
this.selectedVolume = volumeNumber;
this.selectedChapterId = null; // Can't have both
}
setImporting() {
this.status = 'importing';
this.errorMessage = null;
}
setImported() {
this.status = 'imported';
this.processedAt = new Date().toISOString();
this.errorMessage = null;
}
setError(message) {
this.status = 'error';
this.errorMessage = message;
}
// Export selection for API
getImportData() {
if (!this.isReadyForNewImport()) {
throw new Error('File is not ready for import');
}
const data = {
mangaId: this.selectedManga.id
};
if (this.selectedChapterId) {
data.chapterId = this.selectedChapterId;
}
if (this.selectedVolume !== null) {
data.volumeNumber = this.selectedVolume;
}
return data;
}
}

View File

@@ -0,0 +1,174 @@
export class ApiImportRepository {
/**
* Analyse le nom d'un fichier et trouve les mangas correspondants
* @param {string} filename - Nom du fichier à analyser
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
*/
async analyzeFilename(filename) {
try {
console.log('Analyzing filename:', filename);
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Analyze filename failed:', response.status, errorText);
throw new Error(`Failed to analyze filename: ${response.status}`);
}
const result = await response.json();
console.log('Analyze result:', result);
// Extract chapter and volume numbers from the first match if available
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
const chapterNumber = firstMatch?.chapterNumber ?? null;
const volumeNumber = firstMatch?.volumeNumber ?? null;
return {
matches: result.matches || [],
chapterNumber,
volumeNumber,
possibleTitles: result.possibleTitles || []
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Récupère les détails d'un manga par son slug
* @param {string} slug - Slug du manga
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
*/
async getMangaDetails(slug) {
try {
console.log('Fetching manga details for:', slug);
const response = await fetch(`/api/mangas/${slug}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Get manga details failed:', response.status, errorText);
throw new Error(`Failed to get manga details: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Upload et import d'un fichier avec les informations du manga
* @param {File} file - Fichier à uploader
* @param {string} mangaId - ID du manga
* @param {number|null} chapterNumber - Numéro du chapitre (optionnel)
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
* @returns {Promise<Object>} - Résultat de l'import
*/
async importFile(file, mangaId, chapterNumber = null, volumeNumber = null) {
try {
// Déterminer s'il s'agit d'un import de chapitre ou volume
if (chapterNumber !== null && chapterNumber !== undefined) {
return await this.importChapter(file, mangaId, chapterNumber);
} else if (volumeNumber !== null && volumeNumber !== undefined) {
return await this.importVolume(file, mangaId, volumeNumber);
} else {
throw new Error('Either chapterNumber or volumeNumber must be provided');
}
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Import d'un chapitre
* @param {File} file - Fichier CBZ à uploader
* @param {string} mangaId - ID du manga
* @param {number} chapterNumber - Numéro du chapitre
* @returns {Promise<Object>} - Résultat de l'import
*/
async importChapter(file, mangaId, chapterNumber) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('mangaId', mangaId);
formData.append('chapterNumber', chapterNumber.toString());
console.log('Importing chapter:', chapterNumber, 'for manga:', mangaId);
const response = await fetch('/api/chapters/import', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('Import failed:', response.status, errorText);
// Parse the error response if it's JSON
let errorMessage = `Failed to import chapter: ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorJson.details || errorMessage;
} catch (e) {
// Not JSON, use the status message
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log('Import result:', result);
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Import d'un volume (TODO: À implémenter)
* @param {File} file - Fichier CBZ à uploader
* @param {string} mangaId - ID du manga
* @param {number} volumeNumber - Numéro du volume
* @returns {Promise<Object>} - Résultat de l'import
*/
async importVolume(file, mangaId, volumeNumber) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('mangaId', mangaId);
formData.append('volumeNumber', volumeNumber.toString());
console.log('Importing volume:', volumeNumber, 'for manga:', mangaId);
const response = await fetch('/api/volumes/import', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('Import failed:', response.status, errorText);
// Parse the error response if it's JSON
let errorMessage = `Failed to import volume: ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorJson.details || errorMessage;
} catch (e) {
// Not JSON, use the status message
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log('Import result:', result);
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,228 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-start space-x-4">
<!-- File Icon and Info -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<!-- File Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 truncate">
{{ file.filename }}
</h3>
<!-- Status Badge -->
<div class="flex-shrink-0 ml-4">
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
</div>
</div>
<p class="text-sm text-gray-500 mt-1">
{{ file.getFormattedSize() }} {{ file.getFileExtension().toUpperCase() }}
</p>
<!-- Extracted Info -->
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
Chapitre {{ file.getExtractedChapterNumber() }}
</span>
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
Volume {{ file.getExtractedVolumeNumber() }}
</span>
</div>
<!-- Error Display -->
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
</div>
</div>
</div>
<!-- Manga Selection -->
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
</label>
<!-- Matches Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<MangaMatchCard
v-for="match in sortedMatches"
:key="match.id"
:match="match"
:is-selected="file.selectedManga?.id === match.id"
@select-match="handleMangaSelection"
/>
</div>
</div>
<!-- Selected Manga Preview -->
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<img
v-if="file.selectedManga.thumbnailUrl"
:src="file.selectedManga.thumbnailUrl"
:alt="file.selectedManga.title"
class="w-12 h-16 object-cover rounded"
/>
<div class="flex-1">
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
</div>
</div>
<!-- Chapter/Volume Number Inputs -->
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
<!-- Chapter Number -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Numéro de chapitre
</label>
<input
type="number"
step="0.5"
:value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
placeholder="Ex: 1, 1.5, 2..."
/>
</div>
<!-- Volume Number -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Numéro de volume
</label>
<input
type="number"
step="0.5"
:value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
placeholder="Ex: 1, 1.5, 2..."
/>
</div>
</div>
</div>
<!-- No Matches Message -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
<div class="mt-2 text-sm text-yellow-700">
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-between items-center">
<div class="flex space-x-3">
<!-- Import Button -->
<button
v-if="file.isReadyForImport()"
@click="$emit('import-file')"
:disabled="isImporting"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
>
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isImporting ? 'Import en cours...' : 'Importer' }}
</button>
<!-- Retry Button -->
<button
v-if="file.hasError()"
@click="$emit('retry-file')"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Réessayer
</button>
</div>
<!-- Remove Button -->
<button
@click="$emit('remove-file')"
class="text-red-600 hover:text-red-700 text-sm font-medium"
>
Supprimer
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import MangaMatchCard from './MangaMatchCard.vue';
import StatusBadge from './StatusBadge.vue';
const props = defineProps({
file: {
type: Object,
required: true
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'manga-selected',
'chapter-number-selected',
'volume-number-selected',
'import-file',
'retry-file',
'remove-file'
]);
// Computed property to get sorted matches
const sortedMatches = computed(() => {
const matches = props.file.getMatches();
return matches.sort((a, b) => b.matchScore - a.matchScore);
});
const handleMangaSelection = (selectedManga) => {
emit('manga-selected', selectedManga);
};
const handleChapterNumberInput = (event) => {
const value = event.target.value;
const chapterNumber = value ? parseFloat(value) : null;
emit('chapter-number-selected', chapterNumber);
};
const handleVolumeNumberInput = (event) => {
const value = event.target.value;
const volumeNumber = value ? parseFloat(value) : null;
emit('volume-number-selected', volumeNumber);
};
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="text-center mb-6">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
<p class="text-sm text-gray-500">
Voici le résumé de votre session d'import
</p>
</div>
<!-- Statistics -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
<div class="text-sm text-gray-500">Importés</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
<div class="text-sm text-gray-500">Erreurs</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
<div class="text-sm text-gray-500">Total</div>
</div>
</div>
<!-- Success Files List -->
<div v-if="importedFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">
Fichiers importés avec succès ({{ importedFiles.length }})
</h4>
<ul class="space-y-2">
<li
v-for="file in importedFiles"
:key="file.id"
class="flex items-center text-sm"
>
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
→ {{ file.selectedManga.title }}
</span>
</li>
</ul>
</div>
<!-- Error Files List -->
<div v-if="errorFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">
Fichiers en erreur ({{ errorFiles.length }})
</h4>
<ul class="space-y-2">
<li
v-for="file in errorFiles"
:key="file.id"
class="flex items-start text-sm"
>
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div>
<div class="text-gray-900">{{ file.filename }}</div>
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
</div>
</li>
</ul>
</div>
<!-- Actions -->
<div class="flex justify-center space-x-4 pt-6 border-t">
<button
@click="startNewImport"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Nouvel import
</button>
<button
@click="goToLibrary"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Aller à la bibliothèque
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useNewImportStore } from '../../application/store/newImportStore';
const router = useRouter();
const store = useNewImportStore();
const importedFiles = computed(() => store.importedFiles);
const errorFiles = computed(() => store.errorFiles);
const importedCount = computed(() => store.importedCount);
const errorCount = computed(() => store.errorCount);
const totalCount = computed(() => store.totalFiles);
const startNewImport = () => {
store.clearFiles();
};
const goToLibrary = () => {
router.push({ name: 'manga-collection' });
};
</script>

View File

@@ -0,0 +1,116 @@
<template>
<div
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
:class="{
'border-blue-500 bg-blue-50': isSelected,
'border-gray-200 hover:border-gray-300': !isSelected
}"
@click="$emit('select-match', match)"
>
<!-- Match Header with Score -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="{
'bg-blue-500': isSelected,
'bg-gray-300': !isSelected
}"
></div>
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
</div>
<div v-if="isSelected" class="text-blue-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<!-- Manga Thumbnail -->
<div class="flex space-x-3">
<div class="flex-shrink-0">
<img
v-if="match.thumbnailUrl"
:src="match.thumbnailUrl"
:alt="match.title"
class="w-16 h-20 object-cover rounded border"
/>
<div
v-else
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<!-- Manga Info -->
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
{{ match.title }}
</h4>
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
{{ match.slug }}
</p>
<!-- Alternative Slugs -->
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
<p class="text-xs text-gray-400">Autres titres:</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
:key="altSlug"
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
>
{{ altSlug }}
</span>
<span
v-if="match.alternativeSlugs.length > 2"
class="text-xs text-gray-400"
>
+{{ match.alternativeSlugs.length - 2 }} autres
</span>
</div>
</div>
</div>
</div>
<!-- Score Bar -->
<div class="mt-3">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Correspondance</span>
<span>{{ match.matchScore }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="{
'bg-blue-500': isSelected,
'bg-gray-400': !isSelected
}"
:style="{ width: match.matchScore + '%' }"
></div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
match: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['select-match']);
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="manga-option">
<div class="flex items-center space-x-3">
<div v-if="manga.coverUrl" class="flex-shrink-0">
<img
:src="manga.coverUrl"
:alt="manga.title"
class="w-12 h-16 object-cover rounded"
/>
</div>
<div v-else class="flex-shrink-0 w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate">
{{ manga.title }}
</h4>
<div class="text-xs text-gray-500 space-y-1">
<p v-if="manga.author" class="truncate">
{{ manga.author }}
</p>
<p v-if="manga.publicationYear" class="truncate">
{{ manga.publicationYear }}
</p>
<div v-if="manga.genres && manga.genres.length > 0" class="flex flex-wrap gap-1">
<span
v-for="genre in manga.genres.slice(0, 3)"
:key="genre"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{{ genre }}
</span>
<span v-if="manga.genres.length > 3" class="text-xs text-gray-400">
+{{ manga.genres.length - 3 }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
manga: {
type: Object,
required: true
}
});
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="inline-flex items-center">
<!-- Loading Spinner for analyzing/importing -->
<svg v-if="isAnalyzing || isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<!-- Status Badge -->
<span :class="badgeClasses">
{{ badgeText }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: {
type: String,
required: true
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
});
const badgeText = computed(() => {
if (props.isImporting) return 'Import en cours...';
if (props.isAnalyzing) return 'Analyse en cours...';
switch (props.status) {
case 'pending': return 'En attente';
case 'analyzed': return 'Analysé';
case 'importing': return 'Import en cours';
case 'imported': return 'Importé';
case 'error': return 'Erreur';
default: return 'Inconnu';
}
});
const badgeClasses = computed(() => {
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
if (props.isImporting || props.isAnalyzing) {
return `${baseClasses} bg-blue-100 text-blue-800`;
}
switch (props.status) {
case 'pending':
return `${baseClasses} bg-gray-100 text-gray-800`;
case 'analyzed':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
case 'importing':
return `${baseClasses} bg-blue-100 text-blue-800`;
case 'imported':
return `${baseClasses} bg-green-100 text-green-800`;
case 'error':
return `${baseClasses} bg-red-100 text-red-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
});
</script>

View File

@@ -0,0 +1,154 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
<p class="text-gray-600">
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
</p>
</div>
<!-- Progress Bar (if files are being processed) -->
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Progression</span>
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: store.progressPercentage + '%' }"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<span>{{ store.importedCount }} importés</span>
<span>{{ store.errorCount }} erreurs</span>
<span>{{ store.totalFiles }} total</span>
</div>
</div>
</div>
<!-- File Upload Zone -->
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
<FileUpload
label="Importer des fichiers CBZ/CBR"
accept=".cbz,.cbr"
:multiple="true"
description="Formats CBZ ou CBR uniquement"
@files-selected="handleFilesSelected"
/>
</div>
<!-- Files List -->
<div v-if="store.hasFiles" class="space-y-6">
<!-- Action Buttons -->
<div class="flex flex-wrap gap-4 mb-6">
<button
v-if="store.hasReadyFiles"
@click="importAllFiles"
:disabled="store.isLoading"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
>
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
Importer tous les fichiers prêts ({{ store.readyCount }})
</button>
<button
v-if="store.analyzedFiles.length > 0"
@click="autoSelectMatches"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
>
Sélection automatique
</button>
<button
@click="clearAllFiles"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
>
Effacer tout
</button>
</div>
<!-- Files Grid -->
<div class="grid gap-6">
<FileImportCard
v-for="file in store.files"
:key="file.id"
:file="file"
:is-analyzing="store.analyzingFiles.has(file.id)"
:is-importing="store.importingFiles.has(file.id)"
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
@import-file="() => importSingleFile(file.id)"
@retry-file="() => retryFile(file.id)"
@remove-file="() => store.removeFile(file.id)"
/>
</div>
</div>
<!-- Results Summary (when all files are processed) -->
<div v-if="store.allFilesProcessed" class="mt-8">
<ImportResults />
</div>
</div>
</template>
<script setup>
import { onUnmounted } from 'vue';
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
import { useNewImportStore } from '../../application/store/newImportStore';
import FileImportCard from '../components/FileImportCard.vue';
import ImportResults from '../components/ImportResults.vue';
const store = useNewImportStore();
// === EVENT HANDLERS ===
const handleFilesSelected = (files) => {
store.addFiles(files);
};
const importAllFiles = async () => {
try {
await store.importAllReadyFiles();
} catch (error) {
console.error('Error importing files:', error);
}
};
const importSingleFile = async (fileId) => {
try {
await store.importFile(fileId);
} catch (error) {
console.error('Error importing file:', error);
}
};
const retryFile = async (fileId) => {
try {
await store.retryFile(fileId);
} catch (error) {
console.error('Error retrying file:', error);
}
};
const autoSelectMatches = () => {
store.autoSelectBestMatches();
};
const clearAllFiles = () => {
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
store.clearFiles();
}
};
// === LIFECYCLE ===
// Reset state when component unmounts
onUnmounted(() => {
store.resetGlobalState();
});
</script>

View File

@@ -80,7 +80,7 @@ export class ApiMangaRepository {
async searchMangas(query) { async searchMangas(query) {
try { try {
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`); const response = await fetch(`/api/manga-search?q=${encodeURIComponent(query)}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to search mangas'); throw new Error('Failed to search mangas');
} }

View File

@@ -82,13 +82,13 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaList from '../components/MangaList.vue';
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'; import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid'; import { ArrowPathIcon } from '@heroicons/vue/24/solid';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaList from '../components/MangaList.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -147,7 +147,6 @@
try { try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId); await mangaStore.createFromMangaDex(selectedManga.value.externalId);
await mangaStore.fetchMangaChapters(selectedManga.value.id);
router.push('/manga'); router.push('/manga');
} catch (e) { } catch (e) {
console.error("Erreur d'ajout:", e); console.error("Erreur d'ajout:", e);

View File

@@ -17,21 +17,21 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaGrid from '../components/MangaGrid.vue';
import MangaList from '../components/MangaList.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { import {
ArrowPathIcon, ArrowPathIcon,
MagnifyingGlassIcon, ArrowsUpDownIcon,
Cog6ToothIcon, Cog6ToothIcon,
EyeIcon, EyeIcon,
ArrowsUpDownIcon, FunnelIcon,
FunnelIcon MagnifyingGlassIcon
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaGrid from '../components/MangaGrid.vue';
import MangaList from '../components/MangaList.vue';
const router = useRouter(); const router = useRouter();
const mangaStore = useMangaStore(); const mangaStore = useMangaStore();
@@ -60,7 +60,7 @@
label: 'Refresh', label: 'Refresh',
type: 'button', type: 'button',
onClick: () => mangaStore.refreshCollectionInBackground(), onClick: () => mangaStore.refreshCollectionInBackground(),
active: isBackgroundLoading active: isBackgroundLoading.value
}, },
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} } { icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
], ],

View File

@@ -9,7 +9,7 @@
<div v-else-if="currentManga" class="relative"> <div v-else-if="currentManga" class="relative">
<!-- Composant invisible qui écoute les mises à jour Mercure --> <!-- Composant invisible qui écoute les mises à jour Mercure -->
<MercureListener :manga-id="mangaId" /> <MercureListener :manga-id="String(mangaId)" />
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" /> <Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
@@ -129,7 +129,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter(); const router = useRouter();
const mangaStore = useMangaStore(); const mangaStore = useMangaStore();
const mangaId = computed(() => route.params.id || null); const mangaId = computed(() => Number(route.params.id) || null);
// État de la modale // État de la modale
const isPreferredSourcesModalOpen = ref(false); const isPreferredSourcesModalOpen = ref(false);

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue'; import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
@@ -56,10 +57,16 @@ const routes = [
component: ChapterPage, component: ChapterPage,
props: { title: 'Lecteur' } props: { title: 'Lecteur' }
}, },
// Import routes
{
path: '/import',
name: 'import',
component: NewImportPage
},
// Pages placeholder avec chargement différé // Pages placeholder avec chargement différé
{ {
path: '/manga/import', path: '/manga/import',
name: 'import', name: 'manga-import',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Import de bibliothèque' } props: { title: 'Import de bibliothèque' }
}, },

View File

@@ -58,7 +58,7 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{ {
icon: ArrowDownTrayIcon, icon: ArrowDownTrayIcon,
text: 'Import bibliothèque', text: 'Import bibliothèque',
to: '/manga/import' to: '/import'
}, },
{ icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' } { icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' }
] ]

View File

@@ -0,0 +1,127 @@
<template>
<div class="file-upload">
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
{{ label }}
</label>
<div
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
@drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
>
<div class="space-y-1 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="flex text-sm text-gray-600">
<label
:for="inputId"
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
>
<span>Sélectionner des fichiers</span>
<input
:id="inputId"
ref="fileInput"
type="file"
class="sr-only"
:accept="accept"
:multiple="multiple"
@change="handleFileSelect"
>
</label>
<p class="pl-1">ou glisser-déposer</p>
</div>
<p class="text-xs text-gray-500">
{{ description }}
</p>
<div v-if="selectedFiles.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
<ul class="text-xs text-gray-600 space-y-1">
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
<span class="truncate">{{ file.name }}</span>
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
label: {
type: String,
default: 'Choisir des fichiers'
},
accept: {
type: String,
default: '.cbz,.cbr'
},
multiple: {
type: Boolean,
default: true
},
description: {
type: String,
default: 'CBZ ou CBR jusqu\'à 100MB chacun'
},
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'files-selected']);
const fileInput = ref(null);
const isDragOver = ref(false);
const selectedFiles = ref([]);
const inputId = computed(() => `file-upload-${Math.random().toString(36).substr(2, 9)}`);
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
selectedFiles.value = files;
emit('update:modelValue', files);
emit('files-selected', files);
};
const handleDrop = (event) => {
isDragOver.value = false;
const files = Array.from(event.dataTransfer.files);
selectedFiles.value = files;
emit('update:modelValue', files);
emit('files-selected', files);
};
// Watch for external changes to modelValue
watch(() => props.modelValue, (newFiles) => {
selectedFiles.value = newFiles;
}, { deep: true });
</script>

View File

@@ -0,0 +1,46 @@
<template>
<svg
class="animate-spin"
:class="sizeClasses"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
}
});
const sizeClasses = computed(() => {
const sizes = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
xl: 'h-16 w-16'
};
return sizes[props.size];
});
</script>

View File

@@ -27,11 +27,15 @@ framework:
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands 'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands 'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands 'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
# Events
# Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent)
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events 'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
'App\Domain\Manga\Domain\Event\MangaCreated': events
'App\Domain\Shared\Domain\Event\ChapterImported': events
'App\Domain\Shared\Domain\Event\VolumeImported': events
# Legacy messages (à garder si nécessaire) # Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands 'App\Message\DownloadChapter': commands

View File

@@ -126,11 +126,13 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler: App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~
tags:
- { name: messenger.message_handler, bus: command.bus } # Shared Manga Path/File Manager
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
App\Domain\Scraping\Infrastructure\Service\CbzGenerator: alias: App\Domain\Shared\Infrastructure\Service\MangaFileManager
App\Domain\Shared\Infrastructure\Service\MangaFileManager:
arguments: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
@@ -146,10 +148,6 @@ services:
$publicDir: '%kernel.project_dir%/public' $publicDir: '%kernel.project_dir%/public'
$httpClient: '@GuzzleHttp\Client' $httpClient: '@GuzzleHttp\Client'
App\Domain\Manga\Infrastructure\EventListener\MangaCreatedListener:
tags:
- { name: messenger.message_handler }
# Chapter Repository # Chapter Repository
App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface: App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface:
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyChapterRepository alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyChapterRepository
@@ -162,3 +160,35 @@ services:
App\Domain\Manga\Infrastructure\Service\FileService: App\Domain\Manga\Infrastructure\Service\FileService:
arguments: arguments:
$cbzStoragePath: '%kernel.project_dir%/public/cbz' $cbzStoragePath: '%kernel.project_dir%/public/cbz'
App\Domain\Shared\Domain\Contract\EventDispatcherInterface:
alias: App\Domain\Shared\Infrastructure\Messenger\SymfonyMessengerEventDispatcher
# Shared Domain Services Configuration
App\Domain\Shared\Domain\Contract\FileUploadInterface:
alias: App\Domain\Shared\Infrastructure\Service\SymfonyFileUpload
App\Domain\Shared\Infrastructure\Service\SymfonyFileUpload:
arguments:
$uploadsDirectory: '%kernel.project_dir%/public/tmp'
App\Domain\Shared\Domain\Contract\NotificationInterface:
alias: App\Domain\Shared\Infrastructure\Service\SymfonyNotification
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
tags:
- { name: messenger.message_handler, bus: command.bus }
# Import Domain Services
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
# Import Domain Query/Command Handlers
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
# Import Domain API Platform Services
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~

View File

@@ -51,6 +51,7 @@
"puppeteer": "^22.10.0", "puppeteer": "^22.10.0",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7" "tailwindcss": "^3.2.7",
"vuedraggable": "^2.24.3"
} }
} }

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
readonly class ImportChapter
{
public function __construct(
public string $mangaId,
public float $chapterNumber,
public string $fileBinary
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Manga\Application\Command;
readonly class ImportVolume
{
public function __construct(
public string $mangaId,
public int $volumeNumber,
public string $fileBinary
) {}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domain\Manga\Application\CommandHandler; namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex; use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
use App\Domain\Manga\Application\Response\CreateMangaResponse;
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface; use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface; use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
@@ -10,7 +11,7 @@ use App\Domain\Manga\Domain\Event\MangaCreated;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId; use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls; use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use Symfony\Component\Messenger\MessageBusInterface; use App\Domain\Shared\Domain\Contract\EventDispatcherInterface;
readonly class CreateMangaFromMangadexHandler readonly class CreateMangaFromMangadexHandler
{ {
@@ -18,7 +19,7 @@ readonly class CreateMangaFromMangadexHandler
private MangaProviderInterface $mangaProvider, private MangaProviderInterface $mangaProvider,
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private ImageProcessorInterface $imageProcessor, private ImageProcessorInterface $imageProcessor,
private MessageBusInterface $messageBus private EventDispatcherInterface $eventDispatcher
) {} ) {}
public function handle(CreateMangaFromMangadex $command): void public function handle(CreateMangaFromMangadex $command): void
@@ -44,6 +45,6 @@ readonly class CreateMangaFromMangadexHandler
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
$this->messageBus->dispatch(new MangaCreated($manga->getId()->getValue(), $command->externalId)); $this->eventDispatcher->dispatch(new MangaCreated($manga->getId()->getValue(), $command->externalId));
} }
} }

View File

@@ -5,6 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters; use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface; use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Exception\MangadexApiException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
readonly class FetchMangaChaptersHandler readonly class FetchMangaChaptersHandler
{ {
@@ -18,7 +20,11 @@ readonly class FetchMangaChaptersHandler
$manga = $this->mangaRepository->findById($command->mangaId->getValue()); $manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) { if ($manga === null) {
throw new \RuntimeException('Manga not found'); throw new MangaNotFoundException();
}
if($manga->getExternalId() === null){
throw new MangadexApiException("Manga has no external_id");
} }
// Synchronisation initiale (pas d'événements) // Synchronisation initiale (pas d'événements)

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
use Ramsey\Uuid\Uuid;
readonly class ImportChapterHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaPathManagerInterface $pathManager
) {}
public function handle(ImportChapter $command): void
{
// 1. Validate that the manga exists
$manga = $this->mangaRepository->findById($command->mangaId);
if (!$manga) {
throw new MangaNotFoundException($command->mangaId);
}
// 2. Validate that the file is a valid CBZ
if (!$this->isValidCbzFile($command->fileBinary)) {
throw new \InvalidArgumentException('The provided file is not a valid CBZ file');
}
// 3. Check if chapter exists
$existingChapter = $this->chapterRepository->findByMangaIdAndChapterNumber(
$command->mangaId,
$command->chapterNumber
);
if (!$existingChapter) {
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
}
// 4. Save the CBZ file to storage using the path manager
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter);
// 5. Update existing chapter with new CBZ path
$updatedChapter = new Chapter(
id: new ChapterId($existingChapter->getId()),
mangaId: $existingChapter->getMangaId(),
number: $existingChapter->getNumber(),
title: $existingChapter->getTitle(),
volume: $existingChapter->getVolume(),
isVisible: $existingChapter->isVisible(),
cbzPath: $cbzPath,
createdAt: $existingChapter->getCreatedAt()
);
$this->chapterRepository->save($updatedChapter);
}
/**
* Validate that the binary data is a valid CBZ (ZIP) file
*/
private function isValidCbzFile(string $fileBinary): bool
{
// CBZ files are ZIP archives, check for ZIP magic number
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
return strpos($fileBinary, $zipMagicNumber) === 0;
}
/**
* Save the CBZ file to storage and return the path
*/
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, Chapter $chapter): string
{
// Build the final CBZ path using the path manager (creates directories)
$volumeNumber = $chapter->getVolume() ?? 0;
$cbzPath = $this->pathManager->buildChapterCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$volumeNumber,
(string)$command->chapterNumber
);
// Write the binary content directly to the CBZ path
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
readonly class ImportVolumeHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaPathManagerInterface $pathManager
) {}
public function handle(ImportVolume $command): void
{
// 1. Validate that the manga exists
$manga = $this->mangaRepository->findById($command->mangaId);
if (!$manga) {
throw new MangaNotFoundException($command->mangaId);
}
// 2. Validate that the file is a valid CBZ
if (!$this->isValidCbzFile($command->fileBinary)) {
throw new \InvalidArgumentException('The provided file is not a valid CBZ file');
}
// 3. Get all chapters for this volume
$chapters = $this->chapterRepository->findByMangaIdAndVolume(
$command->mangaId,
$command->volumeNumber
);
if (empty($chapters)) {
throw new \InvalidArgumentException(
"No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}"
);
}
// 4. Save the CBZ file to storage using the path manager
$cbzPath = $this->saveCbzFile($command, $manga);
// 5. Update all chapters with the volume CBZ path
foreach ($chapters as $chapter) {
$updatedChapter = new Chapter(
id: new ChapterId($chapter->getId()),
mangaId: $chapter->getMangaId(),
number: $chapter->getNumber(),
title: $chapter->getTitle(),
volume: $chapter->getVolume(),
isVisible: $chapter->isVisible(),
cbzPath: $cbzPath,
createdAt: $chapter->getCreatedAt()
);
$this->chapterRepository->save($updatedChapter);
}
}
/**
* Validate that the binary data is a valid CBZ (ZIP) file
*/
private function isValidCbzFile(string $fileBinary): bool
{
// CBZ files are ZIP archives, check for ZIP magic number
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
return strpos($fileBinary, $zipMagicNumber) === 0;
}
/**
* Save the CBZ file to storage and return the path
*/
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
{
// Build the final CBZ path using the path manager (creates directories)
$cbzPath = $this->pathManager->buildVolumeCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$command->volumeNumber
);
// Write the binary content directly to the CBZ path
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\EventListener;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Shared\Domain\Event\ChapterImported;
readonly class ChapterImportedEventListener
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
) {}
public function __invoke(ChapterImported $event): void
{
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
if (!$manga) {
return; // Manga introuvable, on ignore
}
$chapters = $this->chapterRepository->findVisibleByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
foreach ($chapters as $chapter) {
if ($chapter->getNumber() === (float) $event->chapterNumber) {
$updated = new Chapter(
new ChapterId($chapter->getId()),
$chapter->getMangaId(),
$chapter->getNumber(),
$chapter->getTitle(),
$chapter->getVolume(),
$chapter->isVisible(),
$event->cbzPath,
$chapter->getCreatedAt(),
);
$this->chapterRepository->save($updated);
break;
}
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domain\Manga\Application\EventListener;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use App\Domain\Manga\Domain\Event\MangaCreated;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class MangaCreatedEventListener
{
public function __construct(
private FetchMangaChaptersHandler $fetchMangaChaptersHandler
) {}
public function __invoke(MangaCreated $event): void
{
$this->fetchMangaChaptersHandler->handle(
new FetchMangaChapters(new MangaId($event->mangaId))
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\EventListener;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Shared\Domain\Event\VolumeImported;
readonly class VolumeImportedEventListener
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
) {}
public function __invoke(VolumeImported $event): void
{
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
if (!$manga) {
return;
}
$chapters = $this->chapterRepository->findByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
if ($chapters === []) {
return;
}
foreach ($chapters as $chapter) {
$updated = new Chapter(
new ChapterId($chapter->getId()),
$chapter->getMangaId(),
$chapter->getNumber(),
$chapter->getTitle(),
$chapter->getVolume(),
$chapter->isVisible(),
$event->cbzPath,
$chapter->getCreatedAt(),
);
$this->chapterRepository->save($updated);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\Query;
readonly class FindMangaMatchByFilename
{
public function __construct(
public string $filename
) {
}
}

View File

@@ -46,7 +46,7 @@ readonly class DownloadVolumeHandler implements QueryHandlerInterface
$cbzPaths[] = $chapter->getCbzPath(); $cbzPaths[] = $chapter->getCbzPath();
} }
$volumeName = sprintf('%s-volume-%d', $volumeName = sprintf('%s_vol%d',
$manga->getSlug()->getValue(), $manga->getSlug()->getValue(),
$query->volume $query->volume
); );

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\QueryHandler;
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
use App\Domain\Manga\Application\Response\MangaMatchItem;
use App\Domain\Manga\Application\Response\MangaMatchResponse;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
use App\Domain\Manga\Domain\Model\Manga;
readonly class FindMangaMatchByFilenameHandler
{
public function __construct(
private FilenameAnalyzerInterface $filenameAnalyzer,
private MangaRepositoryInterface $mangaRepository
) {
}
public function handle(FindMangaMatchByFilename $query): MangaMatchResponse
{
// Analyser le nom de fichier pour extraire les informations
$analyzedFilename = $this->filenameAnalyzer->analyze($query->filename);
$searchedTitle = $analyzedFilename->getTitle()->getValue();
$chapterNumber = $analyzedFilename->hasChapterNumber()
? $analyzedFilename->getChapterNumber()->getValue()
: null;
$volumeNumber = $analyzedFilename->hasVolumeNumber()
? $analyzedFilename->getVolumeNumber()->getValue()
: null;
// Rechercher les mangas correspondants
$foundMangas = $this->mangaRepository->search($searchedTitle, 1, 10);
$matches = [];
foreach ($foundMangas as $manga) {
$mangaId = $manga->getId()->getValue();
// Calculer un score de correspondance
$matchScore = $this->calculateMatchScore(
$manga,
$searchedTitle
);
$matches[] = new MangaMatchItem(
id: $mangaId,
title: $manga->getTitle()->getValue(),
slug: $manga->getSlug()->getValue(),
alternativeSlugs: $manga->getAlternativeSlugs(),
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
matchScore: $matchScore,
chapterNumber: $chapterNumber,
volumeNumber: $volumeNumber
);
}
// Trier les résultats par score de correspondance (du plus élevé au plus faible)
usort($matches, fn($a, $b) => $b->matchScore <=> $a->matchScore);
return new MangaMatchResponse(
matches: $matches,
chapterNumber: $chapterNumber,
volumeNumber: $volumeNumber,
possibleTitles: [$searchedTitle]
);
}
/**
* Calcule un score de correspondance entre le manga et le titre recherché
* Score plus élevé = meilleure correspondance
*/
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
{
$score = 0;
$mangaTitle = $manga->getTitle()->getValue();
$mangaSlug = $manga->getSlug()->getValue();
// Correspondance exacte avec le titre
if (strtolower($mangaTitle) === strtolower($searchedTitle)) {
$score += 100;
}
// Correspondance exacte avec le slug
if (strtolower($mangaSlug) === strtolower($searchedTitle)) {
$score += 90;
}
// Correspondance avec les slugs alternatifs
foreach ($manga->getAlternativeSlugs() as $altSlug) {
if (strtolower($altSlug) === strtolower($searchedTitle)) {
$score += 80;
break;
}
}
// Le titre du manga contient le terme recherché
if (stripos($mangaTitle, $searchedTitle) !== false) {
$score += 50;
}
// Le terme recherché contient le titre du manga
if (stripos($searchedTitle, $mangaTitle) !== false) {
$score += 40;
}
// Similarité de Levenshtein (pour les fautes de frappe)
$levenshteinDistance = levenshtein(
strtolower($mangaTitle),
strtolower($searchedTitle)
);
if ($levenshteinDistance <= 3) {
$score += (3 - $levenshteinDistance) * 10;
}
return $score;
}
}

View File

@@ -26,6 +26,7 @@ readonly class GetMangaBySlugHandler
id: $manga->getId()->getValue(), id: $manga->getId()->getValue(),
title: $manga->getTitle()->getValue(), title: $manga->getTitle()->getValue(),
slug: $manga->getSlug()->getValue(), slug: $manga->getSlug()->getValue(),
alternativeSlugs: $manga->getAlternativeSlugs(),
description: $manga->getDescription(), description: $manga->getDescription(),
author: $manga->getAuthor(), author: $manga->getAuthor(),
publicationYear: $manga->getPublicationYear(), publicationYear: $manga->getPublicationYear(),
@@ -34,7 +35,8 @@ readonly class GetMangaBySlugHandler
externalId: $manga->getExternalId()?->getValue(), externalId: $manga->getExternalId()?->getValue(),
imageUrl: $manga->getImageUrl(), imageUrl: $manga->getImageUrl(),
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(), thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
rating: $manga->getRating() rating: $manga->getRating(),
monitored: $manga->isMonitored()
); );
} }
} }

View File

@@ -25,6 +25,7 @@ readonly class SearchLocalMangaHandler
id: $manga->getId()->getValue(), id: $manga->getId()->getValue(),
title: $manga->getTitle()->getValue(), title: $manga->getTitle()->getValue(),
slug: $manga->getSlug()->getValue(), slug: $manga->getSlug()->getValue(),
alternativeSlugs: $manga->getAlternativeSlugs(),
description: $manga->getDescription(), description: $manga->getDescription(),
author: $manga->getAuthor(), author: $manga->getAuthor(),
publicationYear: $manga->getPublicationYear(), publicationYear: $manga->getPublicationYear(),
@@ -33,7 +34,8 @@ readonly class SearchLocalMangaHandler
externalId: $manga->getExternalId()?->getValue() ?? '', externalId: $manga->getExternalId()?->getValue() ?? '',
imageUrl: $manga->getImageUrls()->getFull(), imageUrl: $manga->getImageUrls()->getFull(),
thumbnailUrl: $manga->getImageUrls()->getThumbnail(), thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
rating: $manga->getRating() rating: $manga->getRating(),
monitored: $manga->isMonitored()
), ),
$mangas $mangas
), ),

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\Response;
readonly class MangaMatchItem
{
public function __construct(
public string $id,
public string $title,
public string $slug,
public array $alternativeSlugs,
public ?string $thumbnailUrl,
public int $matchScore,
public ?float $chapterNumber = null,
public ?float $volumeNumber = null
) {
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\Response;
readonly class MangaMatchResponse
{
/**
* @param MangaMatchItem[] $matches
*/
public function __construct(
public array $matches,
public ?float $chapterNumber,
public ?float $volumeNumber,
public array $possibleTitles
) {
}
public function hasMatches(): bool
{
return count($this->matches) > 0;
}
public function getBestMatch(): ?MangaMatchItem
{
return $this->matches[0] ?? null;
}
}

View File

@@ -8,6 +8,7 @@ interface ChapterRepositoryInterface
{ {
public function findById(string $id): ?Chapter; public function findById(string $id): ?Chapter;
public function findVisibleById(string $id): ?Chapter; public function findVisibleById(string $id): ?Chapter;
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter;
public function save(Chapter $chapter): void; public function save(Chapter $chapter): void;
public function delete(Chapter $chapter): void; public function delete(Chapter $chapter): void;

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Contract\Service;
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
interface FilenameAnalyzerInterface
{
public function analyze(string $filename): AnalyzedFilename;
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
readonly class AnalyzedFilename
{
public function __construct(
private MangaTitle $title,
private ?ChapterNumber $chapterNumber = null,
private ?VolumeNumber $volumeNumber = null
) {
}
public function getTitle(): MangaTitle
{
return $this->title;
}
public function getChapterNumber(): ?ChapterNumber
{
return $this->chapterNumber;
}
public function getVolumeNumber(): ?VolumeNumber
{
return $this->volumeNumber;
}
public function hasChapterNumber(): bool
{
return $this->chapterNumber !== null;
}
public function hasVolumeNumber(): bool
{
return $this->volumeNumber !== null;
}
}

View File

@@ -163,6 +163,11 @@ final class Manga
return $this->monitoringStatus->isEnabled(); return $this->monitoringStatus->isEnabled();
} }
public function isMonitored(): bool
{
return $this->monitoringStatus->isEnabled();
}
public function enableMonitoring(): void public function enableMonitoring(): void
{ {
$this->monitoringStatus = MonitoringStatus::enabled(); $this->monitoringStatus = MonitoringStatus::enabled();

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model\ValueObject;
use InvalidArgumentException;
readonly class ChapterNumber
{
public function __construct(
private float $value
) {
if ($value < 0) {
throw new InvalidArgumentException('Chapter number cannot be negative');
}
}
public function getValue(): float
{
return $this->value;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model\ValueObject;
use InvalidArgumentException;
readonly class VolumeNumber
{
public function __construct(
private float $value
) {
if ($value < 0) {
throw new InvalidArgumentException('Volume number cannot be negative');
}
}
public function getValue(): float
{
return $this->value;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportChapterResource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
#[AsController]
final class ImportChapterController extends AbstractController
{
public function __construct(
private readonly ImportChapterHandler $commandHandler
) {}
public function __invoke(Request $request): Response
{
// Get form parameters
$mangaId = $request->request->get('mangaId');
$chapterNumber = $request->request->get('chapterNumber');
$uploadedFile = $request->files->get('file');
// Validate required fields
if (!$mangaId) {
return $this->json([
['propertyPath' => 'mangaId', 'message' => 'mangaId is required']
], 422);
}
if (!$chapterNumber) {
return $this->json([
['propertyPath' => 'chapterNumber', 'message' => 'chapterNumber is required']
], 422);
}
if (!$uploadedFile) {
return $this->json([
['propertyPath' => 'file', 'message' => 'Please upload a file']
], 422);
}
// Validate file
$errors = $this->validateFile($uploadedFile);
if (!empty($errors)) {
return $this->json($errors, 422);
}
try {
// Read file binary content
$fileBinary = file_get_contents($uploadedFile->getPathname());
if ($fileBinary === false) {
return $this->json([
['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file']
], 400);
}
// Create the command
$command = new ImportChapter(
mangaId: $mangaId,
chapterNumber: (float) $chapterNumber,
fileBinary: $fileBinary
);
// Execute the import
$this->commandHandler->handle($command);
return $this->json([
'message' => 'Chapter imported successfully',
'mangaId' => $mangaId,
'chapterNumber' => $chapterNumber
], 200);
} catch (MangaNotFoundException $e) {
return $this->json([
'error' => 'Manga not found',
'details' => $e->getMessage()
], 404);
} catch (ChapterNotFoundException $e) {
return $this->json([
'error' => 'Chapter not found',
'details' => $e->getMessage()
], 404);
} catch (\InvalidArgumentException $e) {
return $this->json([
'error' => 'Invalid file',
'details' => $e->getMessage()
], 400);
} catch (\Exception $e) {
return $this->json([
'error' => 'Import failed',
'details' => $e->getMessage()
], 500);
}
}
private function validateFile($uploadedFile): array
{
$errors = [];
// Check if file is valid
if (!$uploadedFile->isValid()) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
];
return $errors;
}
// Check file size (500MB max)
$maxSize = 500 * 1024 * 1024; // 500MB in bytes
if ($uploadedFile->getSize() > $maxSize) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is too large. Allowed size is 500MB.'
];
}
// Check file extension
$allowedExtensions = ['cbz'];
$extension = strtolower($uploadedFile->getClientOriginalExtension());
if (!in_array($extension, $allowedExtensions)) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'Please upload a valid CBZ file'
];
}
return $errors;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportVolumeResource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
#[AsController]
final class ImportVolumeController extends AbstractController
{
public function __construct(
private readonly ImportVolumeHandler $commandHandler
) {}
public function __invoke(Request $request): Response
{
// Get form parameters
$mangaId = $request->request->get('mangaId');
$volumeNumber = $request->request->get('volumeNumber');
$uploadedFile = $request->files->get('file');
// Validate required fields
if (!$mangaId) {
return $this->json([
['propertyPath' => 'mangaId', 'message' => 'mangaId is required']
], 422);
}
if (!$volumeNumber) {
return $this->json([
['propertyPath' => 'volumeNumber', 'message' => 'volumeNumber is required']
], 422);
}
if (!$uploadedFile) {
return $this->json([
['propertyPath' => 'file', 'message' => 'Please upload a file']
], 422);
}
// Validate file
$errors = $this->validateFile($uploadedFile);
if (!empty($errors)) {
return $this->json($errors, 422);
}
try {
// Read file binary content
$fileBinary = file_get_contents($uploadedFile->getPathname());
if ($fileBinary === false) {
return $this->json([
['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file']
], 400);
}
// Create the command
$command = new ImportVolume(
mangaId: $mangaId,
volumeNumber: (int) $volumeNumber,
fileBinary: $fileBinary
);
// Execute the import
$this->commandHandler->handle($command);
return $this->json([
'message' => 'Volume imported successfully',
'mangaId' => $mangaId,
'volumeNumber' => (int) $volumeNumber
], 200);
} catch (MangaNotFoundException $e) {
return $this->json([
'error' => 'Manga not found',
'details' => $e->getMessage()
], 404);
} catch (\InvalidArgumentException $e) {
$statusCode = str_contains($e->getMessage(), 'not found') ? 404 : 400;
return $this->json([
'error' => 'Invalid request',
'details' => $e->getMessage()
], $statusCode);
} catch (\Exception $e) {
return $this->json([
'error' => 'Import failed',
'details' => $e->getMessage()
], 500);
}
}
private function validateFile($uploadedFile): array
{
$errors = [];
// Check if file is valid
if (!$uploadedFile->isValid()) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
];
return $errors;
}
// Check file size (500MB max)
$maxSize = 500 * 1024 * 1024; // 500MB in bytes
if ($uploadedFile->getSize() > $maxSize) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is too large. Allowed size is 500MB.'
];
}
// Check file extension
$allowedExtensions = ['cbz'];
$extension = strtolower($uploadedFile->getClientOriginalExtension());
if (!in_array($extension, $allowedExtensions)) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'Please upload a valid CBZ file'
];
}
return $errors;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class FilenameMatchCollection
{
/**
* @param FilenameMatchItem[] $matches
* @param string[] $possibleTitles
*/
public function __construct(
public array $matches,
public ?float $chapterNumber,
public ?int $volumeNumber,
public array $possibleTitles
) {
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class FilenameMatchItem
{
public function __construct(
public string $id,
public string $title,
public string $slug,
public array $alternativeSlugs,
public ?string $thumbnailUrl,
public int $matchScore,
public ?float $chapterNumber,
public ?float $volumeNumber
) {
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\FindMangaMatchByFilenameStateProvider;
#[ApiResource(
shortName: 'MangaMatch',
operations: [
new Get(
uriTemplate: '/manga-matches',
provider: FindMangaMatchByFilenameStateProvider::class,
openapiContext: [
'summary' => 'Trouve des correspondances de manga à partir d\'un nom de fichier',
'description' => 'Analyse un nom de fichier (cbz/cbr) et trouve les mangas correspondants dans la base de données. Extrait automatiquement le titre, le numéro de chapitre et le numéro de volume du nom de fichier.',
'parameters' => [
[
'name' => 'filename',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'string',
'example' => 'one-piece_vol108_ch1094.cbz'
],
'description' => 'Nom du fichier à analyser (avec ou sans extension .cbz/.cbr)'
]
],
'responses' => [
'200' => [
'description' => 'Correspondances trouvées',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'matches' => [
'type' => 'array',
'description' => 'Liste des mangas correspondants triés par score de pertinence',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'description' => 'Identifiant du manga'],
'title' => ['type' => 'string', 'description' => 'Titre du manga'],
'slug' => ['type' => 'string', 'description' => 'Slug du manga'],
'alternativeSlugs' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Slugs alternatifs'
],
'thumbnailUrl' => ['type' => 'string', 'nullable' => true, 'description' => 'URL de la miniature'],
'matchScore' => ['type' => 'integer', 'description' => 'Score de correspondance (plus élevé = meilleure correspondance)'],
'chapterNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de chapitre extrait'],
'volumeNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de volume extrait']
]
]
],
'chapterNumber' => [
'type' => 'number',
'nullable' => true,
'description' => 'Numéro de chapitre extrait du nom de fichier'
],
'volumeNumber' => [
'type' => 'number',
'nullable' => true,
'description' => 'Numéro de volume extrait du nom de fichier'
],
'possibleTitles' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Variantes de titres générées à partir du nom de fichier'
]
]
],
'example' => [
'matches' => [
[
'id' => '123',
'title' => 'One Piece',
'slug' => 'one-piece',
'alternativeSlugs' => [],
'thumbnailUrl' => 'https://example.com/thumb.jpg',
'matchScore' => 100,
'chapterNumber' => 1094.0,
'volumeNumber' => 108
]
],
]
]
]
],
'400' => [
'description' => 'Nom de fichier manquant ou invalide'
]
]
]
)
]
)]
class FindMangaMatchByFilenameResource
{
public function __construct(
public readonly array $matches = [],
) {
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportChapterController;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ImportChapter',
operations: [
new Post(
uriTemplate: '/chapters/import',
controller: ImportChapterController::class,
deserialize: false,
openapiContext: [
'summary' => 'Import a chapter from CBZ file',
'description' => 'Imports a CBZ file for an existing chapter and stores it',
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'required' => ['mangaId', 'chapterNumber', 'file'],
'properties' => [
'mangaId' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'The manga UUID'
],
'chapterNumber' => [
'type' => 'number',
'description' => 'The chapter number (e.g., 1.5)'
],
'file' => [
'type' => 'string',
'format' => 'binary',
'description' => 'CBZ file to import (max 500MB)'
]
]
]
]
]
],
'responses' => [
'200' => [
'description' => 'Chapter imported successfully',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'message' => ['type' => 'string'],
'chapterId' => ['type' => 'string']
]
]
]
]
]
]
]
)
]
)]
class ImportChapterResource
{
public ?string $mangaId = null;
public ?float $chapterNumber = null;
public ?File $file = null;
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportVolumeController;
use Symfony\Component\HttpFoundation\File\File;
#[ApiResource(
shortName: 'ImportVolume',
operations: [
new Post(
uriTemplate: '/volumes/import',
controller: ImportVolumeController::class,
deserialize: false,
openapiContext: [
'summary' => 'Import a volume from CBZ file',
'description' => 'Imports a CBZ file for an existing volume and updates all chapters',
'requestBody' => [
'content' => [
'multipart/form-data' => [
'schema' => [
'type' => 'object',
'required' => ['mangaId', 'volumeNumber', 'file'],
'properties' => [
'mangaId' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'The manga UUID'
],
'volumeNumber' => [
'type' => 'integer',
'description' => 'The volume number'
],
'file' => [
'type' => 'string',
'format' => 'binary',
'description' => 'CBZ file to import (max 500MB)'
]
]
]
]
]
],
'responses' => [
'200' => [
'description' => 'Volume imported successfully',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'message' => ['type' => 'string'],
'mangaId' => ['type' => 'string'],
'volumeNumber' => ['type' => 'integer']
]
]
]
]
]
]
]
)
]
)]
class ImportVolumeResource
{
public ?string $mangaId = null;
public ?int $volumeNumber = null;
public ?File $file = null;
}

View File

@@ -3,15 +3,15 @@
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource; namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection; use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider; use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider;
#[ApiResource( #[ApiResource(
shortName: 'Manga', shortName: 'MangaSearch',
operations: [ operations: [
new Get( new GetCollection(
uriTemplate: '/mangas/search', uriTemplate: '/manga-search',
provider: SearchLocalMangaStateProvider::class, provider: SearchLocalMangaStateProvider::class,
output: MangaSearchCollection::class, output: MangaSearchCollection::class,
status: 200, status: 200,
@@ -82,4 +82,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaS
)] )]
class SearchLocalMangaResource class SearchLocalMangaResource
{ {
} }

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
use App\Domain\Manga\Application\QueryHandler\FindMangaMatchByFilenameHandler;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchItem;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FindMangaMatchByFilenameResource;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
readonly class FindMangaMatchByFilenameStateProvider implements ProviderInterface
{
public function __construct(
private FindMangaMatchByFilenameHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): FindMangaMatchByFilenameResource
{
$filename = $context['filters']['filename'] ?? '';
if (empty($filename)) {
throw new BadRequestHttpException('Le nom de fichier est requis');
}
$query = new FindMangaMatchByFilename($filename);
$response = $this->handler->handle($query);
// Pour Get, on retourne directement la resource
return new FindMangaMatchByFilenameResource(
matches: array_map(
fn($match) => new FilenameMatchItem(
id: $match->id,
title: $match->title,
slug: $match->slug,
alternativeSlugs: $match->alternativeSlugs,
thumbnailUrl: $match->thumbnailUrl,
matchScore: $match->matchScore,
chapterNumber: $match->chapterNumber,
volumeNumber: $match->volumeNumber
),
$response->matches
),
);
}
}

View File

@@ -23,6 +23,7 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
id: $response->id, id: $response->id,
title: $response->title, title: $response->title,
slug: $response->slug, slug: $response->slug,
alternativeSlugs: $response->alternativeSlugs,
description: $response->description, description: $response->description,
author: $response->author, author: $response->author,
publicationYear: $response->publicationYear, publicationYear: $response->publicationYear,
@@ -31,7 +32,8 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
externalId: $response->externalId, externalId: $response->externalId,
imageUrl: $response->imageUrl, imageUrl: $response->imageUrl,
thumbnailUrl: $response->thumbnailUrl, thumbnailUrl: $response->thumbnailUrl,
rating: $response->rating rating: $response->rating,
monitored: $response->monitored
); );
} }
} }

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Domain\Manga\Infrastructure\EventListener;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Event\MangaCreated;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class MangaCreatedListener
{
public function __construct(
private MessageBusInterface $messageBus
) {}
public function __invoke(MangaCreated $event): void
{
$this->messageBus->dispatch(
new FetchMangaChapters($event->mangaId)
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\MessageHandler;
use App\Domain\Manga\Application\EventListener\ChapterImportedEventListener;
use App\Domain\Shared\Domain\Event\ChapterImported;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ChapterImportedMessageHandler
{
public function __construct(private ChapterImportedEventListener $listener)
{
}
public function __invoke(ChapterImported $event): void
{
$this->listener->__invoke($event);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Manga\Infrastructure\MessageHandler;
use App\Domain\Manga\Application\EventListener\MangaCreatedEventListener;
use App\Domain\Manga\Domain\Event\MangaCreated;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class MangaCreatedMessageHandler
{
public function __construct(private MangaCreatedEventListener $listener)
{
}
public function __invoke(MangaCreated $event): void
{
$this->listener->__invoke($event);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\MessageHandler;
use App\Domain\Manga\Application\EventListener\VolumeImportedEventListener;
use App\Domain\Shared\Domain\Event\VolumeImported;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class VolumeImportedMessageHandler
{
public function __construct(private VolumeImportedEventListener $listener)
{
}
public function __invoke(VolumeImported $event): void
{
$this->listener->__invoke($event);
}
}

View File

@@ -191,36 +191,59 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
{ {
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
$queryBuilder = $this->entityManager->createQueryBuilder() // Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
->select('m') $sql = "SELECT m.* FROM manga m
->from(EntityManga::class, 'm') WHERE m.title LIKE :query
->where('m.title LIKE :query') OR m.slug LIKE :query
->orWhere('m.slug LIKE :query') OR CAST(m.alternative_slugs AS TEXT) LIKE :query
// ->orWhere('m.author LIKE :query') ORDER BY m.title ASC
// ->orWhere('m.description LIKE :query') LIMIT :limit OFFSET :offset";
->setParameter('query', '%' . $query . '%')
->orderBy('m.title', 'ASC') $rsm = new \Doctrine\ORM\Query\ResultSetMapping();
->setFirstResult($offset) $rsm->addEntityResult(EntityManga::class, 'm');
->setMaxResults($limit); $rsm->addFieldResult('m', 'id', 'id');
$rsm->addFieldResult('m', 'title', 'title');
$rsm->addFieldResult('m', 'slug', 'slug');
$rsm->addFieldResult('m', 'image_url', 'imageUrl');
$rsm->addFieldResult('m', 'publication_year', 'publicationYear');
$rsm->addFieldResult('m', 'description', 'description');
$rsm->addFieldResult('m', 'genres', 'genres');
$rsm->addFieldResult('m', 'created_at', 'createdAt');
$rsm->addFieldResult('m', 'rating', 'rating');
$rsm->addFieldResult('m', 'author', 'author');
$rsm->addFieldResult('m', 'external_id', 'externalId');
$rsm->addFieldResult('m', 'status', 'status');
$rsm->addFieldResult('m', 'thumbnail_url', 'thumbnailUrl');
$rsm->addFieldResult('m', 'monitored', 'monitored');
$rsm->addFieldResult('m', 'last_monitoring_check', 'lastMonitoringCheck');
$rsm->addFieldResult('m', 'alternative_slugs', 'AlternativeSlugs');
$nativeQuery = $this->entityManager->createNativeQuery($sql, $rsm);
$nativeQuery->setParameter('query', '%' . $query . '%');
$nativeQuery->setParameter('limit', $limit);
$nativeQuery->setParameter('offset', $offset);
return array_map( return array_map(
fn (EntityManga $entity) => $this->toDomain($entity), fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult() $nativeQuery->getResult()
); );
} }
public function countSearch(string $query): int public function countSearch(string $query): int
{ {
return $this->entityManager->createQueryBuilder() // Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
->select('COUNT(m.id)') $sql = "SELECT COUNT(m.id) FROM manga m
->from(EntityManga::class, 'm') WHERE m.title LIKE :query
->where('m.title LIKE :query') OR m.slug LIKE :query
->orWhere('m.slug LIKE :query') OR m.author LIKE :query
->orWhere('m.author LIKE :query') OR m.description LIKE :query
->orWhere('m.description LIKE :query') OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
->setParameter('query', '%' . $query . '%')
->getQuery() $conn = $this->entityManager->getConnection();
->getSingleScalarResult(); $stmt = $conn->prepare($sql);
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
return (int) $result->fetchOne();
} }
/** /**

View File

@@ -36,6 +36,21 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
return $entity ? $this->toDomainModel($entity) : null; return $entity ? $this->toDomainModel($entity) : null;
} }
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter
{
$qb = $this->entityManager->createQueryBuilder()
->select('c')
->from(ChapterEntity::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.number = :chapterNumber')
->setParameter('mangaId', $mangaId)
->setParameter('chapterNumber', $chapterNumber);
$entity = $qb->getQuery()->getOneOrNullResult();
return $entity ? $this->toDomainModel($entity) : null;
}
public function save(Chapter $chapter): void public function save(Chapter $chapter): void
{ {
$entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId()); $entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId());

View File

@@ -114,7 +114,11 @@ readonly class MangadexProvider implements MangaProviderInterface
$mangas $mangas
); );
$ratings = $this->client->getMangaRatings($externalIds); try {
$ratings = $this->client->getMangaRatings($externalIds);
} catch (\Exception $e) {
return;
}
if (isset($ratings['statistics'])) { if (isset($ratings['statistics'])) {
foreach ($mangas as $manga) { foreach ($mangas as $manga) {
@@ -146,4 +150,4 @@ readonly class MangadexProvider implements MangaProviderInterface
return null; return null;
} }
} }
} }

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
readonly class FilenameAnalyzer implements FilenameAnalyzerInterface
{
public function analyze(string $filename): AnalyzedFilename
{
// Enlever l'extension
$nameWithoutExtension = preg_replace('/\.(cbz|cbr)$/i', '', $filename);
// Extraire les informations en utilisant la logique du CbzService
$titleStr = $this->extractTitle($nameWithoutExtension);
$volumeNumber = $this->extractVolume($nameWithoutExtension);
$chapterNumber = $this->extractChapter($nameWithoutExtension);
$cleanedTitle = $this->cleanTitle($titleStr);
return new AnalyzedFilename(
title: new MangaTitle($cleanedTitle),
chapterNumber: $chapterNumber !== null ? new ChapterNumber($chapterNumber) : null,
volumeNumber: $volumeNumber !== null ? new VolumeNumber((float) $volumeNumber) : null
);
}
private function extractTitle(string $fileName): string
{
// Pattern avec tirets complets : titre-volume-42-chapter-100 ou titre-chapitre-100-volume-42
$fullDashCombinedPattern = '/^(?P<title>.*?)-(?:volume|chapter|chapitre)-\d+-(?:volume|chapter|chapitre)-\d+/i';
if (preg_match($fullDashCombinedPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern avec tirets complets : titre-volume-42 ou titre-chapter-100 ou titre-chapitre-100
$fullDashPattern = '/^(?P<title>.*?)-(?:volume|chapter|chapitre)-\d+/i';
if (preg_match($fullDashPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern principal : titre suivi de volume/chapitre (inspiré du CbzService)
// Supporte: vol, volume, tome, t, ch, chap, chapter, chapitre
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Cc]h(?:ap(?:itre|ter)?)?|[Tt])\s*\d+)/';
if (preg_match($titlePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore : titre_vol123 ou titre_ch456 ou titre_chapter_1094
$underscorePattern = '/^(?P<title>.*?)_(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($underscorePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern avec tiret : titre-vol123 ou titre-ch456 ou titre-tome-50
$dashPattern = '/^(?P<title>.*?)-(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($dashPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore simple : titre_123
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Si aucun pattern ne matche, retourner le nom sans extension
return $fileName;
}
private function extractVolume(string $fileName): ?int
{
// Pattern pour volume : vol123, volume123, tome123, t123, v123
$volumePattern = '/(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Tt]|[Vv])[\s\-_]*(?P<volume>\d+)/i';
if (preg_match($volumePattern, $fileName, $matches)) {
return (int) $matches['volume'];
}
// Pattern avec tirets complets pour volume : volume-42
$fullDashVolumePattern = '/volume-(?P<volume>\d+)/i';
if (preg_match($fullDashVolumePattern, $fileName, $matches)) {
return (int) $matches['volume'];
}
return null;
}
private function extractChapter(string $fileName): ?float
{
// Pattern pour chapitre : ch123, chap123, chapter123, chapitre123
$chapterPattern = '/[Cc]h(?:ap(?:itre|ter)?)?[\s\-_]*(?P<chapter>\d+(?:\.\d+)?)/i';
if (preg_match($chapterPattern, $fileName, $matches)) {
return (float) $matches['chapter'];
}
// Pattern avec tirets complets pour chapitre : chapter-100 ou chapitre-100
$fullDashChapterPattern = '/(?:chapter|chapitre)-(?P<chapter>\d+(?:\.\d+)?)/i';
if (preg_match($fullDashChapterPattern, $fileName, $matches)) {
return (float) $matches['chapter'];
}
// Pattern underscore à la fin : _123.cbz
$newFormatPattern = '/_ch(?P<chapter>\d+(?:\.\d+)?)(?:\.\w+)?$/i';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return (float) $matches['chapter'];
}
return null;
}
private function cleanTitle(string $title): string
{
// Enlever les patterns communs (avec séparateurs possibles)
$cleanTitle = preg_replace('/[\s\-_]?(?:scan|raw|fr|en|jp|hq|lq)[\s\-_]?/i', ' ', $title);
// Enlever les caractères spéciaux en début/fin
$cleanTitle = trim($cleanTitle, ' -_.');
// Normaliser les espaces multiples
$cleanTitle = preg_replace('/\s+/', ' ', $cleanTitle);
return trim($cleanTitle);
}
}

View File

@@ -5,84 +5,24 @@ namespace App\Domain\Scraping\Infrastructure\Service;
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface; use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest; use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath; use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
readonly class CbzGenerator implements CbzGeneratorInterface readonly class CbzGenerator implements CbzGeneratorInterface
{ {
public function __construct( public function __construct(
private string $projectDir private MangaPathManagerInterface $mangaPathManager,
) { ) {
} }
public function generate(CbzGenerationRequest $request): CbzPath public function generate(CbzGenerationRequest $request): CbzPath
{ {
$cbzPath = $this->generateCbzPath($request); $cbzPath = $this->mangaPathManager->buildChapterCbzPath(
$this->createCbzArchive($request->getFiles(), $cbzPath); $request->getMangaTitle(),
$request->getPublicationYear(),
$request->getVolumeNumber(),
$request->getChapterNumber(),
);
$this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath);
return new CbzPath($cbzPath); return new CbzPath($cbzPath);
} }
private function generateCbzPath(CbzGenerationRequest $request): string
{
$mangaDir = $this->createMangaDirectory(
$this->slugify($request->getMangaTitle()),
$request->getPublicationYear()
);
$volumeDir = $this->createVolumeDirectory($mangaDir, $request->getVolumeNumber());
return sprintf(
'%s/%s_vol%d_ch%s.cbz',
$volumeDir,
$this->slugify($request->getMangaTitle()),
$request->getVolumeNumber(),
$request->getChapterNumber()
);
}
private function createCbzArchive(array $files, string $cbzPath): void
{
$zip = new \ZipArchive();
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Failed to create CBZ archive');
}
foreach ($files as $file) {
if (!file_exists($file)) {
throw new \RuntimeException("File not found: $file");
}
$zip->addFile($file, basename($file));
}
if (!$zip->close()) {
throw new \RuntimeException('Failed to close CBZ archive');
}
}
private function createMangaDirectory(string $mangaSlug, string $publicationYear): string
{
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, ucfirst($mangaSlug) . ' (' . $publicationYear . ')');
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new \RuntimeException("Failed to create directory: $dir");
}
return $dir;
}
private function createVolumeDirectory(string $mangaDir, int $volumeNumber): string
{
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new \RuntimeException("Failed to create directory: $dir");
}
return $dir;
}
private function slugify(string $text): string
{
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
$text = preg_replace('~[^-\w]+~', '', $text);
$text = trim($text, '-');
$text = preg_replace('~-+~', '-', $text);
$text = strtolower($text);
return $text ?: 'n-a';
}
} }

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
interface EventDispatcherInterface
{
public function dispatch(object $event): void;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Contract;
use App\Domain\Shared\Domain\Model\FileUpload;
interface FileUploadInterface
{
/**
* Déplace un fichier uploadé vers un répertoire temporaire
*/
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string;
/**
* Vérifie si un fichier existe
*/
public function fileExists(string $filePath): bool;
/**
* Supprime un fichier
*/
public function deleteFile(string $filePath): void;
/**
* Déplace un fichier d'un emplacement à un autre
*/
public function moveFile(string $sourcePath, string $targetPath): void;
/**
* Crée un répertoire s'il n'existe pas
*/
public function createDirectory(string $path): void;
/**
* Valide le format d'un fichier
*/
public function validateFileFormat(string $filePath, array $allowedExtensions): bool;
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Contract;
/**
* Service centralisé de gestion des chemins et de l'enregistrement des fichiers
* liés aux mangas (manga/volume/chapter) et des archives CBZ.
*/
interface MangaPathManagerInterface
{
/**
* Retourne (et crée si nécessaire) le dossier du manga.
*/
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string;
/**
* Retourne (et crée si nécessaire) le dossier du volume.
*/
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
/**
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de chapitre.
*/
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string;
/**
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de volume.
*/
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
/**
* Crée une archive CBZ à partir d'une liste de fichiers et l'écrit au chemin fourni.
*
* @param array<int, string> $files Chemins absolus des fichiers à packager
*/
public function createCbzArchive(array $files, string $cbzPath): void;
/**
* Déplace un fichier existant vers une destination. Crée les dossiers si nécessaire.
*/
public function moveFileTo(string $sourcePath, string $destinationPath): void;
/**
* Indique si un fichier existe et est lisible.
*/
public function fileExists(string $path): bool;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Contract;
use App\Domain\Shared\Domain\Model\FileMetadata;
interface MetadataExtractorInterface
{
/**
* Extrait les métadonnées d'un fichier
*/
public function extractMetadata(string $filePath, string $originalFileName): FileMetadata;
/**
* Vérifie si le fichier peut être traité par cet extracteur
*/
public function canHandle(string $filePath): bool;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Contract;
interface NotificationInterface
{
/**
* Envoie une notification de succès
*/
public function sendSuccess(string $message): void;
/**
* Envoie une notification d'erreur
*/
public function sendError(string $message): void;
/**
* Envoie une notification avec un statut personnalisé
*/
public function sendUpdate(array $data): void;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Event;
readonly class ChapterImported
{
public function __construct(
public string $mangaSlug,
public int $volume,
public float|string $chapterNumber,
public string $cbzPath,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Event;
readonly class VolumeImported
{
public function __construct(
public string $mangaSlug,
public int $volume,
public string $cbzPath,
) {}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Exception;
class FileProcessingException extends \RuntimeException
{
public static function invalidFormat(string $fileName, array $allowedFormats): self
{
return new self(
sprintf(
'Le fichier "%s" doit être au format %s.',
$fileName,
implode(' ou ', $allowedFormats)
)
);
}
public static function uploadFailed(string $fileName, string $reason): self
{
return new self(
sprintf('Une erreur est survenue lors de l\'upload du fichier "%s" : %s', $fileName, $reason)
);
}
public static function fileNotFound(string $filePath): self
{
return new self(sprintf('Le fichier "%s" n\'a pas été trouvé.', $filePath));
}
public static function metadataExtractionFailed(string $fileName, string $reason): self
{
return new self(
sprintf('Impossible d\'extraire les métadonnées du fichier "%s" : %s', $fileName, $reason)
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Model;
readonly class FileMetadata
{
public function __construct(
public string $title,
public ?int $volume = null,
public ?int $chapter = null,
public ?string $author = null,
public ?string $description = null,
public array $additionalData = []
) {
}
public static function fromArray(array $data): self
{
return new self(
title: $data['title'] ?? '',
volume: $data['volume'] ?? null,
chapter: $data['chapter'] ?? null,
author: $data['author'] ?? null,
description: $data['description'] ?? null,
additionalData: $data['additionalData'] ?? []
);
}
public function toArray(): array
{
return [
'title' => $this->title,
'volume' => $this->volume,
'chapter' => $this->chapter,
'author' => $this->author,
'description' => $this->description,
'additionalData' => $this->additionalData,
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Domain\Model;
readonly class FileUpload
{
public function __construct(
public string $id,
public string $originalName,
public string $path,
public string $extension,
public int $size,
public \DateTimeImmutable $uploadedAt
) {
}
public static function create(
string $originalName,
string $path,
int $size
): self {
return new self(
id: uniqid('file_', true),
originalName: $originalName,
path: $path,
extension: strtolower(pathinfo($originalName, PATHINFO_EXTENSION)),
size: $size,
uploadedAt: new \DateTimeImmutable()
);
}
public function isValidFormat(array $allowedExtensions): bool
{
return in_array($this->extension, $allowedExtensions, true);
}
public function getFormattedSize(int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($this->size, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domain\Shared\Infrastructure\Messenger;
use App\Domain\Shared\Domain\Contract\EventDispatcherInterface;
use Symfony\Component\Messenger\MessageBusInterface;
class SymfonyMessengerEventDispatcher implements EventDispatcherInterface
{
public function __construct(private MessageBusInterface $bus)
{
}
public function dispatch(object $event): void
{
$this->bus->dispatch($event);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
/**
* Implémentation centralisée basée sur la logique éprouvée de CbzGenerator.
*/
readonly class MangaFileManager implements MangaPathManagerInterface
{
public function __construct(
private string $projectDir,
private FileUploadInterface $fileUpload,
) {
}
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string
{
$mangaDirName = ucfirst($this->slugify($mangaTitle)) . ' (' . $publicationYear . ')';
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, $mangaDirName);
$this->ensureDirectory($dir);
return $dir;
}
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string
{
$mangaDir = $this->getMangaDirectory($mangaTitle, $publicationYear);
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
$this->ensureDirectory($dir);
return $dir;
}
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string
{
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
return sprintf(
'%s/%s_vol%d_ch%s.cbz',
$volumeDir,
$this->slugify($mangaTitle),
$volumeNumber,
$chapterNumber,
);
}
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string
{
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
return sprintf(
'%s/%s_vol%d.cbz',
$volumeDir,
$this->slugify($mangaTitle),
$volumeNumber,
);
}
/** @param array<int, string> $files */
public function createCbzArchive(array $files, string $cbzPath): void
{
$zip = new \ZipArchive();
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Failed to create CBZ archive');
}
foreach ($files as $file) {
if (!file_exists($file)) {
throw new \RuntimeException("File not found: $file");
}
$zip->addFile($file, basename($file));
}
if (!$zip->close()) {
throw new \RuntimeException('Failed to close CBZ archive');
}
}
public function moveFileTo(string $sourcePath, string $destinationPath): void
{
$destinationDir = dirname($destinationPath);
$this->ensureDirectory($destinationDir);
$this->fileUpload->moveFile($sourcePath, $destinationPath);
}
public function fileExists(string $path): bool
{
return $this->fileUpload->fileExists($path);
}
private function ensureDirectory(string $dir): void
{
if (!is_dir($dir)) {
$this->fileUpload->createDirectory($dir);
}
}
private function slugify(string $text): string
{
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
$text = preg_replace('~[^-\w]+~', '', $text);
$text = trim($text, '-');
$text = preg_replace('~-+~', '-', $text);
$text = strtolower($text);
return $text ?: 'n-a';
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
use App\Domain\Shared\Domain\Exception\FileProcessingException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
readonly class SymfonyFileUpload implements FileUploadInterface
{
public function __construct(
private Filesystem $filesystem,
private string $uploadsDirectory
) {
}
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string
{
try {
$targetPath = $targetDirectory . '/' . uniqid() . '_' . $originalName;
$this->filesystem->copy($sourcePath, $targetPath);
return $targetPath;
} catch (FileException $e) {
throw FileProcessingException::uploadFailed($originalName, $e->getMessage());
}
}
public function fileExists(string $filePath): bool
{
return $this->filesystem->exists($filePath);
}
public function deleteFile(string $filePath): void
{
if ($this->filesystem->exists($filePath)) {
$this->filesystem->remove($filePath);
}
}
public function moveFile(string $sourcePath, string $targetPath): void
{
try {
$this->filesystem->rename($sourcePath, $targetPath, true);
} catch (FileException $e) {
throw FileProcessingException::uploadFailed(
basename($sourcePath),
$e->getMessage()
);
}
}
public function createDirectory(string $path): void
{
if (!$this->filesystem->exists($path)) {
$this->filesystem->mkdir($path, 0755);
}
}
public function validateFileFormat(string $filePath, array $allowedExtensions): bool
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return in_array($extension, $allowedExtensions, true);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\NotificationInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
readonly class SymfonyNotification implements NotificationInterface
{
public function __construct(
private HubInterface $hub
) {
}
public function sendSuccess(string $message): void
{
$this->sendUpdate([
'status' => 'success',
'message' => $message
]);
}
public function sendError(string $message): void
{
$this->sendUpdate([
'status' => 'error',
'message' => $message
]);
}
public function sendUpdate(array $data): void
{
$update = new Update(
'notifications',
json_encode($data)
);
$this->hub->publish($update);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
class InMemoryChapterRepository implements ChapterRepositoryInterface
{
/** @var array<string, Chapter> */
private array $chapters = [];
public function findById(string $id): ?Chapter
{
return $this->chapters[$id] ?? null;
}
public function findVisibleById(string $id): ?Chapter
{
$chapter = $this->chapters[$id] ?? null;
if ($chapter && $chapter->isVisible()) {
return $chapter;
}
return null;
}
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter
{
foreach ($this->chapters as $chapter) {
if ($chapter->getMangaId() === $mangaId && $chapter->getNumber() === $chapterNumber) {
return $chapter;
}
}
return null;
}
public function save(Chapter $chapter): void
{
$this->chapters[$chapter->getId()] = $chapter;
}
public function delete(Chapter $chapter): void
{
unset($this->chapters[$chapter->getId()]);
}
public function findByMangaIdAndVolume(string $mangaId, int $volume): array
{
return array_filter(
$this->chapters,
fn (Chapter $chapter) => $chapter->getMangaId() === $mangaId && $chapter->getVolume() === $volume
);
}
public function findVisibleByMangaIdAndVolume(string $mangaId, int $volume): array
{
return array_filter(
$this->chapters,
fn (Chapter $chapter) =>
$chapter->getMangaId() === $mangaId &&
$chapter->getVolume() === $volume &&
$chapter->isVisible()
);
}
public function findVisibleWithCbzByMangaIdAndVolume(string $mangaId, int $volume): array
{
return array_filter(
$this->chapters,
fn (Chapter $chapter) =>
$chapter->getMangaId() === $mangaId &&
$chapter->getVolume() === $volume &&
$chapter->isVisible() &&
$chapter->isAvailable()
);
}
/**
* Get all chapters
*/
public function getAll(): array
{
return array_values($this->chapters);
}
/**
* Clear all chapters
*/
public function clear(): void
{
$this->chapters = [];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Model\Manga;
class InMemoryChapterSynchronizationService implements ChapterSynchronizationServiceInterface
{
/** @var array<string, array> */
private array $synchronizedChapters = [];
public function synchronizeChapters(Manga $manga): array
{
$this->synchronizedChapters[$manga->getId()->getValue()] = [
'manga_id' => $manga->getId()->getValue(),
'external_id' => $manga->getExternalId()?->getValue(),
'synchronized_at' => new \DateTimeImmutable()
];
// Retourne les IDs des chapitres synchronisés (simulation)
return ['chapter-1', 'chapter-2'];
}
/**
* @return array<string, array>
*/
public function getSynchronizedChapters(): array
{
return $this->synchronizedChapters;
}
public function clear(): void
{
$this->synchronizedChapters = [];
}
}

View File

@@ -128,13 +128,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
return null; return null;
} }
public function saveChapter(Chapter $chapter): void public function saveChapter(Chapter $chapter): ChapterId
{ {
$this->savedChapters[] = $chapter; $this->savedChapters[] = $chapter;
if (!isset($this->chapters[$chapter->getMangaId()])) { if (!isset($this->chapters[$chapter->getMangaId()])) {
$this->chapters[$chapter->getMangaId()] = []; $this->chapters[$chapter->getMangaId()] = [];
} }
$this->chapters[$chapter->getMangaId()][] = $chapter; $this->chapters[$chapter->getMangaId()][] = $chapter;
return new ChapterId($chapter->getId());
} }
/** @return array<Chapter> */ /** @return array<Chapter> */
@@ -160,6 +161,11 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
$manga->getDescription() $manga->getDescription()
]; ];
// Ajouter les slugs alternatifs aux champs de recherche
foreach ($manga->getAlternativeSlugs() as $altSlug) {
$searchableFields[] = $altSlug;
}
foreach ($searchableFields as $field) { foreach ($searchableFields as $field) {
if (str_contains(strtolower($field), strtolower($query))) { if (str_contains(strtolower($field), strtolower($query))) {
return true; return true;

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
class InMemoryPathManager implements MangaPathManagerInterface
{
/** @var array<string, string> */
private array $files = [];
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string
{
$dir = '/tmp/manga/' . $this->slugify($mangaTitle) . '_' . $publicationYear;
$this->ensureDirectory($dir);
return $dir;
}
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string
{
$dir = $this->getMangaDirectory($mangaTitle, $publicationYear) . '/volume_' . $volumeNumber;
$this->ensureDirectory($dir);
return $dir;
}
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string
{
$dir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
return $dir . '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '_ch' . $chapterNumber . '.cbz';
}
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string
{
return $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber)
. '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '.cbz';
}
public function createCbzArchive(array $files, string $cbzPath): void
{
// For testing, just store the CBZ path
$this->files[$cbzPath] = json_encode($files);
}
public function moveFileTo(string $sourcePath, string $destinationPath): void
{
// In-memory: just copy content if source exists
if (file_exists($sourcePath)) {
$content = file_get_contents($sourcePath);
$this->files[$destinationPath] = $content;
}
}
public function fileExists(string $path): bool
{
return isset($this->files[$path]) || file_exists($path);
}
/**
* Get all stored files
*/
public function getFiles(): array
{
return $this->files;
}
/**
* Clear all stored files
*/
public function clear(): void
{
$this->files = [];
}
private function slugify(string $text): string
{
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
$text = preg_replace('~[^-\w]+~', '', $text);
$text = trim($text, '-');
$text = preg_replace('~-+~', '-', $text);
$text = strtolower($text);
return $text ?: 'n-a';
}
private function ensureDirectory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0777, true);
}
}
}

View File

@@ -13,7 +13,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider; use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor; use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
use App\Tests\Shared\Adapter\InMemoryMessageBus; use App\Tests\Shared\Adapter\InMemoryEventDispatcher;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class CreateMangaFromMangadexHandlerTest extends TestCase class CreateMangaFromMangadexHandlerTest extends TestCase
@@ -22,7 +22,7 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
private InMemoryMangaRepository $repository; private InMemoryMangaRepository $repository;
private InMemoryImageProcessor $imageProcessor; private InMemoryImageProcessor $imageProcessor;
private CreateMangaFromMangadexHandler $handler; private CreateMangaFromMangadexHandler $handler;
private InMemoryMessageBus $messageBus; private InMemoryEventDispatcher $eventDispatcher;
protected function setUp(): void protected function setUp(): void
{ {
$manga = new Manga( $manga = new Manga(
@@ -41,12 +41,12 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
$this->provider = new InMemoryMangaProvider([$manga]); $this->provider = new InMemoryMangaProvider([$manga]);
$this->repository = new InMemoryMangaRepository(); $this->repository = new InMemoryMangaRepository();
$this->imageProcessor = new InMemoryImageProcessor(); $this->imageProcessor = new InMemoryImageProcessor();
$this->messageBus = new InMemoryMessageBus(); $this->eventDispatcher = new InMemoryEventDispatcher();
$this->handler = new CreateMangaFromMangadexHandler( $this->handler = new CreateMangaFromMangadexHandler(
$this->provider, $this->provider,
$this->repository, $this->repository,
$this->imageProcessor, $this->imageProcessor,
$this->messageBus $this->eventDispatcher
); );
} }
@@ -76,4 +76,4 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
// Act // Act
$this->handler->handle($command); $this->handler->handle($command);
} }
} }

View File

@@ -4,28 +4,29 @@ namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters; use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler; use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use App\Domain\Manga\Domain\Exception\MangadexApiException;
use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId; use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId; use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangadexClient; use App\Tests\Domain\Manga\Adapter\InMemoryChapterSynchronizationService;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class FetchMangaChaptersHandlerTest extends TestCase class FetchMangaChaptersHandlerTest extends TestCase
{ {
private InMemoryMangadexClient $mangadexClient; private InMemoryChapterSynchronizationService $chapterSynchronizationService;
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private FetchMangaChaptersHandler $handler; private FetchMangaChaptersHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangadexClient = new InMemoryMangadexClient(); $this->chapterSynchronizationService = new InMemoryChapterSynchronizationService();
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->handler = new FetchMangaChaptersHandler( $this->handler = new FetchMangaChaptersHandler(
$this->mangadexClient, $this->mangaRepository,
$this->mangaRepository $this->chapterSynchronizationService
); );
} }
@@ -47,22 +48,12 @@ class FetchMangaChaptersHandlerTest extends TestCase
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
$this->mangadexClient->addFeed($externalId, [ $command = new FetchMangaChapters(new MangaId($mangaId));
[
'id' => 'chapter-1',
'attributes' => [
'chapter' => '1',
'title' => 'Chapter 1',
'volume' => '1',
'translatedLanguage' => 'fr'
]
]
]);
$command = new FetchMangaChapters($mangaId);
$this->handler->handle($command); $this->handler->handle($command);
$this->assertCount(1, $this->mangaRepository->getSavedChapters()); $synchronizedChapters = $this->chapterSynchronizationService->getSynchronizedChapters();
$this->assertCount(1, $synchronizedChapters);
$this->assertArrayHasKey($mangaId, $synchronizedChapters);
} }
public function testHandleWithNonExistingManga(): void public function testHandleWithNonExistingManga(): void
@@ -72,7 +63,7 @@ class FetchMangaChaptersHandlerTest extends TestCase
$this->expectException(\RuntimeException::class); $this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Manga not found'); $this->expectExceptionMessage('Manga not found');
$command = new FetchMangaChapters($mangaId); $command = new FetchMangaChapters(new MangaId($mangaId));
$this->handler->handle($command); $this->handler->handle($command);
} }
@@ -93,10 +84,10 @@ class FetchMangaChaptersHandlerTest extends TestCase
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
$this->expectException(\RuntimeException::class); $this->expectException(MangadexApiException::class);
$this->expectExceptionMessage('Manga has no external ID'); $this->expectExceptionMessage('Manga has no external_id');
$command = new FetchMangaChapters($mangaId); $command = new FetchMangaChapters(new MangaId($mangaId));
$this->handler->handle($command); $this->handler->handle($command);
} }
} }

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
use PHPUnit\Framework\TestCase;
class ImportChapterHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryPathManager $pathManager;
private ImportChapterHandler $handler;
protected function setUp(): void
{
$this->mangaRepository = new InMemoryMangaRepository();
$this->chapterRepository = new InMemoryChapterRepository();
$this->pathManager = new InMemoryPathManager();
$this->handler = new ImportChapterHandler(
$this->mangaRepository,
$this->chapterRepository,
$this->pathManager
);
}
public function test_it_throws_exception_when_chapter_not_found(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportChapter(
mangaId: $mangaId,
chapterNumber: 1.5,
fileBinary: $cbzBinary
);
// Assert
$this->expectException(ChapterNotFoundException::class);
// Act
$this->handler->handle($command);
}
public function test_it_updates_existing_chapter_with_new_cbz(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
// Create an existing chapter without CBZ
$existingChapter = new Chapter(
new ChapterId('chapter-123'),
$mangaId,
1.5,
'Chapter 1.5',
1,
true,
null
);
$this->chapterRepository->save($existingChapter);
// Import the same chapter with CBZ
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportChapter(
mangaId: $mangaId,
chapterNumber: 1.5,
fileBinary: $cbzBinary
);
// Act
$this->handler->handle($command);
// Assert
$chapters = $this->chapterRepository->getAll();
$this->assertCount(1, $chapters); // Still only one chapter
$updatedChapter = $chapters[0];
$this->assertEquals('chapter-123', $updatedChapter->getId());
$this->assertEquals($mangaId, $updatedChapter->getMangaId());
$this->assertEquals(1.5, $updatedChapter->getNumber());
$this->assertEquals('Chapter 1.5', $updatedChapter->getTitle()); // Title preserved
$this->assertEquals(1, $updatedChapter->getVolume()); // Volume preserved
$this->assertTrue($updatedChapter->isVisible());
$this->assertTrue($updatedChapter->isAvailable()); // Now has CBZ
$this->assertStringContainsString('_vol1_ch1.5.cbz', $updatedChapter->getCbzPath());
}
public function test_it_throws_exception_when_manga_not_found(): void
{
// Arrange
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportChapter(
mangaId: 'non-existent-manga',
chapterNumber: 1.0,
fileBinary: $cbzBinary
);
// Assert
$this->expectException(MangaNotFoundException::class);
// Act
$this->handler->handle($command);
}
public function test_it_throws_exception_when_file_is_not_valid_cbz(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
$invalidBinary = 'This is not a CBZ file';
$command = new ImportChapter(
mangaId: $mangaId,
chapterNumber: 1.0,
fileBinary: $invalidBinary
);
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The provided file is not a valid CBZ file');
// Act
$this->handler->handle($command);
}
/**
* Create a minimal valid CBZ (ZIP) binary for testing
*/
private function createValidCbzBinary(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz');
// Delete the empty file created by tempnam
unlink($tmpFile);
$zip = new \ZipArchive();
// Create a new ZIP archive (avoid opening empty file)
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create test CBZ file');
}
// Add a dummy image file to the ZIP
$zip->addFromString('image1.jpg', 'fake-image-data');
$zip->close();
$binaryContent = file_get_contents($tmpFile);
unlink($tmpFile);
return $binaryContent;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
use PHPUnit\Framework\TestCase;
class ImportVolumeHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryPathManager $pathManager;
private ImportVolumeHandler $handler;
protected function setUp(): void
{
$this->mangaRepository = new InMemoryMangaRepository();
$this->chapterRepository = new InMemoryChapterRepository();
$this->pathManager = new InMemoryPathManager();
$this->handler = new ImportVolumeHandler(
$this->mangaRepository,
$this->chapterRepository,
$this->pathManager
);
}
public function test_it_updates_all_chapters_in_volume(): void
{
// Arrange
$mangaId = 'manga-123';
$volumeNumber = 1;
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
// Create chapters in volume 1
for ($i = 1; $i <= 3; $i++) {
$chapter = new Chapter(
new ChapterId("chapter-$i"),
$mangaId,
(float)$i,
"Chapter $i",
$volumeNumber,
true,
null
);
$this->chapterRepository->save($chapter);
}
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportVolume(
mangaId: $mangaId,
volumeNumber: $volumeNumber,
fileBinary: $cbzBinary
);
// Act
$this->handler->handle($command);
// Assert
$chapters = $this->chapterRepository->findByMangaIdAndVolume($mangaId, $volumeNumber);
$this->assertCount(3, $chapters);
foreach ($chapters as $chapter) {
$this->assertTrue($chapter->isAvailable());
$this->assertStringContainsString('_vol' . $volumeNumber . '.cbz', $chapter->getCbzPath());
}
}
public function test_it_throws_exception_when_manga_not_found(): void
{
// Arrange
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportVolume(
mangaId: 'non-existent-manga',
volumeNumber: 1,
fileBinary: $cbzBinary
);
// Assert
$this->expectException(MangaNotFoundException::class);
// Act
$this->handler->handle($command);
}
public function test_it_throws_exception_when_file_is_not_valid_cbz(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
$invalidBinary = 'This is not a CBZ file';
$command = new ImportVolume(
mangaId: $mangaId,
volumeNumber: 1,
fileBinary: $invalidBinary
);
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The provided file is not a valid CBZ file');
// Act
$this->handler->handle($command);
}
public function test_it_throws_exception_when_no_chapters_in_volume(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('One Piece'),
new MangaSlug('one-piece'),
'Description',
'Eiichiro Oda',
1997,
['action', 'adventure'],
'ongoing'
);
$this->mangaRepository->save($manga);
$cbzBinary = $this->createValidCbzBinary();
$command = new ImportVolume(
mangaId: $mangaId,
volumeNumber: 999, // Non-existent volume
fileBinary: $cbzBinary
);
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('No chapters found');
// Act
$this->handler->handle($command);
}
/**
* Create a minimal valid CBZ (ZIP) binary for testing
*/
private function createValidCbzBinary(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
unlink($tmpFile);
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create test CBZ file');
}
$zip->addFromString('image1.jpg', 'fake-image-data');
$zip->close();
$binaryContent = file_get_contents($tmpFile);
unlink($tmpFile);
return $binaryContent;
}
}

View File

@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
namespace App\Tests\Domain\Manga\Application\QueryHandler;
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
use App\Domain\Manga\Application\QueryHandler\FindMangaMatchByFilenameHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Infrastructure\Service\FilenameAnalyzer;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase;
class FindMangaMatchByFilenameHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private FilenameAnalyzer $filenameAnalyzer;
private FindMangaMatchByFilenameHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->filenameAnalyzer = new FilenameAnalyzer();
$this->handler = new FindMangaMatchByFilenameHandler(
$this->filenameAnalyzer,
$this->repository
);
}
public function test_it_finds_exact_match_by_title(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'One Piece',
slug: 'one-piece'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('one-piece_vol108_ch1094.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$this->assertCount(1, $response->matches);
$match = $response->matches[0];
$this->assertEquals('123', $match->id);
$this->assertEquals('One Piece', $match->title);
$this->assertEquals('one-piece', $match->slug);
$this->assertEquals(1094.0, $match->chapterNumber);
$this->assertEquals(108, $match->volumeNumber);
// Vérifier aussi dans la réponse globale
$this->assertEquals(1094.0, $response->chapterNumber);
$this->assertEquals(108, $response->volumeNumber);
$this->assertNotEmpty($response->possibleTitles);
}
public function test_it_returns_empty_matches_when_no_manga_found(): void
{
// Given - no manga in repository
// When
$query = new FindMangaMatchByFilename('unknown-manga_vol1_ch1.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertFalse($response->hasMatches());
$this->assertCount(0, $response->matches);
$this->assertNull($response->getBestMatch());
}
public function test_it_finds_multiple_matches_and_sorts_by_score(): void
{
// Given
$manga1 = $this->createManga(
id: '1',
title: 'One Piece',
slug: 'one-piece'
);
$manga2 = $this->createManga(
id: '2',
title: 'One Piece Z',
slug: 'one-piece-z'
);
$manga3 = $this->createManga(
id: '3',
title: 'The One Piece',
slug: 'the-one-piece'
);
$this->repository->save($manga1);
$this->repository->save($manga2);
$this->repository->save($manga3);
// When
$query = new FindMangaMatchByFilename('one-piece_vol108_ch1094.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$this->assertGreaterThanOrEqual(1, count($response->matches));
// Le meilleur match devrait être "One Piece" (correspondance exacte)
$bestMatch = $response->getBestMatch();
$this->assertNotNull($bestMatch);
$this->assertEquals('One Piece', $bestMatch->title);
// Les scores doivent être triés par ordre décroissant
$scores = array_map(fn($match) => $match->matchScore, $response->matches);
$sortedScores = $scores;
rsort($sortedScores);
$this->assertEquals($sortedScores, $scores);
}
public function test_it_extracts_chapter_and_volume_numbers(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Attack on Titan',
slug: 'attack-on-titan'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('attack-on-titan_vol32_ch130.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertEquals(130.0, $response->chapterNumber);
$this->assertEquals(32, $response->volumeNumber);
// Vérifier que chaque match contient aussi ces informations
foreach ($response->matches as $match) {
$this->assertEquals(130.0, $match->chapterNumber);
$this->assertEquals(32, $match->volumeNumber);
}
}
public function test_it_handles_decimal_chapter_numbers(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Naruto',
slug: 'naruto'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('naruto_vol50_ch456.5.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertEquals(456.5, $response->chapterNumber);
$this->assertEquals(50, $response->volumeNumber);
// Vérifier que chaque match contient aussi ces informations
foreach ($response->matches as $match) {
$this->assertEquals(456.5, $match->chapterNumber);
$this->assertEquals(50, $match->volumeNumber);
}
}
public function test_it_finds_matches_with_alternative_slugs(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Shingeki no Kyojin',
slug: 'shingeki-no-kyojin',
alternativeSlugs: ['attack-on-titan', 'aot']
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('attack-on-titan_vol1_ch1.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$bestMatch = $response->getBestMatch();
$this->assertNotNull($bestMatch);
$this->assertEquals('123', $bestMatch->id);
$this->assertEquals('Shingeki no Kyojin', $bestMatch->title);
}
public function test_it_handles_filename_without_chapter_or_volume(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'One Piece',
slug: 'one-piece'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('one-piece.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$this->assertNull($response->chapterNumber);
$this->assertNull($response->volumeNumber);
$bestMatch = $response->getBestMatch();
$this->assertEquals('123', $bestMatch->id);
$this->assertNull($bestMatch->chapterNumber);
$this->assertNull($bestMatch->volumeNumber);
}
public function test_it_finds_single_match_with_unique_id(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'One Piece',
slug: 'one-piece'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('one-piece_vol1_ch1.cbz');
$response = $this->handler->handle($query);
// Then - Vérifier qu'on a bien un seul résultat avec l'ID correct
$this->assertCount(1, $response->matches);
$this->assertEquals('123', $response->matches[0]->id);
}
public function test_it_provides_possible_titles_in_response(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Dragon Ball',
slug: 'dragon-ball'
);
$this->repository->save($manga);
// When
$query = new FindMangaMatchByFilename('dragon-ball_vol1_ch5.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertNotEmpty($response->possibleTitles);
$this->assertEquals(['dragon-ball'], $response->possibleTitles);
}
public function test_it_handles_filename_with_only_volume(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'One Piece',
slug: 'one-piece'
);
$this->repository->save($manga);
// When - Fichier avec seulement un volume, sans chapitre
$query = new FindMangaMatchByFilename('one-piece_vol108.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$this->assertEquals(108, $response->volumeNumber);
$this->assertNull($response->chapterNumber);
$bestMatch = $response->getBestMatch();
$this->assertNotNull($bestMatch);
$this->assertEquals('123', $bestMatch->id);
$this->assertEquals(108, $bestMatch->volumeNumber);
$this->assertNull($bestMatch->chapterNumber);
}
public function test_it_handles_filename_with_only_chapter(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Naruto',
slug: 'naruto'
);
$this->repository->save($manga);
// When - Fichier avec seulement un chapitre, sans volume
$query = new FindMangaMatchByFilename('naruto_ch456.cbz');
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches());
$this->assertEquals(456.0, $response->chapterNumber);
$this->assertNull($response->volumeNumber);
$bestMatch = $response->getBestMatch();
$this->assertNotNull($bestMatch);
$this->assertEquals('123', $bestMatch->id);
$this->assertEquals(456.0, $bestMatch->chapterNumber);
$this->assertNull($bestMatch->volumeNumber);
}
public function test_it_handles_various_volume_only_formats(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'Attack on Titan',
slug: 'attack-on-titan'
);
$this->repository->save($manga);
$testCases = [
['filename' => 'attack-on-titan vol 32.cbz', 'expectedVolume' => 32],
['filename' => 'attack-on-titan-tome-15.cbz', 'expectedVolume' => 15],
['filename' => 'attack-on-titan_t10.cbz', 'expectedVolume' => 10],
['filename' => 'attack-on-titan Volume 5.cbr', 'expectedVolume' => 5],
];
foreach ($testCases as $case) {
// When
$query = new FindMangaMatchByFilename($case['filename']);
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches(), "Should find match for {$case['filename']}");
$this->assertEquals($case['expectedVolume'], $response->volumeNumber,
"Failed volume extraction for: {$case['filename']}");
$this->assertNull($response->chapterNumber,
"Should not have chapter for: {$case['filename']}");
$bestMatch = $response->getBestMatch();
$this->assertEquals($case['expectedVolume'], $bestMatch->volumeNumber,
"Match should have correct volume for: {$case['filename']}");
$this->assertNull($bestMatch->chapterNumber,
"Match should not have chapter for: {$case['filename']}");
}
}
public function test_it_handles_various_chapter_only_formats(): void
{
// Given
$manga = $this->createManga(
id: '123',
title: 'My Hero Academia',
slug: 'my-hero-academia'
);
$this->repository->save($manga);
$testCases = [
['filename' => 'my-hero-academia ch 150.cbz', 'expectedChapter' => 150.0],
['filename' => 'my-hero-academia-chap-200.cbz', 'expectedChapter' => 200.0],
['filename' => 'my-hero-academia_chapter_75.cbz', 'expectedChapter' => 75.0],
['filename' => 'my-hero-academia chapitre 100.cbr', 'expectedChapter' => 100.0],
['filename' => 'my-hero-academia_ch99.5.cbz', 'expectedChapter' => 99.5],
];
foreach ($testCases as $case) {
// When
$query = new FindMangaMatchByFilename($case['filename']);
$response = $this->handler->handle($query);
// Then
$this->assertTrue($response->hasMatches(), "Should find match for {$case['filename']}");
$this->assertEquals($case['expectedChapter'], $response->chapterNumber,
"Failed chapter extraction for: {$case['filename']}");
$this->assertNull($response->volumeNumber,
"Should not have volume for: {$case['filename']}");
$bestMatch = $response->getBestMatch();
$this->assertEquals($case['expectedChapter'], $bestMatch->chapterNumber,
"Match should have correct chapter for: {$case['filename']}");
$this->assertNull($bestMatch->volumeNumber,
"Match should not have volume for: {$case['filename']}");
}
}
private function createManga(
string $id,
string $title,
string $slug,
array $alternativeSlugs = [],
?string $thumbnailUrl = null
): Manga {
return new Manga(
id: new MangaId($id),
title: new MangaTitle($title),
slug: new MangaSlug($slug),
description: 'Test description',
author: 'Test Author',
publicationYear: 2000,
genres: ['action'],
status: 'ongoing',
imageUrls: new ImageUrls(
'http://example.com/full.jpg',
$thumbnailUrl ?? 'http://example.com/thumbnail.jpg'
),
alternativeSlugs: $alternativeSlugs
);
}
protected function tearDown(): void
{
$this->repository->clear();
}
}

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace Tests\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Infrastructure\Service\FilenameAnalyzer;
use PHPUnit\Framework\TestCase;
class FilenameAnalyzerTest extends TestCase
{
private FilenameAnalyzer $analyzer;
protected function setUp(): void
{
$this->analyzer = new FilenameAnalyzer();
}
public function test_it_analyzes_one_piece_filename_correctly(): void
{
// Given
$filename = 'one-piece_vol108_ch1094.cbz';
// When
$result = $this->analyzer->analyze($filename);
// Then
$this->assertEquals('one-piece', $result->getTitle()->getValue());
$this->assertEquals(1094.0, $result->getChapterNumber()->getValue());
$this->assertEquals(108.0, $result->getVolumeNumber()->getValue());
$this->assertTrue($result->hasChapterNumber());
$this->assertTrue($result->hasVolumeNumber());
}
public function test_it_handles_different_filename_formats(): void
{
$testCases = [
// Format underscore
[
'filename' => 'attack-on-titan_vol32_ch130.cbz',
'expectedTitle' => 'attack-on-titan',
'expectedChapter' => 130.0,
'expectedVolume' => 32.0,
],
// Format avec espaces
[
'filename' => 'Dragon Ball vol 1 ch 5.cbz',
'expectedTitle' => 'Dragon Ball',
'expectedChapter' => 5.0,
'expectedVolume' => 1.0,
],
// Format avec tirets
[
'filename' => 'my-hero-academia-vol15-ch150.cbr',
'expectedTitle' => 'my-hero-academia',
'expectedChapter' => 150.0,
'expectedVolume' => 15.0,
],
// Format chapitre décimal
[
'filename' => 'naruto_vol50_ch456.5.cbz',
'expectedTitle' => 'naruto',
'expectedChapter' => 456.5,
'expectedVolume' => 50.0,
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
"Failed for filename: {$case['filename']}");
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
"Failed chapter extraction for: {$case['filename']}");
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
"Failed volume extraction for: {$case['filename']}");
}
}
public function test_it_extracts_and_cleans_title(): void
{
// Given
$filename = 'one-piece_vol108_ch1094.cbz';
// When
$result = $this->analyzer->analyze($filename);
// Then - should extract and clean the title
$this->assertEquals('one-piece', $result->getTitle()->getValue());
$this->assertNotEmpty($result->getTitle()->getValue(), 'Title should not be empty');
}
public function test_it_handles_files_without_volume_or_chapter(): void
{
$testCases = [
[
'filename' => 'one-piece.cbz',
'expectedTitle' => 'one-piece',
],
[
'filename' => 'manga_title_only.cbr',
'expectedTitle' => 'manga_title_only',
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue());
$this->assertFalse($result->hasChapterNumber());
$this->assertFalse($result->hasVolumeNumber());
$this->assertNull($result->getChapterNumber());
$this->assertNull($result->getVolumeNumber());
}
}
public function test_it_handles_cbz_and_cbr_extensions(): void
{
// Given
$testCases = [
['filename' => 'one-piece.cbz', 'expectedTitle' => 'one-piece'],
['filename' => 'manga.cbr', 'expectedTitle' => 'manga'],
['filename' => 'test.CBZ', 'expectedTitle' => 'test'],
['filename' => 'test.CBR', 'expectedTitle' => 'test'],
];
foreach ($testCases as $case) {
// When
$result = $this->analyzer->analyze($case['filename']);
// Then - L'extension est enlevée et le titre est extrait
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue());
}
}
public function test_it_cleans_common_patterns(): void
{
$testCases = [
[
'filename' => 'one-piece-scan-fr_vol108_ch1094.cbz',
'cleanedTitle' => 'one-piece',
],
[
'filename' => 'manga-raw-jp_vol1_ch1.cbz',
'cleanedTitle' => 'manga',
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$title = $result->getTitle()->getValue();
// Vérifie que le titre est nettoyé
$this->assertEquals($case['cleanedTitle'], $title,
"Title should be cleaned for {$case['filename']}");
// Vérifie que le titre nettoyé ne contient pas les mots indésirables
$this->assertDoesNotMatchRegularExpression('/\b(?:scan|raw|fr|en|jp|hq|lq)\b/i', $title,
"Cleaned title should not contain unwanted patterns for {$case['filename']}");
}
}
public function test_it_handles_filename_with_only_volume(): void
{
$testCases = [
[
'filename' => 'one-piece_vol108.cbz',
'expectedTitle' => 'one-piece',
'expectedVolume' => 108.0,
],
[
'filename' => 'attack-on-titan vol 32.cbz',
'expectedTitle' => 'attack-on-titan',
'expectedVolume' => 32.0,
],
[
'filename' => 'naruto-tome-50.cbz',
'expectedTitle' => 'naruto',
'expectedVolume' => 50.0,
],
[
'filename' => 'bleach_t15.cbz',
'expectedTitle' => 'bleach',
'expectedVolume' => 15.0,
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
"Failed title extraction for: {$case['filename']}");
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
"Failed volume extraction for: {$case['filename']}");
$this->assertFalse($result->hasChapterNumber(),
"Should not have chapter for: {$case['filename']}");
}
}
public function test_it_handles_filename_with_only_chapter(): void
{
$testCases = [
[
'filename' => 'naruto_ch456.cbz',
'expectedTitle' => 'naruto',
'expectedChapter' => 456.0,
],
[
'filename' => 'my-hero-academia ch 150.cbz',
'expectedTitle' => 'my-hero-academia',
'expectedChapter' => 150.0,
],
[
'filename' => 'bleach-chap-200.cbz',
'expectedTitle' => 'bleach',
'expectedChapter' => 200.0,
],
[
'filename' => 'one-piece_chapter_1094.cbz',
'expectedTitle' => 'one-piece',
'expectedChapter' => 1094.0,
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
"Failed title extraction for: {$case['filename']}");
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
"Failed chapter extraction for: {$case['filename']}");
$this->assertFalse($result->hasVolumeNumber(),
"Should not have volume for: {$case['filename']}");
}
}
public function test_it_handles_full_dash_patterns(): void
{
$testCases = [
[
'filename' => 'berserk-volume-42.cbz',
'expectedTitle' => 'berserk',
'expectedVolume' => 42.0,
],
[
'filename' => 'berserk-chapter-100.cbz',
'expectedTitle' => 'berserk',
'expectedChapter' => 100.0,
],
[
'filename' => 'berserk-chapitre-100.cbz',
'expectedTitle' => 'berserk',
'expectedChapter' => 100.0,
],
[
'filename' => 'berserk-volume-42-chapter-100.cbz',
'expectedTitle' => 'berserk',
'expectedVolume' => 42.0,
'expectedChapter' => 100.0,
],
[
'filename' => 'berserk-chapitre-100-volume-42.cbz',
'expectedTitle' => 'berserk',
'expectedVolume' => 42.0,
'expectedChapter' => 100.0,
],
];
foreach ($testCases as $case) {
$result = $this->analyzer->analyze($case['filename']);
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
"Failed title extraction for: {$case['filename']}");
if (isset($case['expectedVolume'])) {
$this->assertTrue($result->hasVolumeNumber(),
"Should have volume for: {$case['filename']}");
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
"Failed volume extraction for: {$case['filename']}");
} else {
$this->assertFalse($result->hasVolumeNumber(),
"Should not have volume for: {$case['filename']}");
}
if (isset($case['expectedChapter'])) {
$this->assertTrue($result->hasChapterNumber(),
"Should have chapter for: {$case['filename']}");
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
"Failed chapter extraction for: {$case['filename']}");
} else {
$this->assertFalse($result->hasChapterNumber(),
"Should not have chapter for: {$case['filename']}");
}
}
}
}

View File

@@ -19,7 +19,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
'A test manga description', 'A test manga description',
'Test Author', 'Test Author',
'2024', '2024',
[] // Pas de sources préférées par défaut false, // monitored
[], // preferredSources
[] // alternativeSlugs
); );
// Ajoute un manga avec des sources préférées pour les tests // Ajoute un manga avec des sources préférées pour les tests
@@ -30,7 +32,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
'A test manga with preferred sources', 'A test manga with preferred sources',
'Test Author', 'Test Author',
'2024', '2024',
['test-source'] // Une source préférée false, // monitored
['test-source'], // preferredSources
[] // alternativeSlugs
); );
} }
@@ -55,7 +59,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
$manga->getDescription(), $manga->getDescription(),
$manga->getAuthor(), $manga->getAuthor(),
$manga->getPublicationYear(), $manga->getPublicationYear(),
$sourceIds // Mise à jour des sources préférées $manga->isMonitored(), // monitored
$sourceIds, // preferredSources
$manga->getAlternativeSlugs() // alternativeSlugs
); );
$this->mangas[$mangaId] = $updatedManga; $this->mangas[$mangaId] = $updatedManga;
} }

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
class InMemoryScraperFactory implements ScraperFactoryInterface
{
/** @var array<string, ScraperInterface> */
private array $scrapers = [];
public function createScraper(string $source): ScraperInterface
{
if (!isset($this->scrapers[$source])) {
$this->scrapers[$source] = new InMemoryScraperAdapter();
}
return $this->scrapers[$source];
}
public function getBestScraper(): ScraperInterface
{
return $this->createScraper('best');
}
public function getFallbackScraper(): ScraperInterface
{
return $this->createScraper('fallback');
}
public function getScraperWithFallback(string $preferredType): ScraperInterface
{
if (isset($this->scrapers[$preferredType])) {
return $this->scrapers[$preferredType];
}
return $this->getFallbackScraper();
}
public function getSupportedTypes(): array
{
return array_keys($this->scrapers);
}
public function isSupported(string $type): bool
{
return isset($this->scrapers[$type]);
}
public function addScraper(string $source, ScraperInterface $scraper): void
{
$this->scrapers[$source] = $scraper;
}
public function clear(): void
{
$this->scrapers = [];
}
}

View File

@@ -14,7 +14,7 @@ use App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus; use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader; use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter; use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository; use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository; use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -23,7 +23,7 @@ use PHPUnit\Framework\MockObject\MockObject;
class ScrapeChapterHandlerTest extends TestCase class ScrapeChapterHandlerTest extends TestCase
{ {
private InMemoryScraperAdapter $scraper; private InMemoryScraperFactory $scraperFactory;
private InMemoryImageDownloader $imageDownloader; private InMemoryImageDownloader $imageDownloader;
private InMemoryCbzGenerator $cbzGenerator; private InMemoryCbzGenerator $cbzGenerator;
private InMemoryJobRepository $jobRepository; private InMemoryJobRepository $jobRepository;
@@ -36,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->scraper = new InMemoryScraperAdapter(); $this->scraperFactory = new InMemoryScraperFactory();
$this->imageDownloader = new InMemoryImageDownloader(); $this->imageDownloader = new InMemoryImageDownloader();
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir'); $this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
$this->jobRepository = new InMemoryJobRepository(); $this->jobRepository = new InMemoryJobRepository();
@@ -59,7 +59,7 @@ class ScrapeChapterHandlerTest extends TestCase
)); ));
$this->handler = new ScrapeChapterHandler( $this->handler = new ScrapeChapterHandler(
$this->scraper, $this->scraperFactory,
$this->imageDownloader, $this->imageDownloader,
$this->cbzGenerator, $this->cbzGenerator,
$this->jobRepository, $this->jobRepository,
@@ -92,31 +92,6 @@ class ScrapeChapterHandlerTest extends TestCase
$this->assertNotNull($chapter->cbzPath); $this->assertNotNull($chapter->cbzPath);
} }
public function testHandleThrowsException(): void
{
$command = new ScrapeChapter(
chapterId: '1'
);
$exception = new \Exception('Scraping failed');
$this->scraper->simulateError($exception);
$this->handler->handle($command);
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
$this->assertCount(1, $dispatchedMessages);
$this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[0]);
$this->assertEquals('test-manga', $dispatchedMessages[0]->getMangaId());
$this->assertEquals('2', $dispatchedMessages[0]->getChapterNumber());
$this->assertEquals('Scraping failed', $dispatchedMessages[0]->getReason());
$jobs = $this->jobRepository->findByType('scraping_job');
$job = array_values($jobs)[0];
$this->assertCount(1, $jobs);
$this->assertEquals(JobStatus::FAILED, $job->status);
$this->assertEquals('Scraping failed', $job->failureReason);
}
protected function tearDown(): void protected function tearDown(): void
{ {
$this->jobRepository->clear(); $this->jobRepository->clear();

View File

@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'manga' => $manga, 'manga' => $manga,
'volume' => 1, 'volume' => 1,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();
@@ -40,7 +40,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
$this->assertResponseHeaderSame('Content-Type', 'application/x-cbz'); $this->assertResponseHeaderSame('Content-Type', 'application/x-cbz');
$contentDisposition = static::getClient()->getResponse()->headers->get('Content-Disposition'); $contentDisposition = static::getClient()->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('attachment; filename=', $contentDisposition); $this->assertStringContainsString('attachment; filename=', $contentDisposition);
$this->assertStringContainsString('one-piece-volume-1.cbz', $contentDisposition); $this->assertStringContainsString('one-piece_vol1.cbz', $contentDisposition);
} }
public function test_it_returns_404_when_manga_not_found(): void public function test_it_returns_404_when_manga_not_found(): void
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 1.0, 'number' => 1.0,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 2.0, 'number' => 2.0,
'visible' => false, // Soft deleted 'visible' => false, // Soft deleted
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 4.0, 'number' => 4.0,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();

View File

@@ -58,7 +58,7 @@ class FetchMangaChaptersTest extends AbstractApiTestCase
$messages = $this->messageBus->getDispatchedMessages(); $messages = $this->messageBus->getDispatchedMessages();
$this->assertCount(1, $messages); $this->assertCount(1, $messages);
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]); $this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
$this->assertEquals($mangaId, $messages[0]->mangaId); $this->assertEquals(new MangaId($mangaId), $messages[0]->mangaId);
} }
public function testFetchChaptersWithInvalidMangaId(): void public function testFetchChaptersWithInvalidMangaId(): void

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Tests\Feature\Manga;
use App\Entity\Manga;
use App\Tests\Feature\AbstractApiTestCase;
use Zenstruck\Foundry\Test\ResetDatabase;
class FindMangaMatchByFilenameTest extends AbstractApiTestCase
{
use ResetDatabase;
public function test_it_finds_exact_match_by_filename(): void
{
// Given
$this->createManga('One Piece', 'one-piece');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'one-piece_vol108_ch1094.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertArrayHasKey('matches', $data);
$this->assertCount(1, $data['matches']);
$this->assertEquals('One Piece', $data['matches'][0]['title']);
$this->assertEquals('one-piece', $data['matches'][0]['slug']);
$this->assertEquals(1094.0, $data['matches'][0]['chapterNumber']);
$this->assertEquals(108, $data['matches'][0]['volumeNumber']);
$this->assertGreaterThan(0, $data['matches'][0]['matchScore']);
}
public function test_it_returns_empty_matches_when_no_manga_found(): void
{
// Given - no manga in database
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'unknown-manga_vol1_ch1.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertArrayHasKey('matches', $data);
$this->assertCount(0, $data['matches']);
}
public function test_it_returns_bad_request_when_filename_is_missing(): void
{
// When
$client = static::createClient();
$client->request('GET', '/api/manga-matches');
// Then
$this->assertResponseStatusCodeSame(400);
}
public function test_it_extracts_chapter_and_volume_correctly(): void
{
// Given
$this->createManga('Attack on Titan', 'attack-on-titan');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'attack-on-titan_vol32_ch130.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertNotEmpty($data['matches']);
}
public function test_it_handles_filename_with_only_volume(): void
{
// Given
$this->createManga('Naruto', 'naruto');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'naruto_vol50.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertNotEmpty($data['matches']);
$this->assertEquals('Naruto', $data['matches'][0]['title']);
}
public function test_it_handles_filename_with_only_chapter(): void
{
// Given
$this->createManga('Bleach', 'bleach');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'bleach_ch200.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertNotEmpty($data['matches']);
$this->assertEquals('Bleach', $data['matches'][0]['title']);
}
public function test_it_sorts_matches_by_score(): void
{
// Given
$this->createManga('One Piece', 'one-piece');
$this->createManga('One Piece Z', 'one-piece-z');
$this->createManga('The One Piece', 'the-one-piece');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'one-piece_vol108_ch1094.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertArrayHasKey('matches', $data);
$this->assertGreaterThanOrEqual(1, count($data['matches']));
// Le premier résultat devrait être "One Piece" (meilleure correspondance)
$this->assertEquals('One Piece', $data['matches'][0]['title']);
// Vérifier que les scores sont triés par ordre décroissant
$scores = array_map(fn($match) => $match['matchScore'], $data['matches']);
$sortedScores = $scores;
rsort($sortedScores);
$this->assertEquals($sortedScores, $scores);
}
public function test_it_handles_alternative_slugs(): void
{
// Given
$manga = $this->createManga('Shingeki no Kyojin', 'shingeki-no-kyojin');
$manga->setAlternativeSlugs(['attack-on-titan', 'aot']);
$this->entityManager->flush();
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'attack-on-titan_vol1_ch1.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertArrayHasKey('matches', $data);
$this->assertNotEmpty($data['matches']);
$this->assertEquals('Shingeki no Kyojin', $data['matches'][0]['title']);
$this->assertContains('attack-on-titan', $data['matches'][0]['alternativeSlugs']);
}
public function test_it_provides_possible_titles_variants(): void
{
// Given
$this->createManga('Dragon Ball', 'dragon-ball');
// When
$client = static::createClient();
$response = $client->request('GET', '/api/manga-matches', [
'query' => [
'filename' => 'dragon-ball_vol1_ch5.cbz'
]
]);
// Then
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertNotEmpty($data['matches']);
// Vérifier que le match a bien le slug 'dragon-ball'
$this->assertEquals('dragon-ball', $data['matches'][0]['slug']);
}
private function createManga(string $title, string $slug): Manga
{
$manga = new Manga();
$manga->setTitle($title)
->setSlug($slug)
->setDescription('Description test')
->setAuthor('Author test')
->setPublicationYear(2020)
->setGenres(['action'])
->setStatus('ongoing')
->setRating(4.5)
->setMonitored(false)
->setImageUrl('https://via.placeholder.com/150')
->setThumbnailUrl('https://via.placeholder.com/150')
->setCreatedAt(new \DateTimeImmutable('2020-01-01'));
$this->entityManager->persist($manga);
$this->entityManager->flush();
return $manga;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Tests\Feature\Manga;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImportChapterTest extends WebTestCase
{
private const API_ENDPOINT = '/api/chapters/import';
public function test_it_returns_404_when_manga_not_found(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'non-existent-manga-id',
'chapterNumber' => '1.5'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(404);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertEquals('Manga not found', $response['error']);
}
public function test_it_returns_422_when_manga_id_is_missing(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'chapterNumber' => '1.5'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('mangaId is required', $response[0]['message']);
}
public function test_it_returns_422_when_chapter_number_is_missing(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('chapterNumber is required', $response[0]['message']);
}
public function test_it_returns_422_when_file_is_missing(): void
{
$client = static::createClient();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id',
'chapterNumber' => '1.5'
]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('Please upload a file', $response[0]['message']);
}
public function test_it_returns_422_when_file_is_not_cbz(): void
{
$client = static::createClient();
// Create a non-CBZ file
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tempFile, 'This is not a CBZ file');
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id',
'chapterNumber' => '1.5'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('valid CBZ file', $response[0]['message']);
}
private function createValidCbzFile(): UploadedFile
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
unlink($tmpFile);
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create test CBZ file');
}
$zip->addFromString('image1.jpg', 'fake-image-data');
$zip->close();
return new UploadedFile($tmpFile, 'test-chapter.cbz', 'application/x-cbz');
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Tests\Feature\Manga;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImportVolumeTest extends WebTestCase
{
private const API_ENDPOINT = '/api/volumes/import';
public function test_it_returns_404_when_manga_not_found(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'non-existent-manga-id',
'volumeNumber' => '1'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(404);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertEquals('Manga not found', $response['error']);
}
public function test_it_returns_422_when_manga_id_is_missing(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'volumeNumber' => '1'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('mangaId is required', $response[0]['message']);
}
public function test_it_returns_422_when_volume_number_is_missing(): void
{
$client = static::createClient();
$file = $this->createValidCbzFile();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('volumeNumber is required', $response[0]['message']);
}
public function test_it_returns_422_when_file_is_missing(): void
{
$client = static::createClient();
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id',
'volumeNumber' => '1'
]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('Please upload a file', $response[0]['message']);
}
public function test_it_returns_422_when_file_is_not_cbz(): void
{
$client = static::createClient();
// Create a non-CBZ file
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tempFile, 'This is not a CBZ file');
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
$client->request(
'POST',
self::API_ENDPOINT,
[
'mangaId' => 'some-manga-id',
'volumeNumber' => '1'
],
['file' => $file]
);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('valid CBZ file', $response[0]['message']);
}
private function createValidCbzFile(): UploadedFile
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
unlink($tmpFile);
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create test CBZ file');
}
$zip->addFromString('image1.jpg', 'fake-image-data');
$zip->close();
return new UploadedFile($tmpFile, 'test-volume.cbz', 'application/x-cbz');
}
}

View File

@@ -14,7 +14,7 @@ class SearchMangaTest extends AbstractApiTestCase
{ {
// When // When
$client = static::createClient(); $client = static::createClient();
$response = $client->request('GET', '/api/mangas/search', [ $response = $client->request('GET', '/api/manga-search', [
'query' => [ 'query' => [
'q' => '' 'q' => ''
] ]
@@ -32,7 +32,7 @@ class SearchMangaTest extends AbstractApiTestCase
{ {
// When // When
$client = static::createClient(); $client = static::createClient();
$response = $client->request('GET', '/api/mangas/search', [ $response = $client->request('GET', '/api/manga-search', [
'query' => [ 'query' => [
'q' => 'on' 'q' => 'on'
] ]
@@ -55,7 +55,7 @@ class SearchMangaTest extends AbstractApiTestCase
// When // When
$client = static::createClient(); $client = static::createClient();
$response = $client->request('GET', '/api/mangas/search', [ $response = $client->request('GET', '/api/manga-search', [
'query' => [ 'query' => [
'q' => 'one' 'q' => 'one'
] ]
@@ -81,7 +81,7 @@ class SearchMangaTest extends AbstractApiTestCase
// When // When
$client = static::createClient(); $client = static::createClient();
$response = $client->request('GET', '/api/mangas/search', [ $response = $client->request('GET', '/api/manga-search', [
'query' => [ 'query' => [
'q' => 'dragon' 'q' => 'dragon'
] ]
@@ -141,4 +141,4 @@ class SearchMangaTest extends AbstractApiTestCase
$this->entityManager->persist($manga); $this->entityManager->persist($manga);
$this->entityManager->flush(); $this->entityManager->flush();
} }
} }

Some files were not shown because too many files have changed in this diff Show More