Le domaine Scraping ne génère plus d'archives CBZ ni ne modifie les
entités du domaine Manga directement. Il scrape, stocke les images
individuellement, et émet un événement partagé.
- Suppression : CbzGeneratorInterface, CbzGenerator, CbzGenerationRequest,
CbzPath, CbzGenerationException
- Suppression : save() de ChapterRepositoryInterface (Scraping)
- Suppression : cbzPath du modèle Chapter (Scraping)
- Ajout : ImageStorageInterface + LocalImageStorage
(stockage dans {MANGA_DATA_PATH}/pages/{chapterId}/)
- ScrapeChapterHandler utilise ImageStorage au lieu du générateur CBZ
- ChapterScraped déplacé dans Domain/Shared/Domain/Event/
avec jobId, chapterId, pagesDirectory, pageCount
- Routing Messenger ajouté
- Ajout : ChapterScrapedEventListener + ChapterScrapedMessageHandler
pour mettre à jour Chapter.pagesDirectory via le Repository Manga
- LegacyChapterRepository en dual-mode :
pagesDirectory en priorité, fallback cbzPath (backward compat)
- Requêtes prev/next : filtrent pagesDirectory IS NOT NULL OR cbzPath IS NOT NULL
- ChapterContext expose pagesDirectory
- phparkitect.php : App\Domain\Shared\Domain\Event autorisé dans
les couches Application (correction violations pré-existantes
ChapterImported/VolumeImported + nouvelle ChapterScraped)
- 218/218 tests passent (+3 nouveaux)
- InMemoryImageStorage créé pour les tests unitaires
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
327 lines
10 KiB
PHP
327 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Reader\Infrastructure\Persistence;
|
|
|
|
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
|
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
|
use App\Domain\Reader\Domain\Model\ChapterContext;
|
|
use App\Domain\Reader\Domain\Model\Page;
|
|
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
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $entityManager
|
|
) {
|
|
}
|
|
|
|
public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array
|
|
{
|
|
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
'id' => $chapterId->getValue()
|
|
]);
|
|
|
|
$pagesDirectory = $chapter->getPagesDirectory();
|
|
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
|
return $this->getPagesFromDirectory($chapterId, $pagesDirectory, $page, $itemsPerPage);
|
|
}
|
|
|
|
$cbzPath = $chapter->getCbzPath();
|
|
if (!$cbzPath) {
|
|
return [];
|
|
}
|
|
|
|
return $this->getPagesFromCbz($chapterId, $cbzPath, $page, $itemsPerPage);
|
|
}
|
|
|
|
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
|
{
|
|
/** @var ChapterEntity $chapter */
|
|
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
'id' => $chapterId->getValue()
|
|
]);
|
|
|
|
if (!$chapter) {
|
|
throw ChapterNotFoundException::forChapter($chapterId);
|
|
}
|
|
|
|
return new ChapterContext(
|
|
id: $chapterId,
|
|
previousChapterId: $this->getPreviousChapterId($chapterId),
|
|
nextChapterId: $this->getNextChapterId($chapterId),
|
|
mangaTitle: $chapter->getManga()->getTitle(),
|
|
number: $chapter->getNumber(),
|
|
chapterTitle: $chapter->getTitle(),
|
|
cbzPath: $chapter->getCbzPath(),
|
|
volume: $chapter->getVolume(),
|
|
totalPages: 0,
|
|
isVisible: $chapter->isVisible(),
|
|
createdAt: new \DateTimeImmutable(),
|
|
pagesDirectory: $chapter->getPagesDirectory(),
|
|
);
|
|
}
|
|
|
|
public function getTotalPagesForChapter(ChapterId $chapterId): int
|
|
{
|
|
$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 count($this->getImageFiles($pagesDirectory));
|
|
}
|
|
|
|
$cbzPath = $chapter->getCbzPath();
|
|
if (!$cbzPath) {
|
|
return 0;
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
$zip->open($cbzPath);
|
|
$count = $zip->numFiles;
|
|
$zip->close();
|
|
|
|
return $count;
|
|
}
|
|
|
|
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
|
{
|
|
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
'id' => $chapterId->getValue()
|
|
]);
|
|
|
|
$qb = $this->entityManager->createQueryBuilder();
|
|
$qb->select('c')
|
|
->from(ChapterEntity::class, 'c')
|
|
->where('c.manga = :manga')
|
|
->andWhere('c.number < :number')
|
|
->andWhere('c.visible = true')
|
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
|
->orderBy('c.number', 'DESC')
|
|
->setMaxResults(1)
|
|
->setParameters([
|
|
'manga' => $currentChapter->getManga(),
|
|
'number' => $currentChapter->getNumber()
|
|
]);
|
|
|
|
$previousChapter = $qb->getQuery()->getOneOrNullResult();
|
|
|
|
return $previousChapter ? new ChapterId((string) $previousChapter->getId()) : null;
|
|
}
|
|
|
|
public function getNextChapterId(ChapterId $chapterId): ?ChapterId
|
|
{
|
|
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
|
'id' => $chapterId->getValue()
|
|
]);
|
|
|
|
$qb = $this->entityManager->createQueryBuilder();
|
|
$qb->select('c')
|
|
->from(ChapterEntity::class, 'c')
|
|
->where('c.manga = :manga')
|
|
->andWhere('c.number > :number')
|
|
->andWhere('c.visible = true')
|
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
|
->orderBy('c.number', 'ASC')
|
|
->setMaxResults(1)
|
|
->setParameters([
|
|
'manga' => $currentChapter->getManga(),
|
|
'number' => $currentChapter->getNumber()
|
|
]);
|
|
|
|
$nextChapter = $qb->getQuery()->getOneOrNullResult();
|
|
|
|
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) ?: [];
|
|
sort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
private function getPagesFromDirectory(ChapterId $chapterId, string $pagesDirectory, int $page, int $itemsPerPage): array
|
|
{
|
|
$files = $this->getImageFiles($pagesDirectory);
|
|
$start = ($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);
|
|
if ($imageSize === false) {
|
|
continue;
|
|
}
|
|
|
|
$pages[] = new Page(
|
|
basename($files[$i]),
|
|
new PageNumber($i + 1),
|
|
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
|
$imageSize[0],
|
|
$imageSize[1]
|
|
);
|
|
}
|
|
|
|
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]
|
|
);
|
|
}
|
|
}
|