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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
45
src/Domain/Manga/Domain/Model/AnalyzedFilename.php
Normal file
45
src/Domain/Manga/Domain/Model/AnalyzedFilename.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
24
src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php
Normal file
24
src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
24
src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php
Normal file
24
src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
106
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal file
106
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,84 +5,24 @@ namespace App\Domain\Scraping\Infrastructure\Service;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
readonly class CbzGenerator implements CbzGeneratorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $projectDir
|
||||
private MangaPathManagerInterface $mangaPathManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function generate(CbzGenerationRequest $request): CbzPath
|
||||
{
|
||||
$cbzPath = $this->generateCbzPath($request);
|
||||
$this->createCbzArchive($request->getFiles(), $cbzPath);
|
||||
$cbzPath = $this->mangaPathManager->buildChapterCbzPath(
|
||||
$request->getMangaTitle(),
|
||||
$request->getPublicationYear(),
|
||||
$request->getVolumeNumber(),
|
||||
$request->getChapterNumber(),
|
||||
);
|
||||
$this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath);
|
||||
return new CbzPath($cbzPath);
|
||||
}
|
||||
|
||||
private function generateCbzPath(CbzGenerationRequest $request): string
|
||||
{
|
||||
$mangaDir = $this->createMangaDirectory(
|
||||
$this->slugify($request->getMangaTitle()),
|
||||
$request->getPublicationYear()
|
||||
);
|
||||
|
||||
$volumeDir = $this->createVolumeDirectory($mangaDir, $request->getVolumeNumber());
|
||||
|
||||
return sprintf(
|
||||
'%s/%s_vol%d_ch%s.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($request->getMangaTitle()),
|
||||
$request->getVolumeNumber(),
|
||||
$request->getChapterNumber()
|
||||
);
|
||||
}
|
||||
|
||||
private function createCbzArchive(array $files, string $cbzPath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Failed to create CBZ archive');
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!file_exists($file)) {
|
||||
throw new \RuntimeException("File not found: $file");
|
||||
}
|
||||
$zip->addFile($file, basename($file));
|
||||
}
|
||||
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException('Failed to close CBZ archive');
|
||||
}
|
||||
}
|
||||
|
||||
private function createMangaDirectory(string $mangaSlug, string $publicationYear): string
|
||||
{
|
||||
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, ucfirst($mangaSlug) . ' (' . $publicationYear . ')');
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException("Failed to create directory: $dir");
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function createVolumeDirectory(string $mangaDir, int $volumeNumber): string
|
||||
{
|
||||
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException("Failed to create directory: $dir");
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function slugify(string $text): string
|
||||
{
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
$text = strtolower($text);
|
||||
return $text ?: 'n-a';
|
||||
}
|
||||
}
|
||||
|
||||
40
src/Domain/Shared/Domain/Contract/FileUploadInterface.php
Normal file
40
src/Domain/Shared/Domain/Contract/FileUploadInterface.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
use App\Domain\Shared\Domain\Model\FileUpload;
|
||||
|
||||
interface FileUploadInterface
|
||||
{
|
||||
/**
|
||||
* Déplace un fichier uploadé vers un répertoire temporaire
|
||||
*/
|
||||
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string;
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier existe
|
||||
*/
|
||||
public function fileExists(string $filePath): bool;
|
||||
|
||||
/**
|
||||
* Supprime un fichier
|
||||
*/
|
||||
public function deleteFile(string $filePath): void;
|
||||
|
||||
/**
|
||||
* Déplace un fichier d'un emplacement à un autre
|
||||
*/
|
||||
public function moveFile(string $sourcePath, string $targetPath): void;
|
||||
|
||||
/**
|
||||
* Crée un répertoire s'il n'existe pas
|
||||
*/
|
||||
public function createDirectory(string $path): void;
|
||||
|
||||
/**
|
||||
* Valide le format d'un fichier
|
||||
*/
|
||||
public function validateFileFormat(string $filePath, array $allowedExtensions): bool;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Service centralisé de gestion des chemins et de l'enregistrement des fichiers
|
||||
* liés aux mangas (manga/volume/chapter) et des archives CBZ.
|
||||
*/
|
||||
interface MangaPathManagerInterface
|
||||
{
|
||||
/**
|
||||
* Retourne (et crée si nécessaire) le dossier du manga.
|
||||
*/
|
||||
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string;
|
||||
|
||||
/**
|
||||
* Retourne (et crée si nécessaire) le dossier du volume.
|
||||
*/
|
||||
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
|
||||
|
||||
/**
|
||||
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de chapitre.
|
||||
*/
|
||||
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string;
|
||||
|
||||
/**
|
||||
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de volume.
|
||||
*/
|
||||
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
|
||||
|
||||
/**
|
||||
* Crée une archive CBZ à partir d'une liste de fichiers et l'écrit au chemin fourni.
|
||||
*
|
||||
* @param array<int, string> $files Chemins absolus des fichiers à packager
|
||||
*/
|
||||
public function createCbzArchive(array $files, string $cbzPath): void;
|
||||
|
||||
/**
|
||||
* Déplace un fichier existant vers une destination. Crée les dossiers si nécessaire.
|
||||
*/
|
||||
public function moveFileTo(string $sourcePath, string $destinationPath): void;
|
||||
|
||||
/**
|
||||
* Indique si un fichier existe et est lisible.
|
||||
*/
|
||||
public function fileExists(string $path): bool;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
use App\Domain\Shared\Domain\Model\FileMetadata;
|
||||
|
||||
interface MetadataExtractorInterface
|
||||
{
|
||||
/**
|
||||
* Extrait les métadonnées d'un fichier
|
||||
*/
|
||||
public function extractMetadata(string $filePath, string $originalFileName): FileMetadata;
|
||||
|
||||
/**
|
||||
* Vérifie si le fichier peut être traité par cet extracteur
|
||||
*/
|
||||
public function canHandle(string $filePath): bool;
|
||||
}
|
||||
23
src/Domain/Shared/Domain/Contract/NotificationInterface.php
Normal file
23
src/Domain/Shared/Domain/Contract/NotificationInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface NotificationInterface
|
||||
{
|
||||
/**
|
||||
* Envoie une notification de succès
|
||||
*/
|
||||
public function sendSuccess(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification d'erreur
|
||||
*/
|
||||
public function sendError(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification avec un statut personnalisé
|
||||
*/
|
||||
public function sendUpdate(array $data): void;
|
||||
}
|
||||
17
src/Domain/Shared/Domain/Event/ChapterImported.php
Normal file
17
src/Domain/Shared/Domain/Event/ChapterImported.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Event;
|
||||
|
||||
readonly class ChapterImported
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaSlug,
|
||||
public int $volume,
|
||||
public float|string $chapterNumber,
|
||||
public string $cbzPath,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
16
src/Domain/Shared/Domain/Event/VolumeImported.php
Normal file
16
src/Domain/Shared/Domain/Event/VolumeImported.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Event;
|
||||
|
||||
readonly class VolumeImported
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaSlug,
|
||||
public int $volume,
|
||||
public string $cbzPath,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Exception;
|
||||
|
||||
class FileProcessingException extends \RuntimeException
|
||||
{
|
||||
public static function invalidFormat(string $fileName, array $allowedFormats): self
|
||||
{
|
||||
return new self(
|
||||
sprintf(
|
||||
'Le fichier "%s" doit être au format %s.',
|
||||
$fileName,
|
||||
implode(' ou ', $allowedFormats)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function uploadFailed(string $fileName, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('Une erreur est survenue lors de l\'upload du fichier "%s" : %s', $fileName, $reason)
|
||||
);
|
||||
}
|
||||
|
||||
public static function fileNotFound(string $filePath): self
|
||||
{
|
||||
return new self(sprintf('Le fichier "%s" n\'a pas été trouvé.', $filePath));
|
||||
}
|
||||
|
||||
public static function metadataExtractionFailed(string $fileName, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('Impossible d\'extraire les métadonnées du fichier "%s" : %s', $fileName, $reason)
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/Domain/Shared/Domain/Model/FileMetadata.php
Normal file
42
src/Domain/Shared/Domain/Model/FileMetadata.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Model;
|
||||
|
||||
readonly class FileMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public ?int $volume = null,
|
||||
public ?int $chapter = null,
|
||||
public ?string $author = null,
|
||||
public ?string $description = null,
|
||||
public array $additionalData = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
title: $data['title'] ?? '',
|
||||
volume: $data['volume'] ?? null,
|
||||
chapter: $data['chapter'] ?? null,
|
||||
author: $data['author'] ?? null,
|
||||
description: $data['description'] ?? null,
|
||||
additionalData: $data['additionalData'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'volume' => $this->volume,
|
||||
'chapter' => $this->chapter,
|
||||
'author' => $this->author,
|
||||
'description' => $this->description,
|
||||
'additionalData' => $this->additionalData,
|
||||
];
|
||||
}
|
||||
}
|
||||
49
src/Domain/Shared/Domain/Model/FileUpload.php
Normal file
49
src/Domain/Shared/Domain/Model/FileUpload.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Model;
|
||||
|
||||
readonly class FileUpload
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $originalName,
|
||||
public string $path,
|
||||
public string $extension,
|
||||
public int $size,
|
||||
public \DateTimeImmutable $uploadedAt
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $originalName,
|
||||
string $path,
|
||||
int $size
|
||||
): self {
|
||||
return new self(
|
||||
id: uniqid('file_', true),
|
||||
originalName: $originalName,
|
||||
path: $path,
|
||||
extension: strtolower(pathinfo($originalName, PATHINFO_EXTENSION)),
|
||||
size: $size,
|
||||
uploadedAt: new \DateTimeImmutable()
|
||||
);
|
||||
}
|
||||
|
||||
public function isValidFormat(array $allowedExtensions): bool
|
||||
{
|
||||
return in_array($this->extension, $allowedExtensions, true);
|
||||
}
|
||||
|
||||
public function getFormattedSize(int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($this->size, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
111
src/Domain/Shared/Infrastructure/Service/MangaFileManager.php
Normal file
111
src/Domain/Shared/Infrastructure/Service/MangaFileManager.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
/**
|
||||
* Implémentation centralisée basée sur la logique éprouvée de CbzGenerator.
|
||||
*/
|
||||
readonly class MangaFileManager implements MangaPathManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private FileUploadInterface $fileUpload,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string
|
||||
{
|
||||
$mangaDirName = ucfirst($this->slugify($mangaTitle)) . ' (' . $publicationYear . ')';
|
||||
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, $mangaDirName);
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
$mangaDir = $this->getMangaDirectory($mangaTitle, $publicationYear);
|
||||
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string
|
||||
{
|
||||
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
|
||||
return sprintf(
|
||||
'%s/%s_vol%d_ch%s.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($mangaTitle),
|
||||
$volumeNumber,
|
||||
$chapterNumber,
|
||||
);
|
||||
}
|
||||
|
||||
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
|
||||
return sprintf(
|
||||
'%s/%s_vol%d.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($mangaTitle),
|
||||
$volumeNumber,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<int, string> $files */
|
||||
public function createCbzArchive(array $files, string $cbzPath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Failed to create CBZ archive');
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!file_exists($file)) {
|
||||
throw new \RuntimeException("File not found: $file");
|
||||
}
|
||||
$zip->addFile($file, basename($file));
|
||||
}
|
||||
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException('Failed to close CBZ archive');
|
||||
}
|
||||
}
|
||||
|
||||
public function moveFileTo(string $sourcePath, string $destinationPath): void
|
||||
{
|
||||
$destinationDir = dirname($destinationPath);
|
||||
$this->ensureDirectory($destinationDir);
|
||||
$this->fileUpload->moveFile($sourcePath, $destinationPath);
|
||||
}
|
||||
|
||||
public function fileExists(string $path): bool
|
||||
{
|
||||
return $this->fileUpload->fileExists($path);
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
$this->fileUpload->createDirectory($dir);
|
||||
}
|
||||
}
|
||||
|
||||
private function slugify(string $text): string
|
||||
{
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
$text = strtolower($text);
|
||||
return $text ?: 'n-a';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
|
||||
use App\Domain\Shared\Domain\Exception\FileProcessingException;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
|
||||
readonly class SymfonyFileUpload implements FileUploadInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Filesystem $filesystem,
|
||||
private string $uploadsDirectory
|
||||
) {
|
||||
}
|
||||
|
||||
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string
|
||||
{
|
||||
try {
|
||||
$targetPath = $targetDirectory . '/' . uniqid() . '_' . $originalName;
|
||||
$this->filesystem->copy($sourcePath, $targetPath);
|
||||
|
||||
return $targetPath;
|
||||
} catch (FileException $e) {
|
||||
throw FileProcessingException::uploadFailed($originalName, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function fileExists(string $filePath): bool
|
||||
{
|
||||
return $this->filesystem->exists($filePath);
|
||||
}
|
||||
|
||||
public function deleteFile(string $filePath): void
|
||||
{
|
||||
if ($this->filesystem->exists($filePath)) {
|
||||
$this->filesystem->remove($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public function moveFile(string $sourcePath, string $targetPath): void
|
||||
{
|
||||
try {
|
||||
$this->filesystem->rename($sourcePath, $targetPath, true);
|
||||
} catch (FileException $e) {
|
||||
throw FileProcessingException::uploadFailed(
|
||||
basename($sourcePath),
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function createDirectory(string $path): void
|
||||
{
|
||||
if (!$this->filesystem->exists($path)) {
|
||||
$this->filesystem->mkdir($path, 0755);
|
||||
}
|
||||
}
|
||||
|
||||
public function validateFileFormat(string $filePath, array $allowedExtensions): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
return in_array($extension, $allowedExtensions, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
readonly class SymfonyNotification implements NotificationInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HubInterface $hub
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendSuccess(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendError(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendUpdate(array $data): void
|
||||
{
|
||||
$update = new Update(
|
||||
'notifications',
|
||||
json_encode($data)
|
||||
);
|
||||
|
||||
$this->hub->publish($update);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user