From 2e3abb76c349c65771a6373836879a66d7058e13 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Sun, 15 Mar 2026 18:26:28 +0100 Subject: [PATCH] fix(import): extraire les images CBZ vers le stockage individuel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige l'import de chapitres/volumes CBZ qui stockait le chemin du fichier CBZ comme pagesDirectory. Le reader ne trouvait aucune image car LegacyChapterRepository attend un dossier d'images individuelles. - Déplace ImageStorageInterface dans Shared (storeChapterImages + extractFromCbz + countCbzImages) - Crée ImageStorageManager dans Shared/Infrastructure (extraction ZIP + copie) - Supprime LocalImageStorage et l'ancienne interface dans Scraping - Refactore ImportChapterHandler et ImportVolumeHandler pour utiliser ImageStorageInterface - Corrige LegacyChapterRepository : construit l'URL depuis basename(pagesDirectory) au lieu de chapterId (fix pour les volumes partagés) --- config/services.yaml | 6 +- config/services_test.yaml | 2 +- .../CommandHandler/ImportChapterHandler.php | 31 ++---- .../CommandHandler/ImportVolumeHandler.php | 27 ++---- .../Persistence/LegacyChapterRepository.php | 2 +- .../CommandHandler/ScrapeChapterHandler.php | 2 +- .../Service/ImageStorageInterface.php | 16 --- .../Service/LocalImageStorage.php | 31 ------ .../Domain/Contract/ImageStorageInterface.php | 28 ++++++ .../Service/ImageStorageManager.php | 97 +++++++++++++++++++ .../ImportChapterHandlerTest.php | 8 +- .../ImportVolumeHandlerTest.php | 8 +- .../Scraping/Adapter/InMemoryImageStorage.php | 23 ++++- 13 files changed, 173 insertions(+), 108 deletions(-) delete mode 100644 src/Domain/Scraping/Domain/Contract/Service/ImageStorageInterface.php delete mode 100644 src/Domain/Scraping/Infrastructure/Service/LocalImageStorage.php create mode 100644 src/Domain/Shared/Domain/Contract/ImageStorageInterface.php create mode 100644 src/Domain/Shared/Infrastructure/Service/ImageStorageManager.php diff --git a/config/services.yaml b/config/services.yaml index dc95038..d3d6b41 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -126,10 +126,10 @@ services: tags: - { name: messenger.message_handler, bus: command.bus } - App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: - alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage + App\Domain\Shared\Domain\Contract\ImageStorageInterface: + alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager - App\Domain\Scraping\Infrastructure\Service\LocalImageStorage: + App\Domain\Shared\Infrastructure\Service\ImageStorageManager: arguments: $storagePath: '%kernel.project_dir%/public/images' diff --git a/config/services_test.yaml b/config/services_test.yaml index 2438f4b..984fa5e 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -12,7 +12,7 @@ services: class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository' public: true - App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: + App\Domain\Shared\Domain\Contract\ImageStorageInterface: class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage' public: true diff --git a/src/Domain/Manga/Application/CommandHandler/ImportChapterHandler.php b/src/Domain/Manga/Application/CommandHandler/ImportChapterHandler.php index ae58352..73a4a60 100644 --- a/src/Domain/Manga/Application/CommandHandler/ImportChapterHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/ImportChapterHandler.php @@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\ChapterNotFoundException; -use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; +use App\Domain\Shared\Domain\Contract\ImageStorageInterface; readonly class ImportChapterHandler { public function __construct( private MangaRepositoryInterface $mangaRepository, - private MangaPathManagerInterface $pathManager + private ImageStorageInterface $imageStorage ) { } @@ -39,11 +39,15 @@ readonly class ImportChapterHandler throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}"); } - // 4. Save the CBZ file to storage using the path manager - $cbzPath = $this->saveCbzFile($command, $manga, $existingChapter); + // 4. Extract CBZ into individual images storage + $pagesDirectory = $this->imageStorage->extractFromCbz( + $existingChapter->getId(), + $command->fileBinary + ); + $pageCount = $this->imageStorage->countCbzImages($command->fileBinary); // 5. Update existing chapter with new path through the aggregate - $manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount()); + $manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount); $this->mangaRepository->save($manga); } @@ -53,21 +57,4 @@ readonly class ImportChapterHandler return strpos($fileBinary, $zipMagicNumber) === 0; } - - private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string - { - $volumeNumber = $chapter->getVolume() ?? 0; - $cbzPath = $this->pathManager->buildChapterCbzPath( - $manga->getTitle()->getValue(), - (string)$manga->getPublicationYear(), - $volumeNumber, - (string)$command->chapterNumber - ); - - if (!file_put_contents($cbzPath, $command->fileBinary)) { - throw new \RuntimeException('Failed to save CBZ file'); - } - - return $cbzPath; - } } diff --git a/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php b/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php index f1c1e66..3b6b1cb 100644 --- a/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php @@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler; use App\Domain\Manga\Application\Command\ImportVolume; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Exception\MangaNotFoundException; -use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; +use App\Domain\Shared\Domain\Contract\ImageStorageInterface; readonly class ImportVolumeHandler { public function __construct( private MangaRepositoryInterface $mangaRepository, - private MangaPathManagerInterface $pathManager + private ImageStorageInterface $imageStorage ) { } @@ -40,12 +40,14 @@ readonly class ImportVolumeHandler ); } - // 4. Save the CBZ file to storage using the path manager - $cbzPath = $this->saveCbzFile($command, $manga); + // 4. Extract CBZ into individual images storage (shared directory for all volume chapters) + $volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber); + $pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary); + $pageCount = $this->imageStorage->countCbzImages($command->fileBinary); // 5. Update all chapters with the volume path through the aggregate foreach ($chapters as $chapter) { - $manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount()); + $manga->updateChapterPages($chapter, $pagesDirectory, $pageCount); } $this->mangaRepository->save($manga); } @@ -56,19 +58,4 @@ readonly class ImportVolumeHandler return strpos($fileBinary, $zipMagicNumber) === 0; } - - private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string - { - $cbzPath = $this->pathManager->buildVolumeCbzPath( - $manga->getTitle()->getValue(), - (string)$manga->getPublicationYear(), - $command->volumeNumber - ); - - if (!file_put_contents($cbzPath, $command->fileBinary)) { - throw new \RuntimeException('Failed to save CBZ file'); - } - - return $cbzPath; - } } diff --git a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php index 52ad031..2d43528 100644 --- a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php +++ b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php @@ -154,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface $pages[] = new Page( basename($files[$i]), new PageNumber($i + 1), - sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])), + sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])), $imageSize[0], $imageSize[1] ); diff --git a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php index 3ae6abc..0f09d66 100644 --- a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php +++ b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php @@ -6,7 +6,7 @@ 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\ImageStorageInterface; +use App\Domain\Shared\Domain\Contract\ImageStorageInterface; use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface; use App\Domain\Shared\Domain\Event\ChapterScraped; diff --git a/src/Domain/Scraping/Domain/Contract/Service/ImageStorageInterface.php b/src/Domain/Scraping/Domain/Contract/Service/ImageStorageInterface.php deleted file mode 100644 index f0bd726..0000000 --- a/src/Domain/Scraping/Domain/Contract/Service/ImageStorageInterface.php +++ /dev/null @@ -1,16 +0,0 @@ -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/Contract/ImageStorageInterface.php b/src/Domain/Shared/Domain/Contract/ImageStorageInterface.php new file mode 100644 index 0000000..715294a --- /dev/null +++ b/src/Domain/Shared/Domain/Contract/ImageStorageInterface.php @@ -0,0 +1,28 @@ +storagePath . '/pages/' . $targetId; + + 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; + } + + public function extractFromCbz(string $targetId, string $cbzBinary): string + { + $targetDir = $this->storagePath . '/pages/' . $targetId; + + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz_'); + file_put_contents($tmpFile, $cbzBinary); + + $zip = new ZipArchive(); + if ($zip->open($tmpFile) !== true) { + unlink($tmpFile); + throw new \RuntimeException('Failed to open CBZ file as ZIP archive'); + } + + $imageEntries = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) { + $imageEntries[] = ['index' => $i, 'name' => $name]; + } + } + + usort($imageEntries, fn ($a, $b) => strcmp($a['name'], $b['name'])); + + foreach ($imageEntries as $seq => $entry) { + $extension = strtolower(pathinfo($entry['name'], PATHINFO_EXTENSION)) ?: 'jpg'; + $targetFile = sprintf('%s/%03d.%s', $targetDir, $seq + 1, $extension); + $content = $zip->getFromIndex($entry['index']); + file_put_contents($targetFile, $content); + } + + $zip->close(); + unlink($tmpFile); + + return $targetDir; + } + + public function countCbzImages(string $cbzBinary): int + { + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz_'); + file_put_contents($tmpFile, $cbzBinary); + + $zip = new ZipArchive(); + if ($zip->open($tmpFile) !== true) { + unlink($tmpFile); + throw new \RuntimeException('Failed to open CBZ file as ZIP archive'); + } + + $count = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) { + $count++; + } + } + + $zip->close(); + unlink($tmpFile); + + return $count; + } +} diff --git a/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php index 0dd29bf..d6f36cb 100644 --- a/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php +++ b/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php @@ -13,22 +13,22 @@ 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\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; -use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; +use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage; use PHPUnit\Framework\TestCase; class ImportChapterHandlerTest extends TestCase { private InMemoryMangaRepository $mangaRepository; - private InMemoryPathManager $pathManager; + private InMemoryImageStorage $imageStorage; private ImportChapterHandler $handler; protected function setUp(): void { $this->mangaRepository = new InMemoryMangaRepository(); - $this->pathManager = new InMemoryPathManager(); + $this->imageStorage = new InMemoryImageStorage(); $this->handler = new ImportChapterHandler( $this->mangaRepository, - $this->pathManager + $this->imageStorage ); } diff --git a/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php index fd86b7e..8ded66c 100644 --- a/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php +++ b/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php @@ -12,22 +12,22 @@ 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\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; -use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; +use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage; use PHPUnit\Framework\TestCase; class ImportVolumeHandlerTest extends TestCase { private InMemoryMangaRepository $mangaRepository; - private InMemoryPathManager $pathManager; + private InMemoryImageStorage $imageStorage; private ImportVolumeHandler $handler; protected function setUp(): void { $this->mangaRepository = new InMemoryMangaRepository(); - $this->pathManager = new InMemoryPathManager(); + $this->imageStorage = new InMemoryImageStorage(); $this->handler = new ImportVolumeHandler( $this->mangaRepository, - $this->pathManager + $this->imageStorage ); } diff --git a/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php b/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php index f33bf88..0287476 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php +++ b/tests/Domain/Scraping/Adapter/InMemoryImageStorage.php @@ -2,18 +2,31 @@ namespace App\Tests\Domain\Scraping\Adapter; -use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; +use App\Domain\Shared\Domain\Contract\ImageStorageInterface; class InMemoryImageStorage implements ImageStorageInterface { - /** @var array chapterId => pagesDirectory */ + /** @var array targetId => pagesDirectory */ public array $stored = []; - public function storeChapterImages(string $chapterId, array $localImagePaths): string + public function storeChapterImages(string $targetId, array $localImagePaths): string { - $dir = '/fake/pages/' . $chapterId; - $this->stored[$chapterId] = $dir; + $dir = '/fake/pages/' . $targetId; + $this->stored[$targetId] = $dir; return $dir; } + + public function extractFromCbz(string $targetId, string $cbzBinary): string + { + $dir = '/fake/pages/' . $targetId; + $this->stored[$targetId] = $dir; + + return $dir; + } + + public function countCbzImages(string $cbzBinary): int + { + return 0; + } }