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