feat: ajout de la fonctionnalité de monitoring des mangas, incluant l'activation et la désactivation du suivi, la synchronisation des chapitres, et la mise à jour de l'API pour gérer ces nouvelles actions. Création de nouveaux composants Vue pour le rafraîchissement des chapitres et l'affichage des notifications. Intégration de tests unitaires pour valider le bon fonctionnement de ces fonctionnalités.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-07-22 15:57:25 +02:00
parent d9e78b5229
commit 00d63dffeb
45 changed files with 2021 additions and 264 deletions

View File

@@ -20,6 +20,7 @@ readonly class MangaDetail
public ?string $externalId,
public ?string $imageUrl,
public ?string $thumbnailUrl,
public ?float $rating
public ?float $rating,
public bool $monitored
) {}
}
}

View File

@@ -28,9 +28,9 @@ use Symfony\Component\Validator\Constraints as Assert;
'properties' => [
'mangaId' => [
'type' => 'string',
'format' => 'uuid',
// 'format' => 'uuid',
'description' => 'L\'identifiant unique du manga',
'example' => '123e4567-e89b-12d3-a456-426614174000'
// 'example' => '123e4567-e89b-12d3-a456-426614174000'
]
],
'required' => ['mangaId']
@@ -54,7 +54,7 @@ class FetchMangaChaptersResource
{
public function __construct(
#[Assert\NotBlank(message: 'L\'identifiant du manga est obligatoire')]
#[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
// #[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
public string $mangaId
) {}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\RefreshMangaChaptersProcessor;
#[ApiResource(
shortName: 'MangaRefresh',
operations: [
new Post(
uriTemplate: '/manga/{mangaId}/chapters/refresh',
processor: RefreshMangaChaptersProcessor::class,
status: 202,
description: 'Déclenche la synchronisation et le scraping des nouveaux chapitres d\'un manga',
openapiContext: [
'summary' => 'Rafraîchir les chapitres d\'un manga',
'description' => 'Lance la synchronisation incrémentale avec scraping automatique des nouveaux chapitres',
'parameters' => [
[
'name' => 'mangaId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'L\'identifiant unique du manga'
]
],
'responses' => [
'202' => [
'description' => 'Demande de refresh acceptée et mise en file d\'attente'
],
'404' => [
'description' => 'Manga non trouvé'
]
]
]
)
]
)]
class RefreshMangaChaptersResource
{
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\ToggleMonitoringProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'MangaMonitoring',
operations: [
new Post(
uriTemplate: '/manga/{mangaId}/monitoring/toggle',
processor: ToggleMonitoringProcessor::class,
read: false,
status: 204,
description: 'Active ou désactive le monitoring automatique d\'un manga',
openapiContext: [
'summary' => 'Activer/Désactiver le monitoring d\'un manga',
'description' => 'Active ou désactive le monitoring automatique pour recevoir les nouveaux chapitres',
'parameters' => [
[
'name' => 'mangaId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'L\'identifiant unique du manga'
]
],
'requestBody' => [
'description' => 'État du monitoring à appliquer',
'required' => true,
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'enabled' => [
'type' => 'boolean',
'description' => 'True pour activer le monitoring, false pour le désactiver',
'example' => true
]
],
'required' => ['enabled']
]
]
]
],
'responses' => [
'204' => [
'description' => 'Monitoring modifié avec succès'
],
'404' => [
'description' => 'Manga non trouvé'
],
'422' => [
'description' => 'Données de validation invalides'
]
]
]
)
]
)]
class ToggleMonitoringResource
{
#[Assert\NotNull(message: 'Le champ enabled est obligatoire')]
#[Assert\Type(type: 'boolean', message: 'Cette valeur doit être de type bool.')]
public mixed $enabled = null;
}

View File

@@ -5,6 +5,7 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -21,7 +22,7 @@ readonly class FetchMangaChaptersProcessor implements ProcessorInterface
}
$this->messageBus->dispatch(
new FetchMangaChapters($data->mangaId)
new FetchMangaChapters(new MangaId($data->mangaId))
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class RefreshMangaChaptersProcessor implements ProcessorInterface
{
public function __construct(
private MessageBusInterface $commandBus,
private MangaRepositoryInterface $mangaRepository
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
$mangaId = $uriVariables['mangaId'] ?? null;
if (!$mangaId) {
throw new \InvalidArgumentException('Manga ID is required');
}
// Vérifier que le manga existe
$manga = $this->mangaRepository->findById($mangaId);
if (!$manga) {
throw new MangaNotFoundException($mangaId);
}
$this->commandBus->dispatch(
new RefreshMangaChapters(new MangaId($mangaId))
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Application\CommandHandler\ToggleMangaMonitoringHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ToggleMonitoringResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class ToggleMonitoringProcessor implements ProcessorInterface
{
public function __construct(
private ToggleMangaMonitoringHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!$data instanceof ToggleMonitoringResource) {
throw new \InvalidArgumentException('Invalid resource type');
}
$mangaId = $uriVariables['mangaId'] ?? null;
if (!$mangaId) {
throw new \InvalidArgumentException('Manga ID is required');
}
// La validation Symfony s'assure que enabled est un booléen valide
if ($data->enabled === null) {
throw new \InvalidArgumentException('Enabled field is required');
}
try {
$command = new ToggleMangaMonitoring(
new MangaId($mangaId),
$data->enabled
);
$this->handler->handle($command);
} catch (MangaNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -32,7 +32,8 @@ readonly class GetMangaStateProvider implements ProviderInterface
externalId: $response->externalId,
imageUrl: $response->imageUrl,
thumbnailUrl: $response->thumbnailUrl,
rating: $response->rating
rating: $response->rating,
monitored: $response->monitored
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\CommandHandler\RefreshMangaChaptersHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyRefreshMangaChaptersHandler
{
public function __construct(
private RefreshMangaChaptersHandler $handler
) {}
public function __invoke(RefreshMangaChapters $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domain\Manga\Infrastructure\Persistence;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
@@ -9,6 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
use App\Entity\Manga as EntityManga;
use Doctrine\ORM\EntityManagerInterface;
use App\Domain\Manga\Domain\Model\Chapter;
@@ -50,7 +52,12 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
public function findById(string $id): ?DomainManga
{
$entity = $this->entityManager->find(EntityManga::class, $id);
// Convertir le string ID en integer pour la base de données
if (!is_numeric($id)) {
return null;
}
$entity = $this->entityManager->find(EntityManga::class, (int) $id);
return $entity ? $this->toDomain($entity) : null;
}
@@ -88,18 +95,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
->setStatus($manga->getStatus())
->setImageUrl($fullImageUrl ?? null)
->setThumbnailUrl($thumbnailUrl ?? null)
->setAlternativeSlugs($manga->getAlternativeSlugs());
->setAlternativeSlugs($manga->getAlternativeSlugs())
->setMonitored($manga->isMonitoringEnabled())
->setLastMonitoringCheck($manga->getLastMonitoringCheck());
// Only set externalId if it exists (to avoid setting null on update)
if ($manga->getExternalId()) {
$entity->setExternalId($manga->getExternalId()->getValue());
}
// Only set monitored for new entities
if (!$entity->getId()) {
$entity->setMonitored(false);
}
if ($manga->getRating() !== null) {
$entity->setRating($manga->getRating());
}
@@ -162,7 +166,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
return $entity ? $this->toDomain($entity) : null;
}
public function saveChapter(Chapter $chapter): void
public function saveChapter(Chapter $chapter): ChapterId
{
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
@@ -179,6 +183,8 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
$this->entityManager->persist($entity);
$this->entityManager->flush();
return new ChapterId((string) $entity->getId());
}
public function search(string $query, int $page = 1, int $limit = 20): array
@@ -241,6 +247,25 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
return $chaptersByNumber;
}
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('m')
->from(EntityManga::class, 'm')
->where('m.monitored = :enabled')
->setParameter('enabled', $criteria->enabled);
if ($criteria->lastCheckBefore) {
$queryBuilder->andWhere('(m.lastMonitoringCheck IS NULL OR m.lastMonitoringCheck < :lastCheckBefore)')
->setParameter('lastCheckBefore', $criteria->lastCheckBefore);
}
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
private function toDomain(EntityManga $entity): DomainManga
{
return new DomainManga(
@@ -258,6 +283,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
createdAt: $entity->getCreatedAt(),
monitoringStatus: $entity->isMonitored() ? MonitoringStatus::enabled() : MonitoringStatus::disabled()
);
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Manga\Infrastructure\Scheduler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use DateTimeImmutable;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class MonitoringSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache
) {}
public function getSchedule(): Schedule
{
return (new Schedule())->add(
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
RecurringMessage::every('2 hours', new CheckMonitoredMangas(
new DateTimeImmutable('-2 hours')
))
)->stateful($this->cache);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use Ramsey\Uuid\Uuid;
readonly class MangadxChapterSynchronizationService implements ChapterSynchronizationServiceInterface
{
public function __construct(
private MangadexClientInterface $mangadxClient,
private MangaRepositoryInterface $mangaRepository
) {}
public function synchronizeChapters(Manga $manga): array
{
if ($manga->getExternalId() === null) {
throw new \RuntimeException('Manga has no external ID');
}
$externalId = $manga->getExternalId()->getValue();
$offset = 0;
$limit = 500;
$hasMore = true;
$chaptersByNumber = [];
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
$chapterNumbers = [];
while ($hasMore) {
$feed = $this->mangadxClient->getMangaFeed(
$externalId,
$offset,
$limit
);
foreach ($feed['data'] as $chapterData) {
$chapterNumber = (float) $chapterData['attributes']['chapter'];
$language = $chapterData['attributes']['translatedLanguage'];
$title = $chapterData['attributes']['title'];
// Pour les langues autres que français et anglais, on utilise un titre générique
if (!in_array($language, ['fr', 'en'])) {
$title = "Chapter {$chapterNumber}";
}
// Définir les règles de priorité des langues (fr > en > autres)
$shouldReplaceChapter = false;
if (!isset($chaptersByNumber[(string) $chapterNumber])) {
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
$shouldReplaceChapter = true;
$chapterNumbers[] = $chapterNumber;
} else if ($language === 'fr') {
// Le français est toujours prioritaire
$shouldReplaceChapter = true;
} else if ($language === 'en' && $chapterLanguages[(string) $chapterNumber] !== 'fr') {
// L'anglais est prioritaire sur les autres langues, sauf le français
$shouldReplaceChapter = true;
}
if ($shouldReplaceChapter) {
$chaptersByNumber[(string) $chapterNumber] = new Chapter(
new ChapterId((string) Uuid::uuid4()),
$manga->getId()->getValue(),
$chapterNumber,
$title,
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
true,
null,
new \DateTimeImmutable()
);
$chapterLanguages[(string) $chapterNumber] = $language;
}
}
$offset += $limit;
$hasMore = count($feed['data']) === $limit;
}
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
$this->harmonizeVolumes($chaptersByNumber);
// Récupère les chapitres existants
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
$manga->getId()->getValue(),
$chapterNumbers
);
$newChapterIds = [];
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) {
$newChapterId = $this->mangaRepository->saveChapter($chapter);
$newChapterIds[] = $newChapterId->getValue(); // ✨ Collecte des IDs
}
}
return $newChapterIds;
}
/**
* Harmonise les volumes des chapitres:
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
* - Remplit les "trous" de volumes manquants dans une séquence
*/
private function harmonizeVolumes(array &$chaptersByNumber): void
{
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
uksort($chaptersByNumber, fn($a, $b) => (float)$a <=> (float)$b);
$chapterNumbers = array_keys($chaptersByNumber);
$count = count($chapterNumbers);
// Première passe : harmonisation locale (chapitres adjacents)
for ($i = 1; $i < $count - 1; $i++) {
$prevChapterNum = $chapterNumbers[$i - 1];
$currentChapterNum = $chapterNumbers[$i];
$nextChapterNum = $chapterNumbers[$i + 1];
$prevChapter = $chaptersByNumber[$prevChapterNum];
$currentChapter = $chaptersByNumber[$currentChapterNum];
$nextChapter = $chaptersByNumber[$nextChapterNum];
$prevVolume = $prevChapter->getVolume();
$currentVolume = $currentChapter->getVolume();
$nextVolume = $nextChapter->getVolume();
// Règle 1: Si précédent et suivant sont null, alors actuel aussi
if ($prevVolume === null && $nextVolume === null && $currentVolume !== null) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
null, // volume = null
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
// Règle 2: Si précédent et suivant ont le même volume, alors actuel aussi
else if ($prevVolume !== null && $prevVolume === $nextVolume && $currentVolume !== $prevVolume) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$prevVolume, // prend le volume des adjacents
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
}
// Deuxième passe : comblement des trous de volumes
$this->fillVolumeGaps($chaptersByNumber, $chapterNumbers);
}
/**
* Remplit les "trous" de volumes manquants dans une séquence
*/
private function fillVolumeGaps(array &$chaptersByNumber, array $chapterNumbers): void
{
$count = count($chapterNumbers);
for ($i = 0; $i < $count; $i++) {
$currentChapterNum = $chapterNumbers[$i];
$currentChapter = $chaptersByNumber[$currentChapterNum];
if ($currentChapter->getVolume() !== null) {
continue; // Ce chapitre a déjà un volume
}
// Cherche le volume précédent non-null
$prevVolume = null;
for ($j = $i - 1; $j >= 0; $j--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$j]];
if ($prevChapter->getVolume() !== null) {
$prevVolume = $prevChapter->getVolume();
break;
}
}
// Cherche le volume suivant non-null
$nextVolume = null;
for ($k = $i + 1; $k < $count; $k++) {
$nextChapter = $chaptersByNumber[$chapterNumbers[$k]];
if ($nextChapter->getVolume() !== null) {
$nextVolume = $nextChapter->getVolume();
break;
}
}
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$prevVolume,
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
else if ($nextVolume !== null && $prevVolume === null) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$nextVolume,
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
}
}
}