Merge pull request 'refactor(scraping): DDD refactoring — stockage images individuelles' (#3) from feat/scraping-ddd-image-storage into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m42s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-03-09 20:53:15 +01:00
26 changed files with 470 additions and 331 deletions

View File

@@ -36,6 +36,7 @@ framework:
'App\Domain\Manga\Domain\Event\MangaCreated': events
'App\Domain\Shared\Domain\Event\ChapterImported': events
'App\Domain\Shared\Domain\Event\VolumeImported': events
'App\Domain\Shared\Domain\Event\ChapterScraped': events
# Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands

View File

@@ -126,7 +126,12 @@ services:
tags:
- { 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
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:

View File

@@ -12,10 +12,8 @@ services:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true
App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator'
arguments:
$projectDir: '%kernel.project_dir%'
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
public: true
App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface:

View File

@@ -65,7 +65,7 @@ return static function (Config $config): void {
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
->should(new NotHaveDependencyOutsideNamespace(
"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");

View File

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

View File

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

View File

@@ -19,7 +19,8 @@ readonly class ChapterContext
private ?int $volume,
private int $totalPages,
private bool $isVisible,
private \DateTimeImmutable $createdAt
private \DateTimeImmutable $createdAt,
private ?string $pagesDirectory = null,
) {
}
@@ -58,6 +59,11 @@ readonly class ChapterContext
return $this->cbzPath;
}
public function getPagesDirectory(): ?string
{
return $this->pagesDirectory;
}
public function getVolume(): ?int
{
return $this->volume;

View File

@@ -29,12 +29,187 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
'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) {
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->open($cbzPath);
@@ -68,121 +243,44 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
}
$zip->close();
return $pages;
}
public function getChapterContext(ChapterId $chapterId): ChapterContext
private function getPageContentFromDirectory(ChapterId $chapterId, string $pagesDirectory, PageNumber $pageNumber): PageContent
{
/** @var ChapterEntity $chapter */
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
$files = $this->getImageFiles($pagesDirectory);
if (!$chapter) {
throw ChapterNotFoundException::forChapter($chapterId);
if (!$files || $pageNumber->getValue() > count($files)) {
throw PageNotFoundException::forPage($chapterId, $pageNumber);
}
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()
$filePath = $files[$pageNumber->getValue() - 1];
$imageContent = file_get_contents($filePath);
if ($imageContent === false) {
throw PageNotFoundException::forPage($chapterId, $pageNumber);
}
$imageSize = @getimagesizefromstring($imageContent);
if ($imageSize === false) {
throw PageNotFoundException::forPage($chapterId, $pageNumber);
}
$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->open($cbzPath);

View File

@@ -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\MangaRepositoryInterface;
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\ScraperInterface;
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\Model\Chapter;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
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\TempDirectory;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
@@ -28,7 +26,7 @@ readonly class ScrapeChapterHandler
public function __construct(
private ScraperFactoryInterface $scraperFactory,
private ImageDownloaderInterface $imageDownloader,
private CbzGeneratorInterface $cbzGenerator,
private ImageStorageInterface $imageStorage,
private JobRepositoryInterface $jobRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaRepositoryInterface $mangaRepository,
@@ -110,30 +108,19 @@ readonly class ScrapeChapterHandler
$job->id
);
// 7. Génération du CBZ
$cbzRequest = new CbzGenerationRequest(
$manga->getTitle(),
$manga->getPublicationYear(),
$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);
// 7. Stockage des images individuelles
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
$pageCount = count($downloadResults);
$job->complete();
$this->jobRepository->save($job);
$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();
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources

View File

@@ -11,5 +11,4 @@ interface ChapterRepositoryInterface
* @throws ChapterNotFoundException
*/
public function getByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): Chapter;
public function save(Chapter $chapter): void;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ class Chapter
public readonly string $mangaId,
public readonly float $chapterNumber,
public readonly ?int $volumeNumber,
public ?string $cbzPath,
) {
}
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
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\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;

View File

@@ -15,9 +15,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
) {
}
/**
* Récupère un chapitre par son identifiant
*/
public function getById(string $id): ?Chapter
{
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->find($id);
@@ -31,7 +28,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
mangaId: $chapterEntity->getManga()->getId(),
chapterNumber: $chapterEntity->getNumber(),
volumeNumber: $chapterEntity->getVolume(),
cbzPath: $chapterEntity->getCbzPath(),
);
}
@@ -54,26 +50,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
mangaId: $entity->getManga()->getId(),
chapterNumber: $entity->getNumber(),
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();
}
}

View File

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

View File

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

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

View File

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

View File

@@ -31,6 +31,8 @@ class InMemoryChapterRepository implements ChapterRepositoryInterface
$this->chapters[$chapter->id] = $chapter;
}
public function clear(): void
{
$this->chapters = [];

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

View File

@@ -4,28 +4,26 @@ namespace App\Tests\Domain\Scraping\Application\CommandHandler;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Domain\Event\ChapterScraped;
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\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 Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ScrapeChapterHandlerTest extends TestCase
{
private InMemoryScraperFactory $scraperFactory;
private InMemoryImageDownloader $imageDownloader;
private InMemoryCbzGenerator $cbzGenerator;
private InMemoryImageStorage $imageStorage;
private InMemoryJobRepository $jobRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryMangaRepository $mangaRepository;
@@ -38,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase
{
$this->scraperFactory = new InMemoryScraperFactory();
$this->imageDownloader = new InMemoryImageDownloader();
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
$this->imageStorage = new InMemoryImageStorage();
$this->jobRepository = new InMemoryJobRepository();
$this->chapterRepository = new InMemoryChapterRepository();
$this->mangaRepository = new InMemoryMangaRepository();
@@ -55,13 +53,12 @@ class ScrapeChapterHandlerTest extends TestCase
mangaId: 'test-manga',
chapterNumber: 2,
volumeNumber: 1,
cbzPath: null,
));
$this->handler = new ScrapeChapterHandler(
$this->scraperFactory,
$this->imageDownloader,
$this->cbzGenerator,
$this->imageStorage,
$this->jobRepository,
$this->chapterRepository,
$this->mangaRepository,
@@ -87,9 +84,9 @@ class ScrapeChapterHandlerTest extends TestCase
$this->assertCount(1, $dispatchedMessages);
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]);
$this->assertEquals($job->id, $dispatchedMessages[0]->getJobId());
$chapter = $this->chapterRepository->getById('1');
$this->assertNotNull($chapter->cbzPath);
$this->assertEquals('1', $dispatchedMessages[0]->chapterId);
$this->assertEquals('/fake/pages/1', $dispatchedMessages[0]->pagesDirectory);
$this->assertNotNull($this->imageStorage->stored['1'] ?? null);
}
protected function tearDown(): void