feat: analyse import + all tests fixed

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-10-15 16:14:15 +02:00
parent fbe9619224
commit 3170a7c60e
74 changed files with 4318 additions and 183 deletions

View File

@@ -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)

View File

@@ -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;
}
}
}
}

View File

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

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Application\Query;
readonly class FindMangaMatchByFilename
{
public function __construct(
public string $filename
) {
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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
),

View 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
) {
}
}

View 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;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Contract\Service;
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
interface FilenameAnalyzerInterface
{
public function analyze(string $filename): AnalyzedFilename;
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
readonly class AnalyzedFilename
{
public function __construct(
private MangaTitle $title,
private ?ChapterNumber $chapterNumber = null,
private ?VolumeNumber $volumeNumber = null
) {
}
public function getTitle(): MangaTitle
{
return $this->title;
}
public function getChapterNumber(): ?ChapterNumber
{
return $this->chapterNumber;
}
public function getVolumeNumber(): ?VolumeNumber
{
return $this->volumeNumber;
}
public function hasChapterNumber(): bool
{
return $this->chapterNumber !== null;
}
public function hasVolumeNumber(): bool
{
return $this->volumeNumber !== null;
}
}

View File

@@ -163,6 +163,11 @@ final class Manga
return $this->monitoringStatus->isEnabled();
}
public function isMonitored(): bool
{
return $this->monitoringStatus->isEnabled();
}
public function enableMonitoring(): void
{
$this->monitoringStatus = MonitoringStatus::enabled();

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model\ValueObject;
use InvalidArgumentException;
readonly class ChapterNumber
{
public function __construct(
private float $value
) {
if ($value < 0) {
throw new InvalidArgumentException('Chapter number cannot be negative');
}
}
public function getValue(): float
{
return $this->value;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Domain\Model\ValueObject;
use InvalidArgumentException;
readonly class VolumeNumber
{
public function __construct(
private float $value
) {
if ($value < 0) {
throw new InvalidArgumentException('Volume number cannot be negative');
}
}
public function getValue(): float
{
return $this->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class FilenameMatchCollection
{
/**
* @param FilenameMatchItem[] $matches
* @param string[] $possibleTitles
*/
public function __construct(
public array $matches,
public ?float $chapterNumber,
public ?int $volumeNumber,
public array $possibleTitles
) {
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class FilenameMatchItem
{
public function __construct(
public string $id,
public string $title,
public string $slug,
public array $alternativeSlugs,
public ?string $thumbnailUrl,
public int $matchScore,
public ?float $chapterNumber,
public ?float $volumeNumber
) {
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\FindMangaMatchByFilenameStateProvider;
#[ApiResource(
shortName: 'MangaMatch',
operations: [
new Get(
uriTemplate: '/manga-matches',
provider: FindMangaMatchByFilenameStateProvider::class,
openapiContext: [
'summary' => 'Trouve des correspondances de manga à partir d\'un nom de fichier',
'description' => 'Analyse un nom de fichier (cbz/cbr) et trouve les mangas correspondants dans la base de données. Extrait automatiquement le titre, le numéro de chapitre et le numéro de volume du nom de fichier.',
'parameters' => [
[
'name' => 'filename',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'string',
'example' => 'one-piece_vol108_ch1094.cbz'
],
'description' => 'Nom du fichier à analyser (avec ou sans extension .cbz/.cbr)'
]
],
'responses' => [
'200' => [
'description' => 'Correspondances trouvées',
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'matches' => [
'type' => 'array',
'description' => 'Liste des mangas correspondants triés par score de pertinence',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'description' => 'Identifiant du manga'],
'title' => ['type' => 'string', 'description' => 'Titre du manga'],
'slug' => ['type' => 'string', 'description' => 'Slug du manga'],
'alternativeSlugs' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Slugs alternatifs'
],
'thumbnailUrl' => ['type' => 'string', 'nullable' => true, 'description' => 'URL de la miniature'],
'matchScore' => ['type' => 'integer', 'description' => 'Score de correspondance (plus élevé = meilleure correspondance)'],
'chapterNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de chapitre extrait'],
'volumeNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de volume extrait']
]
]
],
'chapterNumber' => [
'type' => 'number',
'nullable' => true,
'description' => 'Numéro de chapitre extrait du nom de fichier'
],
'volumeNumber' => [
'type' => 'number',
'nullable' => true,
'description' => 'Numéro de volume extrait du nom de fichier'
],
'possibleTitles' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Variantes de titres générées à partir du nom de fichier'
]
]
],
'example' => [
'matches' => [
[
'id' => '123',
'title' => 'One Piece',
'slug' => 'one-piece',
'alternativeSlugs' => [],
'thumbnailUrl' => 'https://example.com/thumb.jpg',
'matchScore' => 100,
'chapterNumber' => 1094.0,
'volumeNumber' => 108
]
],
]
]
]
],
'400' => [
'description' => 'Nom de fichier manquant ou invalide'
]
]
]
)
]
)]
class FindMangaMatchByFilenameResource
{
public function __construct(
public readonly array $matches = [],
) {
}
}

View File

@@ -3,15 +3,15 @@
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider;
#[ApiResource(
shortName: 'Manga',
shortName: 'MangaSearch',
operations: [
new Get(
uriTemplate: '/mangas/search',
new GetCollection(
uriTemplate: '/manga-search',
provider: SearchLocalMangaStateProvider::class,
output: MangaSearchCollection::class,
status: 200,
@@ -82,4 +82,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaS
)]
class SearchLocalMangaResource
{
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
use App\Domain\Manga\Application\QueryHandler\FindMangaMatchByFilenameHandler;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchItem;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FindMangaMatchByFilenameResource;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
readonly class FindMangaMatchByFilenameStateProvider implements ProviderInterface
{
public function __construct(
private FindMangaMatchByFilenameHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): FindMangaMatchByFilenameResource
{
$filename = $context['filters']['filename'] ?? '';
if (empty($filename)) {
throw new BadRequestHttpException('Le nom de fichier est requis');
}
$query = new FindMangaMatchByFilename($filename);
$response = $this->handler->handle($query);
// Pour Get, on retourne directement la resource
return new FindMangaMatchByFilenameResource(
matches: array_map(
fn($match) => new FilenameMatchItem(
id: $match->id,
title: $match->title,
slug: $match->slug,
alternativeSlugs: $match->alternativeSlugs,
thumbnailUrl: $match->thumbnailUrl,
matchScore: $match->matchScore,
chapterNumber: $match->chapterNumber,
volumeNumber: $match->volumeNumber
),
$response->matches
),
);
}
}

View File

@@ -23,6 +23,7 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
id: $response->id,
title: $response->title,
slug: $response->slug,
alternativeSlugs: $response->alternativeSlugs,
description: $response->description,
author: $response->author,
publicationYear: $response->publicationYear,
@@ -31,7 +32,8 @@ readonly class GetMangaBySlugStateProvider 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,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\MessageHandler;
use App\Domain\Manga\Application\EventListener\ChapterImportedEventListener;
use App\Domain\Shared\Domain\Event\ChapterImported;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ChapterImportedMessageHandler
{
public function __construct(private ChapterImportedEventListener $listener)
{
}
public function __invoke(ChapterImported $event): void
{
$this->listener->__invoke($event);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\MessageHandler;
use App\Domain\Manga\Application\EventListener\VolumeImportedEventListener;
use App\Domain\Shared\Domain\Event\VolumeImported;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class VolumeImportedMessageHandler
{
public function __construct(private VolumeImportedEventListener $listener)
{
}
public function __invoke(VolumeImported $event): void
{
$this->listener->__invoke($event);
}
}

View File

@@ -191,36 +191,59 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
{
$offset = ($page - 1) * $limit;
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('m')
->from(EntityManga::class, 'm')
->where('m.title LIKE :query')
->orWhere('m.slug LIKE :query')
// ->orWhere('m.author LIKE :query')
// ->orWhere('m.description LIKE :query')
->setParameter('query', '%' . $query . '%')
->orderBy('m.title', 'ASC')
->setFirstResult($offset)
->setMaxResults($limit);
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
$sql = "SELECT m.* FROM manga m
WHERE m.title LIKE :query
OR m.slug LIKE :query
OR CAST(m.alternative_slugs AS TEXT) LIKE :query
ORDER BY m.title ASC
LIMIT :limit OFFSET :offset";
$rsm = new \Doctrine\ORM\Query\ResultSetMapping();
$rsm->addEntityResult(EntityManga::class, 'm');
$rsm->addFieldResult('m', 'id', 'id');
$rsm->addFieldResult('m', 'title', 'title');
$rsm->addFieldResult('m', 'slug', 'slug');
$rsm->addFieldResult('m', 'image_url', 'imageUrl');
$rsm->addFieldResult('m', 'publication_year', 'publicationYear');
$rsm->addFieldResult('m', 'description', 'description');
$rsm->addFieldResult('m', 'genres', 'genres');
$rsm->addFieldResult('m', 'created_at', 'createdAt');
$rsm->addFieldResult('m', 'rating', 'rating');
$rsm->addFieldResult('m', 'author', 'author');
$rsm->addFieldResult('m', 'external_id', 'externalId');
$rsm->addFieldResult('m', 'status', 'status');
$rsm->addFieldResult('m', 'thumbnail_url', 'thumbnailUrl');
$rsm->addFieldResult('m', 'monitored', 'monitored');
$rsm->addFieldResult('m', 'last_monitoring_check', 'lastMonitoringCheck');
$rsm->addFieldResult('m', 'alternative_slugs', 'AlternativeSlugs');
$nativeQuery = $this->entityManager->createNativeQuery($sql, $rsm);
$nativeQuery->setParameter('query', '%' . $query . '%');
$nativeQuery->setParameter('limit', $limit);
$nativeQuery->setParameter('offset', $offset);
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult()
$nativeQuery->getResult()
);
}
public function countSearch(string $query): int
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(m.id)')
->from(EntityManga::class, 'm')
->where('m.title LIKE :query')
->orWhere('m.slug LIKE :query')
->orWhere('m.author LIKE :query')
->orWhere('m.description LIKE :query')
->setParameter('query', '%' . $query . '%')
->getQuery()
->getSingleScalarResult();
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
$sql = "SELECT COUNT(m.id) FROM manga m
WHERE m.title LIKE :query
OR m.slug LIKE :query
OR m.author LIKE :query
OR m.description LIKE :query
OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
$conn = $this->entityManager->getConnection();
$stmt = $conn->prepare($sql);
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
return (int) $result->fetchOne();
}
/**

View File

@@ -30,7 +30,7 @@ readonly class MangadexProvider implements MangaProviderInterface
}
$mangas = $this->createMangasFromResults($results['data']);
// $this->enrichWithRatings($mangas);
$this->enrichWithRatings($mangas);
usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
readonly class FilenameAnalyzer implements FilenameAnalyzerInterface
{
public function analyze(string $filename): AnalyzedFilename
{
// Enlever l'extension
$nameWithoutExtension = preg_replace('/\.(cbz|cbr)$/i', '', $filename);
// Extraire les informations en utilisant la logique du CbzService
$titleStr = $this->extractTitle($nameWithoutExtension);
$volumeNumber = $this->extractVolume($nameWithoutExtension);
$chapterNumber = $this->extractChapter($nameWithoutExtension);
$cleanedTitle = $this->cleanTitle($titleStr);
return new AnalyzedFilename(
title: new MangaTitle($cleanedTitle),
chapterNumber: $chapterNumber !== null ? new ChapterNumber($chapterNumber) : null,
volumeNumber: $volumeNumber !== null ? new VolumeNumber((float) $volumeNumber) : null
);
}
private function extractTitle(string $fileName): string
{
// Pattern principal : titre suivi de volume/chapitre (inspiré du CbzService)
// Supporte: vol, volume, tome, t, ch, chap, chapter, chapitre
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Cc]h(?:ap(?:itre|ter)?)?|[Tt])\s*\d+)/';
if (preg_match($titlePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore : titre_vol123 ou titre_ch456 ou titre_chapter_1094
$underscorePattern = '/^(?P<title>.*?)_(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($underscorePattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern avec tiret : titre-vol123 ou titre-ch456 ou titre-tome-50
$dashPattern = '/^(?P<title>.*?)-(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
if (preg_match($dashPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Pattern underscore simple : titre_123
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return trim($matches['title']);
}
// Si aucun pattern ne matche, retourner le nom sans extension
return $fileName;
}
private function extractVolume(string $fileName): ?int
{
// Pattern pour volume : vol123, volume123, tome123, t123, v123
$volumePattern = '/(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Tt]|[Vv])[\s\-_]*(?P<volume>\d+)/i';
if (preg_match($volumePattern, $fileName, $matches)) {
return (int) $matches['volume'];
}
return null;
}
private function extractChapter(string $fileName): ?float
{
// Pattern pour chapitre : ch123, chap123, chapter123, chapitre123
$chapterPattern = '/[Cc]h(?:ap(?:itre|ter)?)?[\s\-_]*(?P<chapter>\d+(?:\.\d+)?)/i';
if (preg_match($chapterPattern, $fileName, $matches)) {
return (float) $matches['chapter'];
}
// Pattern underscore à la fin : _123.cbz
$newFormatPattern = '/_ch(?P<chapter>\d+(?:\.\d+)?)(?:\.\w+)?$/i';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return (float) $matches['chapter'];
}
return null;
}
private function cleanTitle(string $title): string
{
// Enlever les patterns communs (avec séparateurs possibles)
$cleanTitle = preg_replace('/[\s\-_]?(?:scan|raw|fr|en|jp|hq|lq)[\s\-_]?/i', ' ', $title);
// Enlever les caractères spéciaux en début/fin
$cleanTitle = trim($cleanTitle, ' -_.');
// Normaliser les espaces multiples
$cleanTitle = preg_replace('/\s+/', ' ', $cleanTitle);
return trim($cleanTitle);
}
}