diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index fbaf89c..cdf9b6d 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index dc75239..5ce8940 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/config/services_test.yaml b/config/services_test.yaml index 28e6d01..2438f4b 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -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: diff --git a/phparkitect.php b/phparkitect.php index 6a696e4..c2d3b72 100644 --- a/phparkitect.php +++ b/phparkitect.php @@ -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"); diff --git a/src/Domain/Manga/Application/EventListener/ChapterScrapedEventListener.php b/src/Domain/Manga/Application/EventListener/ChapterScrapedEventListener.php new file mode 100644 index 0000000..9496eb0 --- /dev/null +++ b/src/Domain/Manga/Application/EventListener/ChapterScrapedEventListener.php @@ -0,0 +1,32 @@ +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); + } +} diff --git a/src/Domain/Manga/Infrastructure/MessageHandler/ChapterScrapedMessageHandler.php b/src/Domain/Manga/Infrastructure/MessageHandler/ChapterScrapedMessageHandler.php new file mode 100644 index 0000000..c236d32 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/MessageHandler/ChapterScrapedMessageHandler.php @@ -0,0 +1,22 @@ +listener->__invoke($event); + } +} diff --git a/src/Domain/Reader/Domain/Model/ChapterContext.php b/src/Domain/Reader/Domain/Model/ChapterContext.php index 6dc5951..4d7cc11 100644 --- a/src/Domain/Reader/Domain/Model/ChapterContext.php +++ b/src/Domain/Reader/Domain/Model/ChapterContext.php @@ -19,8 +19,10 @@ readonly class ChapterContext private ?int $volume, private int $totalPages, private bool $isVisible, - private \DateTimeImmutable $createdAt - ) {} + private \DateTimeImmutable $createdAt, + private ?string $pagesDirectory = null, + ) { + } public function getId(): ChapterId { @@ -57,6 +59,11 @@ readonly class ChapterContext return $this->cbzPath; } + public function getPagesDirectory(): ?string + { + return $this->pagesDirectory; + } + public function getVolume(): ?int { return $this->volume; @@ -76,4 +83,4 @@ readonly class ChapterContext { return $this->createdAt; } -} \ No newline at end of file +} diff --git a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php index 4d29d3a..d7c249c 100644 --- a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php +++ b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php @@ -20,7 +20,8 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface { public function __construct( private EntityManagerInterface $entityManager - ) {} + ) { + } public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array { @@ -28,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); @@ -67,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); diff --git a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php index ecbcb14..f50be66 100644 --- a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php +++ b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php @@ -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 diff --git a/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php b/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php index 73947f7..6a75e92 100644 --- a/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php +++ b/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php @@ -11,5 +11,4 @@ interface ChapterRepositoryInterface * @throws ChapterNotFoundException */ public function getByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): Chapter; - public function save(Chapter $chapter): void; } diff --git a/src/Domain/Scraping/Domain/Contract/Service/CbzGeneratorInterface.php b/src/Domain/Scraping/Domain/Contract/Service/CbzGeneratorInterface.php deleted file mode 100644 index 0f2e28f..0000000 --- a/src/Domain/Scraping/Domain/Contract/Service/CbzGeneratorInterface.php +++ /dev/null @@ -1,11 +0,0 @@ -jobId; - } -} diff --git a/src/Domain/Scraping/Domain/Exception/CbzGenerationException.php b/src/Domain/Scraping/Domain/Exception/CbzGenerationException.php deleted file mode 100644 index 1632894..0000000 --- a/src/Domain/Scraping/Domain/Exception/CbzGenerationException.php +++ /dev/null @@ -1,21 +0,0 @@ -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; - } -} diff --git a/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php b/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php deleted file mode 100644 index 65b0d14..0000000 --- a/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php +++ /dev/null @@ -1,17 +0,0 @@ -path; - } -} diff --git a/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php b/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php index 03d78d6..a5d6d8b 100644 --- a/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php +++ b/src/Domain/Scraping/Infrastructure/EventSubscriber/ScrapingEventSubscriber.php @@ -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; diff --git a/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php index 97773b4..bbee98a 100644 --- a/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php +++ b/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php @@ -7,15 +7,14 @@ use App\Domain\Scraping\Domain\Exception\ChapterNotFoundException; use App\Domain\Scraping\Domain\Model\Chapter; use App\Entity\Chapter as EntityChapter; use Doctrine\ORM\EntityManagerInterface; + readonly class LegacyChapterRepository implements ChapterRepositoryInterface { public function __construct( private EntityManagerInterface $entityManager, - ) {} + ) { + } - /** - * Récupère un chapitre par son identifiant - */ public function getById(string $id): ?Chapter { $chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->find($id); @@ -29,7 +28,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface mangaId: $chapterEntity->getManga()->getId(), chapterNumber: $chapterEntity->getNumber(), volumeNumber: $chapterEntity->getVolume(), - cbzPath: $chapterEntity->getCbzPath(), ); } @@ -52,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(); - } } diff --git a/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php b/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php deleted file mode 100644 index c571c13..0000000 --- a/src/Domain/Scraping/Infrastructure/Service/CbzGenerator.php +++ /dev/null @@ -1,28 +0,0 @@ -mangaPathManager->buildChapterCbzPath( - $request->getMangaTitle(), - $request->getPublicationYear(), - $request->getVolumeNumber(), - $request->getChapterNumber(), - ); - $this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath); - return new CbzPath($cbzPath); - } -} diff --git a/src/Domain/Scraping/Infrastructure/Service/LocalImageStorage.php b/src/Domain/Scraping/Infrastructure/Service/LocalImageStorage.php new file mode 100644 index 0000000..654c3b2 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Service/LocalImageStorage.php @@ -0,0 +1,31 @@ +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; + } +} diff --git a/src/Domain/Shared/Domain/Event/ChapterScraped.php b/src/Domain/Shared/Domain/Event/ChapterScraped.php new file mode 100644 index 0000000..2d67adc --- /dev/null +++ b/src/Domain/Shared/Domain/Event/ChapterScraped.php @@ -0,0 +1,21 @@ +jobId; + } +} diff --git a/tests/Domain/Manga/Application/EventListener/ChapterScrapedListenerTest.php b/tests/Domain/Manga/Application/EventListener/ChapterScrapedListenerTest.php new file mode 100644 index 0000000..bfe0721 --- /dev/null +++ b/tests/Domain/Manga/Application/EventListener/ChapterScrapedListenerTest.php @@ -0,0 +1,88 @@ +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')); + } +} diff --git a/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php b/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php index b0ec0cb..0d91813 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php +++ b/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php @@ -31,6 +31,8 @@ class InMemoryChapterRepository implements ChapterRepositoryInterface $this->chapters[$chapter->id] = $chapter; } + + public function clear(): void { $this->chapters = []; diff --git a/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php b/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php new file mode 100644 index 0000000..f33bf88 --- /dev/null +++ b/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php @@ -0,0 +1,19 @@ + chapterId => pagesDirectory */ + public array $stored = []; + + public function storeChapterImages(string $chapterId, array $localImagePaths): string + { + $dir = '/fake/pages/' . $chapterId; + $this->stored[$chapterId] = $dir; + + return $dir; + } +} diff --git a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php index 9711cb3..988b50b 100644 --- a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php +++ b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php @@ -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