Compare commits
21 Commits
e525c9b7bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 810e18c26c | |||
|
|
1905581214 | ||
| c0ab40eacd | |||
|
|
e214e1ea46 | ||
| 1f1efd1b16 | |||
|
|
41c1fc5e2e | ||
| 848efd3327 | |||
| 65eef59999 | |||
| 8d8389377d | |||
| a9c5769c8e | |||
|
|
969f4569f5 | ||
| 13eac6954d | |||
|
|
7e6bacd934 | ||
| d1279c90cc | |||
| a0729d2e6e | |||
|
|
f47d1a245f | ||
| 78cc83d465 | |||
| 7204ea7754 | |||
|
|
f42b5a9cf5 | ||
| 5edd28309f | |||
|
|
3f08e1c899 |
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
|
|
||||||
mercure {
|
mercure {
|
||||||
# Transport to use (default to Bolt)
|
# Transport to use (default to Bolt)
|
||||||
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
|
transport bolt {
|
||||||
|
url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
|
||||||
|
}
|
||||||
# Publisher JWT key
|
# Publisher JWT key
|
||||||
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
|
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
|
||||||
# Subscriber JWT key
|
# Subscriber JWT key
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
src/Command/RunMonitoringCommand.php
Normal file
36
src/Command/RunMonitoringCommand.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:monitoring:run',
|
||||||
|
description: 'Déclenche immédiatement la vérification des mangas monitorés (sans attendre le scheduler)',
|
||||||
|
)]
|
||||||
|
class RunMonitoringCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBusInterface $commandBus,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('Déclenchement du monitoring des mangas...');
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(new CheckMonitoredMangas());
|
||||||
|
|
||||||
|
$output->writeln('<info>Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.</info>');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
|||||||
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
|
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
|
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
||||||
@@ -35,8 +36,19 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
|
|||||||
throw new ChapterNotAvailableException($query->chapterId);
|
throw new ChapterNotAvailableException($query->chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
||||||
|
if (!$manga) {
|
||||||
|
throw new MangaNotFoundException($chapter->getMangaId()->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
$pagesDirectory = $chapter->getPagesDirectory();
|
$pagesDirectory = $chapter->getPagesDirectory();
|
||||||
$filename = basename($pagesDirectory);
|
|
||||||
|
$number = $chapter->getNumber();
|
||||||
|
$formattedNumber = fmod($number, 1.0) === 0.0
|
||||||
|
? sprintf('%03d', (int) $number)
|
||||||
|
: rtrim(rtrim(sprintf('%06.2f', $number), '0'), '.');
|
||||||
|
|
||||||
|
$filename = sprintf('%s - Ch.%s', $manga->getTitle()->getValue(), $formattedNumber);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);
|
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ readonly class GetMangaChaptersHandler
|
|||||||
$min = min($numbers);
|
$min = min($numbers);
|
||||||
$max = max($numbers);
|
$max = max($numbers);
|
||||||
|
|
||||||
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n;
|
$fmt = fn (float $n) => $n == (int) $n
|
||||||
|
? str_pad((string) (int) $n, 2, '0', STR_PAD_LEFT)
|
||||||
|
: (string) $n;
|
||||||
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
|
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
|
||||||
|
|
||||||
return new ChapterResponse(
|
return new ChapterResponse(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,93 +17,103 @@ readonly class FileService implements FileServiceInterface
|
|||||||
|
|
||||||
public function downloadCbz(string $filePath, string $filename): Response
|
public function downloadCbz(string $filePath, string $filename): Response
|
||||||
{
|
{
|
||||||
if (!$this->cbzExists($filePath)) {
|
if (!is_dir($filePath)) {
|
||||||
throw new CbzFileNotFoundException($filePath);
|
throw new CbzFileNotFoundException($filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($filePath);
|
$images = $this->listImageFiles($filePath);
|
||||||
|
if ([] === $images) {
|
||||||
|
throw new CbzFileNotFoundException($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempCbzPath = $this->createTempCbzPath($filename);
|
||||||
|
|
||||||
|
$cbz = new \ZipArchive();
|
||||||
|
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||||
|
throw new \RuntimeException('Cannot create CBZ file');
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter = 1;
|
||||||
|
foreach ($images as $imagePath) {
|
||||||
|
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
|
||||||
|
$cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
|
||||||
|
++$counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cbz->close();
|
||||||
|
|
||||||
|
if (!file_exists($tempCbzPath)) {
|
||||||
|
throw new \RuntimeException(sprintf('Failed to write CBZ file "%s"', $tempCbzPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadName = str_ends_with($filename, '.cbz') ? $filename : $filename.'.cbz';
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($tempCbzPath);
|
||||||
$response->setContentDisposition(
|
$response->setContentDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$filename
|
$downloadName
|
||||||
);
|
);
|
||||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
$response->headers->set('Content-Type', 'application/x-cbz');
|
||||||
|
$response->deleteFileAfterSend();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
|
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
|
||||||
{
|
{
|
||||||
$tempCbzPath = sys_get_temp_dir().'/'.$volumeName.'.cbz';
|
$tempCbzPath = $this->createTempCbzPath($volumeName);
|
||||||
|
|
||||||
$cbz = new \ZipArchive();
|
$cbz = new \ZipArchive();
|
||||||
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE)) {
|
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||||
throw new \RuntimeException('Cannot create CBZ file');
|
throw new \RuntimeException('Cannot create CBZ file');
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageCounter = 1;
|
$counter = 1;
|
||||||
|
foreach ($cbzPaths as $directory) {
|
||||||
foreach ($cbzPaths as $cbzPath) {
|
if (!is_dir($directory)) {
|
||||||
if (!$this->cbzExists($cbzPath)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceCbz = new \ZipArchive();
|
foreach ($this->listImageFiles($directory) as $imagePath) {
|
||||||
if (true !== $sourceCbz->open($cbzPath)) {
|
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
|
||||||
continue; // Skip if we can't open the CBZ
|
$cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
|
||||||
|
++$counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all images from the current CBZ
|
|
||||||
for ($i = 0; $i < $sourceCbz->numFiles; ++$i) {
|
|
||||||
$fileName = $sourceCbz->getNameIndex($i);
|
|
||||||
$fileInfo = $sourceCbz->statIndex($i);
|
|
||||||
|
|
||||||
// Skip directories and non-image files
|
|
||||||
if (0 === $fileInfo['size'] || !$this->isImageFile($fileName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the file content
|
|
||||||
$imageContent = $sourceCbz->getFromIndex($i);
|
|
||||||
if (false === $imageContent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file extension
|
|
||||||
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
|
|
||||||
|
|
||||||
// Create a new filename with proper ordering
|
|
||||||
$newFileName = sprintf('%04d.%s', $imageCounter, $extension);
|
|
||||||
|
|
||||||
// Add the image to the volume CBZ
|
|
||||||
$cbz->addFromString($newFileName, $imageContent);
|
|
||||||
|
|
||||||
++$imageCounter;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourceCbz->close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cbz->close();
|
$cbz->close();
|
||||||
|
|
||||||
|
if (1 === $counter || !file_exists($tempCbzPath)) {
|
||||||
|
if (file_exists($tempCbzPath)) {
|
||||||
|
@unlink($tempCbzPath);
|
||||||
|
}
|
||||||
|
throw new \RuntimeException(sprintf('No images found to build volume "%s"', $volumeName));
|
||||||
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($tempCbzPath);
|
$response = new BinaryFileResponse($tempCbzPath);
|
||||||
$response->setContentDisposition(
|
$response->setContentDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$volumeName.'.cbz'
|
$volumeName.'.cbz'
|
||||||
);
|
);
|
||||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
$response->headers->set('Content-Type', 'application/x-cbz');
|
||||||
|
|
||||||
// Clean up temp file after sending
|
|
||||||
$response->deleteFileAfterSend();
|
$response->deleteFileAfterSend();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isImageFile(string $fileName): bool
|
private function createTempCbzPath(string $name): string
|
||||||
{
|
{
|
||||||
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
$safeName = preg_replace('/[^A-Za-z0-9_.-]/', '_', $name) ?? 'archive';
|
||||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
|
||||||
|
|
||||||
return in_array($extension, $imageExtensions);
|
return sys_get_temp_dir().'/'.uniqid($safeName.'_', true).'.cbz';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listImageFiles(string $directory): array
|
||||||
|
{
|
||||||
|
$files = glob(rtrim($directory, '/').'/*.{jpg,jpeg,png,gif,bmp,webp,JPG,JPEG,PNG,GIF,BMP,WEBP}', GLOB_BRACE) ?: [];
|
||||||
|
natsort($files);
|
||||||
|
|
||||||
|
return array_values($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteCbzFile(string $filePath): bool
|
public function deleteCbzFile(string $filePath): bool
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class DownloadCbzTest extends AbstractApiTestCase
|
|||||||
'number' => 1.0,
|
'number' => 1.0,
|
||||||
'title' => 'Chapter 1',
|
'title' => 'Chapter 1',
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => '/app/tests/Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$chapterId = $chapter->getId();
|
$chapterId = $chapter->getId();
|
||||||
@@ -41,7 +41,7 @@ class DownloadCbzTest extends AbstractApiTestCase
|
|||||||
$response = static::getClient()->getResponse();
|
$response = static::getClient()->getResponse();
|
||||||
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
|
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
|
||||||
$this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition'));
|
$this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition'));
|
||||||
$this->assertStringContainsString('test-chapter.cbz', $response->headers->get('Content-Disposition'));
|
$this->assertStringContainsString('Ch.001.cbz', $response->headers->get('Content-Disposition'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testItReturns404ForNonExistentChapter(): void
|
public function testItReturns404ForNonExistentChapter(): void
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'manga' => $manga,
|
'manga' => $manga,
|
||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$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' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mangaId = $manga->getId();
|
$mangaId = $manga->getId();
|
||||||
|
|||||||
BIN
tests/Shared/Files/test-pages/0001.png
Normal file
BIN
tests/Shared/Files/test-pages/0001.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
BIN
tests/Shared/Files/test-pages/0002.png
Normal file
BIN
tests/Shared/Files/test-pages/0002.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Reference in New Issue
Block a user