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

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