feat: Image saving for manga creation
This commit is contained in:
parent
50080f9779
commit
4017cabff2
@@ -107,3 +107,8 @@ services:
|
|||||||
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
|
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
|
||||||
$username: '%env(MANGADEX_USERNAME)%'
|
$username: '%env(MANGADEX_USERNAME)%'
|
||||||
$password: '%env(MANGADEX_PASSWORD)%'
|
$password: '%env(MANGADEX_PASSWORD)%'
|
||||||
|
|
||||||
|
App\Domain\Manga\Infrastructure\Service\ImageProcessor:
|
||||||
|
arguments:
|
||||||
|
$publicDir: '%kernel.project_dir%/public'
|
||||||
|
$httpClient: '@GuzzleHttp\Client'
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
||||||
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
||||||
|
|
||||||
readonly class CreateMangaFromMangadexHandler
|
readonly class CreateMangaFromMangadexHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaProviderInterface $mangaProvider,
|
private MangaProviderInterface $mangaProvider,
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
|
private ImageProcessorInterface $imageProcessor
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(CreateMangaFromMangadex $command): void
|
public function handle(CreateMangaFromMangadex $command): void
|
||||||
@@ -23,6 +26,19 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
throw new MangaNotFoundException('Manga not found on Mangadex');
|
throw new MangaNotFoundException('Manga not found on Mangadex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Télécharge l'image originale
|
||||||
|
$fullImagePath = $this->imageProcessor->downloadImage($manga->getImageUrl());
|
||||||
|
|
||||||
|
// Crée la miniature à partir de l'image originale
|
||||||
|
$thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath);
|
||||||
|
|
||||||
|
// Met à jour le manga avec les nouveaux chemins d'images
|
||||||
|
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Contract\Service;
|
||||||
|
|
||||||
|
interface ImageProcessorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function downloadImage(string $imageUrl): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function createThumbnail(string $originalImagePath): string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domain\Manga\Domain\Model;
|
namespace App\Domain\Manga\Domain\Model;
|
||||||
|
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
@@ -21,6 +22,7 @@ final class Manga
|
|||||||
private ?ExternalId $externalId = null,
|
private ?ExternalId $externalId = null,
|
||||||
private ?string $imageUrl = null,
|
private ?string $imageUrl = null,
|
||||||
private ?float $rating = null,
|
private ?float $rating = null,
|
||||||
|
private ?ImageUrls $imageUrls = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): MangaId
|
public function getId(): MangaId
|
||||||
@@ -78,6 +80,11 @@ final class Manga
|
|||||||
return $this->rating;
|
return $this->rating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getImageUrls(): ?ImageUrls
|
||||||
|
{
|
||||||
|
return $this->imageUrls;
|
||||||
|
}
|
||||||
|
|
||||||
public function setRating(float $rating): void
|
public function setRating(float $rating): void
|
||||||
{
|
{
|
||||||
$this->rating = $rating;
|
$this->rating = $rating;
|
||||||
@@ -87,4 +94,9 @@ final class Manga
|
|||||||
{
|
{
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateImageUrls(ImageUrls $imageUrls): void
|
||||||
|
{
|
||||||
|
$this->imageUrls = $imageUrls;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
21
src/Domain/Manga/Domain/Model/ValueObject/ImageUrls.php
Normal file
21
src/Domain/Manga/Domain/Model/ValueObject/ImageUrls.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Model\ValueObject;
|
||||||
|
|
||||||
|
readonly class ImageUrls
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $full,
|
||||||
|
private string $thumbnail
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getFull(): string
|
||||||
|
{
|
||||||
|
return $this->full;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThumbnail(): string
|
||||||
|
{
|
||||||
|
return $this->thumbnail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
->setGenres($manga->getGenres())
|
->setGenres($manga->getGenres())
|
||||||
->setStatus($manga->getStatus())
|
->setStatus($manga->getStatus())
|
||||||
->setExternalId($manga->getExternalId()->getValue())
|
->setExternalId($manga->getExternalId()->getValue())
|
||||||
->setImageUrl($manga->getImageUrl())
|
->setImageUrl($manga->getImageUrls()->getFull())
|
||||||
|
->setThumbnailUrl($manga->getImageUrls()->getThumbnail())
|
||||||
->setMonitored(false);
|
->setMonitored(false);
|
||||||
|
|
||||||
if ($manga->getRating() !== null) {
|
if ($manga->getRating() !== null) {
|
||||||
|
|||||||
91
src/Domain/Manga/Infrastructure/Service/ImageProcessor.php
Normal file
91
src/Domain/Manga/Infrastructure/Service/ImageProcessor.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||||
|
|
||||||
|
class ImageProcessor implements ImageProcessorInterface
|
||||||
|
{
|
||||||
|
private const string BASE_PATH = '/images';
|
||||||
|
private const string FULL_IMAGES_DIR = self::BASE_PATH . '/full';
|
||||||
|
private const string THUMBNAILS_DIR = self::BASE_PATH . '/thumbnails';
|
||||||
|
private const int THUMBNAIL_WIDTH = 300;
|
||||||
|
private const int THUMBNAIL_HEIGHT = 440;
|
||||||
|
private const int FULL_IMAGE_QUALITY = 90;
|
||||||
|
private const int THUMBNAIL_QUALITY = 85;
|
||||||
|
|
||||||
|
private ImageManager $imageManager;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $publicDir,
|
||||||
|
private readonly Client $httpClient
|
||||||
|
) {
|
||||||
|
$this->imageManager = new ImageManager(new Driver());
|
||||||
|
$this->ensureDirectoriesExist();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadImage(string $imageUrl): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->get($imageUrl);
|
||||||
|
|
||||||
|
if ($response->getStatusCode() !== 200) {
|
||||||
|
throw new \RuntimeException('Échec du téléchargement de l\'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
$extension = pathinfo($imageUrl, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$filename = sprintf('%s.%s', $uuid, $extension);
|
||||||
|
$fullPath = $this->publicDir . self::FULL_IMAGES_DIR . '/' . $filename;
|
||||||
|
|
||||||
|
$image = $this->imageManager->read($response->getBody()->getContents());
|
||||||
|
$image->save($fullPath, quality: self::FULL_IMAGE_QUALITY);
|
||||||
|
|
||||||
|
return self::FULL_IMAGES_DIR . '/' . $filename;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Erreur lors du téléchargement de l\'image : ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createThumbnail(string $originalImagePath): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fullPath = $this->publicDir . $originalImagePath;
|
||||||
|
if (!file_exists($fullPath)) {
|
||||||
|
throw new \RuntimeException('Image originale non trouvée');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = pathinfo($originalImagePath, PATHINFO_BASENAME);
|
||||||
|
$thumbnailPath = $this->publicDir . self::THUMBNAILS_DIR . '/' . $filename;
|
||||||
|
|
||||||
|
$thumbnail = $this->imageManager->read($fullPath);
|
||||||
|
$thumbnail->cover(self::THUMBNAIL_WIDTH, self::THUMBNAIL_HEIGHT);
|
||||||
|
$thumbnail->save($thumbnailPath, quality: self::THUMBNAIL_QUALITY);
|
||||||
|
|
||||||
|
return self::THUMBNAILS_DIR . '/' . $filename;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Erreur lors de la création de la miniature : ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDirectoriesExist(): void
|
||||||
|
{
|
||||||
|
$directories = [
|
||||||
|
$this->publicDir . self::FULL_IMAGES_DIR,
|
||||||
|
$this->publicDir . self::THUMBNAILS_DIR,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($directories as $directory) {
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
if (!mkdir($directory, 0755, true) && !is_dir($directory)) {
|
||||||
|
throw new \RuntimeException(sprintf('Le répertoire "%s" n\'a pas pu être créé', $directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Domain/Manga/Adapter/InMemoryImageProcessor.php
Normal file
23
tests/Domain/Manga/Adapter/InMemoryImageProcessor.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
||||||
|
|
||||||
|
class InMemoryImageProcessor implements ImageProcessorInterface
|
||||||
|
{
|
||||||
|
private const string FULL_IMAGE_PATH = '/images/full';
|
||||||
|
private const string THUMBNAIL_PATH = '/images/thumbnails';
|
||||||
|
|
||||||
|
public function downloadImage(string $imageUrl): string
|
||||||
|
{
|
||||||
|
$filename = sprintf('%s/%s.jpg', self::FULL_IMAGE_PATH, uniqid());
|
||||||
|
return $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createThumbnail(string $originalImagePath): string
|
||||||
|
{
|
||||||
|
$filename = basename($originalImagePath);
|
||||||
|
return sprintf('%s/%s', self::THUMBNAIL_PATH, $filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,63 +12,59 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
|
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class CreateMangaFromMangadexHandlerTest extends TestCase
|
class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryMangaProvider $provider;
|
private InMemoryMangaProvider $provider;
|
||||||
private InMemoryMangaRepository $repository;
|
private InMemoryMangaRepository $repository;
|
||||||
|
private InMemoryImageProcessor $imageProcessor;
|
||||||
private CreateMangaFromMangadexHandler $handler;
|
private CreateMangaFromMangadexHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->provider = new InMemoryMangaProvider();
|
$manga = new Manga(
|
||||||
|
new MangaId('123'),
|
||||||
|
new MangaTitle('One Piece'),
|
||||||
|
new MangaSlug('one-piece'),
|
||||||
|
'Description test',
|
||||||
|
'Eiichiro Oda',
|
||||||
|
1997,
|
||||||
|
['action', 'adventure'],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId('external-123'),
|
||||||
|
'http://example.com/image.jpg',
|
||||||
|
4.5
|
||||||
|
);
|
||||||
|
$this->provider = new InMemoryMangaProvider([$manga]);
|
||||||
$this->repository = new InMemoryMangaRepository();
|
$this->repository = new InMemoryMangaRepository();
|
||||||
|
$this->imageProcessor = new InMemoryImageProcessor();
|
||||||
$this->handler = new CreateMangaFromMangadexHandler(
|
$this->handler = new CreateMangaFromMangadexHandler(
|
||||||
$this->provider,
|
$this->provider,
|
||||||
$this->repository
|
$this->repository,
|
||||||
|
$this->imageProcessor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHandleSuccess(): void
|
public function testHandleSuccess(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
$command = new CreateMangaFromMangadex('external-123');
|
||||||
$externalId = 'manga-123';
|
|
||||||
$manga = new Manga(
|
|
||||||
new MangaId('123'),
|
|
||||||
new MangaTitle('Test Manga'),
|
|
||||||
new MangaSlug('test-manga'),
|
|
||||||
'Description',
|
|
||||||
'Author',
|
|
||||||
2020,
|
|
||||||
['action'],
|
|
||||||
'ongoing',
|
|
||||||
new ExternalId($externalId),
|
|
||||||
'http://example.com/cover.jpg',
|
|
||||||
4.5
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->provider = new InMemoryMangaProvider([$manga]);
|
|
||||||
$this->handler = new CreateMangaFromMangadexHandler(
|
|
||||||
$this->provider,
|
|
||||||
$this->repository
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$command = new CreateMangaFromMangadex($externalId);
|
|
||||||
$this->handler->handle($command);
|
$this->handler->handle($command);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$savedManga = $this->repository->findById('123');
|
$savedManga = $this->repository->findById('123');
|
||||||
$this->assertNotNull($savedManga);
|
$this->assertNotNull($savedManga);
|
||||||
$this->assertEquals($externalId, $savedManga->getExternalId()->getValue());
|
$this->assertEquals('One Piece', $savedManga->getTitle()->getValue());
|
||||||
$this->assertEquals('Test Manga', $savedManga->getTitle()->getValue());
|
$this->assertNotNull($savedManga->getImageUrls());
|
||||||
|
$this->assertStringStartsWith('/images/full/', $savedManga->getImageUrls()->getFull());
|
||||||
|
$this->assertStringStartsWith('/images/thumbnails/', $savedManga->getImageUrls()->getThumbnail());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHandleThrowsExceptionWhenMangaNotFound(): void
|
public function testHandleThrowsExceptionWhenMangaNotFound(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$command = new CreateMangaFromMangadex('non-existent-manga');
|
$command = new CreateMangaFromMangadex('non-existent-id');
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->expectException(MangaNotFoundException::class);
|
$this->expectException(MangaNotFoundException::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user