fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages #47

Merged
colgora merged 1 commits from fix/download-cbz-from-pages-directory into main 2026-04-09 14:49:42 +02:00
6 changed files with 77 additions and 55 deletions

View File

@@ -9,6 +9,7 @@ use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
@@ -35,8 +36,19 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
throw new ChapterNotAvailableException($query->chapterId);
}
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
if (!$manga) {
throw new MangaNotFoundException($chapter->getMangaId()->getValue());
}
$pagesDirectory = $chapter->getPagesDirectory();
$filename = basename($pagesDirectory);
$number = $chapter->getNumber();
$formattedNumber = fmod($number, 1.0) === 0.0
? sprintf('%03d', (int) $number)
: rtrim(rtrim(sprintf('%06.2f', $number), '0'), '.');
$filename = sprintf('%s - Ch.%s', $manga->getTitle()->getValue(), $formattedNumber);
try {
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);

View File

@@ -17,93 +17,103 @@ readonly class FileService implements FileServiceInterface
public function downloadCbz(string $filePath, string $filename): Response
{
if (!$this->cbzExists($filePath)) {
if (!is_dir($filePath)) {
throw new CbzFileNotFoundException($filePath);
}
$response = new BinaryFileResponse($filePath);
$images = $this->listImageFiles($filePath);
if ([] === $images) {
throw new CbzFileNotFoundException($filePath);
}
$tempCbzPath = $this->createTempCbzPath($filename);
$cbz = new \ZipArchive();
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new \RuntimeException('Cannot create CBZ file');
}
$counter = 1;
foreach ($images as $imagePath) {
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
$cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
++$counter;
}
$cbz->close();
if (!file_exists($tempCbzPath)) {
throw new \RuntimeException(sprintf('Failed to write CBZ file "%s"', $tempCbzPath));
}
$downloadName = str_ends_with($filename, '.cbz') ? $filename : $filename.'.cbz';
$response = new BinaryFileResponse($tempCbzPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename
$downloadName
);
$response->headers->set('Content-Type', 'application/x-cbz');
$response->deleteFileAfterSend();
return $response;
}
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
{
$tempCbzPath = sys_get_temp_dir().'/'.$volumeName.'.cbz';
$tempCbzPath = $this->createTempCbzPath($volumeName);
$cbz = new \ZipArchive();
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE)) {
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new \RuntimeException('Cannot create CBZ file');
}
$imageCounter = 1;
foreach ($cbzPaths as $cbzPath) {
if (!$this->cbzExists($cbzPath)) {
$counter = 1;
foreach ($cbzPaths as $directory) {
if (!is_dir($directory)) {
continue;
}
$sourceCbz = new \ZipArchive();
if (true !== $sourceCbz->open($cbzPath)) {
continue; // Skip if we can't open the CBZ
foreach ($this->listImageFiles($directory) as $imagePath) {
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
$cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
++$counter;
}
// Extract all images from the current CBZ
for ($i = 0; $i < $sourceCbz->numFiles; ++$i) {
$fileName = $sourceCbz->getNameIndex($i);
$fileInfo = $sourceCbz->statIndex($i);
// Skip directories and non-image files
if (0 === $fileInfo['size'] || !$this->isImageFile($fileName)) {
continue;
}
// Get the file content
$imageContent = $sourceCbz->getFromIndex($i);
if (false === $imageContent) {
continue;
}
// Get file extension
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
// Create a new filename with proper ordering
$newFileName = sprintf('%04d.%s', $imageCounter, $extension);
// Add the image to the volume CBZ
$cbz->addFromString($newFileName, $imageContent);
++$imageCounter;
}
$sourceCbz->close();
}
$cbz->close();
if (1 === $counter || !file_exists($tempCbzPath)) {
if (file_exists($tempCbzPath)) {
@unlink($tempCbzPath);
}
throw new \RuntimeException(sprintf('No images found to build volume "%s"', $volumeName));
}
$response = new BinaryFileResponse($tempCbzPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$volumeName.'.cbz'
);
$response->headers->set('Content-Type', 'application/x-cbz');
// Clean up temp file after sending
$response->deleteFileAfterSend();
return $response;
}
private function isImageFile(string $fileName): bool
private function createTempCbzPath(string $name): string
{
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$safeName = preg_replace('/[^A-Za-z0-9_.-]/', '_', $name) ?? 'archive';
return in_array($extension, $imageExtensions);
return sys_get_temp_dir().'/'.uniqid($safeName.'_', true).'.cbz';
}
private function listImageFiles(string $directory): array
{
$files = glob(rtrim($directory, '/').'/*.{jpg,jpeg,png,gif,bmp,webp,JPG,JPEG,PNG,GIF,BMP,WEBP}', GLOB_BRACE) ?: [];
natsort($files);
return array_values($files);
}
public function deleteCbzFile(string $filePath): bool

View File

@@ -27,7 +27,7 @@ class DownloadCbzTest extends AbstractApiTestCase
'number' => 1.0,
'title' => 'Chapter 1',
'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz',
'pagesDirectory' => '/app/tests/Shared/Files/test-pages',
]);
$chapterId = $chapter->getId();
@@ -41,7 +41,7 @@ class DownloadCbzTest extends AbstractApiTestCase
$response = static::getClient()->getResponse();
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
$this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition'));
$this->assertStringContainsString('test-chapter.cbz', $response->headers->get('Content-Disposition'));
$this->assertStringContainsString('Ch.001.cbz', $response->headers->get('Content-Disposition'));
}
public function testItReturns404ForNonExistentChapter(): void

View File

@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'manga' => $manga,
'volume' => 1,
'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]);
$mangaId = $manga->getId();
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1,
'number' => 1.0,
'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]);
ChapterFactory::createOne([
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1,
'number' => 2.0,
'visible' => false, // Soft deleted
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]);
ChapterFactory::createOne([
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1,
'number' => 4.0,
'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]);
$mangaId = $manga->getId();

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B