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

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
use DateTimeImmutable;
readonly class CheckMonitoredMangas
{
public function __construct(
public ?DateTimeImmutable $since = null
) {}
}

View File

@@ -2,9 +2,11 @@
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class FetchMangaChapters
{
public function __construct(
public string $mangaId
public MangaId $mangaId
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class RefreshMangaChapters
{
public function __construct(
public MangaId $mangaId
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class ToggleMangaMonitoring
{
public function __construct(
public MangaId $mangaId,
public bool $enabled
) {}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class CheckMonitoredMangasHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private MessageBusInterface $commandBus
) {}
public function handle(CheckMonitoredMangas $command): void
{
$criteria = new MonitoringCriteria(
enabled: true,
lastCheckBefore: $command->since ?? new DateTimeImmutable('-1 hour')
);
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
foreach ($monitoredMangas as $manga) {
$this->commandBus->dispatch(new RefreshMangaChapters($manga->getId()));
}
}
}

View File

@@ -3,249 +3,25 @@
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use Ramsey\Uuid\Uuid;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
readonly class FetchMangaChaptersHandler
{
public function __construct(
private MangadexClientInterface $mangadexClient,
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
) {}
public function handle(FetchMangaChapters $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId);
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
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->mangadexClient->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,
false,
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
);
// Sauvegarde uniquement les nouveaux chapitres
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) {
$this->mangaRepository->saveChapter($chapter);
}
}
}
/**
* 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];
$shouldUpdateVolume = false;
$newVolume = $currentChapter->getVolume();
// Si les chapitres adjacents ont un volume null, alors le chapitre actuel aussi
if ($prevChapter->getVolume() === null && $nextChapter->getVolume() === null && $currentChapter->getVolume() !== null) {
$shouldUpdateVolume = true;
$newVolume = null;
}
// Si les chapitres adjacents ont le même volume non-null, alors le chapitre actuel aura ce volume
else if ($prevChapter->getVolume() !== null && $prevChapter->getVolume() === $nextChapter->getVolume() && $currentChapter->getVolume() !== $prevChapter->getVolume()) {
$shouldUpdateVolume = true;
$newVolume = $prevChapter->getVolume();
}
if ($shouldUpdateVolume) {
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $newVolume);
}
}
// Deuxième passe : remplissage des trous de volumes
$this->fillVolumeGaps($chaptersByNumber);
}
/**
* Remplit les "trous" de volumes dans une séquence de chapitres.
* Par exemple, si on a : Ch.317(Vol.34), Ch.318(Vol.34), Ch.319(null), Ch.320(null), Ch.321(Vol.34)
* Alors Ch.319 et Ch.320 seront assignés au Vol.34
*/
private function fillVolumeGaps(array &$chaptersByNumber): void
{
$chapterNumbers = array_keys($chaptersByNumber);
$count = count($chapterNumbers);
for ($i = 0; $i < $count; $i++) {
$currentChapterNum = $chapterNumbers[$i];
$currentChapter = $chaptersByNumber[$currentChapterNum];
// Si le chapitre actuel n'a pas de volume, on cherche à le combler
if ($currentChapter->getVolume() === null) {
$volumeToAssign = $this->findVolumeForGap($chaptersByNumber, $chapterNumbers, $i);
if ($volumeToAssign !== null) {
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $volumeToAssign);
}
}
}
}
/**
* Trouve le volume à assigner pour un chapitre sans volume en analysant son contexte
*/
private function findVolumeForGap(array $chaptersByNumber, array $chapterNumbers, int $currentIndex): ?int
{
$count = count($chapterNumbers);
// Cherche le volume précédent non-null
$prevVolume = null;
for ($i = $currentIndex - 1; $i >= 0; $i--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($prevChapter->getVolume() !== null) {
$prevVolume = $prevChapter->getVolume();
break;
}
}
// Cherche le volume suivant non-null
$nextVolume = null;
for ($i = $currentIndex + 1; $i < $count; $i++) {
$nextChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($nextChapter->getVolume() !== null) {
$nextVolume = $nextChapter->getVolume();
break;
}
}
// Si les volumes précédent et suivant sont identiques et non-null, on utilise ce volume
if ($prevVolume !== null && $prevVolume === $nextVolume) {
return $prevVolume;
}
// Si on a seulement un volume précédent, on vérifie s'il est raisonnable de l'utiliser
// (pas plus de 10 chapitres d'écart pour éviter les erreurs)
if ($prevVolume !== null && $nextVolume === null) {
$currentChapterNumber = (float) $chapterNumbers[$currentIndex];
// Trouve le numéro du chapitre qui a ce volume précédent
for ($i = $currentIndex - 1; $i >= 0; $i--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($prevChapter->getVolume() === $prevVolume) {
$prevChapterNumber = (float) $chapterNumbers[$i];
// Si l'écart est raisonnable (moins de 10 chapitres), on assigne le volume
if ($currentChapterNumber - $prevChapterNumber <= 10) {
return $prevVolume;
}
break;
}
}
}
return null;
}
/**
* Crée un nouveau chapitre avec un volume différent (les chapitres sont immuables)
*/
private function createChapterWithNewVolume(Chapter $chapter, ?int $newVolume): Chapter
{
return new Chapter(
id: new ChapterId($chapter->getId()),
mangaId: $chapter->getMangaId(),
number: $chapter->getNumber(),
title: $chapter->getTitle(),
volume: $newVolume,
isVisible: $chapter->isVisible(),
cbzPath: $chapter->getCbzPath(),
createdAt: $chapter->getCreatedAt()
);
// Synchronisation initiale (pas d'événements)
$this->chapterSynchronizationService->synchronizeChapters($manga);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class RefreshMangaChaptersHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
private MessageBusInterface $eventBus
) {}
public function handle(RefreshMangaChapters $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
// Synchronisation + récupération des nouveaux IDs
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Mise à jour de la date de monitoring
$manga->updateLastMonitoringCheck(new DateTimeImmutable());
$this->mangaRepository->save($manga);
// Événement de scraping pour chaque nouveau chapitre
foreach ($newChapterIds as $chapterId) {
$this->eventBus->dispatch(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
readonly class ToggleMangaMonitoringHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
) {}
public function handle(ToggleMangaMonitoring $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if (!$manga) {
throw new MangaNotFoundException($command->mangaId->getValue());
}
if ($command->enabled) {
$manga->enableMonitoring();
} else {
$manga->disableMonitoring();
}
$this->mangaRepository->save($manga);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Manga\Application\Query;
use DateTimeImmutable;
readonly class MonitoringCriteria
{
public function __construct(
public bool $enabled,
public ?DateTimeImmutable $lastCheckBefore = null
) {}
}

View File

@@ -34,7 +34,8 @@ readonly class GetMangaByIdHandler
externalId: $manga->getExternalId()?->getValue(),
imageUrl: $manga->getImageUrl(),
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
rating: $manga->getRating()
rating: $manga->getRating(),
monitored: $manga->isMonitoringEnabled()
);
}
}

View File

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

View File

@@ -2,9 +2,11 @@
namespace App\Domain\Manga\Domain\Contract\Repository;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
interface MangaRepositoryInterface
@@ -17,7 +19,7 @@ interface MangaRepositoryInterface
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
public function countChapters(string $mangaId): int;
public function findByExternalId(ExternalId $externalId): ?Manga;
public function saveChapter(Chapter $chapter): void;
public function saveChapter(Chapter $chapter): ChapterId;
public function findBySlug(MangaSlug $slug): ?Manga;
public function search(string $query, int $page = 1, int $limit = 20): array;
public function countSearch(string $query): int;
@@ -26,4 +28,9 @@ interface MangaRepositoryInterface
* @return array<float, Chapter>
*/
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array;
/**
* @return Manga[]
*/
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domain\Manga\Domain\Contract\Service;
use App\Domain\Manga\Domain\Model\Manga;
interface ChapterSynchronizationServiceInterface
{
/**
* Synchronise les chapitres d'un manga depuis la source externe
* @return string[] IDs des nouveaux chapitres ajoutés
*/
public function synchronizeChapters(Manga $manga): array;
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Domain\Event;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
readonly class ChapterReadyForScraping
{
public function __construct(
public ChapterId $chapterId
) {}
}

View File

@@ -7,6 +7,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 DateTimeImmutable;
final class Manga
@@ -26,7 +27,11 @@ final class Manga
private ?ImageUrls $imageUrls = null,
private array $alternativeSlugs = [],
private ?DateTimeImmutable $createdAt = null,
) {}
private ?MonitoringStatus $monitoringStatus = null,
private ?DateTimeImmutable $lastMonitoringCheck = null,
) {
$this->monitoringStatus = $this->monitoringStatus ?? MonitoringStatus::disabled();
}
public function getId(): MangaId
{
@@ -147,4 +152,36 @@ final class Manga
{
return $this->createdAt;
}
public function getMonitoringStatus(): MonitoringStatus
{
return $this->monitoringStatus;
}
public function isMonitoringEnabled(): bool
{
return $this->monitoringStatus->isEnabled();
}
public function enableMonitoring(): void
{
$this->monitoringStatus = MonitoringStatus::enabled();
$this->lastMonitoringCheck = new DateTimeImmutable();
}
public function disableMonitoring(): void
{
$this->monitoringStatus = MonitoringStatus::disabled();
$this->lastMonitoringCheck = null;
}
public function getLastMonitoringCheck(): ?DateTimeImmutable
{
return $this->lastMonitoringCheck;
}
public function updateLastMonitoringCheck(DateTimeImmutable $lastMonitoringCheck): void
{
$this->lastMonitoringCheck = $lastMonitoringCheck;
}
}

View File

@@ -17,4 +17,4 @@ readonly class ChapterId
{
return $this->value === $other->value;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Manga\Domain\Model\ValueObject;
readonly class MonitoringStatus
{
public function __construct(
private bool $enabled
) {}
public static function enabled(): self
{
return new self(true);
}
public static function disabled(): self
{
return new self(false);
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function equals(self $other): bool
{
return $this->enabled === $other->enabled;
}
}

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

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener
{
public function __construct(
private readonly ScrapeChapterHandler $scrapeChapterHandler
) {}
#[AsMessageHandler]
public function onChapterReadyForScraping(ChapterReadyForScraping $event): void
{
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
}
}