feat: Image saving for manga creation

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-11 00:40:47 +01:00
parent 50080f9779
commit 4017cabff2
9 changed files with 212 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

View File

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

View 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));
}
}
}
}
}

View 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);
}
}

View File

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