diff --git a/deploy.php b/deploy.php index a72efc5..f61ccd3 100644 --- a/deploy.php +++ b/deploy.php @@ -116,14 +116,13 @@ task('webpack_encore:build', function () { sh -c '$installCmd'"); }); -// Restart Docker containers (entrypoint gère les migrations automatiquement) -// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage -// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release. +// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement) +// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre, +// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent. desc('Restart Docker containers'); task('docker:restart', function () { run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler'); run('docker restart mangarr'); - run('docker exec mangarr php bin/console cache:clear --env=prod'); }); // Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin diff --git a/frankenphp/docker-entrypoint.sh b/frankenphp/docker-entrypoint.sh index 907822c..f197e41 100755 --- a/frankenphp/docker-entrypoint.sh +++ b/frankenphp/docker-entrypoint.sh @@ -53,11 +53,12 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then fi fi - # Vider le cache prod stale avant le démarrage des workers FrankenPHP. - # Sans ça, les workers chargent l'ancien cache du volume Docker et crashent - # en boucle si les classes du cache ne correspondent plus à la version déployée. + # Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP. + # Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle + # → fichiers partiellement écrits → crash au démarrage des workers. if [ "$APP_ENV" = "prod" ]; then rm -rf var/cache/prod + php bin/console cache:warmup --env=prod fi setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var diff --git a/src/Command/RunMonitoringCommand.php b/src/Command/RunMonitoringCommand.php new file mode 100644 index 0000000..80c7ce0 --- /dev/null +++ b/src/Command/RunMonitoringCommand.php @@ -0,0 +1,36 @@ +writeln('Déclenchement du monitoring des mangas...'); + + $this->commandBus->dispatch(new CheckMonitoredMangas()); + + $output->writeln('Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.'); + + return Command::SUCCESS; + } +} diff --git a/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php b/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php index abb1b04..2b41f6f 100644 --- a/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandler.php @@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler { $criteria = new MonitoringCriteria( enabled: true, - lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour') + lastCheckBefore: new \DateTimeImmutable('-2 hours') ); $monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria); diff --git a/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php b/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php index 4e58337..67f6702 100644 --- a/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/RefreshMangaChaptersHandler.php @@ -26,18 +26,26 @@ readonly class RefreshMangaChaptersHandler throw new \RuntimeException('Manga not found'); } - // Synchronisation + récupération des nouveaux IDs - $newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga); + // Synchronisation + récupération des numéros de nouveaux chapitres + $newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga); // Mise à jour de la date de monitoring $manga->updateLastMonitoringCheck(new \DateTimeImmutable()); $this->mangaRepository->save($manga); // Événement de scraping pour chaque nouveau chapitre - foreach ($newChapterIds as $chapterId) { - $this->eventBus->dispatch( - new ChapterReadyForScraping(new ChapterId($chapterId)) + // On retrouve l'ID réel (PK integer) après save() car le chapitre n'a + // son identifiant définitif qu'une fois persisté en base. + foreach ($newChapterNumbers as $chapterNumber) { + $saved = $this->mangaRepository->findChapterByMangaIdAndNumber( + $manga->getId()->getValue(), + $chapterNumber ); + if ($saved) { + $this->eventBus->dispatch( + new ChapterReadyForScraping(new ChapterId($saved->getId())) + ); + } } } } diff --git a/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php b/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php index ff44389..1e93473 100644 --- a/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php +++ b/src/Domain/Manga/Domain/Contract/Service/ChapterSynchronizationServiceInterface.php @@ -9,7 +9,7 @@ interface ChapterSynchronizationServiceInterface /** * Synchronise les chapitres d'un manga depuis la source externe. * - * @return string[] IDs des nouveaux chapitres ajoutés + * @return float[] Numéros des nouveaux chapitres ajoutés */ public function synchronizeChapters(Manga $manga): array; } diff --git a/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php new file mode 100644 index 0000000..5692752 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyCheckMonitoredMangasHandler.php @@ -0,0 +1,21 @@ +handler->handle($command); + } +} diff --git a/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php b/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php index 6081e51..b809cc8 100644 --- a/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php +++ b/src/Domain/Manga/Infrastructure/Scheduler/MonitoringSchedule.php @@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface { return (new Schedule())->add( // Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures - RecurringMessage::every('2 hours', new CheckMonitoredMangas( - new \DateTimeImmutable('-2 hours') - )) + RecurringMessage::every('2 hours', new CheckMonitoredMangas()) )->stateful($this->cache); } } diff --git a/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php b/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php index a935770..d76f021 100644 --- a/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php +++ b/src/Domain/Manga/Infrastructure/Service/MangadxChapterSynchronizationService.php @@ -96,11 +96,11 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz $newChapterIds = []; - // Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs + // Sauvegarde uniquement les nouveaux chapitres et collecte leurs numéros foreach ($chaptersByNumber as $chapterNumber => $chapter) { if (!isset($existingChapters[(float) $chapterNumber])) { $manga->addChapter($chapter); - $newChapterIds[] = $chapter->getId(); + $newChapterIds[] = $chapter->getNumber(); } } diff --git a/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php b/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php index bd4210b..829a349 100644 --- a/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php +++ b/src/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListener.php @@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; +use App\Domain\Scraping\Domain\Model\ScrapingJob; +use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; +use Ramsey\Uuid\Uuid; use Symfony\Component\Messenger\Attribute\AsMessageHandler; class AutoScrapingListener @@ -15,6 +18,7 @@ class AutoScrapingListener private readonly ScrapeChapterHandler $scrapeChapterHandler, private readonly ChapterRepositoryInterface $chapterRepository, private readonly MangaRepositoryInterface $mangaRepository, + private readonly JobRepositoryInterface $jobRepository, ) { } @@ -25,7 +29,12 @@ class AutoScrapingListener $manga = $this->mangaRepository->getById($chapter->mangaId); if ($manga->isMonitored()) { - $this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue())); + $jobId = Uuid::uuid4()->toString(); + $job = new ScrapingJob($jobId); + $job->context['chapterId'] = $event->chapterId->getValue(); + $this->jobRepository->save($job); + + $this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue(), $jobId)); } } } diff --git a/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php index 5ce6b30..53a8cd2 100644 --- a/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php +++ b/tests/Domain/Manga/Adapter/InMemoryChapterSynchronizationService.php @@ -18,8 +18,8 @@ class InMemoryChapterSynchronizationService implements ChapterSynchronizationSer 'synchronized_at' => new \DateTimeImmutable(), ]; - // Retourne les IDs des chapitres synchronisés (simulation) - return ['chapter-1', 'chapter-2']; + // Retourne les numéros des chapitres synchronisés (simulation) + return [1.0, 2.0]; } /** diff --git a/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php new file mode 100644 index 0000000..53bd168 --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/CheckMonitoredMangasHandlerTest.php @@ -0,0 +1,110 @@ +mangaRepository = new InMemoryMangaRepository(); + $this->commandBus = new InMemoryMessageBus(); + $this->commandBus->clear(); + $this->handler = new CheckMonitoredMangasHandler($this->mangaRepository, $this->commandBus); + } + + private function createManga(string $id): Manga + { + return new Manga( + new MangaId($id), + new MangaTitle('Manga ' . $id), + new MangaSlug('manga-' . $id), + 'Description', + 'Author', + 2024, + [], + 'ongoing', + new ExternalId('ext-' . $id) + ); + } + + public function testDispatchesRefreshForMonitoredMangaWithOldCheck(): void + { + $manga = $this->createManga('manga-1'); + $manga->enableMonitoring(); + $manga->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours')); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertTrue($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + $dispatched = array_filter( + $this->commandBus->getDispatchedMessages(), + fn ($m) => $m instanceof RefreshMangaChapters + ); + $this->assertCount(1, $dispatched); + $this->assertSame('manga-1', array_values($dispatched)[0]->mangaId->getValue()); + } + + public function testDoesNotDispatchForNonMonitoredManga(): void + { + $manga = $this->createManga('manga-2'); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + } + + public function testDoesNotDispatchForMangaWithRecentCheck(): void + { + $manga = $this->createManga('manga-3'); + $manga->enableMonitoring(); + $manga->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes')); + $this->mangaRepository->save($manga); + + $this->handler->handle(new CheckMonitoredMangas()); + + $this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class)); + } + + public function testDispatchesOnlyMangasWithOldCheck(): void + { + $mangaOld = $this->createManga('manga-old'); + $mangaOld->enableMonitoring(); + $mangaOld->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours')); + $this->mangaRepository->save($mangaOld); + + $mangaRecent = $this->createManga('manga-recent'); + $mangaRecent->enableMonitoring(); + $mangaRecent->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes')); + $this->mangaRepository->save($mangaRecent); + + $mangaDisabled = $this->createManga('manga-disabled'); + $this->mangaRepository->save($mangaDisabled); + + $this->handler->handle(new CheckMonitoredMangas()); + + $dispatched = array_filter( + $this->commandBus->getDispatchedMessages(), + fn ($m) => $m instanceof RefreshMangaChapters + ); + $this->assertCount(1, $dispatched); + $this->assertSame('manga-old', array_values($dispatched)[0]->mangaId->getValue()); + } +} diff --git a/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php b/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php new file mode 100644 index 0000000..7d798d2 --- /dev/null +++ b/tests/Domain/Scraping/Infrastructure/EventListener/AutoScrapingListenerTest.php @@ -0,0 +1,104 @@ +chapterRepository = new InMemoryChapterRepository(); + $this->mangaRepository = new InMemoryMangaRepository(); + $this->mangaRepository->clear(); + $this->jobRepository = new InMemoryJobRepository(); + $this->eventBus = new InMemoryEventBus(); + + $handler = new ScrapeChapterHandler( + new InMemoryScraperFactory(), + new InMemoryImageDownloader(), + new InMemoryImageStorage(), + $this->jobRepository, + $this->chapterRepository, + $this->mangaRepository, + new InMemorySourceRepository(), + $this->eventBus, + ); + + $this->listener = new AutoScrapingListener( + $handler, + $this->chapterRepository, + $this->mangaRepository, + $this->jobRepository, + ); + } + + public function testCreatesJobAndScrapesWhenMangaIsMonitored(): void + { + $chapterId = 'chapter-uuid-1'; + $mangaId = 'manga-monitored'; + + $this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1177.0, null)); + $this->mangaRepository->save(new Manga( + $mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', true + )); + + $this->listener->onChapterReadyForScraping( + new ChapterReadyForScraping(new ChapterId($chapterId)) + ); + + $jobs = $this->jobRepository->findByType('scraping_job'); + $this->assertCount(1, $jobs); + + $job = array_values($jobs)[0]; + $this->assertSame($chapterId, $job->context['chapterId']); + $this->assertInstanceOf(ScrapingJob::class, $job); + + $hasChapterScraped = count(array_filter( + $this->eventBus->getDispatchedMessages(), + fn ($m) => $m instanceof ChapterScraped + )) > 0; + $this->assertTrue($hasChapterScraped); + } + + public function testDoesNothingWhenMangaIsNotMonitored(): void + { + $chapterId = 'chapter-uuid-2'; + $mangaId = 'manga-not-monitored'; + + $this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1176.0, null)); + $this->mangaRepository->save(new Manga( + $mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', false + )); + + $this->listener->onChapterReadyForScraping( + new ChapterReadyForScraping(new ChapterId($chapterId)) + ); + + $this->assertEmpty($this->jobRepository->findByType('scraping_job')); + $this->assertEmpty($this->eventBus->getDispatchedMessages()); + } +}