Compare commits
2 Commits
d444f86315
...
6875ad4222
| Author | SHA1 | Date | |
|---|---|---|---|
| 6875ad4222 | |||
|
|
c311cfe80c |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 $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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,4 @@ interface ChapterRepositoryInterface
|
||||
* @throws ChapterNotFoundException
|
||||
*/
|
||||
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 float $chapterNumber,
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$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\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
|
||||
|
||||
Reference in New Issue
Block a user