diff --git a/assets/vue/app/App.vue b/assets/vue/app/App.vue index 9756a83..ef20952 100644 --- a/assets/vue/app/App.vue +++ b/assets/vue/app/App.vue @@ -1,11 +1,10 @@ - \ No newline at end of file + diff --git a/assets/vue/app/domain/import/README.md b/assets/vue/app/domain/import/README.md new file mode 100644 index 0000000..856183c --- /dev/null +++ b/assets/vue/app/domain/import/README.md @@ -0,0 +1,195 @@ +# 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/import/upload-file +``` +FormData : +- `file`: Le fichier CBZ/CBR +- `mangaId`: ID du manga sélectionné +- `chapterNumber`: Numéro de chapitre (optionnel, float) +- `volumeNumber`: Numéro de volume (optionnel, float) + +## 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 diff --git a/assets/vue/app/domain/import/application/store/newImportStore.js b/assets/vue/app/domain/import/application/store/newImportStore.js new file mode 100644 index 0000000..fe921a4 --- /dev/null +++ b/assets/vue/app/domain/import/application/store/newImportStore.js @@ -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(); + } + } +}); diff --git a/assets/vue/app/domain/import/domain/entities/FileImport.js b/assets/vue/app/domain/import/domain/entities/FileImport.js new file mode 100644 index 0000000..e39c1af --- /dev/null +++ b/assets/vue/app/domain/import/domain/entities/FileImport.js @@ -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; + } +} diff --git a/assets/vue/app/domain/import/domain/entities/ImportFile.js b/assets/vue/app/domain/import/domain/entities/ImportFile.js new file mode 100644 index 0000000..bae4dc8 --- /dev/null +++ b/assets/vue/app/domain/import/domain/entities/ImportFile.js @@ -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; + } +} diff --git a/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js b/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js new file mode 100644 index 0000000..13f4f62 --- /dev/null +++ b/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js @@ -0,0 +1,105 @@ +export class ApiImportRepository { + /** + * Analyse le nom d'un fichier et trouve les mangas correspondants + * @param {string} filename - Nom du fichier à analyser + * @returns {Promise} - 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} - 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 {string|null} chapterId - ID du chapitre (optionnel) + * @param {number|null} volumeNumber - Numéro du volume (optionnel) + * @returns {Promise} - Résultat de l'import + */ + async importFile(file, mangaId, chapterId = null, volumeNumber = null) { + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('mangaId', mangaId); + + if (chapterId) { + formData.append('chapterId', chapterId); + } + + if (volumeNumber) { + formData.append('volumeNumber', volumeNumber.toString()); + } + + console.log('Importing file:', file.name, 'for manga:', mangaId); + const response = await fetch('/api/import/upload-file', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Import failed:', response.status, errorText); + throw new Error(`Failed to import file: ${response.status}`); + } + + const result = await response.json(); + console.log('Import result:', result); + return result; + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + +} diff --git a/assets/vue/app/domain/import/presentation/components/FileImportCard.vue b/assets/vue/app/domain/import/presentation/components/FileImportCard.vue new file mode 100644 index 0000000..3d6290f --- /dev/null +++ b/assets/vue/app/domain/import/presentation/components/FileImportCard.vue @@ -0,0 +1,226 @@ + + + diff --git a/assets/vue/app/domain/import/presentation/components/ImportResults.vue b/assets/vue/app/domain/import/presentation/components/ImportResults.vue new file mode 100644 index 0000000..e0868f4 --- /dev/null +++ b/assets/vue/app/domain/import/presentation/components/ImportResults.vue @@ -0,0 +1,114 @@ + + + diff --git a/assets/vue/app/domain/import/presentation/components/MangaOption.vue b/assets/vue/app/domain/import/presentation/components/MangaOption.vue new file mode 100644 index 0000000..84ac096 --- /dev/null +++ b/assets/vue/app/domain/import/presentation/components/MangaOption.vue @@ -0,0 +1,53 @@ + + + diff --git a/assets/vue/app/domain/import/presentation/components/StatusBadge.vue b/assets/vue/app/domain/import/presentation/components/StatusBadge.vue new file mode 100644 index 0000000..dece715 --- /dev/null +++ b/assets/vue/app/domain/import/presentation/components/StatusBadge.vue @@ -0,0 +1,70 @@ + + + diff --git a/assets/vue/app/domain/import/presentation/pages/NewImportPage.vue b/assets/vue/app/domain/import/presentation/pages/NewImportPage.vue new file mode 100644 index 0000000..56deb4d --- /dev/null +++ b/assets/vue/app/domain/import/presentation/pages/NewImportPage.vue @@ -0,0 +1,154 @@ + + + diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index bf24be3..214764f 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.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 HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; @@ -56,10 +57,16 @@ const routes = [ component: ChapterPage, props: { title: 'Lecteur' } }, + // Import routes + { + path: '/import', + name: 'import', + component: NewImportPage + }, // Pages placeholder avec chargement différé { path: '/manga/import', - name: 'import', + name: 'manga-import', component: PlaceholderComponent, props: { title: 'Import de bibliothèque' } }, diff --git a/assets/vue/app/shared/components/layout/Sidebar.vue b/assets/vue/app/shared/components/layout/Sidebar.vue index 50ab6cb..7cb86f1 100644 --- a/assets/vue/app/shared/components/layout/Sidebar.vue +++ b/assets/vue/app/shared/components/layout/Sidebar.vue @@ -58,7 +58,7 @@ import MenuGroup from './sidebar/MenuGroup.vue'; { icon: ArrowDownTrayIcon, text: 'Import bibliothèque', - to: '/manga/import' + to: '/import' }, { icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' } ] diff --git a/assets/vue/app/shared/components/ui/FileUpload.vue b/assets/vue/app/shared/components/ui/FileUpload.vue new file mode 100644 index 0000000..2c57bf2 --- /dev/null +++ b/assets/vue/app/shared/components/ui/FileUpload.vue @@ -0,0 +1,127 @@ + + + \ No newline at end of file diff --git a/assets/vue/app/shared/components/ui/LoadingSpinner.vue b/assets/vue/app/shared/components/ui/LoadingSpinner.vue new file mode 100644 index 0000000..3b22c7f --- /dev/null +++ b/assets/vue/app/shared/components/ui/LoadingSpinner.vue @@ -0,0 +1,46 @@ + + + + diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index fcfb399..fbaf89c 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -34,6 +34,8 @@ framework: 'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': 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) 'App\Message\DownloadChapter': commands diff --git a/config/services.yaml b/config/services.yaml index e2dd6c6..66c2c77 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -126,7 +126,13 @@ services: tags: - { name: messenger.message_handler, bus: command.bus } - App\Domain\Scraping\Infrastructure\Service\CbzGenerator: + App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~ + + # Shared Manga Path/File Manager + App\Domain\Shared\Domain\Contract\MangaPathManagerInterface: + alias: App\Domain\Shared\Infrastructure\Service\MangaFileManager + + App\Domain\Shared\Infrastructure\Service\MangaFileManager: arguments: $projectDir: '%kernel.project_dir%' @@ -158,6 +164,31 @@ services: 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: ~ diff --git a/package.json b/package.json index 05bbbb4..d89b13d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "puppeteer": "^22.10.0", "react-router-dom": "^7.1.5", "sortablejs": "^1.15.2", - "tailwindcss": "^3.2.7" + "tailwindcss": "^3.2.7", + "vuedraggable": "^2.24.3" } } diff --git a/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php b/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php index 833e549..2a12cc1 100644 --- a/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php @@ -5,6 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler; use App\Domain\Manga\Application\Command\FetchMangaChapters; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; 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 { @@ -18,7 +20,11 @@ readonly class FetchMangaChaptersHandler $manga = $this->mangaRepository->findById($command->mangaId->getValue()); 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) diff --git a/src/Domain/Manga/Application/EventListener/ChapterImportedEventListener.php b/src/Domain/Manga/Application/EventListener/ChapterImportedEventListener.php new file mode 100644 index 0000000..39e2d22 --- /dev/null +++ b/src/Domain/Manga/Application/EventListener/ChapterImportedEventListener.php @@ -0,0 +1,48 @@ +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; + } + } + } +} + + diff --git a/src/Domain/Manga/Application/EventListener/VolumeImportedEventListener.php b/src/Domain/Manga/Application/EventListener/VolumeImportedEventListener.php new file mode 100644 index 0000000..6ed8923 --- /dev/null +++ b/src/Domain/Manga/Application/EventListener/VolumeImportedEventListener.php @@ -0,0 +1,49 @@ +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); + } + } +} + + diff --git a/src/Domain/Manga/Application/Query/FindMangaMatchByFilename.php b/src/Domain/Manga/Application/Query/FindMangaMatchByFilename.php new file mode 100644 index 0000000..4470f57 --- /dev/null +++ b/src/Domain/Manga/Application/Query/FindMangaMatchByFilename.php @@ -0,0 +1,14 @@ +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; + } +} + diff --git a/src/Domain/Manga/Application/QueryHandler/GetMangaBySlugHandler.php b/src/Domain/Manga/Application/QueryHandler/GetMangaBySlugHandler.php index bb20740..70dab0f 100644 --- a/src/Domain/Manga/Application/QueryHandler/GetMangaBySlugHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/GetMangaBySlugHandler.php @@ -26,6 +26,7 @@ readonly class GetMangaBySlugHandler id: $manga->getId()->getValue(), title: $manga->getTitle()->getValue(), slug: $manga->getSlug()->getValue(), + alternativeSlugs: $manga->getAlternativeSlugs(), description: $manga->getDescription(), author: $manga->getAuthor(), publicationYear: $manga->getPublicationYear(), @@ -34,7 +35,8 @@ readonly class GetMangaBySlugHandler externalId: $manga->getExternalId()?->getValue(), imageUrl: $manga->getImageUrl(), thumbnailUrl: $manga->getImageUrls()?->getThumbnail(), - rating: $manga->getRating() + rating: $manga->getRating(), + monitored: $manga->isMonitored() ); } -} \ No newline at end of file +} diff --git a/src/Domain/Manga/Application/QueryHandler/SearchLocalMangaHandler.php b/src/Domain/Manga/Application/QueryHandler/SearchLocalMangaHandler.php index e493d0e..9d33a42 100644 --- a/src/Domain/Manga/Application/QueryHandler/SearchLocalMangaHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/SearchLocalMangaHandler.php @@ -25,6 +25,7 @@ readonly class SearchLocalMangaHandler id: $manga->getId()->getValue(), title: $manga->getTitle()->getValue(), slug: $manga->getSlug()->getValue(), + alternativeSlugs: $manga->getAlternativeSlugs(), description: $manga->getDescription(), author: $manga->getAuthor(), publicationYear: $manga->getPublicationYear(), @@ -33,7 +34,8 @@ readonly class SearchLocalMangaHandler externalId: $manga->getExternalId()?->getValue() ?? '', imageUrl: $manga->getImageUrls()->getFull(), thumbnailUrl: $manga->getImageUrls()->getThumbnail(), - rating: $manga->getRating() + rating: $manga->getRating(), + monitored: $manga->isMonitored() ), $mangas ), diff --git a/src/Domain/Manga/Application/Response/MangaMatchItem.php b/src/Domain/Manga/Application/Response/MangaMatchItem.php new file mode 100644 index 0000000..c010859 --- /dev/null +++ b/src/Domain/Manga/Application/Response/MangaMatchItem.php @@ -0,0 +1,21 @@ +matches) > 0; + } + + public function getBestMatch(): ?MangaMatchItem + { + return $this->matches[0] ?? null; + } +} + diff --git a/src/Domain/Manga/Domain/Contract/Service/FilenameAnalyzerInterface.php b/src/Domain/Manga/Domain/Contract/Service/FilenameAnalyzerInterface.php new file mode 100644 index 0000000..cdc00b9 --- /dev/null +++ b/src/Domain/Manga/Domain/Contract/Service/FilenameAnalyzerInterface.php @@ -0,0 +1,12 @@ +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; + } +} + diff --git a/src/Domain/Manga/Domain/Model/Manga.php b/src/Domain/Manga/Domain/Model/Manga.php index eb35bcb..169c0ec 100644 --- a/src/Domain/Manga/Domain/Model/Manga.php +++ b/src/Domain/Manga/Domain/Model/Manga.php @@ -163,6 +163,11 @@ final class Manga return $this->monitoringStatus->isEnabled(); } + public function isMonitored(): bool + { + return $this->monitoringStatus->isEnabled(); + } + public function enableMonitoring(): void { $this->monitoringStatus = MonitoringStatus::enabled(); diff --git a/src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php b/src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php new file mode 100644 index 0000000..0a6056e --- /dev/null +++ b/src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php @@ -0,0 +1,24 @@ +value; + } +} + diff --git a/src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php b/src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php new file mode 100644 index 0000000..5f848b2 --- /dev/null +++ b/src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php @@ -0,0 +1,24 @@ +value; + } +} + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/FilenameMatchCollection.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/FilenameMatchCollection.php new file mode 100644 index 0000000..83f5c8e --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/FilenameMatchCollection.php @@ -0,0 +1,21 @@ + '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 = [], + ) { + } +} + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/SearchLocalMangaResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/SearchLocalMangaResource.php index d841a10..1a6a5af 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/SearchLocalMangaResource.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/SearchLocalMangaResource.php @@ -3,15 +3,15 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource; 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\State\Provider\SearchLocalMangaStateProvider; #[ApiResource( - shortName: 'Manga', + shortName: 'MangaSearch', operations: [ - new Get( - uriTemplate: '/mangas/search', + new GetCollection( + uriTemplate: '/manga-search', provider: SearchLocalMangaStateProvider::class, output: MangaSearchCollection::class, status: 200, @@ -82,4 +82,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaS )] class SearchLocalMangaResource { -} \ No newline at end of file +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/FindMangaMatchByFilenameStateProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/FindMangaMatchByFilenameStateProvider.php new file mode 100644 index 0000000..9988bd9 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/FindMangaMatchByFilenameStateProvider.php @@ -0,0 +1,51 @@ +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 + ), + ); + } +} + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaBySlugStateProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaBySlugStateProvider.php index 22484bd..ae6bce1 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaBySlugStateProvider.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaBySlugStateProvider.php @@ -23,6 +23,7 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface id: $response->id, title: $response->title, slug: $response->slug, + alternativeSlugs: $response->alternativeSlugs, description: $response->description, author: $response->author, publicationYear: $response->publicationYear, @@ -31,7 +32,8 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface externalId: $response->externalId, imageUrl: $response->imageUrl, thumbnailUrl: $response->thumbnailUrl, - rating: $response->rating + rating: $response->rating, + monitored: $response->monitored ); } -} \ No newline at end of file +} diff --git a/src/Domain/Manga/Infrastructure/MessageHandler/ChapterImportedMessageHandler.php b/src/Domain/Manga/Infrastructure/MessageHandler/ChapterImportedMessageHandler.php new file mode 100644 index 0000000..47a655b --- /dev/null +++ b/src/Domain/Manga/Infrastructure/MessageHandler/ChapterImportedMessageHandler.php @@ -0,0 +1,24 @@ +listener->__invoke($event); + } +} + + diff --git a/src/Domain/Manga/Infrastructure/MessageHandler/VolumeImportedMessageHandler.php b/src/Domain/Manga/Infrastructure/MessageHandler/VolumeImportedMessageHandler.php new file mode 100644 index 0000000..a0ed72a --- /dev/null +++ b/src/Domain/Manga/Infrastructure/MessageHandler/VolumeImportedMessageHandler.php @@ -0,0 +1,24 @@ +listener->__invoke($event); + } +} + + diff --git a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php index a9db29f..8c01282 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php @@ -191,36 +191,59 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface { $offset = ($page - 1) * $limit; - $queryBuilder = $this->entityManager->createQueryBuilder() - ->select('m') - ->from(EntityManga::class, 'm') - ->where('m.title LIKE :query') - ->orWhere('m.slug LIKE :query') -// ->orWhere('m.author LIKE :query') -// ->orWhere('m.description LIKE :query') - ->setParameter('query', '%' . $query . '%') - ->orderBy('m.title', 'ASC') - ->setFirstResult($offset) - ->setMaxResults($limit); + // Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs + $sql = "SELECT m.* FROM manga m + WHERE m.title LIKE :query + OR m.slug LIKE :query + OR CAST(m.alternative_slugs AS TEXT) LIKE :query + ORDER BY m.title ASC + LIMIT :limit OFFSET :offset"; + + $rsm = new \Doctrine\ORM\Query\ResultSetMapping(); + $rsm->addEntityResult(EntityManga::class, 'm'); + $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( fn (EntityManga $entity) => $this->toDomain($entity), - $queryBuilder->getQuery()->getResult() + $nativeQuery->getResult() ); } public function countSearch(string $query): int { - return $this->entityManager->createQueryBuilder() - ->select('COUNT(m.id)') - ->from(EntityManga::class, 'm') - ->where('m.title LIKE :query') - ->orWhere('m.slug LIKE :query') - ->orWhere('m.author LIKE :query') - ->orWhere('m.description LIKE :query') - ->setParameter('query', '%' . $query . '%') - ->getQuery() - ->getSingleScalarResult(); + // Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs + $sql = "SELECT COUNT(m.id) FROM manga m + WHERE m.title LIKE :query + OR m.slug LIKE :query + OR m.author LIKE :query + OR m.description LIKE :query + OR CAST(m.alternative_slugs AS TEXT) LIKE :query"; + + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery(['query' => '%' . $query . '%']); + + return (int) $result->fetchOne(); } /** diff --git a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php index ef804ff..374d68f 100644 --- a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php +++ b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php @@ -30,7 +30,7 @@ readonly class MangadexProvider implements MangaProviderInterface } $mangas = $this->createMangasFromResults($results['data']); - // $this->enrichWithRatings($mangas); + $this->enrichWithRatings($mangas); usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0)); diff --git a/src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php b/src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php new file mode 100644 index 0000000..f71818a --- /dev/null +++ b/src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php @@ -0,0 +1,106 @@ +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 principal : titre suivi de volume/chapitre (inspiré du CbzService) + // Supporte: vol, volume, tome, t, ch, chap, chapter, chapitre + $titlePattern = '/^(?P.+?)(?:\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']; + } + + 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 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); + } +} diff --git a/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php b/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php index 04cdcdc..c571c13 100644 --- a/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php +++ b/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php @@ -5,84 +5,24 @@ namespace App\Domain\Scraping\Infrastructure\Service; use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface; use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest; use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath; +use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; readonly class CbzGenerator implements CbzGeneratorInterface { public function __construct( - private string $projectDir + private MangaPathManagerInterface $mangaPathManager, ) { } public function generate(CbzGenerationRequest $request): CbzPath { - $cbzPath = $this->generateCbzPath($request); - $this->createCbzArchive($request->getFiles(), $cbzPath); + $cbzPath = $this->mangaPathManager->buildChapterCbzPath( + $request->getMangaTitle(), + $request->getPublicationYear(), + $request->getVolumeNumber(), + $request->getChapterNumber(), + ); + $this->mangaPathManager->createCbzArchive($request->getFiles(), $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'; - } } diff --git a/src/Domain/Shared/Domain/Contract/FileUploadInterface.php b/src/Domain/Shared/Domain/Contract/FileUploadInterface.php new file mode 100644 index 0000000..917d077 --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/FileUploadInterface.php @@ -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; +} diff --git a/src/Domain/Shared/Domain/Contract/MangaPathManagerInterface.php b/src/Domain/Shared/Domain/Contract/MangaPathManagerInterface.php new file mode 100644 index 0000000..fc07618 --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/MangaPathManagerInterface.php @@ -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; +} + + diff --git a/src/Domain/Shared/Domain/Contract/MetadataExtractorInterface.php b/src/Domain/Shared/Domain/Contract/MetadataExtractorInterface.php new file mode 100644 index 0000000..fc2ef43 --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/MetadataExtractorInterface.php @@ -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; +} diff --git a/src/Domain/Shared/Domain/Contract/NotificationInterface.php b/src/Domain/Shared/Domain/Contract/NotificationInterface.php new file mode 100644 index 0000000..7153ebd --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/NotificationInterface.php @@ -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; +} diff --git a/src/Domain/Shared/Domain/Event/ChapterImported.php b/src/Domain/Shared/Domain/Event/ChapterImported.php new file mode 100644 index 0000000..a8d5275 --- /dev/null +++ b/src/Domain/Shared/Domain/Event/ChapterImported.php @@ -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, + ) {} +} + + diff --git a/src/Domain/Shared/Domain/Event/VolumeImported.php b/src/Domain/Shared/Domain/Event/VolumeImported.php new file mode 100644 index 0000000..ea74eb5 --- /dev/null +++ b/src/Domain/Shared/Domain/Event/VolumeImported.php @@ -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, + ) {} +} + + diff --git a/src/Domain/Shared/Domain/Exception/FileProcessingException.php b/src/Domain/Shared/Domain/Exception/FileProcessingException.php new file mode 100644 index 0000000..0054ba7 --- /dev/null +++ b/src/Domain/Shared/Domain/Exception/FileProcessingException.php @@ -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) + ); + } +} diff --git a/src/Domain/Shared/Domain/Model/FileMetadata.php b/src/Domain/Shared/Domain/Model/FileMetadata.php new file mode 100644 index 0000000..465fd4e --- /dev/null +++ b/src/Domain/Shared/Domain/Model/FileMetadata.php @@ -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, + ]; + } +} diff --git a/src/Domain/Shared/Domain/Model/FileUpload.php b/src/Domain/Shared/Domain/Model/FileUpload.php new file mode 100644 index 0000000..f15325b --- /dev/null +++ b/src/Domain/Shared/Domain/Model/FileUpload.php @@ -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]; + } +} diff --git a/src/Domain/Shared/Infrastructure/Service/MangaFileManager.php b/src/Domain/Shared/Infrastructure/Service/MangaFileManager.php new file mode 100644 index 0000000..5c1e1c3 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Service/MangaFileManager.php @@ -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'; + } +} + + diff --git a/src/Domain/Shared/Infrastructure/Service/SymfonyFileUpload.php b/src/Domain/Shared/Infrastructure/Service/SymfonyFileUpload.php new file mode 100644 index 0000000..f37ae80 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Service/SymfonyFileUpload.php @@ -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); + } +} diff --git a/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php b/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php new file mode 100644 index 0000000..58edb52 --- /dev/null +++ b/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php @@ -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); + } +} diff --git a/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php new file mode 100644 index 0000000..f22c5a5 --- /dev/null +++ b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php @@ -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 = []; + } +} diff --git a/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php b/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php index 39cbf77..8f31db7 100644 --- a/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php +++ b/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php @@ -128,13 +128,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface return null; } - public function saveChapter(Chapter $chapter): void + public function saveChapter(Chapter $chapter): ChapterId { $this->savedChapters[] = $chapter; if (!isset($this->chapters[$chapter->getMangaId()])) { $this->chapters[$chapter->getMangaId()] = []; } $this->chapters[$chapter->getMangaId()][] = $chapter; + return new ChapterId($chapter->getId()); } /** @return array<Chapter> */ @@ -160,6 +161,11 @@ class InMemoryMangaRepository implements MangaRepositoryInterface $manga->getDescription() ]; + // Ajouter les slugs alternatifs aux champs de recherche + foreach ($manga->getAlternativeSlugs() as $altSlug) { + $searchableFields[] = $altSlug; + } + foreach ($searchableFields as $field) { if (str_contains(strtolower($field), strtolower($query))) { return true; diff --git a/tests/Domain/Manga/Application/CommandHandler/CreateMangaFromMangadexHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/CreateMangaFromMangadexHandlerTest.php index 85b21dc..70b9015 100644 --- a/tests/Domain/Manga/Application/CommandHandler/CreateMangaFromMangadexHandlerTest.php +++ b/tests/Domain/Manga/Application/CommandHandler/CreateMangaFromMangadexHandlerTest.php @@ -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\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor; -use App\Tests\Shared\Adapter\InMemoryMessageBus; +use App\Tests\Shared\Adapter\InMemoryEventDispatcher; use PHPUnit\Framework\TestCase; class CreateMangaFromMangadexHandlerTest extends TestCase @@ -22,7 +22,7 @@ class CreateMangaFromMangadexHandlerTest extends TestCase private InMemoryMangaRepository $repository; private InMemoryImageProcessor $imageProcessor; private CreateMangaFromMangadexHandler $handler; - private InMemoryMessageBus $messageBus; + private InMemoryEventDispatcher $eventDispatcher; protected function setUp(): void { $manga = new Manga( @@ -41,12 +41,12 @@ class CreateMangaFromMangadexHandlerTest extends TestCase $this->provider = new InMemoryMangaProvider([$manga]); $this->repository = new InMemoryMangaRepository(); $this->imageProcessor = new InMemoryImageProcessor(); - $this->messageBus = new InMemoryMessageBus(); + $this->eventDispatcher = new InMemoryEventDispatcher(); $this->handler = new CreateMangaFromMangadexHandler( $this->provider, $this->repository, $this->imageProcessor, - $this->messageBus + $this->eventDispatcher ); } @@ -76,4 +76,4 @@ class CreateMangaFromMangadexHandlerTest extends TestCase // Act $this->handler->handle($command); } -} \ No newline at end of file +} diff --git a/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php index 997886d..4df6684 100644 --- a/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php +++ b/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php @@ -4,28 +4,29 @@ namespace App\Tests\Domain\Manga\Application\CommandHandler; use App\Domain\Manga\Application\Command\FetchMangaChapters; 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\ValueObject\ExternalId; 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\InMemoryMangadexClient; +use App\Tests\Domain\Manga\Adapter\InMemoryChapterSynchronizationService; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use PHPUnit\Framework\TestCase; class FetchMangaChaptersHandlerTest extends TestCase { - private InMemoryMangadexClient $mangadexClient; + private InMemoryChapterSynchronizationService $chapterSynchronizationService; private InMemoryMangaRepository $mangaRepository; private FetchMangaChaptersHandler $handler; protected function setUp(): void { - $this->mangadexClient = new InMemoryMangadexClient(); + $this->chapterSynchronizationService = new InMemoryChapterSynchronizationService(); $this->mangaRepository = new InMemoryMangaRepository(); $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->mangadexClient->addFeed($externalId, [ - [ - 'id' => 'chapter-1', - 'attributes' => [ - 'chapter' => '1', - 'title' => 'Chapter 1', - 'volume' => '1', - 'translatedLanguage' => 'fr' - ] - ] - ]); - - $command = new FetchMangaChapters($mangaId); + $command = new FetchMangaChapters(new MangaId($mangaId)); $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 @@ -72,7 +63,7 @@ class FetchMangaChaptersHandlerTest extends TestCase $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Manga not found'); - $command = new FetchMangaChapters($mangaId); + $command = new FetchMangaChapters(new MangaId($mangaId)); $this->handler->handle($command); } @@ -93,10 +84,10 @@ class FetchMangaChaptersHandlerTest extends TestCase $this->mangaRepository->save($manga); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Manga has no external ID'); + $this->expectException(MangadexApiException::class); + $this->expectExceptionMessage('Manga has no external_id'); - $command = new FetchMangaChapters($mangaId); + $command = new FetchMangaChapters(new MangaId($mangaId)); $this->handler->handle($command); } } diff --git a/tests/Domain/Manga/Application/QueryHandler/FindMangaMatchByFilenameHandlerTest.php b/tests/Domain/Manga/Application/QueryHandler/FindMangaMatchByFilenameHandlerTest.php new file mode 100644 index 0000000..873f9b2 --- /dev/null +++ b/tests/Domain/Manga/Application/QueryHandler/FindMangaMatchByFilenameHandlerTest.php @@ -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(); + } +} + diff --git a/tests/Domain/Manga/Infrastructure/Service/FilenameAnalyzerTest.php b/tests/Domain/Manga/Infrastructure/Service/FilenameAnalyzerTest.php new file mode 100644 index 0000000..db16e0d --- /dev/null +++ b/tests/Domain/Manga/Infrastructure/Service/FilenameAnalyzerTest.php @@ -0,0 +1,236 @@ +<?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']}"); + } + } +} diff --git a/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php b/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php index 7c1c1ed..126437c 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php +++ b/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php @@ -19,7 +19,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface 'A test manga description', 'Test Author', '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 @@ -30,7 +32,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface 'A test manga with preferred sources', 'Test Author', '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->getAuthor(), $manga->getPublicationYear(), - $sourceIds // Mise à jour des sources préférées + $manga->isMonitored(), // monitored + $sourceIds, // preferredSources + $manga->getAlternativeSlugs() // alternativeSlugs ); $this->mangas[$mangaId] = $updatedManga; } diff --git a/tests/Domain/Scraping/Adapter/InMemoryScraperFactory.php b/tests/Domain/Scraping/Adapter/InMemoryScraperFactory.php new file mode 100644 index 0000000..8f4bd71 --- /dev/null +++ b/tests/Domain/Scraping/Adapter/InMemoryScraperFactory.php @@ -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 = []; + } +} diff --git a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php index 28acfb6..9711cb3 100644 --- a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php +++ b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php @@ -14,7 +14,7 @@ use App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator; use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus; use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader; 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\Shared\Adapter\InMemoryJobRepository; use Doctrine\ORM\EntityManagerInterface; @@ -23,7 +23,7 @@ use PHPUnit\Framework\MockObject\MockObject; class ScrapeChapterHandlerTest extends TestCase { - private InMemoryScraperAdapter $scraper; + private InMemoryScraperFactory $scraperFactory; private InMemoryImageDownloader $imageDownloader; private InMemoryCbzGenerator $cbzGenerator; private InMemoryJobRepository $jobRepository; @@ -36,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase protected function setUp(): void { - $this->scraper = new InMemoryScraperAdapter(); + $this->scraperFactory = new InMemoryScraperFactory(); $this->imageDownloader = new InMemoryImageDownloader(); $this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir'); $this->jobRepository = new InMemoryJobRepository(); @@ -59,7 +59,7 @@ class ScrapeChapterHandlerTest extends TestCase )); $this->handler = new ScrapeChapterHandler( - $this->scraper, + $this->scraperFactory, $this->imageDownloader, $this->cbzGenerator, $this->jobRepository, @@ -92,31 +92,6 @@ class ScrapeChapterHandlerTest extends TestCase $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 { $this->jobRepository->clear(); diff --git a/tests/Feature/Manga/DownloadVolumeTest.php b/tests/Feature/Manga/DownloadVolumeTest.php index c5094b1..c001394 100644 --- a/tests/Feature/Manga/DownloadVolumeTest.php +++ b/tests/Feature/Manga/DownloadVolumeTest.php @@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase 'manga' => $manga, 'volume' => 1, 'visible' => true, - 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz' ]); $mangaId = $manga->getId(); @@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase 'volume' => 1, 'number' => 1.0, 'visible' => true, - 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz' ]); ChapterFactory::createOne([ @@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase 'volume' => 1, 'number' => 2.0, 'visible' => false, // Soft deleted - 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz' ]); ChapterFactory::createOne([ @@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase 'volume' => 1, 'number' => 4.0, 'visible' => true, - 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz' ]); $mangaId = $manga->getId(); diff --git a/tests/Feature/Manga/FetchMangaChaptersTest.php b/tests/Feature/Manga/FetchMangaChaptersTest.php index 6058ebf..cd00e6c 100644 --- a/tests/Feature/Manga/FetchMangaChaptersTest.php +++ b/tests/Feature/Manga/FetchMangaChaptersTest.php @@ -58,7 +58,7 @@ class FetchMangaChaptersTest extends AbstractApiTestCase $messages = $this->messageBus->getDispatchedMessages(); $this->assertCount(1, $messages); $this->assertInstanceOf(FetchMangaChapters::class, $messages[0]); - $this->assertEquals($mangaId, $messages[0]->mangaId); + $this->assertEquals(new MangaId($mangaId), $messages[0]->mangaId); } public function testFetchChaptersWithInvalidMangaId(): void diff --git a/tests/Feature/Manga/FindMangaMatchByFilenameTest.php b/tests/Feature/Manga/FindMangaMatchByFilenameTest.php new file mode 100644 index 0000000..aa3a736 --- /dev/null +++ b/tests/Feature/Manga/FindMangaMatchByFilenameTest.php @@ -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; + } +} + diff --git a/tests/Feature/Manga/SearchMangaTest.php b/tests/Feature/Manga/SearchMangaTest.php index 3c73e5a..1c8e3d7 100644 --- a/tests/Feature/Manga/SearchMangaTest.php +++ b/tests/Feature/Manga/SearchMangaTest.php @@ -14,7 +14,7 @@ class SearchMangaTest extends AbstractApiTestCase { // When $client = static::createClient(); - $response = $client->request('GET', '/api/mangas/search', [ + $response = $client->request('GET', '/api/manga-search', [ 'query' => [ 'q' => '' ] @@ -32,7 +32,7 @@ class SearchMangaTest extends AbstractApiTestCase { // When $client = static::createClient(); - $response = $client->request('GET', '/api/mangas/search', [ + $response = $client->request('GET', '/api/manga-search', [ 'query' => [ 'q' => 'on' ] @@ -55,7 +55,7 @@ class SearchMangaTest extends AbstractApiTestCase // When $client = static::createClient(); - $response = $client->request('GET', '/api/mangas/search', [ + $response = $client->request('GET', '/api/manga-search', [ 'query' => [ 'q' => 'one' ] @@ -81,7 +81,7 @@ class SearchMangaTest extends AbstractApiTestCase // When $client = static::createClient(); - $response = $client->request('GET', '/api/mangas/search', [ + $response = $client->request('GET', '/api/manga-search', [ 'query' => [ 'q' => 'dragon' ] @@ -141,4 +141,4 @@ class SearchMangaTest extends AbstractApiTestCase $this->entityManager->persist($manga); $this->entityManager->flush(); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/chapter.cbr b/tests/Fixtures/chapter.cbr new file mode 100644 index 0000000..d2fbc4d Binary files /dev/null and b/tests/Fixtures/chapter.cbr differ diff --git a/tests/Fixtures/large-file.cbz b/tests/Fixtures/large-file.cbz new file mode 100644 index 0000000..d2fbc4d Binary files /dev/null and b/tests/Fixtures/large-file.cbz differ diff --git a/tests/Fixtures/test.txt b/tests/Fixtures/test.txt new file mode 100644 index 0000000..d670460 --- /dev/null +++ b/tests/Fixtures/test.txt @@ -0,0 +1 @@ +test content diff --git a/tests/Shared/Adapter/InMemoryEventDispatcher.php b/tests/Shared/Adapter/InMemoryEventDispatcher.php new file mode 100644 index 0000000..bc2f668 --- /dev/null +++ b/tests/Shared/Adapter/InMemoryEventDispatcher.php @@ -0,0 +1,43 @@ +<?php + +namespace App\Tests\Shared\Adapter; + +use App\Domain\Shared\Domain\Contract\EventDispatcherInterface; + +class InMemoryEventDispatcher implements EventDispatcherInterface +{ + /** @var array<object> */ + private array $dispatchedEvents = []; + + public function dispatch(object $event): void + { + $this->dispatchedEvents[] = $event; + } + + /** + * @return array<object> + */ + public function getDispatchedEvents(): array + { + return $this->dispatchedEvents; + } + + public function clear(): void + { + $this->dispatchedEvents = []; + } + + /** + * @template T of object + * @param class-string<T> $eventClass + * @return array<T> + */ + public function getDispatchedEventsOfType(string $eventClass): array + { + return array_filter( + $this->dispatchedEvents, + fn(object $event) => $event instanceof $eventClass + ); + } +} + diff --git a/tests/Shared/Files/test-chapter.cbz b/tests/Shared/Files/test-chapter.cbz index 866aef9..0e19da3 100644 Binary files a/tests/Shared/Files/test-chapter.cbz and b/tests/Shared/Files/test-chapter.cbz differ