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