diff --git a/Makefile b/Makefile index 4d080af..117cda3 100644 --- a/Makefile +++ b/Makefile @@ -145,6 +145,13 @@ twig-extension: ## Create a new twig extension stimulus: ## Create a new stimulus controller @$(SYMFONY) make:stimulus-controller +notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle + @for type in info success error warning; do \ + $(SYMFONY) app:notify:test --type=$$type --message="Test $$type depuis Mangarr"; \ + echo "[$$type] envoyé"; \ + sleep 2; \ + done + consume-commands: ## Consume commands messages @$(SYMFONY) messenger:consume commands -vv diff --git a/assets/vue/app/App.vue b/assets/vue/app/App.vue index ef20952..6219e3b 100644 --- a/assets/vue/app/App.vue +++ b/assets/vue/app/App.vue @@ -5,6 +5,9 @@ diff --git a/assets/vue/app/shared/composables/useMercureNotifications.js b/assets/vue/app/shared/composables/useMercureNotifications.js new file mode 100644 index 0000000..4d724e3 --- /dev/null +++ b/assets/vue/app/shared/composables/useMercureNotifications.js @@ -0,0 +1,45 @@ +import { onMounted, onBeforeUnmount } from 'vue'; +import { useNotifications } from './useNotifications'; + +export function useMercureNotifications() { + const { showSuccess, showError, showInfo, showWarning } = useNotifications(); + let eventSource = null; + + const handleNotification = data => { + const message = data.message ?? 'Notification'; + switch (data.status) { + case 'success': showSuccess(message); break; + case 'error': showError(message); break; + case 'warning': showWarning(message); break; + default: showInfo(message); + } + }; + + const setup = () => { + const url = new URL('/.well-known/mercure', window.location.origin); + url.searchParams.append('topic', 'notifications'); + + eventSource = new EventSource(url, { withCredentials: true }); + + eventSource.onmessage = event => { + try { + const data = JSON.parse(event.data); + handleNotification(data); + } catch (e) { + console.error('useMercureNotifications: erreur de parsing', e); + } + }; + + eventSource.onerror = () => { + eventSource?.close(); + setTimeout(setup, 5000); + }; + }; + + onMounted(setup); + + onBeforeUnmount(() => { + eventSource?.close(); + eventSource = null; + }); +} diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index cdf9b6d..3fffec8 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -29,7 +29,7 @@ framework: 'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands # Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent) - 'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events + # ChapterScrapingStarted est synchrone pour que la notif "démarrage" arrive AVANT le scraping 'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events 'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events diff --git a/src/Command/SendTestNotificationCommand.php b/src/Command/SendTestNotificationCommand.php new file mode 100644 index 0000000..76fe9bf --- /dev/null +++ b/src/Command/SendTestNotificationCommand.php @@ -0,0 +1,55 @@ +addOption('type', 't', InputOption::VALUE_REQUIRED, 'Type de notification : info, success, error, warning', 'info') + ->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Message à envoyer', 'Notification de test depuis Mangarr'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $type = $input->getOption('type'); + $message = $input->getOption('message'); + + $allowed = ['info', 'success', 'error', 'warning']; + if (!in_array($type, $allowed, true)) { + $output->writeln(sprintf('Type invalide "%s". Valeurs acceptées : %s', $type, implode(', ', $allowed))); + return Command::FAILURE; + } + + match ($type) { + 'success' => $this->notification->sendSuccess($message), + 'error' => $this->notification->sendError($message), + 'warning' => $this->notification->sendWarning($message), + default => $this->notification->sendInfo($message), + }; + + $output->writeln(sprintf('[%s] Notification envoyée : %s', strtoupper($type), $message)); + + return Command::SUCCESS; + } +} diff --git a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php index a3f7c3c..3ae6abc 100644 --- a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php +++ b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php @@ -11,6 +11,7 @@ use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface; use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; +use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Scraping\Domain\Model\ScrapingJob; use App\Domain\Scraping\Domain\Model\Source; @@ -53,13 +54,16 @@ readonly class ScrapeChapterHandler throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}"); } - // 3. Détermination des sources à utiliser + // 3. Dispatch de l'événement de démarrage + $this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber)); + + // 4. Détermination des sources à utiliser $sources = $this->getSourcesToTry($manga); if (empty($sources)) { throw new \InvalidArgumentException("No sources available for scraping"); } - // 4. Essai de scraping sur chaque source jusqu'à succès + // 5. Essai de scraping sur chaque source jusqu'à succès $success = false; $lastException = null; diff --git a/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php b/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php index 20c64ec..966e395 100644 --- a/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php +++ b/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php @@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event; class ChapterScrapingStarted { public function __construct( - private readonly string $jobId + private readonly string $mangaTitle, + private readonly float $chapterNumber, ) { } - public function getJobId(): string + public function getMangaTitle(): string { - return $this->jobId; + return $this->mangaTitle; + } + + public function getChapterNumber(): float + { + return $this->chapterNumber; } } diff --git a/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php b/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php index a5d6d8b..a17f4a4 100644 --- a/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php +++ b/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php @@ -4,8 +4,10 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber; use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; +use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; +use App\Domain\Shared\Domain\Contract\NotificationInterface; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Mercure\HubInterface; @@ -18,15 +20,25 @@ class ScrapingEventSubscriber implements EventSubscriberInterface private readonly HubInterface $hub, private readonly ChapterRepositoryInterface $chapterRepository, private readonly JobRepositoryInterface $jobRepository, + private readonly NotificationInterface $notification, private readonly LoggerInterface $logger ) { } public static function getSubscribedEvents(): array { - return [ - // Les événements sont capturés via le système de message handlers - ]; + return []; + } + + #[AsMessageHandler] + public function onChapterScrapingStarted(ChapterScrapingStarted $event): void + { + $chapterNumber = $event->getChapterNumber(); + $mangaTitle = $event->getMangaTitle(); + + $this->notification->sendInfo( + sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle) + ); } #[AsMessageHandler] @@ -42,7 +54,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface return; } - // Récupérer le chapitre associé au job $chapterId = $job->context['chapterId'] ?? null; $this->logger->info('ChapterId extrait du job: ' . $chapterId); @@ -55,7 +66,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface $this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber); - // Préparer les données à envoyer au front $data = [ 'type' => 'chapter.scraped', 'chapterId' => $chapter->id, @@ -65,21 +75,19 @@ class ScrapingEventSubscriber implements EventSubscriberInterface 'timestamp' => (new \DateTimeImmutable())->format('c') ]; - $this->logger->info('Données préparées pour Mercure: ' . json_encode($data)); - - // Publier une mise à jour sur le hub Mercure $topics = [ - 'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre - 'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga - 'scraping/status' // Topic général pour les événements de scraping + 'manga/chapter/' . $chapter->id, + 'manga/' . $chapter->mangaId . '/chapters', + 'scraping/status' ]; - $this->logger->info('Topics Mercure: ' . implode(', ', $topics)); - $update = new Update($topics, json_encode($data)); $this->hub->publish($update); - $this->logger->info('Mise à jour publiée sur Mercure'); + $mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu'; + $this->notification->sendSuccess( + sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle) + ); } #[AsMessageHandler] @@ -87,7 +95,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface { $this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber()); - // Préparer les données à envoyer au front $data = [ 'type' => 'chapter.scraping.failed', 'mangaId' => $event->getMangaId(), @@ -96,19 +103,16 @@ class ScrapingEventSubscriber implements EventSubscriberInterface 'timestamp' => (new \DateTimeImmutable())->format('c') ]; - $this->logger->info('Données préparées pour Mercure: ' . json_encode($data)); - - // Publier une mise à jour sur le hub Mercure $topics = [ - 'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga - 'scraping/status' // Topic général pour les événements de scraping + 'manga/' . $event->getMangaId() . '/chapters', + 'scraping/status' ]; - $this->logger->info('Topics Mercure: ' . implode(', ', $topics)); - $update = new Update($topics, json_encode($data)); $this->hub->publish($update); - $this->logger->info('Mise à jour publiée sur Mercure'); + $this->notification->sendError( + sprintf('Échec du scraping du chapitre %s : %s', $event->getChapterNumber(), $event->getReason()) + ); } } diff --git a/src/Domain/Shared/Domain/Contract/NotificationInterface.php b/src/Domain/Shared/Domain/Contract/NotificationInterface.php index 7153ebd..79d7947 100644 --- a/src/Domain/Shared/Domain/Contract/NotificationInterface.php +++ b/src/Domain/Shared/Domain/Contract/NotificationInterface.php @@ -6,18 +6,13 @@ 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 sendInfo(string $message): void; + + public function sendWarning(string $message): void; + public function sendUpdate(array $data): void; } diff --git a/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php b/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php index 58edb52..4393d71 100644 --- a/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php +++ b/src/Domain/Shared/Infrastructure/Service/SymfonyNotification.php @@ -17,18 +17,22 @@ readonly class SymfonyNotification implements NotificationInterface public function sendSuccess(string $message): void { - $this->sendUpdate([ - 'status' => 'success', - 'message' => $message - ]); + $this->sendUpdate(['status' => 'success', 'message' => $message]); } public function sendError(string $message): void { - $this->sendUpdate([ - 'status' => 'error', - 'message' => $message - ]); + $this->sendUpdate(['status' => 'error', 'message' => $message]); + } + + public function sendInfo(string $message): void + { + $this->sendUpdate(['status' => 'info', 'message' => $message]); + } + + public function sendWarning(string $message): void + { + $this->sendUpdate(['status' => 'warning', 'message' => $message]); } public function sendUpdate(array $data): void diff --git a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php index 988b50b..0c0fdec 100644 --- a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php +++ b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Domain\Scraping\Application\CommandHandler; use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; +use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository; @@ -81,11 +82,15 @@ class ScrapeChapterHandlerTest extends TestCase $job = array_values($job)[0]; $dispatchedMessages = $this->eventBus->getDispatchedMessages(); - $this->assertCount(1, $dispatchedMessages); - $this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]); - $this->assertEquals($job->id, $dispatchedMessages[0]->getJobId()); - $this->assertEquals('1', $dispatchedMessages[0]->chapterId); - $this->assertEquals('/fake/pages/1', $dispatchedMessages[0]->pagesDirectory); + $this->assertCount(2, $dispatchedMessages); + + $this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]); + $this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber()); + + $this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]); + $this->assertEquals($job->id, $dispatchedMessages[1]->getJobId()); + $this->assertEquals('1', $dispatchedMessages[1]->chapterId); + $this->assertEquals('/fake/pages/1', $dispatchedMessages[1]->pagesDirectory); $this->assertNotNull($this->imageStorage->stored['1'] ?? null); }