feat: analyse import + all tests fixed
This commit is contained in:
parent
fbe9619224
commit
3170a7c60e
@@ -5,6 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangadexApiException;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
|
||||
readonly class FetchMangaChaptersHandler
|
||||
{
|
||||
@@ -18,7 +20,11 @@ readonly class FetchMangaChaptersHandler
|
||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||
|
||||
if ($manga === null) {
|
||||
throw new \RuntimeException('Manga not found');
|
||||
throw new MangaNotFoundException();
|
||||
}
|
||||
|
||||
if($manga->getExternalId() === null){
|
||||
throw new MangadexApiException("Manga has no external_id");
|
||||
}
|
||||
|
||||
// Synchronisation initiale (pas d'événements)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\EventListener;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
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\MangaSlug;
|
||||
use App\Domain\Shared\Domain\Event\ChapterImported;
|
||||
|
||||
readonly class ChapterImportedEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(ChapterImported $event): void
|
||||
{
|
||||
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
|
||||
if (!$manga) {
|
||||
return; // Manga introuvable, on ignore
|
||||
}
|
||||
|
||||
$chapters = $this->chapterRepository->findVisibleByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
||||
foreach ($chapters as $chapter) {
|
||||
if ($chapter->getNumber() === (float) $event->chapterNumber) {
|
||||
$updated = new Chapter(
|
||||
new ChapterId($chapter->getId()),
|
||||
$chapter->getMangaId(),
|
||||
$chapter->getNumber(),
|
||||
$chapter->getTitle(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->isVisible(),
|
||||
$event->cbzPath,
|
||||
$chapter->getCreatedAt(),
|
||||
);
|
||||
$this->chapterRepository->save($updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\EventListener;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
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\MangaSlug;
|
||||
use App\Domain\Shared\Domain\Event\VolumeImported;
|
||||
|
||||
readonly class VolumeImportedEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(VolumeImported $event): void
|
||||
{
|
||||
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
|
||||
if (!$manga) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chapters = $this->chapterRepository->findByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
||||
if ($chapters === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$updated = new Chapter(
|
||||
new ChapterId($chapter->getId()),
|
||||
$chapter->getMangaId(),
|
||||
$chapter->getNumber(),
|
||||
$chapter->getTitle(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->isVisible(),
|
||||
$event->cbzPath,
|
||||
$chapter->getCreatedAt(),
|
||||
);
|
||||
$this->chapterRepository->save($updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Query;
|
||||
|
||||
readonly class FindMangaMatchByFilename
|
||||
{
|
||||
public function __construct(
|
||||
public string $filename
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
|
||||
use App\Domain\Manga\Application\Response\MangaMatchItem;
|
||||
use App\Domain\Manga\Application\Response\MangaMatchResponse;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
readonly class FindMangaMatchByFilenameHandler
|
||||
{
|
||||
public function __construct(
|
||||
private FilenameAnalyzerInterface $filenameAnalyzer,
|
||||
private MangaRepositoryInterface $mangaRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(FindMangaMatchByFilename $query): MangaMatchResponse
|
||||
{
|
||||
// Analyser le nom de fichier pour extraire les informations
|
||||
$analyzedFilename = $this->filenameAnalyzer->analyze($query->filename);
|
||||
|
||||
$searchedTitle = $analyzedFilename->getTitle()->getValue();
|
||||
$chapterNumber = $analyzedFilename->hasChapterNumber()
|
||||
? $analyzedFilename->getChapterNumber()->getValue()
|
||||
: null;
|
||||
$volumeNumber = $analyzedFilename->hasVolumeNumber()
|
||||
? $analyzedFilename->getVolumeNumber()->getValue()
|
||||
: null;
|
||||
|
||||
// Rechercher les mangas correspondants
|
||||
$foundMangas = $this->mangaRepository->search($searchedTitle, 1, 10);
|
||||
$matches = [];
|
||||
|
||||
foreach ($foundMangas as $manga) {
|
||||
$mangaId = $manga->getId()->getValue();
|
||||
|
||||
// Calculer un score de correspondance
|
||||
$matchScore = $this->calculateMatchScore(
|
||||
$manga,
|
||||
$searchedTitle
|
||||
);
|
||||
|
||||
$matches[] = new MangaMatchItem(
|
||||
id: $mangaId,
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
||||
matchScore: $matchScore,
|
||||
chapterNumber: $chapterNumber,
|
||||
volumeNumber: $volumeNumber
|
||||
);
|
||||
}
|
||||
|
||||
// Trier les résultats par score de correspondance (du plus élevé au plus faible)
|
||||
usort($matches, fn($a, $b) => $b->matchScore <=> $a->matchScore);
|
||||
|
||||
return new MangaMatchResponse(
|
||||
matches: $matches,
|
||||
chapterNumber: $chapterNumber,
|
||||
volumeNumber: $volumeNumber,
|
||||
possibleTitles: [$searchedTitle]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule un score de correspondance entre le manga et le titre recherché
|
||||
* Score plus élevé = meilleure correspondance
|
||||
*/
|
||||
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
|
||||
{
|
||||
$score = 0;
|
||||
$mangaTitle = $manga->getTitle()->getValue();
|
||||
$mangaSlug = $manga->getSlug()->getValue();
|
||||
|
||||
// Correspondance exacte avec le titre
|
||||
if (strtolower($mangaTitle) === strtolower($searchedTitle)) {
|
||||
$score += 100;
|
||||
}
|
||||
|
||||
// Correspondance exacte avec le slug
|
||||
if (strtolower($mangaSlug) === strtolower($searchedTitle)) {
|
||||
$score += 90;
|
||||
}
|
||||
|
||||
// Correspondance avec les slugs alternatifs
|
||||
foreach ($manga->getAlternativeSlugs() as $altSlug) {
|
||||
if (strtolower($altSlug) === strtolower($searchedTitle)) {
|
||||
$score += 80;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Le titre du manga contient le terme recherché
|
||||
if (stripos($mangaTitle, $searchedTitle) !== false) {
|
||||
$score += 50;
|
||||
}
|
||||
|
||||
// Le terme recherché contient le titre du manga
|
||||
if (stripos($searchedTitle, $mangaTitle) !== false) {
|
||||
$score += 40;
|
||||
}
|
||||
|
||||
// Similarité de Levenshtein (pour les fautes de frappe)
|
||||
$levenshteinDistance = levenshtein(
|
||||
strtolower($mangaTitle),
|
||||
strtolower($searchedTitle)
|
||||
);
|
||||
|
||||
if ($levenshteinDistance <= 3) {
|
||||
$score += (3 - $levenshteinDistance) * 10;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ readonly class GetMangaBySlugHandler
|
||||
id: $manga->getId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
@@ -34,7 +35,8 @@ readonly class GetMangaBySlugHandler
|
||||
externalId: $manga->getExternalId()?->getValue(),
|
||||
imageUrl: $manga->getImageUrl(),
|
||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
rating: $manga->getRating(),
|
||||
monitored: $manga->isMonitored()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ readonly class SearchLocalMangaHandler
|
||||
id: $manga->getId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
@@ -33,7 +34,8 @@ readonly class SearchLocalMangaHandler
|
||||
externalId: $manga->getExternalId()?->getValue() ?? '',
|
||||
imageUrl: $manga->getImageUrls()->getFull(),
|
||||
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
rating: $manga->getRating(),
|
||||
monitored: $manga->isMonitored()
|
||||
),
|
||||
$mangas
|
||||
),
|
||||
|
||||
21
src/Domain/Manga/Application/Response/MangaMatchItem.php
Normal file
21
src/Domain/Manga/Application/Response/MangaMatchItem.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Response;
|
||||
|
||||
readonly class MangaMatchItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $slug,
|
||||
public array $alternativeSlugs,
|
||||
public ?string $thumbnailUrl,
|
||||
public int $matchScore,
|
||||
public ?float $chapterNumber = null,
|
||||
public ?float $volumeNumber = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
30
src/Domain/Manga/Application/Response/MangaMatchResponse.php
Normal file
30
src/Domain/Manga/Application/Response/MangaMatchResponse.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Response;
|
||||
|
||||
readonly class MangaMatchResponse
|
||||
{
|
||||
/**
|
||||
* @param MangaMatchItem[] $matches
|
||||
*/
|
||||
public function __construct(
|
||||
public array $matches,
|
||||
public ?float $chapterNumber,
|
||||
public ?float $volumeNumber,
|
||||
public array $possibleTitles
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasMatches(): bool
|
||||
{
|
||||
return count($this->matches) > 0;
|
||||
}
|
||||
|
||||
public function getBestMatch(): ?MangaMatchItem
|
||||
{
|
||||
return $this->matches[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user