refactor(reader): serve pages as static files instead of base64
Replace the per-page API call (base64 payload) with static image URLs
served directly by Caddy from public/images/pages/{chapterId}/.
- LocalImageStorage now stores to public/images/ (was MANGA_DATA_PATH)
- LegacyChapterRepository returns /images/pages/{id}/{file} URLs,
uses getimagesize() instead of loading file content into memory
- Delete GetChapterPage query/handler/response, ChapterPageResource,
ChapterPageProvider, PageContent model
- Remove getPageContent() from ChapterRepositoryInterface
- Frontend: loadChapter() fetches chapter + all pages in parallel,
ReaderPage uses URL instead of base64 data URI, InfiniteReader drops
lazy-load observer side effect, readerStore drops loadedPages/preload
- GetChapterPagesTest: extract fixture images from CBZ at runtime,
ignore tests/Fixtures/pages/ in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6875ad4222
commit
322c396165
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Query;
|
||||
|
||||
final readonly class GetChapterPage
|
||||
{
|
||||
public function __construct(
|
||||
private string $chapterId,
|
||||
private int $pageNumber
|
||||
) {
|
||||
}
|
||||
|
||||
public function getChapterId(): string
|
||||
{
|
||||
return $this->chapterId;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class GetChapterPageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ChapterRepositoryInterface $chapterRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(GetChapterPage $query): ChapterPageResponse
|
||||
{
|
||||
$chapterId = new ChapterId($query->getChapterId());
|
||||
$pageNumber = new PageNumber($query->getPageNumber());
|
||||
|
||||
$totalPages = $this->chapterRepository->getTotalPagesForChapter($chapterId);
|
||||
|
||||
if ($totalPages === 0) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
if ($pageNumber->getValue() > $totalPages) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$page = $this->chapterRepository->getPageContent($chapterId, $pageNumber);
|
||||
|
||||
return new ChapterPageResponse(
|
||||
$page->getId(),
|
||||
$page->getPageNumber()->getValue(),
|
||||
$page->getBase64Content(),
|
||||
$page->getMimeType(),
|
||||
$page->getDimensions()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Response;
|
||||
|
||||
final readonly class ChapterPageResponse
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private int $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private array $dimensions
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return $this->dimensions;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ namespace App\Domain\Reader\Domain\Contract\Repository;
|
||||
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
interface ChapterRepositoryInterface
|
||||
{
|
||||
@@ -24,6 +22,4 @@ interface ChapterRepositoryInterface
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getNextChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Domain\Model;
|
||||
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class PageContent
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private PageNumber $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private int $width,
|
||||
private int $height
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): PageNumber
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPageProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Reader',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/reader/chapter/{chapterId}/page/{pageNumber}',
|
||||
openapiContext: [
|
||||
'summary' => 'Récupère une page spécifique d\'un chapitre',
|
||||
'description' => 'Retourne le contenu d\'une page en base64 avec ses métadonnées',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'chapterId',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string'],
|
||||
'description' => 'L\'identifiant du chapitre'
|
||||
],
|
||||
[
|
||||
'name' => 'pageNumber',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'integer', 'minimum' => 1],
|
||||
'description' => 'Le numéro de la page à récupérer'
|
||||
],
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Page du chapitre',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string'],
|
||||
'pageNumber' => ['type' => 'integer'],
|
||||
'base64Content' => ['type' => 'string', 'description' => 'Contenu de l\'image en base64'],
|
||||
'mimeType' => ['type' => 'string', 'example' => 'image/jpeg'],
|
||||
'dimensions' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'width' => ['type' => 'integer'],
|
||||
'height' => ['type' => 'integer']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'404' => [
|
||||
'description' => 'Chapitre ou page non trouvé'
|
||||
]
|
||||
]
|
||||
],
|
||||
provider: ChapterPageProvider::class
|
||||
),
|
||||
],
|
||||
)]
|
||||
class ChapterPageResource
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\QueryHandler\GetChapterPageHandler;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
|
||||
final readonly class ChapterPageProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetChapterPageHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ChapterPageResponse
|
||||
{
|
||||
return $this->handler->handle(
|
||||
new GetChapterPage(
|
||||
$uriVariables['chapterId'],
|
||||
(int) $uriVariables['pageNumber']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
use App\Entity\Chapter as ChapterEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use ZipArchive;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
|
||||
readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
@@ -34,12 +31,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $this->getPagesFromDirectory($chapterId, $pagesDirectory, $page, $itemsPerPage);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getPagesFromCbz($chapterId, $cbzPath, $page, $itemsPerPage);
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
||||
@@ -84,17 +76,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return count($this->getImageFiles($pagesDirectory));
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
$count = $zip->numFiles;
|
||||
$zip->close();
|
||||
|
||||
return $count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
||||
@@ -147,29 +129,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
|
||||
}
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||
'id' => $chapterId->getValue()
|
||||
]);
|
||||
|
||||
if (!$chapter) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
$pagesDirectory = $chapter->getPagesDirectory();
|
||||
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||
return $this->getPageContentFromDirectory($chapterId, $pagesDirectory, $pageNumber);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
return $this->getPageContentFromCbz($chapterId, $cbzPath, $pageNumber);
|
||||
}
|
||||
|
||||
private function getImageFiles(string $pagesDirectory): array
|
||||
{
|
||||
$files = glob($pagesDirectory . '/*.{jpg,jpeg,png,webp,gif}', GLOB_BRACE) ?: [];
|
||||
@@ -181,17 +140,12 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
private function getPagesFromDirectory(ChapterId $chapterId, string $pagesDirectory, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$start = max(0, ($page - 1) * $itemsPerPage);
|
||||
$end = min($start + $itemsPerPage, count($files));
|
||||
$pages = [];
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$imageContent = file_get_contents($files[$i]);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
$imageSize = @getimagesize($files[$i]);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
@@ -199,7 +153,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
$pages[] = new Page(
|
||||
basename($files[$i]),
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
@@ -207,120 +161,4 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPagesFromCbz(ChapterId $chapterId, string $cbzPath, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
$pages = [];
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$end = min($start + $itemsPerPage, $zip->numFiles);
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($i);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pages[] = new Page(
|
||||
$stat['name'],
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPageContentFromDirectory(ChapterId $chapterId, string $pagesDirectory, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
|
||||
if (!$files || $pageNumber->getValue() > count($files)) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$filePath = $files[$pageNumber->getValue() - 1];
|
||||
$imageContent = file_get_contents($filePath);
|
||||
|
||||
if ($imageContent === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
|
||||
return new PageContent(
|
||||
basename($filePath),
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
private function getPageContentFromCbz(ChapterId $chapterId, string $cbzPath, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
if ($pageNumber->getValue() > $zip->numFiles) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$index = $pageNumber->getValue() - 1;
|
||||
$stat = $zip->statIndex($index);
|
||||
|
||||
if ($stat === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($index);
|
||||
|
||||
if ($imageContent === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
|
||||
if ($imageSize === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
$zip->close();
|
||||
|
||||
return new PageContent(
|
||||
$stat['name'],
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user