feat: analyse import + all tests fixed

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-10-15 16:14:15 +02:00
parent fbe9619224
commit 3170a7c60e
74 changed files with 4318 additions and 183 deletions

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
export class ApiImportRepository {
/**
* Analyse le nom d'un fichier et trouve les mangas correspondants
* @param {string} filename - Nom du fichier à analyser
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
*/
async analyzeFilename(filename) {
try {
console.log('Analyzing filename:', filename);
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Analyze filename failed:', response.status, errorText);
throw new Error(`Failed to analyze filename: ${response.status}`);
}
const result = await response.json();
console.log('Analyze result:', result);
// Extract chapter and volume numbers from the first match if available
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
const chapterNumber = firstMatch?.chapterNumber ?? null;
const volumeNumber = firstMatch?.volumeNumber ?? null;
return {
matches: result.matches || [],
chapterNumber,
volumeNumber,
possibleTitles: result.possibleTitles || []
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Récupère les détails d'un manga par son slug
* @param {string} slug - Slug du manga
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
*/
async getMangaDetails(slug) {
try {
console.log('Fetching manga details for:', slug);
const response = await fetch(`/api/mangas/${slug}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Get manga details failed:', response.status, errorText);
throw new Error(`Failed to get manga details: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Upload et import d'un fichier avec les informations du manga
* @param {File} file - Fichier à uploader
* @param {string} mangaId - ID du manga
* @param {string|null} chapterId - ID du chapitre (optionnel)
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
* @returns {Promise<Object>} - 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ framework:
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events 'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
'App\Domain\Manga\Domain\Event\MangaCreated': events 'App\Domain\Manga\Domain\Event\MangaCreated': events
'App\Domain\Shared\Domain\Event\ChapterImported': events
'App\Domain\Shared\Domain\Event\VolumeImported': events
# Legacy messages (à garder si nécessaire) # Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands 'App\Message\DownloadChapter': commands

View File

@@ -126,7 +126,13 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { 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: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
@@ -158,6 +164,31 @@ services:
App\Domain\Shared\Domain\Contract\EventDispatcherInterface: App\Domain\Shared\Domain\Contract\EventDispatcherInterface:
alias: App\Domain\Shared\Infrastructure\Messenger\SymfonyMessengerEventDispatcher 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: App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
# Import Domain Services
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
# Import Domain Query/Command Handlers
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
# Import Domain API Platform Services
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ readonly class MangadexProvider implements MangaProviderInterface
} }
$mangas = $this->createMangasFromResults($results['data']); $mangas = $this->createMangasFromResults($results['data']);
// $this->enrichWithRatings($mangas); $this->enrichWithRatings($mangas);
usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0)); usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
readonly class FilenameAnalyzer implements FilenameAnalyzerInterface
{
public function analyze(string $filename): AnalyzedFilename
{
// Enlever l'extension
$nameWithoutExtension = preg_replace('/\.(cbz|cbr)$/i', '', $filename);
// Extraire les informations en utilisant la logique du CbzService
$titleStr = $this->extractTitle($nameWithoutExtension);
$volumeNumber = $this->extractVolume($nameWithoutExtension);
$chapterNumber = $this->extractChapter($nameWithoutExtension);
$cleanedTitle = $this->cleanTitle($titleStr);
return new AnalyzedFilename(
title: new MangaTitle($cleanedTitle),
chapterNumber: $chapterNumber !== null ? new ChapterNumber($chapterNumber) : null,
volumeNumber: $volumeNumber !== null ? new VolumeNumber((float) $volumeNumber) : null
);
}
private function extractTitle(string $fileName): string
{
// Pattern principal : titre suivi de volume/chapitre (inspiré du CbzService)
// Supporte: vol, volume, tome, t, ch, chap, chapter, chapitre
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Cc]h(?:ap(?:itre|ter)?)?|[Tt])\s*\d+)/';
if (preg_match($titlePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore : titre_vol123 ou titre_ch456 ou titre_chapter_1094
$underscorePattern = '/^(?P<title>.*?)_(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($underscorePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern avec tiret : titre-vol123 ou titre-ch456 ou titre-tome-50
$dashPattern = '/^(?P<title>.*?)-(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($dashPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore simple : titre_123
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Si aucun pattern ne matche, retourner le nom sans extension
return $fileName;
}
private function extractVolume(string $fileName): ?int
{
// Pattern pour volume : vol123, volume123, tome123, t123, v123
$volumePattern = '/(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Tt]|[Vv])[\s\-_]*(?P<volume>\d+)/i';
if (preg_match($volumePattern, $fileName, $matches)) {
return (int) $matches['volume'];
}
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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']}");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'manga' => $manga, 'manga' => $manga,
'volume' => 1, 'volume' => 1,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 1.0, 'number' => 1.0,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 2.0, 'number' => 2.0,
'visible' => false, // Soft deleted 'visible' => false, // Soft deleted
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 4.0, 'number' => 4.0,
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' 'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();

View File

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

View File

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

View File

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

BIN
tests/Fixtures/chapter.cbr Normal file

Binary file not shown.

Binary file not shown.

1
tests/Fixtures/test.txt Normal file
View File

@@ -0,0 +1 @@
test content

View File

@@ -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
);
}
}

Binary file not shown.