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:
ext.jeremy.guillot@maxicoffee.domains
2026-03-09 20:44:10 +01:00
parent d444f86315
commit c311cfe80c
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\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

View File

@@ -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:

View File

@@ -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:

View File

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

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 $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;

View File

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

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\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

View File

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

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 string $mangaId,
public readonly float $chapterNumber, public readonly float $chapterNumber,
public readonly ?int $volumeNumber, 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; 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;

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

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; $this->chapters[$chapter->id] = $chapter;
} }
public function clear(): void public function clear(): void
{ {
$this->chapters = []; $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\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