refactor(scraping): DDD refactoring — stockage images individuelles
Le domaine Scraping ne génère plus d'archives CBZ ni ne modifie les
entités du domaine Manga directement. Il scrape, stocke les images
individuellement, et émet un événement partagé.
- Suppression : CbzGeneratorInterface, CbzGenerator, CbzGenerationRequest,
CbzPath, CbzGenerationException
- Suppression : save() de ChapterRepositoryInterface (Scraping)
- Suppression : cbzPath du modèle Chapter (Scraping)
- Ajout : ImageStorageInterface + LocalImageStorage
(stockage dans {MANGA_DATA_PATH}/pages/{chapterId}/)
- ScrapeChapterHandler utilise ImageStorage au lieu du générateur CBZ
- ChapterScraped déplacé dans Domain/Shared/Domain/Event/
avec jobId, chapterId, pagesDirectory, pageCount
- Routing Messenger ajouté
- Ajout : ChapterScrapedEventListener + ChapterScrapedMessageHandler
pour mettre à jour Chapter.pagesDirectory via le Repository Manga
- LegacyChapterRepository en dual-mode :
pagesDirectory en priorité, fallback cbzPath (backward compat)
- Requêtes prev/next : filtrent pagesDirectory IS NOT NULL OR cbzPath IS NOT NULL
- ChapterContext expose pagesDirectory
- phparkitect.php : App\Domain\Shared\Domain\Event autorisé dans
les couches Application (correction violations pré-existantes
ChapterImported/VolumeImported + nouvelle ChapterScraped)
- 218/218 tests passent (+3 nouveaux)
- InMemoryImageStorage créé pour les tests unitaires
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d444f86315
commit
c311cfe80c
@@ -36,6 +36,7 @@ framework:
|
|||||||
'App\Domain\Manga\Domain\Event\MangaCreated': events
|
'App\Domain\Manga\Domain\Event\MangaCreated': events
|
||||||
'App\Domain\Shared\Domain\Event\ChapterImported': events
|
'App\Domain\Shared\Domain\Event\ChapterImported': events
|
||||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||||
|
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
||||||
|
|
||||||
# Legacy messages (à garder si nécessaire)
|
# Legacy messages (à garder si nécessaire)
|
||||||
'App\Message\DownloadChapter': commands
|
'App\Message\DownloadChapter': commands
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: messenger.message_handler, bus: command.bus }
|
- { name: messenger.message_handler, bus: command.bus }
|
||||||
|
|
||||||
App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~
|
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||||
|
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage
|
||||||
|
|
||||||
|
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
||||||
|
arguments:
|
||||||
|
$storagePath: '%env(MANGA_DATA_PATH)%'
|
||||||
|
|
||||||
# Shared Manga Path/File Manager
|
# Shared Manga Path/File Manager
|
||||||
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
|
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ services:
|
|||||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface:
|
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator'
|
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
|
||||||
arguments:
|
|
||||||
$projectDir: '%kernel.project_dir%'
|
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface:
|
App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ return static function (Config $config): void {
|
|||||||
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
||||||
->should(new NotHaveDependencyOutsideNamespace(
|
->should(new NotHaveDependencyOutsideNamespace(
|
||||||
"App\Domain\\$domain",
|
"App\Domain\\$domain",
|
||||||
array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Domain\Contract'])
|
array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Domain\Contract', 'App\Domain\Shared\Domain\Event'])
|
||||||
))
|
))
|
||||||
->because("la couche Application de $domain ne peut dépendre que de son propre domaine, des contrats partagés et des dépendances autorisées");
|
->because("la couche Application de $domain ne peut dépendre que de son propre domaine, des contrats partagés et des dépendances autorisées");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\EventListener;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
|
|
||||||
|
readonly class ChapterScrapedEventListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ChapterScraped $event): void
|
||||||
|
{
|
||||||
|
$chapter = $this->mangaRepository->findChapterById($event->chapterId);
|
||||||
|
if (!$chapter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
||||||
|
if (!$manga) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manga->updateChapterPages($chapter, $event->pagesDirectory, $event->pageCount);
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\MessageHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\EventListener\ChapterScrapedEventListener;
|
||||||
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
readonly class ChapterScrapedMessageHandler
|
||||||
|
{
|
||||||
|
public function __construct(private ChapterScrapedEventListener $listener)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ChapterScraped $event): void
|
||||||
|
{
|
||||||
|
$this->listener->__invoke($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ readonly class ChapterContext
|
|||||||
private ?int $volume,
|
private ?int $volume,
|
||||||
private int $totalPages,
|
private int $totalPages,
|
||||||
private bool $isVisible,
|
private bool $isVisible,
|
||||||
private \DateTimeImmutable $createdAt
|
private \DateTimeImmutable $createdAt,
|
||||||
|
private ?string $pagesDirectory = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ readonly class ChapterContext
|
|||||||
return $this->cbzPath;
|
return $this->cbzPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPagesDirectory(): ?string
|
||||||
|
{
|
||||||
|
return $this->pagesDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
public function getVolume(): ?int
|
public function getVolume(): ?int
|
||||||
{
|
{
|
||||||
return $this->volume;
|
return $this->volume;
|
||||||
|
|||||||
@@ -29,12 +29,187 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
'id' => $chapterId->getValue()
|
'id' => $chapterId->getValue()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cbzPath = $chapter->getCbzPath();
|
$pagesDirectory = $chapter->getPagesDirectory();
|
||||||
|
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||||
|
return $this->getPagesFromDirectory($chapterId, $pagesDirectory, $page, $itemsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cbzPath = $chapter->getCbzPath();
|
||||||
if (!$cbzPath) {
|
if (!$cbzPath) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->getPagesFromCbz($chapterId, $cbzPath, $page, $itemsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
||||||
|
{
|
||||||
|
/** @var ChapterEntity $chapter */
|
||||||
|
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||||
|
'id' => $chapterId->getValue()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$chapter) {
|
||||||
|
throw ChapterNotFoundException::forChapter($chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChapterContext(
|
||||||
|
id: $chapterId,
|
||||||
|
previousChapterId: $this->getPreviousChapterId($chapterId),
|
||||||
|
nextChapterId: $this->getNextChapterId($chapterId),
|
||||||
|
mangaTitle: $chapter->getManga()->getTitle(),
|
||||||
|
number: $chapter->getNumber(),
|
||||||
|
chapterTitle: $chapter->getTitle(),
|
||||||
|
cbzPath: $chapter->getCbzPath(),
|
||||||
|
volume: $chapter->getVolume(),
|
||||||
|
totalPages: 0,
|
||||||
|
isVisible: $chapter->isVisible(),
|
||||||
|
createdAt: new \DateTimeImmutable(),
|
||||||
|
pagesDirectory: $chapter->getPagesDirectory(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalPagesForChapter(ChapterId $chapterId): int
|
||||||
|
{
|
||||||
|
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||||
|
'id' => $chapterId->getValue()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$chapter) {
|
||||||
|
throw ChapterNotFoundException::forChapter($chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pagesDirectory = $chapter->getPagesDirectory();
|
||||||
|
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||||
|
return count($this->getImageFiles($pagesDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cbzPath = $chapter->getCbzPath();
|
||||||
|
if (!$cbzPath) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($cbzPath);
|
||||||
|
$count = $zip->numFiles;
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
||||||
|
{
|
||||||
|
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||||
|
'id' => $chapterId->getValue()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$qb = $this->entityManager->createQueryBuilder();
|
||||||
|
$qb->select('c')
|
||||||
|
->from(ChapterEntity::class, 'c')
|
||||||
|
->where('c.manga = :manga')
|
||||||
|
->andWhere('c.number < :number')
|
||||||
|
->andWhere('c.visible = true')
|
||||||
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
||||||
|
->orderBy('c.number', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->setParameters([
|
||||||
|
'manga' => $currentChapter->getManga(),
|
||||||
|
'number' => $currentChapter->getNumber()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$previousChapter = $qb->getQuery()->getOneOrNullResult();
|
||||||
|
|
||||||
|
return $previousChapter ? new ChapterId((string) $previousChapter->getId()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextChapterId(ChapterId $chapterId): ?ChapterId
|
||||||
|
{
|
||||||
|
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||||
|
'id' => $chapterId->getValue()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$qb = $this->entityManager->createQueryBuilder();
|
||||||
|
$qb->select('c')
|
||||||
|
->from(ChapterEntity::class, 'c')
|
||||||
|
->where('c.manga = :manga')
|
||||||
|
->andWhere('c.number > :number')
|
||||||
|
->andWhere('c.visible = true')
|
||||||
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
||||||
|
->orderBy('c.number', 'ASC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->setParameters([
|
||||||
|
'manga' => $currentChapter->getManga(),
|
||||||
|
'number' => $currentChapter->getNumber()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nextChapter = $qb->getQuery()->getOneOrNullResult();
|
||||||
|
|
||||||
|
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||||
|
{
|
||||||
|
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||||
|
'id' => $chapterId->getValue()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$chapter) {
|
||||||
|
throw ChapterNotFoundException::forChapter($chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pagesDirectory = $chapter->getPagesDirectory();
|
||||||
|
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||||
|
return $this->getPageContentFromDirectory($chapterId, $pagesDirectory, $pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cbzPath = $chapter->getCbzPath();
|
||||||
|
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||||
|
throw ChapterNotFoundException::forChapter($chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getPageContentFromCbz($chapterId, $cbzPath, $pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getImageFiles(string $pagesDirectory): array
|
||||||
|
{
|
||||||
|
$files = glob($pagesDirectory . '/*.{jpg,jpeg,png,webp,gif}', GLOB_BRACE) ?: [];
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPagesFromDirectory(ChapterId $chapterId, string $pagesDirectory, int $page, int $itemsPerPage): array
|
||||||
|
{
|
||||||
|
$files = $this->getImageFiles($pagesDirectory);
|
||||||
|
$start = ($page - 1) * $itemsPerPage;
|
||||||
|
$end = min($start + $itemsPerPage, count($files));
|
||||||
|
$pages = [];
|
||||||
|
|
||||||
|
for ($i = $start; $i < $end; $i++) {
|
||||||
|
$imageContent = file_get_contents($files[$i]);
|
||||||
|
if ($imageContent === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageSize = @getimagesizefromstring($imageContent);
|
||||||
|
if ($imageSize === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages[] = new Page(
|
||||||
|
basename($files[$i]),
|
||||||
|
new PageNumber($i + 1),
|
||||||
|
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||||
|
$imageSize[0],
|
||||||
|
$imageSize[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPagesFromCbz(ChapterId $chapterId, string $cbzPath, int $page, int $itemsPerPage): array
|
||||||
|
{
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
$zip->open($cbzPath);
|
$zip->open($cbzPath);
|
||||||
|
|
||||||
@@ -68,121 +243,44 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
return $pages;
|
return $pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
private function getPageContentFromDirectory(ChapterId $chapterId, string $pagesDirectory, PageNumber $pageNumber): PageContent
|
||||||
{
|
{
|
||||||
/** @var ChapterEntity $chapter */
|
$files = $this->getImageFiles($pagesDirectory);
|
||||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
||||||
'id' => $chapterId->getValue()
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$chapter) {
|
if (!$files || $pageNumber->getValue() > count($files)) {
|
||||||
throw ChapterNotFoundException::forChapter($chapterId);
|
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ChapterContext(
|
$filePath = $files[$pageNumber->getValue() - 1];
|
||||||
id: $chapterId,
|
$imageContent = file_get_contents($filePath);
|
||||||
previousChapterId: $this->getPreviousChapterId($chapterId),
|
|
||||||
nextChapterId: $this->getNextChapterId($chapterId),
|
if ($imageContent === false) {
|
||||||
mangaTitle: $chapter->getManga()->getTitle(),
|
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||||
number: $chapter->getNumber(),
|
}
|
||||||
chapterTitle: $chapter->getTitle(),
|
|
||||||
cbzPath: $chapter->getCbzPath(),
|
$imageSize = @getimagesizefromstring($imageContent);
|
||||||
volume: $chapter->getVolume(),
|
if ($imageSize === false) {
|
||||||
totalPages: 0,
|
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||||
isVisible: $chapter->isVisible(),
|
}
|
||||||
createdAt: new \DateTimeImmutable()
|
|
||||||
|
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||||
|
|
||||||
|
return new PageContent(
|
||||||
|
basename($filePath),
|
||||||
|
$pageNumber,
|
||||||
|
base64_encode($imageContent),
|
||||||
|
$mimeType,
|
||||||
|
$imageSize[0],
|
||||||
|
$imageSize[1]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTotalPagesForChapter(ChapterId $chapterId): int
|
private function getPageContentFromCbz(ChapterId $chapterId, string $cbzPath, PageNumber $pageNumber): PageContent
|
||||||
{
|
{
|
||||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
||||||
'id' => $chapterId->getValue()
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$chapter) {
|
|
||||||
throw ChapterNotFoundException::forChapter($chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cbzPath = $chapter->getCbzPath();
|
|
||||||
|
|
||||||
if (!$cbzPath) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
|
||||||
$zip->open($cbzPath);
|
|
||||||
return $zip->numFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
|
||||||
{
|
|
||||||
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
||||||
'id' => $chapterId->getValue()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$qb = $this->entityManager->createQueryBuilder();
|
|
||||||
$qb->select('c')
|
|
||||||
->from(ChapterEntity::class, 'c')
|
|
||||||
->where('c.manga = :manga')
|
|
||||||
->andWhere('c.number < :number')
|
|
||||||
->andWhere('c.visible = true')
|
|
||||||
->andWhere('c.cbzPath IS NOT NULL')
|
|
||||||
->orderBy('c.number', 'DESC')
|
|
||||||
->setMaxResults(1)
|
|
||||||
->setParameters([
|
|
||||||
'manga' => $currentChapter->getManga(),
|
|
||||||
'number' => $currentChapter->getNumber()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$previousChapter = $qb->getQuery()->getOneOrNullResult();
|
|
||||||
|
|
||||||
return $previousChapter ? new ChapterId((string) $previousChapter->getId()) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNextChapterId(ChapterId $chapterId): ?ChapterId
|
|
||||||
{
|
|
||||||
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
||||||
'id' => $chapterId->getValue()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$qb = $this->entityManager->createQueryBuilder();
|
|
||||||
$qb->select('c')
|
|
||||||
->from(ChapterEntity::class, 'c')
|
|
||||||
->where('c.manga = :manga')
|
|
||||||
->andWhere('c.number > :number')
|
|
||||||
->andWhere('c.visible = true')
|
|
||||||
->andWhere('c.cbzPath IS NOT NULL')
|
|
||||||
->orderBy('c.number', 'ASC')
|
|
||||||
->setMaxResults(1)
|
|
||||||
->setParameters([
|
|
||||||
'manga' => $currentChapter->getManga(),
|
|
||||||
'number' => $currentChapter->getNumber()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$nextChapter = $qb->getQuery()->getOneOrNullResult();
|
|
||||||
|
|
||||||
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
|
||||||
{
|
|
||||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
||||||
'id' => $chapterId->getValue()
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$chapter) {
|
|
||||||
throw ChapterNotFoundException::forChapter($chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cbzPath = $chapter->getCbzPath();
|
|
||||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
|
||||||
throw ChapterNotFoundException::forChapter($chapterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
$zip->open($cbzPath);
|
$zip->open($cbzPath);
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
|||||||
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\Contract\Repository\SourceRepositoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
|
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||||
use App\Domain\Scraping\Domain\Model\Source;
|
use App\Domain\Scraping\Domain\Model\Source;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
|
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
|
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
|
||||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
@@ -28,7 +26,7 @@ readonly class ScrapeChapterHandler
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private ScraperFactoryInterface $scraperFactory,
|
private ScraperFactoryInterface $scraperFactory,
|
||||||
private ImageDownloaderInterface $imageDownloader,
|
private ImageDownloaderInterface $imageDownloader,
|
||||||
private CbzGeneratorInterface $cbzGenerator,
|
private ImageStorageInterface $imageStorage,
|
||||||
private JobRepositoryInterface $jobRepository,
|
private JobRepositoryInterface $jobRepository,
|
||||||
private ChapterRepositoryInterface $chapterRepository,
|
private ChapterRepositoryInterface $chapterRepository,
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
@@ -110,30 +108,19 @@ readonly class ScrapeChapterHandler
|
|||||||
$job->id
|
$job->id
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Génération du CBZ
|
// 7. Stockage des images individuelles
|
||||||
$cbzRequest = new CbzGenerationRequest(
|
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
|
||||||
$manga->getTitle(),
|
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
|
||||||
$manga->getPublicationYear(),
|
$pageCount = count($downloadResults);
|
||||||
$chapter->volumeNumber,
|
|
||||||
$chapter->chapterNumber,
|
|
||||||
$tempDir,
|
|
||||||
array_map(fn ($r) => $r->getLocalPath(), $downloadResults)
|
|
||||||
);
|
|
||||||
|
|
||||||
$cbzPath = $this->cbzGenerator->generate($cbzRequest);
|
|
||||||
|
|
||||||
// 8. Mise à jour et sauvegarde
|
|
||||||
$chapter->cbzPath = $cbzPath->getPath();
|
|
||||||
$this->chapterRepository->save($chapter);
|
|
||||||
|
|
||||||
$job->complete();
|
$job->complete();
|
||||||
$this->jobRepository->save($job);
|
$this->jobRepository->save($job);
|
||||||
|
|
||||||
$this->entityManager->commit();
|
$this->entityManager->commit();
|
||||||
|
|
||||||
$this->eventBus->dispatch(new ChapterScraped($job->id));
|
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
|
||||||
|
|
||||||
// 9. Nettoyage
|
// 8. Nettoyage
|
||||||
$tempDir->cleanup();
|
$tempDir->cleanup();
|
||||||
|
|
||||||
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources
|
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources
|
||||||
|
|||||||
@@ -11,5 +11,4 @@ interface ChapterRepositoryInterface
|
|||||||
* @throws ChapterNotFoundException
|
* @throws ChapterNotFoundException
|
||||||
*/
|
*/
|
||||||
public function getByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): Chapter;
|
public function getByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): Chapter;
|
||||||
public function save(Chapter $chapter): void;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Contract\Service;
|
|
||||||
|
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
|
||||||
|
|
||||||
interface CbzGeneratorInterface
|
|
||||||
{
|
|
||||||
public function generate(CbzGenerationRequest $request): CbzPath;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Domain\Contract\Service;
|
||||||
|
|
||||||
|
interface ImageStorageInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Copies images to permanent storage. Returns the pagesDirectory path.
|
||||||
|
*
|
||||||
|
* @param string $chapterId The chapter UUID used as directory name
|
||||||
|
* @param string[] $localImagePaths Paths to the locally downloaded image files
|
||||||
|
*
|
||||||
|
* @return string Absolute path to the directory where images were stored
|
||||||
|
*/
|
||||||
|
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Event;
|
|
||||||
|
|
||||||
readonly class ChapterScraped
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private string $jobId
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getJobId(): string
|
|
||||||
{
|
|
||||||
return $this->jobId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Exception;
|
|
||||||
|
|
||||||
class CbzGenerationException extends \RuntimeException
|
|
||||||
{
|
|
||||||
public static function unableToCreateDirectory(string $path): self
|
|
||||||
{
|
|
||||||
return new self(sprintf('Impossible de créer le répertoire : %s', $path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function unableToCreateCbz(string $path): self
|
|
||||||
{
|
|
||||||
return new self(sprintf('Impossible de créer le fichier CBZ : %s', $path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function unableToAddFileToArchive(string $filePath): self
|
|
||||||
{
|
|
||||||
return new self(sprintf('Impossible d\'ajouter le fichier à l\'archive : %s', $filePath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ class Chapter
|
|||||||
public readonly string $mangaId,
|
public readonly string $mangaId,
|
||||||
public readonly float $chapterNumber,
|
public readonly float $chapterNumber,
|
||||||
public readonly ?int $volumeNumber,
|
public readonly ?int $volumeNumber,
|
||||||
public ?string $cbzPath,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Model\ValueObject;
|
|
||||||
|
|
||||||
readonly class CbzGenerationRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private string $mangaTitle,
|
|
||||||
private string $publicationYear,
|
|
||||||
private int $volumeNumber,
|
|
||||||
private float $chapterNumber,
|
|
||||||
private TempDirectory $sourceDirectory,
|
|
||||||
private array $files
|
|
||||||
) {
|
|
||||||
if (empty($mangaTitle)) {
|
|
||||||
throw new \InvalidArgumentException('Manga title cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($publicationYear)) {
|
|
||||||
throw new \InvalidArgumentException('Publication year cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($volumeNumber < 1) {
|
|
||||||
throw new \InvalidArgumentException('Volume number must be greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($chapterNumber <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Chapter number must be greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($files)) {
|
|
||||||
throw new \InvalidArgumentException('Files array cannot be empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMangaTitle(): string
|
|
||||||
{
|
|
||||||
return $this->mangaTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPublicationYear(): string
|
|
||||||
{
|
|
||||||
return $this->publicationYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVolumeNumber(): int
|
|
||||||
{
|
|
||||||
return $this->volumeNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getChapterNumber(): float
|
|
||||||
{
|
|
||||||
return $this->chapterNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSourceDirectory(): TempDirectory
|
|
||||||
{
|
|
||||||
return $this->sourceDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFiles(): array
|
|
||||||
{
|
|
||||||
return $this->files;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Model\ValueObject;
|
|
||||||
|
|
||||||
readonly class CbzPath
|
|
||||||
{
|
|
||||||
public function __construct(private string $path)
|
|
||||||
{
|
|
||||||
if (empty($path)) {
|
|
||||||
throw new \InvalidArgumentException('Le chemin du fichier CBZ ne peut pas être vide');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public function getPath(): string
|
|
||||||
{
|
|
||||||
return $this->path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||||
|
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère un chapitre par son identifiant
|
|
||||||
*/
|
|
||||||
public function getById(string $id): ?Chapter
|
public function getById(string $id): ?Chapter
|
||||||
{
|
{
|
||||||
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->find($id);
|
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->find($id);
|
||||||
@@ -31,7 +28,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
mangaId: $chapterEntity->getManga()->getId(),
|
mangaId: $chapterEntity->getManga()->getId(),
|
||||||
chapterNumber: $chapterEntity->getNumber(),
|
chapterNumber: $chapterEntity->getNumber(),
|
||||||
volumeNumber: $chapterEntity->getVolume(),
|
volumeNumber: $chapterEntity->getVolume(),
|
||||||
cbzPath: $chapterEntity->getCbzPath(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,26 +50,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
mangaId: $entity->getManga()->getId(),
|
mangaId: $entity->getManga()->getId(),
|
||||||
chapterNumber: $entity->getNumber(),
|
chapterNumber: $entity->getNumber(),
|
||||||
volumeNumber: $entity->getVolume(),
|
volumeNumber: $entity->getVolume(),
|
||||||
cbzPath: $entity->getCbzPath(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ChapterNotFoundException
|
|
||||||
*/
|
|
||||||
public function save(Chapter $chapter): void
|
|
||||||
{
|
|
||||||
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([
|
|
||||||
'id' => $chapter->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$chapterEntity) {
|
|
||||||
throw new ChapterNotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapterEntity->setCbzPath($chapter->cbzPath);
|
|
||||||
|
|
||||||
$this->entityManager->persist($chapterEntity);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Infrastructure\Service;
|
|
||||||
|
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
|
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
|
||||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
|
||||||
|
|
||||||
readonly class CbzGenerator implements CbzGeneratorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private MangaPathManagerInterface $mangaPathManager,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generate(CbzGenerationRequest $request): CbzPath
|
|
||||||
{
|
|
||||||
$cbzPath = $this->mangaPathManager->buildChapterCbzPath(
|
|
||||||
$request->getMangaTitle(),
|
|
||||||
$request->getPublicationYear(),
|
|
||||||
$request->getVolumeNumber(),
|
|
||||||
$request->getChapterNumber(),
|
|
||||||
);
|
|
||||||
$this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath);
|
|
||||||
return new CbzPath($cbzPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||||
|
|
||||||
|
readonly class LocalImageStorage implements ImageStorageInterface
|
||||||
|
{
|
||||||
|
public function __construct(private string $storagePath)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
||||||
|
{
|
||||||
|
$targetDir = $this->storagePath . '/pages/' . $chapterId;
|
||||||
|
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
mkdir($targetDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($localImagePaths);
|
||||||
|
|
||||||
|
foreach ($localImagePaths as $index => $localPath) {
|
||||||
|
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
||||||
|
copy($localPath, $targetFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targetDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Domain/Shared/Domain/Event/ChapterScraped.php
Normal file
21
src/Domain/Shared/Domain/Event/ChapterScraped.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Event;
|
||||||
|
|
||||||
|
readonly class ChapterScraped
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $jobId,
|
||||||
|
public string $chapterId,
|
||||||
|
public string $pagesDirectory,
|
||||||
|
public int $pageCount,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJobId(): string
|
||||||
|
{
|
||||||
|
return $this->jobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Application\EventListener;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\EventListener\ChapterScrapedEventListener;
|
||||||
|
use App\Domain\Manga\Domain\Model\Chapter;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
|
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\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ChapterScrapedListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
|
private ChapterScrapedEventListener $listener;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
|
$this->listener = new ChapterScrapedEventListener($this->mangaRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItUpdatesPagesDirectoryOnChapter(): void
|
||||||
|
{
|
||||||
|
$mangaId = 'manga-1';
|
||||||
|
$chapterId = '42';
|
||||||
|
|
||||||
|
$chapter = new Chapter(
|
||||||
|
id: new ChapterId($chapterId),
|
||||||
|
mangaId: new MangaId($mangaId),
|
||||||
|
number: 5.0,
|
||||||
|
title: 'Chapter 5',
|
||||||
|
volume: 1,
|
||||||
|
isVisible: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2020,
|
||||||
|
[],
|
||||||
|
'ongoing'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
// Add chapter via separate path (bypass aggregate to seed state)
|
||||||
|
$manga->addChapter($chapter);
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
$event = new ChapterScraped(
|
||||||
|
jobId: 'job-abc',
|
||||||
|
chapterId: $chapterId,
|
||||||
|
pagesDirectory: '/data/pages/' . $chapterId,
|
||||||
|
pageCount: 25,
|
||||||
|
);
|
||||||
|
|
||||||
|
($this->listener)($event);
|
||||||
|
|
||||||
|
$updatedChapter = $this->mangaRepository->findChapterById($chapterId);
|
||||||
|
$this->assertNotNull($updatedChapter);
|
||||||
|
$this->assertEquals('/data/pages/' . $chapterId, $updatedChapter->getPagesDirectory());
|
||||||
|
$this->assertEquals(25, $updatedChapter->getPageCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItDoesNothingWhenChapterNotFound(): void
|
||||||
|
{
|
||||||
|
$event = new ChapterScraped(
|
||||||
|
jobId: 'job-xyz',
|
||||||
|
chapterId: 'non-existent-chapter',
|
||||||
|
pagesDirectory: '/data/pages/non-existent-chapter',
|
||||||
|
pageCount: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
($this->listener)($event);
|
||||||
|
|
||||||
|
$this->assertNull($this->mangaRepository->findChapterById('non-existent-chapter'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class InMemoryChapterRepository implements ChapterRepositoryInterface
|
|||||||
$this->chapters[$chapter->id] = $chapter;
|
$this->chapters[$chapter->id] = $chapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
$this->chapters = [];
|
$this->chapters = [];
|
||||||
|
|||||||
19
tests/Domain/Scraping/Adapter/InMemoryImageStorage.php
Normal file
19
tests/Domain/Scraping/Adapter/InMemoryImageStorage.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Scraping\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||||
|
|
||||||
|
class InMemoryImageStorage implements ImageStorageInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, string> chapterId => pagesDirectory */
|
||||||
|
public array $stored = [];
|
||||||
|
|
||||||
|
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
||||||
|
{
|
||||||
|
$dir = '/fake/pages/' . $chapterId;
|
||||||
|
$this->stored[$chapterId] = $dir;
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,28 +4,26 @@ namespace App\Tests\Domain\Scraping\Application\CommandHandler;
|
|||||||
|
|
||||||
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
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\Event\ChapterScraped;
|
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
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\Chapter;
|
||||||
use App\Domain\Shared\Domain\Model\JobStatus;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator;
|
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
|
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\InMemoryMangaRepository;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
||||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ScrapeChapterHandlerTest extends TestCase
|
class ScrapeChapterHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryScraperFactory $scraperFactory;
|
private InMemoryScraperFactory $scraperFactory;
|
||||||
private InMemoryImageDownloader $imageDownloader;
|
private InMemoryImageDownloader $imageDownloader;
|
||||||
private InMemoryCbzGenerator $cbzGenerator;
|
private InMemoryImageStorage $imageStorage;
|
||||||
private InMemoryJobRepository $jobRepository;
|
private InMemoryJobRepository $jobRepository;
|
||||||
private InMemoryChapterRepository $chapterRepository;
|
private InMemoryChapterRepository $chapterRepository;
|
||||||
private InMemoryMangaRepository $mangaRepository;
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
@@ -38,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->scraperFactory = new InMemoryScraperFactory();
|
$this->scraperFactory = new InMemoryScraperFactory();
|
||||||
$this->imageDownloader = new InMemoryImageDownloader();
|
$this->imageDownloader = new InMemoryImageDownloader();
|
||||||
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
|
$this->imageStorage = new InMemoryImageStorage();
|
||||||
$this->jobRepository = new InMemoryJobRepository();
|
$this->jobRepository = new InMemoryJobRepository();
|
||||||
$this->chapterRepository = new InMemoryChapterRepository();
|
$this->chapterRepository = new InMemoryChapterRepository();
|
||||||
$this->mangaRepository = new InMemoryMangaRepository();
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
@@ -55,13 +53,12 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
mangaId: 'test-manga',
|
mangaId: 'test-manga',
|
||||||
chapterNumber: 2,
|
chapterNumber: 2,
|
||||||
volumeNumber: 1,
|
volumeNumber: 1,
|
||||||
cbzPath: null,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->handler = new ScrapeChapterHandler(
|
$this->handler = new ScrapeChapterHandler(
|
||||||
$this->scraperFactory,
|
$this->scraperFactory,
|
||||||
$this->imageDownloader,
|
$this->imageDownloader,
|
||||||
$this->cbzGenerator,
|
$this->imageStorage,
|
||||||
$this->jobRepository,
|
$this->jobRepository,
|
||||||
$this->chapterRepository,
|
$this->chapterRepository,
|
||||||
$this->mangaRepository,
|
$this->mangaRepository,
|
||||||
@@ -87,9 +84,9 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
$this->assertCount(1, $dispatchedMessages);
|
$this->assertCount(1, $dispatchedMessages);
|
||||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]);
|
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]);
|
||||||
$this->assertEquals($job->id, $dispatchedMessages[0]->getJobId());
|
$this->assertEquals($job->id, $dispatchedMessages[0]->getJobId());
|
||||||
|
$this->assertEquals('1', $dispatchedMessages[0]->chapterId);
|
||||||
$chapter = $this->chapterRepository->getById('1');
|
$this->assertEquals('/fake/pages/1', $dispatchedMessages[0]->pagesDirectory);
|
||||||
$this->assertNotNull($chapter->cbzPath);
|
$this->assertNotNull($this->imageStorage->stored['1'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user