From 41c1fc5e2ecdc320b3b38a361484648facb128de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guillot?= Date: Thu, 9 Apr 2026 14:48:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(manga):=20g=C3=A9n=C3=A9rer=20le=20CBZ=20de?= =?UTF-8?q?=20t=C3=A9l=C3=A9chargement=20depuis=20les=20dossiers=20de=20pa?= =?UTF-8?q?ges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les endpoints de téléchargement chapitre/volume plantaient (500 "file does not exist") car le FileService traitait `pagesDirectory` comme un CBZ. Le service reconstruit maintenant l'archive à la volée à partir des images du dossier, et le nom du fichier chapitre inclut le titre du manga et le numéro. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../QueryHandler/DownloadCbzHandler.php | 14 ++- .../Infrastructure/Service/FileService.php | 106 ++++++++++-------- tests/Feature/Manga/DownloadCbzTest.php | 4 +- tests/Feature/Manga/DownloadVolumeTest.php | 8 +- tests/Shared/Files/test-pages/0001.png | Bin 0 -> 68 bytes tests/Shared/Files/test-pages/0002.png | Bin 0 -> 68 bytes 6 files changed, 77 insertions(+), 55 deletions(-) create mode 100644 tests/Shared/Files/test-pages/0001.png create mode 100644 tests/Shared/Files/test-pages/0002.png diff --git a/src/Domain/Manga/Application/QueryHandler/DownloadCbzHandler.php b/src/Domain/Manga/Application/QueryHandler/DownloadCbzHandler.php index 9d548c7..e0b7fd9 100644 --- a/src/Domain/Manga/Application/QueryHandler/DownloadCbzHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/DownloadCbzHandler.php @@ -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); diff --git a/src/Domain/Manga/Infrastructure/Service/FileService.php b/src/Domain/Manga/Infrastructure/Service/FileService.php index 688119d..f36e3f0 100644 --- a/src/Domain/Manga/Infrastructure/Service/FileService.php +++ b/src/Domain/Manga/Infrastructure/Service/FileService.php @@ -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 diff --git a/tests/Feature/Manga/DownloadCbzTest.php b/tests/Feature/Manga/DownloadCbzTest.php index f2a7973..1c45d71 100644 --- a/tests/Feature/Manga/DownloadCbzTest.php +++ b/tests/Feature/Manga/DownloadCbzTest.php @@ -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 diff --git a/tests/Feature/Manga/DownloadVolumeTest.php b/tests/Feature/Manga/DownloadVolumeTest.php index 6266912..c1b5738 100644 --- a/tests/Feature/Manga/DownloadVolumeTest.php +++ b/tests/Feature/Manga/DownloadVolumeTest.php @@ -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(); diff --git a/tests/Shared/Files/test-pages/0001.png b/tests/Shared/Files/test-pages/0001.png new file mode 100644 index 0000000000000000000000000000000000000000..18eaf9fbf3abb25a2dc8b429ad352116465bdfd3 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$2@F6UD+A-pUw3W* Pg&90u{an^LB{Ts5Z59ol literal 0 HcmV?d00001 diff --git a/tests/Shared/Files/test-pages/0002.png b/tests/Shared/Files/test-pages/0002.png new file mode 100644 index 0000000000000000000000000000000000000000..18eaf9fbf3abb25a2dc8b429ad352116465bdfd3 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$2@F6UD+A-pUw3W* Pg&90u{an^LB{Ts5Z59ol literal 0 HcmV?d00001 -- 2.49.1