fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages #47
@@ -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\CbzFileNotFoundException;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
|
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
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\QueryHandlerInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
||||||
@@ -35,8 +36,19 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
|
|||||||
throw new ChapterNotAvailableException($query->chapterId);
|
throw new ChapterNotAvailableException($query->chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
||||||
|
if (!$manga) {
|
||||||
|
throw new MangaNotFoundException($chapter->getMangaId()->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
$pagesDirectory = $chapter->getPagesDirectory();
|
$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 {
|
try {
|
||||||
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);
|
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);
|
||||||
|
|||||||
@@ -17,93 +17,103 @@ readonly class FileService implements FileServiceInterface
|
|||||||
|
|
||||||
public function downloadCbz(string $filePath, string $filename): Response
|
public function downloadCbz(string $filePath, string $filename): Response
|
||||||
{
|
{
|
||||||
if (!$this->cbzExists($filePath)) {
|
if (!is_dir($filePath)) {
|
||||||
throw new CbzFileNotFoundException($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(
|
$response->setContentDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$filename
|
$downloadName
|
||||||
);
|
);
|
||||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
$response->headers->set('Content-Type', 'application/x-cbz');
|
||||||
|
$response->deleteFileAfterSend();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
|
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
|
||||||
{
|
{
|
||||||
$tempCbzPath = sys_get_temp_dir().'/'.$volumeName.'.cbz';
|
$tempCbzPath = $this->createTempCbzPath($volumeName);
|
||||||
|
|
||||||
$cbz = new \ZipArchive();
|
$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');
|
throw new \RuntimeException('Cannot create CBZ file');
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageCounter = 1;
|
$counter = 1;
|
||||||
|
foreach ($cbzPaths as $directory) {
|
||||||
foreach ($cbzPaths as $cbzPath) {
|
if (!is_dir($directory)) {
|
||||||
if (!$this->cbzExists($cbzPath)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceCbz = new \ZipArchive();
|
foreach ($this->listImageFiles($directory) as $imagePath) {
|
||||||
if (true !== $sourceCbz->open($cbzPath)) {
|
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
|
||||||
continue; // Skip if we can't open the CBZ
|
$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();
|
$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 = new BinaryFileResponse($tempCbzPath);
|
||||||
$response->setContentDisposition(
|
$response->setContentDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$volumeName.'.cbz'
|
$volumeName.'.cbz'
|
||||||
);
|
);
|
||||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
$response->headers->set('Content-Type', 'application/x-cbz');
|
||||||
|
|
||||||
// Clean up temp file after sending
|
|
||||||
$response->deleteFileAfterSend();
|
$response->deleteFileAfterSend();
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isImageFile(string $fileName): bool
|
private function createTempCbzPath(string $name): string
|
||||||
{
|
{
|
||||||
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
$safeName = preg_replace('/[^A-Za-z0-9_.-]/', '_', $name) ?? 'archive';
|
||||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
|
||||||
|
|
||||||
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
|
public function deleteCbzFile(string $filePath): bool
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DownloadCbzTest extends AbstractApiTestCase
|
|||||||
'number' => 1.0,
|
'number' => 1.0,
|
||||||
'title' => 'Chapter 1',
|
'title' => 'Chapter 1',
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => '/app/tests/Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$chapterId = $chapter->getId();
|
$chapterId = $chapter->getId();
|
||||||
@@ -41,7 +41,7 @@ class DownloadCbzTest extends AbstractApiTestCase
|
|||||||
$response = static::getClient()->getResponse();
|
$response = static::getClient()->getResponse();
|
||||||
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
|
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
|
||||||
$this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition'));
|
$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
|
public function testItReturns404ForNonExistentChapter(): void
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'manga' => $manga,
|
'manga' => $manga,
|
||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mangaId = $manga->getId();
|
$mangaId = $manga->getId();
|
||||||
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 1.0,
|
'number' => 1.0,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ChapterFactory::createOne([
|
ChapterFactory::createOne([
|
||||||
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 2.0,
|
'number' => 2.0,
|
||||||
'visible' => false, // Soft deleted
|
'visible' => false, // Soft deleted
|
||||||
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ChapterFactory::createOne([
|
ChapterFactory::createOne([
|
||||||
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 4.0,
|
'number' => 4.0,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz',
|
'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mangaId = $manga->getId();
|
$mangaId = $manga->getId();
|
||||||
|
|||||||
BIN
tests/Shared/Files/test-pages/0001.png
Normal file
BIN
tests/Shared/Files/test-pages/0001.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
BIN
tests/Shared/Files/test-pages/0002.png
Normal file
BIN
tests/Shared/Files/test-pages/0002.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Reference in New Issue
Block a user