fix(import): extraire les images CBZ vers le stockage individuel #15

Merged
colgora merged 2 commits from fix/import-cbz-image-storage into main 2026-03-15 18:27:29 +01:00
13 changed files with 173 additions and 108 deletions

View File

@@ -126,10 +126,10 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage: App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
arguments: arguments:
$storagePath: '%kernel.project_dir%/public/images' $storagePath: '%kernel.project_dir%/public/images'

View File

@@ -12,7 +12,7 @@ services:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true public: true
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
public: true public: true

View File

@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException; use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportChapterHandler readonly class ImportChapterHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, 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}"); throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
} }
// 4. Save the CBZ file to storage using the path manager // 4. Extract CBZ into individual images storage
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter); $pagesDirectory = $this->imageStorage->extractFromCbz(
$existingChapter->getId(),
$command->fileBinary
);
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
// 5. Update existing chapter with new path through the aggregate // 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); $this->mangaRepository->save($manga);
} }
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
return strpos($fileBinary, $zipMagicNumber) === 0; 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;
}
} }

View File

@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume; use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportVolumeHandler readonly class ImportVolumeHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, 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 // 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
$cbzPath = $this->saveCbzFile($command, $manga); $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 // 5. Update all chapters with the volume path through the aggregate
foreach ($chapters as $chapter) { foreach ($chapters as $chapter) {
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount()); $manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
} }
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
} }
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
return strpos($fileBinary, $zipMagicNumber) === 0; 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;
}
} }

View File

@@ -154,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
$pages[] = new Page( $pages[] = new Page(
basename($files[$i]), basename($files[$i]),
new PageNumber($i + 1), 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[0],
$imageSize[1] $imageSize[1]
); );

View File

@@ -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\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Shared\Domain\Event\ChapterScraped;

View File

@@ -1,16 +0,0 @@
<?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,31 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Service;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
readonly class LocalImageStorage implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $chapterId, array $localImagePaths): string
{
$targetDir = $this->storagePath . '/pages/' . $chapterId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
sort($localImagePaths);
foreach ($localImagePaths as $index => $localPath) {
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
copy($localPath, $targetFile);
}
return $targetDir;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
interface ImageStorageInterface
{
/**
* Store images from local file paths into the individual images storage.
* Used by the scraping flow.
*
* @param string[] $localImagePaths
* @return string The directory path where images are stored (pagesDirectory)
*/
public function storeChapterImages(string $targetId, array $localImagePaths): string;
/**
* Extract images from a CBZ binary into the individual images storage.
* Used by the import flow.
*
* @return string The directory path where images are stored (pagesDirectory)
*/
public function extractFromCbz(string $targetId, string $cbzBinary): string;
/**
* Count images in a CBZ binary.
*/
public function countCbzImages(string $cbzBinary): int;
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use ZipArchive;
class ImageStorageManager implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $targetId, array $localImagePaths): string
{
$targetDir = $this->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;
}
}

View File

@@ -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\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; 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; use PHPUnit\Framework\TestCase;
class ImportChapterHandlerTest extends TestCase class ImportChapterHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportChapterHandler $handler; private ImportChapterHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportChapterHandler( $this->handler = new ImportChapterHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -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\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; 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; use PHPUnit\Framework\TestCase;
class ImportVolumeHandlerTest extends TestCase class ImportVolumeHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportVolumeHandler $handler; private ImportVolumeHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportVolumeHandler( $this->handler = new ImportVolumeHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -2,18 +2,31 @@
namespace App\Tests\Domain\Scraping\Adapter; 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 class InMemoryImageStorage implements ImageStorageInterface
{ {
/** @var array<string, string> chapterId => pagesDirectory */ /** @var array<string, string> targetId => pagesDirectory */
public array $stored = []; public array $stored = [];
public function storeChapterImages(string $chapterId, array $localImagePaths): string public function storeChapterImages(string $targetId, array $localImagePaths): string
{ {
$dir = '/fake/pages/' . $chapterId; $dir = '/fake/pages/' . $targetId;
$this->stored[$chapterId] = $dir; $this->stored[$targetId] = $dir;
return $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;
}
} }