12 Commits

Author SHA1 Message Date
8d8389377d Merge pull request 'fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex' (#45) from fix/monitoring-chapter-id-mismatch into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #45
2026-03-27 15:04:20 +01:00
a9c5769c8e Merge branch 'main' into fix/monitoring-chapter-id-mismatch 2026-03-27 15:04:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
969f4569f5 fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex
synchronizeChapters() retournait des UUID temporaires générés en mémoire. Ces UUID
n'étant jamais persistés, le Scraping domain ne pouvait pas retrouver le chapitre
(SQLSTATE 22P02 : invalid input syntax for type integer).

- ChapterSynchronizationServiceInterface : retourne float[] (numéros) au lieu de string[] (UUID)
- MangadxChapterSynchronizationService : retourne getNumber() au lieu de getId()
- RefreshMangaChaptersHandler : après save(), retrouve chaque chapitre par manga+numéro
  via findChapterByMangaIdAndNumber() pour obtenir le vrai PK integer avant de dispatcher
  ChapterReadyForScraping
2026-03-27 15:03:05 +01:00
13eac6954d Merge pull request 'fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas' (#44) from fix/monitoring-missing-symfony-handler into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #44
2026-03-27 14:36:25 +01:00
ext.jeremy.guillot@maxicoffee.domains
7e6bacd934 fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas
Sans ce wrapper #[AsMessageHandler], Messenger ne trouvait aucun handler pour
le message CheckMonitoredMangas — le scheduler et la commande console échouaient
silencieusement avec NoHandlerForMessageException.
2026-03-27 14:35:47 +01:00
d1279c90cc Merge pull request 'fix(deploy): corriger la race condition sur le cache prod au déploiement' (#43) from fix/deploy-cache-race-condition into main
All checks were successful
Deploy / deploy (push) Successful in 1m6s
Reviewed-on: #43
2026-03-27 14:29:06 +01:00
a0729d2e6e Merge branch 'main' into fix/deploy-cache-race-condition 2026-03-27 14:28:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f47d1a245f fix(deploy): corriger la race condition sur le cache prod au déploiement
L'entrypoint faisait rm -rf var/cache/prod puis lançait FrankenPHP.
FrankenPHP compilait partiellement le container DI pendant que le script
Deployer lançait aussi cache:clear → fichiers manquants → crash.

- entrypoint.sh : ajouter cache:warmup après rm -rf, avant exec FrankenPHP
  (l'entrypoint est séquentiel, FrankenPHP ne démarre qu'une fois le cache prêt)
- deploy.php : supprimer le docker exec cache:clear devenu inutile et dangereux
2026-03-27 14:28:30 +01:00
78cc83d465 Merge pull request 'feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement' (#42) from feat/monitoring-run-command into main
Some checks failed
Deploy / deploy (push) Failing after 1m7s
Reviewed-on: #42
2026-03-27 14:23:30 +01:00
7204ea7754 Merge branch 'main' into feat/monitoring-run-command 2026-03-27 14:23:21 +01:00
5edd28309f Merge pull request 'fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres' (#41) from fix/monitoring-scheduler-since-frozen into main
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Reviewed-on: #41
2026-03-27 12:08:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
3f08e1c899 fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres
- MonitoringSchedule : supprimer la date passée au message (était évaluée une
  seule fois au démarrage du container, rendant la requête caduque après le
  premier cycle)
- CheckMonitoredMangasHandler : calculer `since` dynamiquement à l'exécution
  (`new \DateTimeImmutable('-2 hours')`) plutôt que de dépendre du message
- AutoScrapingListener : corriger le TypeError silencieux — créer un ScrapingJob
  avant d'appeler ScrapeChapterHandler (paramètre jobId manquant)

Ajoute les tests unitaires CheckMonitoredMangasHandlerTest et AutoScrapingListenerTest.
2026-03-27 12:08:06 +01:00
12 changed files with 272 additions and 22 deletions

View File

@@ -116,14 +116,13 @@ task('webpack_encore:build', function () {
sh -c '$installCmd'"); sh -c '$installCmd'");
}); });
// Restart Docker containers (entrypoint gère les migrations automatiquement) // Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage // Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release. // ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
desc('Restart Docker containers'); desc('Restart Docker containers');
task('docker:restart', function () { task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler'); run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr'); 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 // Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin

View File

@@ -53,11 +53,12 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi fi
fi fi
# Vider le cache prod stale avant le démarrage des workers FrankenPHP. # Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP.
# Sans ça, les workers chargent l'ancien cache du volume Docker et crashent # Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle
# en boucle si les classes du cache ne correspondent plus à la version déployée. # → fichiers partiellement écrits → crash au démarrage des workers.
if [ "$APP_ENV" = "prod" ]; then if [ "$APP_ENV" = "prod" ]; then
rm -rf var/cache/prod rm -rf var/cache/prod
php bin/console cache:warmup --env=prod
fi fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var

View File

@@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
{ {
$criteria = new MonitoringCriteria( $criteria = new MonitoringCriteria(
enabled: true, enabled: true,
lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour') lastCheckBefore: new \DateTimeImmutable('-2 hours')
); );
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria); $monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);

View File

@@ -26,18 +26,26 @@ readonly class RefreshMangaChaptersHandler
throw new \RuntimeException('Manga not found'); throw new \RuntimeException('Manga not found');
} }
// Synchronisation + récupération des nouveaux IDs // Synchronisation + récupération des numéros de nouveaux chapitres
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga); $newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Mise à jour de la date de monitoring // Mise à jour de la date de monitoring
$manga->updateLastMonitoringCheck(new \DateTimeImmutable()); $manga->updateLastMonitoringCheck(new \DateTimeImmutable());
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
// Événement de scraping pour chaque nouveau chapitre // Événement de scraping pour chaque nouveau chapitre
foreach ($newChapterIds as $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( $this->eventBus->dispatch(
new ChapterReadyForScraping(new ChapterId($chapterId)) new ChapterReadyForScraping(new ChapterId($saved->getId()))
); );
} }
} }
}
} }

View File

@@ -9,7 +9,7 @@ interface ChapterSynchronizationServiceInterface
/** /**
* Synchronise les chapitres d'un manga depuis la source externe. * 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; public function synchronizeChapters(Manga $manga): array;
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyCheckMonitoredMangasHandler
{
public function __construct(
private CheckMonitoredMangasHandler $handler,
) {
}
public function __invoke(CheckMonitoredMangas $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface
{ {
return (new Schedule())->add( return (new Schedule())->add(
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures // 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( RecurringMessage::every('2 hours', new CheckMonitoredMangas())
new \DateTimeImmutable('-2 hours')
))
)->stateful($this->cache); )->stateful($this->cache);
} }
} }

View File

@@ -96,11 +96,11 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
$newChapterIds = []; $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) { foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) { if (!isset($existingChapters[(float) $chapterNumber])) {
$manga->addChapter($chapter); $manga->addChapter($chapter);
$newChapterIds[] = $chapter->getId(); $newChapterIds[] = $chapter->getNumber();
} }
} }

View File

@@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; 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; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener class AutoScrapingListener
@@ -15,6 +18,7 @@ class AutoScrapingListener
private readonly ScrapeChapterHandler $scrapeChapterHandler, private readonly ScrapeChapterHandler $scrapeChapterHandler,
private readonly ChapterRepositoryInterface $chapterRepository, private readonly ChapterRepositoryInterface $chapterRepository,
private readonly MangaRepositoryInterface $mangaRepository, private readonly MangaRepositoryInterface $mangaRepository,
private readonly JobRepositoryInterface $jobRepository,
) { ) {
} }
@@ -25,7 +29,12 @@ class AutoScrapingListener
$manga = $this->mangaRepository->getById($chapter->mangaId); $manga = $this->mangaRepository->getById($chapter->mangaId);
if ($manga->isMonitored()) { 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));
} }
} }
} }

View File

@@ -18,8 +18,8 @@ class InMemoryChapterSynchronizationService implements ChapterSynchronizationSer
'synchronized_at' => new \DateTimeImmutable(), 'synchronized_at' => new \DateTimeImmutable(),
]; ];
// Retourne les IDs des chapitres synchronisés (simulation) // Retourne les numéros des chapitres synchronisés (simulation)
return ['chapter-1', 'chapter-2']; return [1.0, 2.0];
} }
/** /**

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CheckMonitoredMangasHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryMessageBus $commandBus;
private CheckMonitoredMangasHandler $handler;
protected function setUp(): void
{
$this->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());
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Tests\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\Manga;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Infrastructure\EventListener\AutoScrapingListener;
use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use PHPUnit\Framework\TestCase;
class AutoScrapingListenerTest extends TestCase
{
private InMemoryChapterRepository $chapterRepository;
private InMemoryMangaRepository $mangaRepository;
private InMemoryJobRepository $jobRepository;
private InMemoryEventBus $eventBus;
private AutoScrapingListener $listener;
protected function setUp(): void
{
$this->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());
}
}