7 Commits

Author SHA1 Message Date
810e18c26c Merge pull request 'fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy' (#49) from fix/mercure-transport-directive into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #49
2026-04-10 15:24:55 +02:00
Jérémy Guillot
1905581214 fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy
La directive transport_url a été supprimée dans les versions récentes
de Mercure, remplacée par un sous-bloc transport bolt { url ... }.
2026-04-10 15:23:42 +02:00
c0ab40eacd Merge pull request 'fix(manga): conserver le padding du numéro de chapitre après scraping' (#48) from fix/chapter-number-padding-after-scraping into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #48
2026-04-09 15:11:56 +02:00
Jérémy Guillot
e214e1ea46 fix(manga): conserver le padding du numéro de chapitre après scraping 2026-04-09 15:11:23 +02:00
1f1efd1b16 Merge pull request 'fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages' (#47) from fix/download-cbz-from-pages-directory into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #47
2026-04-09 14:49:41 +02:00
Jérémy Guillot
41c1fc5e2e fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages
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) <noreply@anthropic.com>
2026-04-09 14:48:17 +02:00
848efd3327 Merge pull request 'feat(home): toolbar filtre/affichage et modale options d'affichage' (#46) from feat/home-toolbar-display-settings into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #46
2026-03-27 16:26:33 +01:00
8 changed files with 83 additions and 57 deletions

View File

@@ -31,7 +31,9 @@
mercure { mercure {
# Transport to use (default to Bolt) # Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport bolt {
url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
}
# Publisher JWT key # Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key # Subscriber JWT key

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\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);

View File

@@ -103,7 +103,9 @@ readonly class GetMangaChaptersHandler
$min = min($numbers); $min = min($numbers);
$max = max($numbers); $max = max($numbers);
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n; $fmt = fn (float $n) => $n == (int) $n
? str_pad((string) (int) $n, 2, '0', STR_PAD_LEFT)
: (string) $n;
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min); $range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
return new ChapterResponse( return new ChapterResponse(

View File

@@ -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

View File

@@ -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

View File

@@ -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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B