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:
parent
d9e78b5229
commit
00d63dffeb
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\Command;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
readonly class CheckMonitoredMangas
|
||||
{
|
||||
public function __construct(
|
||||
public ?DateTimeImmutable $since = null
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
13
src/Domain/Manga/Application/Query/MonitoringCriteria.php
Normal file
13
src/Domain/Manga/Application/Query/MonitoringCriteria.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
12
src/Domain/Manga/Domain/Event/ChapterReadyForScraping.php
Normal file
12
src/Domain/Manga/Domain/Event/ChapterReadyForScraping.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ readonly class ChapterId
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ class Manga
|
||||
#[ORM\Column]
|
||||
private ?bool $monitored = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $lastMonitoringCheck = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $AlternativeSlugs = null;
|
||||
|
||||
@@ -318,4 +321,16 @@ class Manga
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastMonitoringCheck(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->lastMonitoringCheck;
|
||||
}
|
||||
|
||||
public function setLastMonitoringCheck(?\DateTimeImmutable $lastMonitoringCheck): self
|
||||
{
|
||||
$this->lastMonitoringCheck = $lastMonitoringCheck;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user