feat: ajout de la gestion des commandes pour la suppression des fichiers CBZ et des chapitres, avec création des gestionnaires et des ressources API correspondantes

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-06-29 18:33:33 +02:00
parent 7fe4ac0d3b
commit 37e1b202c2
42 changed files with 1413 additions and 21 deletions

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\DeleteCbzProcessor;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DeleteCbzProvider;
#[ApiResource(
shortName: 'Cbz',
operations: [
new Delete(
uriTemplate: '/manga/chapters/{id}/cbz',
provider: DeleteCbzProvider::class,
processor: DeleteCbzProcessor::class,
name: 'delete_cbz',
openapiContext: [
'summary' => 'Delete chapter CBZ file',
'description' => 'Removes the CBZ file for a specific chapter and updates the chapter accordingly',
'parameters' => [
[
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => [
'type' => 'string'
],
'description' => 'The chapter ID'
]
],
'responses' => [
'204' => [
'description' => 'CBZ file successfully deleted'
],
'404' => [
'description' => 'Chapter or CBZ file not found'
]
]
]
)
]
)]
class DeleteCbzResource
{
public function __construct(
public string $id
) {}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\DeleteChapterProcessor;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DeleteChapterProvider;
#[ApiResource(
shortName: 'Chapters',
operations: [
new Delete(
uriTemplate: '/manga/chapters/{id}',
provider: DeleteChapterProvider::class,
processor: DeleteChapterProcessor::class,
name: 'delete_chapter',
openapiContext: [
'summary' => 'Delete a chapter (soft delete)',
'description' => 'Marks a chapter as deleted by setting its visibility to false',
'parameters' => [
[
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => [
'type' => 'string'
],
'description' => 'The chapter ID'
]
],
'responses' => [
'204' => [
'description' => 'Chapter successfully deleted'
],
'404' => [
'description' => 'Chapter not found'
]
]
]
)
]
)]
class DeleteChapterResource
{
public function __construct(
public string $id
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DownloadCbzProvider;
#[ApiResource(
shortName: 'Cbz',
operations: [
new Get(
uriTemplate: '/manga/chapters/{id}/download',
provider: DownloadCbzProvider::class,
output: false,
name: 'download_chapter_cbz'
)
]
)]
class DownloadCbzResource
{
public function __construct(
public string $id
) {}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DownloadVolumeProvider;
#[ApiResource(
shortName: 'Cbz',
operations: [
new Get(
uriTemplate: '/mangas/{id}/volumes/{volume}/download',
provider: DownloadVolumeProvider::class,
output: false,
name: 'download_manga_volume'
)
]
)]
class DownloadVolumeResource
{
public function __construct(
public string $id,
public int $volume
) {}
}

View File

@@ -8,7 +8,7 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\FetchMangaChapte
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'MangaChapters',
shortName: 'Chapters',
operations: [
new Post(
uriTemplate: '/manga/chapters/fetch',
@@ -23,4 +23,4 @@ class FetchMangaChaptersResource
#[Assert\NotBlank]
public string $externalId
) {}
}
}

View File

@@ -8,7 +8,7 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\ChapterCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaChaptersStateProvider;
#[ApiResource(
shortName: 'MangaChapters',
shortName: 'Chapters',
operations: [
new Get(
uriTemplate: '/mangas/{id}/chapters',
@@ -63,4 +63,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaChaptersS
)]
class MangaChaptersResource
{
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\DeleteCbz;
use App\Domain\Manga\Application\CommandHandler\DeleteCbzHandler;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\DeleteCbzResource;
readonly class DeleteCbzProcessor implements ProcessorInterface
{
public function __construct(
private DeleteCbzHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
assert($data instanceof DeleteCbzResource);
$command = new DeleteCbz($data->id);
$this->handler->handle($command);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\DeleteChapter;
use App\Domain\Manga\Application\CommandHandler\DeleteChapterHandler;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteChapterProcessor implements ProcessorInterface
{
public function __construct(
private DeleteChapterHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!isset($uriVariables['id'])) {
throw new \InvalidArgumentException('Chapter ID is required');
}
try {
$command = new DeleteChapter($uriVariables['id']);
$this->handler->handle($command);
} catch (ChapterNotFoundException $e) {
throw new NotFoundHttpException('Chapter not found');
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\DeleteCbzResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteCbzProvider implements ProviderInterface
{
public function __construct(
private ChapterRepositoryInterface $chapterRepository
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): DeleteCbzResource
{
if (!isset($uriVariables['id'])) {
throw new NotFoundHttpException('Chapter ID is required');
}
$chapterId = $uriVariables['id'];
try {
$chapter = $this->chapterRepository->findVisibleById($chapterId);
if (!$chapter) {
throw new ChapterNotFoundException($chapterId);
}
if (!$chapter->isAvailable()) {
throw new CbzFileNotFoundException($chapterId);
}
return new DeleteCbzResource($chapterId);
} catch (ChapterNotFoundException $e) {
throw new NotFoundHttpException('Chapter not found');
} catch (CbzFileNotFoundException $e) {
throw new NotFoundHttpException('CBZ file not found for this chapter');
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\DeleteChapterResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteChapterProvider implements ProviderInterface
{
public function __construct(
private ChapterRepositoryInterface $chapterRepository
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): DeleteChapterResource
{
if (!isset($uriVariables['id'])) {
throw new NotFoundHttpException('Chapter ID is required');
}
$chapterId = $uriVariables['id'];
try {
$chapter = $this->chapterRepository->findVisibleById($chapterId);
if (!$chapter) {
throw new ChapterNotFoundException($chapterId);
}
return new DeleteChapterResource($chapterId);
} catch (ChapterNotFoundException $e) {
throw new NotFoundHttpException('Chapter not found');
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Application\Query\DownloadCbz;
use App\Domain\Manga\Application\QueryHandler\DownloadCbzHandler;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DownloadCbzProvider implements ProviderInterface
{
public function __construct(
private DownloadCbzHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
if (!isset($uriVariables['id'])) {
throw new \InvalidArgumentException('Chapter ID is required');
}
$query = new DownloadCbz($uriVariables['id']);
try {
$downloadResponse = $this->handler->handle($query);
return $downloadResponse->httpResponse;
} catch (ChapterNotAvailableException|ChapterNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Application\Query\DownloadVolume;
use App\Domain\Manga\Application\QueryHandler\DownloadVolumeHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\VolumeNotFoundException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DownloadVolumeProvider implements ProviderInterface
{
public function __construct(
private DownloadVolumeHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
if (!isset($uriVariables['id']) || !isset($uriVariables['volume'])) {
throw new \InvalidArgumentException('Manga ID and volume are required');
}
$query = new DownloadVolume($uriVariables['id'], (int) $uriVariables['volume']);
try {
$downloadResponse = $this->handler->handle($query);
return $downloadResponse->httpResponse;
} catch (MangaNotFoundException|VolumeNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -52,8 +52,8 @@ readonly class GetMangaChaptersStateProvider implements ProviderInterface
title: $chapter->title,
volume: $chapter->volume,
isVisible: $chapter->isVisible,
isAvailable: $chapter->isAvailable,
isAvailable: $chapter->cbzPath !== null,
createdAt: $chapter->createdAt->format(\DateTimeInterface::RFC3339)
);
}
}
}

View File

@@ -230,15 +230,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
id: new MangaId((string) $entity->getId()),
title: new MangaTitle($entity->getTitle()),
slug: new MangaSlug($entity->getSlug()),
description: $entity->getDescription(),
author: $entity->getAuthor(),
publicationYear: $entity->getPublicationYear(),
genres: $entity->getGenres(),
status: $entity->getStatus(),
description: $entity->getDescription() ?? '',
author: $entity->getAuthor() ?? '',
publicationYear: $entity->getPublicationYear() ?? 0,
genres: $entity->getGenres() ?? [],
status: $entity->getStatus() ?? '',
externalId: $entity->getExternalId() ? new ExternalId($entity->getExternalId()) : null,
imageUrl: $entity->getImageUrl(),
rating: $entity->getRating(),
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl(), $entity->getThumbnailUrl()) : null,
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
createdAt: $entity->getCreatedAt(),
);
}
@@ -252,7 +252,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
title: $entity->getTitle(),
volume: $entity->getVolume(),
isVisible: $entity->isVisible(),
isAvailable: $entity->getCbzPath() !== null
cbzPath: $entity->getCbzPath()
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Domain\Manga\Infrastructure\Persistence\Repository;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Entity\Chapter as ChapterEntity;
use Doctrine\ORM\EntityManagerInterface;
readonly class LegacyChapterRepository implements ChapterRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function findById(string $id): ?Chapter
{
$entity = $this->entityManager->find(ChapterEntity::class, $id);
return $entity ? $this->toDomainModel($entity) : null;
}
public function findVisibleById(string $id): ?Chapter
{
$qb = $this->entityManager->createQueryBuilder()
->select('c')
->from(ChapterEntity::class, 'c')
->where('c.id = :id')
->andWhere('c.visible = :visible')
->setParameter('id', $id)
->setParameter('visible', 1);
$entity = $qb->getQuery()->getOneOrNullResult();
return $entity ? $this->toDomainModel($entity) : null;
}
public function save(Chapter $chapter): void
{
$entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId());
if (!$entity) {
throw new \RuntimeException(sprintf('Chapter with id %s not found', $chapter->getId()));
}
$entity->setVisible($chapter->isVisible());
$entity->setCbzPath($chapter->getCbzPath());
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
public function delete(Chapter $chapter): void
{
$entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId());
if ($entity) {
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
}
public function findByMangaIdAndVolume(string $mangaId, int $volume): array
{
$entities = $this->entityManager->getRepository(ChapterEntity::class)
->findBy(['manga' => $mangaId, 'volume' => $volume]);
return array_map([$this, 'toDomainModel'], $entities);
}
public function findVisibleByMangaIdAndVolume(string $mangaId, int $volume): array
{
$entities = $this->entityManager->getRepository(ChapterEntity::class)
->findBy(['manga' => $mangaId, 'volume' => $volume, 'visible' => true]);
return array_map([$this, 'toDomainModel'], $entities);
}
public function findVisibleWithCbzByMangaIdAndVolume(string $mangaId, int $volume): array
{
$qb = $this->entityManager->createQueryBuilder()
->select('c')
->from(ChapterEntity::class, 'c')
->where('c.manga = :mangaId')
->andWhere('c.volume = :volume')
->andWhere('c.visible = true')
->andWhere('c.cbzPath IS NOT NULL')
->setParameter('mangaId', $mangaId)
->setParameter('volume', $volume)
->orderBy('c.number', 'ASC');
$entities = $qb->getQuery()->getResult();
return array_map([$this, 'toDomainModel'], $entities);
}
private function toDomainModel(ChapterEntity $entity): Chapter
{
return new Chapter(
new ChapterId((string) $entity->getId()),
(string) $entity->getManga()->getId(),
$entity->getNumber(),
$entity->getTitle(),
$entity->getVolume(),
$entity->isVisible(),
$entity->getCbzPath(),
new \DateTimeImmutable()
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
readonly class FileService implements FileServiceInterface
{
public function __construct(
private string $cbzStoragePath = '/app/public/cbz'
) {}
public function downloadCbz(string $filePath, string $filename): Response
{
if (!$this->cbzExists($filePath)) {
throw new CbzFileNotFoundException($filePath);
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename
);
$response->headers->set('Content-Type', 'application/x-cbz');
return $response;
}
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
{
$tempCbzPath = sys_get_temp_dir() . '/' . $volumeName . '.cbz';
$cbz = new \ZipArchive();
if ($cbz->open($tempCbzPath, \ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Cannot create CBZ file');
}
foreach ($cbzPaths as $cbzPath) {
if ($this->cbzExists($cbzPath)) {
$filename = basename($cbzPath);
$cbz->addFile($cbzPath, $filename);
}
}
$cbz->close();
$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;
}
public function deleteCbzFile(string $filePath): bool
{
if (!$this->cbzExists($filePath)) {
return false;
}
return unlink($filePath);
}
public function cbzExists(string $filePath): bool
{
return file_exists($filePath) && is_readable($filePath);
}
}