refactor: supprimer tout le code legacy MVC/Twig/Stimulus

Supprime toutes les couches pré-DDD pour ne garder que l'architecture
hexagonale (src/Domain/), les entités Doctrine et le front Vue.js SPA.

Supprimé :
- src/Controller/ (9 controllers Twig, garde SecurityController)
- src/Service/, src/Message/, src/MessageHandler/ (services et messages legacy)
- src/Manager/, src/Twig/, src/Form/ (UI legacy)
- src/Event/, src/EventListener/, src/EventSubscriber/QueueStatusSubscriber
- src/Client/MangadexClient.php (doublon du Domain)
- src/Interface/, src/Factory/, src/DataFixtures/, src/Scheduler/MainSchedule
- templates/ (tous sauf vue/ et base retiré — SecurityController = pur JSON)
- assets/controllers/ (20 Stimulus controllers), app.js, bootstrap.js, controllers.json

Modifié :
- config/routes.yaml : suppression du chargement des controllers legacy
- config/packages/messenger.yaml : suppression des routes legacy
- config/services.yaml : suppression des bindings legacy + entrées Domain\Import fantômes
- webpack.config.js : suppression entry 'app' et enableStimulusBridge
- src/Entity/Chapter.php : suppression #[Broadcast] (Turbo Streams legacy)

Déplacé :
- src/Factory/*.php → tests/Factory/ (namespace App\Tests\Factory)
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-26 17:00:46 +01:00
parent d7e6bf56d0
commit 5a0888eb28
146 changed files with 25 additions and 8035 deletions

View File

View File

@@ -1,86 +0,0 @@
<?php
namespace App\Client;
use App\Interface\ClientInterface;
use GuzzleHttp\ClientInterface as GuzzleInterface;
class MangadexClient implements ClientInterface
{
private const AUTHENTICATION_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
private const API_URL = 'https://api.mangadex.org';
private GuzzleInterface $httpClient;
private string $clientId;
private string $clientSecret;
private string $username;
private string $password;
private ?string $accessToken = null;
private ?string $refreshToken = null;
public function __construct(GuzzleInterface $httpClient, string $clientId, string $clientSecret, string $username, string $password)
{
$this->httpClient = $httpClient;
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->username = $username;
$this->password = $password;
$this->authenticate();
}
public function authenticate(): void
{
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
'form_params' => [
'grant_type' => 'password',
'username' => $this->username,
'password' => $this->password,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'];
}
public function refresh(): void
{
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $this->refreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$this->accessToken = $data['access_token'];
}
private function request(string $method, string $endpoint, array $options = []): array
{
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
if ($response->getStatusCode() === 429) {
$this->refresh();
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
}
return json_decode($response->getBody()->getContents(), true);
}
public function get(string $endpoint, array $params = []): array
{
return $this->request('GET', $endpoint, ['query' => $params]);
}
public function post(string $endpoint, array $data): array
{
return $this->request('POST', $endpoint, ['json' => $data]);
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Controller;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Manager\ToolbarManager;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Routing\Annotation\Route;
class ActivityController extends AbstractController
{
public function __construct(
private readonly Connection $connection,
private readonly ChapterRepository $chapterRepository,
private readonly ToolbarFactory $toolbarFactory
) {
}
#[Route('/activity', name: 'app_activity')]
public function index(): Response
{
$queueStatus = $this->getQueueStatus();
$decodedPending = $this->decodeMessages($queueStatus['pending']);
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
$status = array_merge(
$this->buildStatusActivity($decodedPending),
$this->buildStatusActivity($decodedProcessing)
);
return $this->render('activity/index.html.twig', [
'controller_name' => 'ActivityController',
'status' => $status,
'toolbar' => $this->toolbarFactory->createToolbar('activity')->getGroups(),
]);
}
#[Route('/activity/status', name: 'app_activity_status', methods: ['GET'])]
public function getStatus(): JsonResponse
{
$queueStatus = $this->getQueueStatus();
$decodedPending = $this->decodeMessages($queueStatus['pending']);
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
$status = array_merge(
$this->buildStatusActivity($decodedPending),
$this->buildStatusActivity($decodedProcessing)
);
return new JsonResponse($status);
}
// TODO refactorer ce code avec celui du QueueStatusSubscriber
private function getQueueStatus(): array
{
// Requête pour récupérer les messages en attente
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
// Requête pour récupérer les messages en cours de traitement
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
return [
'pending' => $pending,
'processing' => $processing
];
}
private function buildStatusActivity(array $activity): array
{
$status = [];
foreach ($activity as $envelope) {
$envelope = $envelope['body'];
if ($envelope instanceof Envelope) {
if (!$envelope->getMessage() instanceof DownloadChapter) {
continue;
}
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
$manga = $chapter->getManga();
$status[] = [
'manga' => $manga->getTitle(),
'volume' => $chapter->getVolume(),
'chapter' => $chapter->getNumber(),
'chapterId' => $chapter->getId(),
'title' => $chapter->getTitle(),
];
}
}
return $status;
}
private function decodeMessages(array $messages): array
{
$decodedMessages = [];
foreach ($messages as $message) {
$decodedMessages[] = [
'id' => $message['id'],
'body' => $this->decodeMessageBody($message['body']),
'headers' => json_decode($message['headers'], true),
];
}
return $decodedMessages;
}
private function decodeMessageBody(string $body)
{
return unserialize(stripcslashes($body));
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class CalendarController extends AbstractController
{
#[Route('/calendar', name: 'app_calendar')]
public function index(): Response
{
return $this->render('calendar/index.html.twig', [
'controller_name' => 'CalendarController',
]);
}
}

View File

@@ -1,64 +0,0 @@
<?php
namespace App\Controller;
use App\Service\CbrToCbzConverter;
use App\Service\NotificationService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Annotation\Route;
class ConversionController extends AbstractController
{
public function __construct(
private readonly CbrToCbzConverter $cbrToCbzConverter,
private readonly NotificationService $notificationService
) {
}
#[Route('/convert', name: 'app_convert')]
public function convert(Request $request): Response
{
if ($request->isMethod('POST')) {
/** @var UploadedFile $file */
$file = $request->files->get('file');
if ($file && $file->getClientOriginalExtension() === 'cbr') {
$originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$tempFilePath = $file->getPathname();
try {
$cbzPath = $this->cbrToCbzConverter->convert($tempFilePath);
$response = new BinaryFileResponse($cbzPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$originalFileName . '.cbz'
);
$response->headers->set('Content-Type', 'application/x-cbz');
$response->headers->set('Turbo-Visit-Control', 'reload');
$response->deleteFileAfterSend(true);
return $response;
} catch (\Exception $e) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Une erreur est survenue lors de la conversion : ' . $e->getMessage()
]);
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Veuillez sélectionner un fichier CBR valide.'
]);
}
}
return $this->render('conversion/index.html.twig');
}
}

View File

@@ -1,220 +0,0 @@
<?php
namespace App\Controller;
use App\Manager\FileSystemManager;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository;
use App\Service\CbrToCbzConverter;
use App\Service\CbzService;
use App\Service\MangaImportService;
use App\Service\NotificationService;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
class ImportController extends AbstractController
{
public function __construct(
private readonly FileSystemManager $fileSystemManager,
private readonly CbzService $cbzService,
private readonly MangaImportService $mangaImportService,
private readonly NotificationService $notificationService,
private readonly MangaRepository $mangaRepository,
private readonly CbrToCbzConverter $cbrToCbzConverter
) {
}
#[Route('/manga/import', name: 'app_manga_import')]
public function index(Request $request, SessionInterface $session): Response
{
if ($request->isMethod('POST')) {
$files = $request->files->get('files');
if ($files) {
$importFiles = [];
foreach ($files as $file) {
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
$originalFileName = $file->getClientOriginalName();
try {
$tmpPath = $this->fileSystemManager->moveUploadedFile(
$file->getPathname(),
$this->fileSystemManager->getUploadsDirectory(),
$file->getClientOriginalName()
);
$importFiles[] = [
'id' => uniqid(),
'path' => $tmpPath,
'original_name' => $originalFileName,
];
} catch (FileException $e) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
]);
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.',
]);
}
}
if (!empty($importFiles)) {
$session->set('import_files', $importFiles);
return $this->redirectToRoute('import_match');
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Aucun fichier n\'a été sélectionné.',
]);
}
}
return $this->render('import/index.html.twig');
}
/**
* @throws Exception
*/
#[Route('/import/match', name: 'import_match')]
public function match(SessionInterface $session): Response
{
$files = $session->get('import_files', []);
if (empty($files)) {
return $this->redirectToRoute('app_manga_import');
}
$processedFiles = [];
foreach ($files as $fileId => $fileInfo) {
$filePath = $fileInfo['path'];
$originalFileName = $fileInfo['original_name'];
$fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
if (strtolower($fileExtension) === 'cbr') {
$cbzPath = $this->cbrToCbzConverter->convert($filePath);
$filePath = $cbzPath;
$originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz';
$files[$fileId]['path'] = $filePath;
$files[$fileId]['original_name'] = $originalFileName;
}
$metadata = $this->cbzService->extractMetadata($filePath, $originalFileName);
$mangas = $this->mangaRepository->findBySlug($metadata['title']);
$mangaOptions = [];
foreach ($mangas as $manga) {
$mangaOptions[] = [
'slug' => $manga->getSlug(),
'title' => $manga->getTitle(),
'author' => $manga->getAuthor(),
'publicationYear' => $manga->getPublicationYear(),
'genres' => $manga->getGenres(),
'description' => $manga->getDescription()
];
}
$processedFiles[] = [
'id' => $fileId,
'originalFileName' => $originalFileName,
'fileSize' => $this->formatBytes(filesize($filePath)),
'metadata' => $metadata,
'mangaOptions' => $mangaOptions
];
}
$session->set('import_files', $files);
return $this->render('import/match.html.twig', [
'files' => $processedFiles
]);
}
private function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 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];
}
#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
public function confirm(Request $request, SessionInterface $session): Response
{
$files = $session->get('import_files', []);
$selectedFiles = $request->request->all('selected');
$mangaSlugs = $request->request->all('manga_slug');
$volumes = $request->request->all('volume');
$chapters = $request->request->all('chapter');
$importedFiles = [];
$errors = [];
foreach ($selectedFiles as $fileId) {
if (!isset($files[$fileId])) {
continue;
}
$file = $files[$fileId];
$mangaSlug = $mangaSlugs[$fileId] ?? null;
$volume = $volumes[$fileId] ?? null;
$chapter = $chapters[$fileId] ?? null;
try {
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw new \Exception('Manga non trouvé.');
}
if (!is_null($chapter)) {
$chapter = $manga->getChapterByNumber($chapter);
if (!$chapter) {
throw new \Exception('Chapitre non trouvé.');
}
}
$importedFiles[] = $file['original_name'];
$this->mangaImportService->importFile($manga, $volume, $chapter, $file['path']);
} catch (\Exception $e) {
$errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage();
}
}
// Nettoyer les fichiers temporaires non importés
foreach ($files as $file) {
$this->fileSystemManager->deleteFile($file['path']);
}
// Nettoyer la session
$session->remove('import_files');
// Préparer le message de notification
if (!empty($importedFiles)) {
$successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles);
$this->notificationService->sendUpdate([
'status' => 'success',
'message' => $successMessage
]);
}
if (!empty($errors)) {
$errorMessage = implode("\n", $errors);
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => $errorMessage
]);
}
return $this->redirectToRoute('app_manga');
}
}

View File

@@ -1,475 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Form\MangaEditType;
use App\Manager\FileSystemManager;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Message\DownloadChapter;
use App\Message\RefreshMetadata;
use App\Repository\ChapterRepository;
use App\Repository\ContentSourceRepository;
use App\Repository\MangaRepository;
use App\Service\CbzService;
use App\Service\MangadexProvider;
use App\Service\NotificationService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
class MangaController extends AbstractController
{
private ImageManager $imageManager;
public function __construct(
private readonly FileSystemManager $fileSystemManager,
private readonly MangaRepository $mangaRepository,
private readonly ChapterRepository $chapterRepository,
private readonly MessageBusInterface $bus,
private readonly CbzService $cbzService,
private readonly ToolbarFactory $toolbarFactory,
private readonly MangadexProvider $mangadexProvider,
private readonly EntityManagerInterface $entityManager,
private readonly NotificationService $notificationService,
private readonly ContentSourceRepository $contentSourceRepository
) {
$this->imageManager = new ImageManager(new Driver());
}
#[Route('/legacy', name: 'app_legacy')]
public function index(Request $request): Response
{
$sort = $request->query->get('sort', 'title');
$order = $request->query->get('order', 'asc');
$status = $request->query->get('status', 'all');
$view = $request->query->get('view', 'poster');
$mangas = $this->mangaRepository->findAllSortedAndFiltered($sort, $order, $status);
return $this->render('manga/index.html.twig', [
'mangas' => $mangas,
'toolbar' => $this->toolbarFactory->createToolbar('manga_list')->getGroups(),
'currentStatus' => $status,
'currentView' => $view,
]);
}
#[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')]
public function showChapters(string $mangaSlug, Request $request): Response
{
// $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]);
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw new NotFoundHttpException("Le manga demandé n'existe pas.");
}
$form = $this->createForm(MangaEditType::class, $manga);
$contentSources = $this->contentSourceRepository->findAll();
return $this->render('manga/show_chapters.html.twig', [
'manga' => $manga,
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(),
'form' => $form->createView(),
'contentSources' => $contentSources,
]);
}
#[Route('/manga/delete/{id}', name: 'app_manga_delete', methods: ['DELETE'])]
public function deleteManga(Manga $manga): JsonResponse
{
try {
foreach ($manga->getChapters() as $chapter) {
file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath());
$this->entityManager->remove($chapter);
}
$this->entityManager->remove($manga);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'error' => 'Unable to delete manga.'], 500);
}
}
#[Route('/manga/{id}/edit', name: 'app_manga_edit', methods: ['POST'])]
public function edit(Request $request, Manga $manga, EntityManagerInterface $entityManager): JsonResponse|Response
{
$form = $this->createForm(MangaEditType::class, $manga);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
$errors = [];
foreach ($form->getErrors(true) as $error) {
$errors[] = $error->getMessage();
}
return new JsonResponse(['errors' => $errors], 400);
}
#[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])]
public function updatePreferredSources(
Request $request,
Manga $manga,
ContentSourceRepository $contentSourceRepository
): JsonResponse {
$data = json_decode($request->getContent(), true);
$preferredSourceIds = $data['preferredSources'] ?? [];
$preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]);
// This will maintain the order of the sources as they were sent in the request
$orderedPreferredSources = array_map(
fn ($id) => current(array_filter($preferredSources, fn ($s) => $s->getId() == $id)),
$preferredSourceIds
);
$manga->setPreferredSources(array_filter($orderedPreferredSources));
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
public function _chaptersByManga(int $id): Response
{
$manga = $this->mangaRepository->find($id);
$chaptersByVolume = [];
foreach ($manga->getChapters() as $chapter) {
$volume = $chapter->getVolume() ?? 'Not Found';
$chaptersByVolume[$volume][] = $chapter;
}
foreach ($chaptersByVolume as $volume => &$chapters) {
usort($chapters, function ($a, $b) {
return $b->getNumber() <=> $a->getNumber();
});
}
unset($chapters);
uksort($chaptersByVolume, function ($a, $b) {
if ($a == 0) {
return -1;
}
if ($b == 0) {
return 1;
}
return $b <=> $a;
});
return $this->render('manga/_chapter_list.html.twig', [
'manga' => $manga,
'chapters_by_volume' => $chaptersByVolume
]);
}
#[Route('/delete_cbz/{id}', name: 'app_delete_cbz')]
public function deleteChapterCbz(Chapter $chapter): JsonResponse
{
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath) {
return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400);
}
file_exists($cbzPath) ?? unlink($cbzPath);
$chapter->setCbzPath(null);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'CBZ file deleted.'], 200);
}
#[Route('/chapter/{id}/edit', name: 'app_chapter_edit', methods: ['POST'])]
public function editChapter(Request $request, Chapter $chapter): JsonResponse
{
$data = json_decode($request->getContent(), true);
$chapter->setNumber($data['number']);
$chapter->setTitle($data['title']);
$this->entityManager->flush();
return new JsonResponse(['success' => true, 'message' => 'Chapter updated successfully']);
}
#[Route('/hide_chapter/{id}', name: 'app_hide_chapter')]
public function hideChapter(Chapter $chapter): JsonResponse
{
$chapter->setVisible(false);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'Chapter hidden.'], 200);
}
#[Route('/manga/search/{query}', name: 'app_manga_search')]
public function search(string $query = ''): Response
{
return $this->render('manga/add_new.html.twig', [
'query' => $query,
]);
}
/**
* @throws GuzzleException
*/
#[Route('/addManga', name: 'app_manga_add')]
public function addManga(Request $request): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $request->request->get('slug')]);
if ($manga) {
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
$manga = new Manga();
$manga->setTitle($request->request->get('title'))
->setSlug($request->request->get('slug'))
->setDescription($request->request->get('description'))
->setStatus($request->request->get('status'))
->setGenres(explode(',', $request->request->get('genres')))
->setAuthor($request->request->get('author'))
->setPublicationYear($request->request->get('publicationYear'))
->setRating($request->request->get('rating'))
->setExternalId($request->request->get('externalId'))
->setMonitored(false);
// Traitement de l'image
$imageUrl = $request->request->get('imageUrl');
try {
$imageUrls = $this->processAndSaveImage($imageUrl);
$manga->setImageUrl($imageUrls['full']);
$manga->setThumbnailUrl($imageUrls['thumbnail']);
} catch (\Exception|GuzzleException $e) {
throw $e;
}
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
if (empty($mergedChapters)) {
return $this->redirectToRoute('app_manga_search', ['query' => $manga->getTitle()]);
}
try {
foreach ($manga->getChapters() as $chapter) {
$this->entityManager->persist($chapter);
}
$this->entityManager->persist($manga);
$this->entityManager->flush();
} catch (\Exception $e) {
if ($e instanceof UniqueConstraintViolationException) {
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
throw $e;
}
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
/**
* @throws GuzzleException
*/
private function processAndSaveImage(string $imageUrl): array
{
$client = new Client();
$response = $client->get($imageUrl);
$tempImage = tmpfile();
fwrite($tempImage, $response->getBody()->getContents());
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
// Générer un nom de fichier unique
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
$newFilename = $this->fileSystemManager->generateUniqueImageFilename($imageUrl);
try {
// Créer et sauvegarder la miniature
$thumbnail = $this->imageManager->read($tempImagePath);
$thumbnail->cover(300, 440);
$thumbnail->save($this->fileSystemManager->getImagePath('thumbnails') . '/' . $newFilename, quality: 85);
// Sauvegarder l'image en taille réelle
$fullImage = $this->imageManager->read($tempImagePath);
$fullImage->save($this->fileSystemManager->getImagePath('full') . '/' . $newFilename, quality: 90);
// Fermer et supprimer le fichier temporaire
fclose($tempImage);
return [
'full' => '/images/full/' . $newFilename,
'thumbnail' => '/images/thumbnails/' . $newFilename
];
} catch (FileException $e) {
// Fermer le fichier temporaire en cas d'erreur
fclose($tempImage);
throw $e;
}
}
#[Route('/searchChapter/{id}', name: 'search_chapter')]
public function addChapterMessenger(int $id): JsonResponse
{
$chapter = $this->chapterRepository->find($id);
if (!$chapter) {
return new JsonResponse(['error' => 'Chapter Not Found.'], 400);
} elseif ($chapter->getCbzPath() !== null) {
return new JsonResponse(['error' => 'Chapter already scraped.'], 400);
}
$this->bus->dispatch(new DownloadChapter($id));
return new JsonResponse(['success' => 'Scrapping started...'], 200);
}
#[Route('/searchVolume/{mangaSlug}/{volume}', name: 'search_volume')]
public function searchVolume(string $mangaSlug, int $volume): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga,
'volume' => $volume,
'visible' => true
]);
if (empty($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'No chapters found for this volume.']);
return new JsonResponse(['error' => 'No chapters found for this volume.'], 200);
}
foreach ($volumeChapters as $chapter) {
if ($chapter->getCbzPath() === null) {
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
}
}
return new JsonResponse(['success' => 'Scrapping started...'], 200);
}
#[Route('/download-cbz/{chapterId}', name: 'download_cbz')]
public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse
{
$chapter = $this->chapterRepository->find($chapterId);
if (!$chapter) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapitre non trouvé.']);
return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200);
}
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath || !file_exists($cbzPath)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Le fichier CBZ n\'existe pas.']);
return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200);
}
$isFullVolume = $this->isFullVolume($chapter);
$fileName = $isFullVolume
? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume())
: $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber());
return $this->cbzService->createBinaryFileResponse($cbzPath, $fileName);
}
#[Route('/download-volume/{mangaSlug}/{volume}', name: 'download_volume')]
public function downloadVolume(string $mangaSlug, int $volume): BinaryFileResponse|JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga,
'volume' => $volume,
'visible' => true
], ['number' => 'ASC']);
if (empty($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Aucun chapitre trouvé pour ce volume.']);
}
if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Tous les chapitres du volume ne sont pas scrapés.']);
return new JsonResponse(['error' => 'Tous les chapitres du volume ne sont pas scrapés.'], 200);
}
$fileName = $this->cbzService->generateFileName($manga, $volume);
if ($this->cbzService->areAllChaptersCbzIdentical($volumeChapters)) {
return $this->cbzService->createBinaryFileResponse($volumeChapters[0]->getCbzPath(), $fileName);
} else {
$tempFile = $this->cbzService->createVolumeArchive($volumeChapters);
$response = $this->cbzService->createBinaryFileResponse($tempFile, $fileName);
$response->deleteFileAfterSend(true);
return $response;
}
}
#[Route('/refresh_metadata', name: 'refresh_metadata')]
public function refreshMetadata(Request $request): JsonResponse
{
$mangaId = json_decode($request->getContent(), true)['mangaId'];
$manga = $this->mangaRepository->find($mangaId);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$this->bus->dispatch(new RefreshMetadata($mangaId));
return new JsonResponse(['success' => 'Metadata refresh started...'], 200);
}
#[Route('/toggle_monitored', name: 'toggle_monitored')]
public function toogleMonitored(Request $request): JsonResponse
{
$id = json_decode($request->getContent(), true)['mangaId'];
$manga = $this->mangaRepository->find($id);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$manga->setMonitored(!$manga->isMonitored());
$this->entityManager->persist($manga);
$this->entityManager->flush();
return new JsonResponse(['success' => 'Monitored status updated.', 'isMonitored' => $manga->isMonitored()], 200);
}
private function isFullVolume(Chapter $chapter): bool
{
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $chapter->getManga(),
'volume' => $chapter->getVolume()
]);
$firstChapterPath = $volumeChapters[0]->getCbzPath();
foreach ($volumeChapters as $volumeChapter) {
if ($volumeChapter->getCbzPath() !== $firstChapterPath) {
return false;
}
}
return true;
}
}

View File

@@ -1,138 +0,0 @@
<?php
namespace App\Controller;
use App\Repository\MangaRepository;
use App\Service\CbzService;
use App\Service\NotificationService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ReaderController extends AbstractController
{
public function __construct(
private readonly MangaRepository $mangaRepository,
private readonly CbzService $cbzService,
private readonly NotificationService $notificationService,
) {
}
#[Route('/read/{mangaSlug}/{chapterNumber}', name: 'app_reader')]
public function read(string $mangaSlug, float $chapterNumber): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapter = $manga->getChapterByNumber($chapterNumber);
if (!$chapter) {
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
}
if (is_null($chapter->getCbzPath())) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Le chapitre demandé n\'est pas encore disponible.',
]);
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
}
$totalPages = $this->cbzService->getPageCount($chapter->getCbzPath());
return $this->render('reader/index.html.twig', [
'manga' => $manga,
'chapter' => $chapter,
'totalPages' => $totalPages,
]);
}
#[Route('/api/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_reader_page')]
public function getPage(string $mangaSlug, float $chapterNumber, int $pageNumber): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapter = $manga->getChapterByNumber($chapterNumber);
if (!$chapter) {
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
}
$pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
if (!$pageContent) {
throw $this->createNotFoundException("La page demandée n'existe pas.");
}
return new Response(base64_encode($pageContent), 200, ['Content-Type' => 'text/plain']);
}
#[Route('/api/chapters/{mangaSlug}', name: 'app_reader_chapters')]
public function getChapters(string $mangaSlug): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapters = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && !is_null($chapter->getCbzPath()))
->toArray();
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
$chapters = array_values(array_map(fn ($chapter) => [
'number' => $chapter->getNumber(),
'title' => $chapter->getTitle(),
], $chapters));
return $this->json($chapters);
}
#[Route('/api/previous-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_previous_chapter')]
public function getPreviousChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapters = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber)
->toArray();
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
$previousChapter = reset($chapters) ?: null;
return $this->json($previousChapter ? [
'number' => $previousChapter->getNumber(),
'title' => $previousChapter->getTitle(),
] : null);
}
#[Route('/api/next-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_next_chapter')]
public function getNextChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$nextChapter = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() > $currentChapterNumber)
->toArray();
usort($nextChapter, fn ($a, $b) => $a->getNumber() <=> $b->getNumber());
$nextChapter = reset($nextChapter) ?: null;
return $this->json($nextChapter ? [
'number' => $nextChapter->getNumber(),
'title' => $nextChapter->getTitle(),
] : null);
}
}

View File

@@ -1,203 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\ContentSource;
use App\Form\AppSettingsType;
use App\Form\ContentSourceType;
use App\Manager\AppSettingsManager;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Repository\ContentSourceRepository;
use App\Service\NotificationService;
use App\Service\Scraper\MangaScraperService;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SettingsController extends AbstractController
{
public function __construct(
private MangaScraperService $mangaScraperService,
private EntityManagerInterface $entityManager,
private NotificationService $notificationService,
private ContentSourceRepository $contentSourceRepository
) {
}
#[Route('/settings', name: 'app_settings')]
public function index(): Response
{
return $this->render('settings/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/settings/general', name: 'app_settings_general')]
public function general(): Response
{
return $this->render('settings/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/settings/folders', name: 'app_settings_folders')]
public function folders(Request $request, AppSettingsManager $settingsManager): Response
{
$currentSettings = $settingsManager->getSettings();
$form = $this->createForm(AppSettingsType::class, $currentSettings);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newSettings = $form->getData();
$settingsManager->updateSettings($newSettings);
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Settings updated successfully.']);
return $this->json(['success' => true]);
}
return $this->render('settings/folders.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/settings/scrappers/list', name: 'app_settings_scrappers_list')]
public function list(ContentSourceRepository $repository, ToolbarFactory $toolbarFactory): Response
{
$contentSources = $repository->findAll();
return $this->render('settings/scrapper_list.html.twig', [
'contentSources' => $contentSources,
'toolbar' => $toolbarFactory->createToolbar('scraper_list')->getGroups(),
]);
}
#[Route('/settings/scrappers/{id}', name: 'app_settings_scrappers', defaults: ['id' => null])]
public function scrappers(Request $request, ?ContentSource $contentSource): Response
{
$isNew = $contentSource === null;
$contentSource = $contentSource ?? new ContentSource();
$form = $this->createForm(ContentSourceType::class, $contentSource);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($contentSource);
$this->entityManager->flush();
$this->notificationService->sendUpdate(['status' => 'success', 'message' => ($isNew ? 'New scrapper configuration saved' : 'Scrapper configuration updated') . ' successfully.']);
return $this->redirectToRoute('app_settings_scrappers_list');
}
return $this->render('settings/scrappers.html.twig', [
'form' => $form->createView(),
'isNew' => $isNew,
]);
}
/**
* @throws GuzzleException
*/
#[Route('/settings/scrappers_test', name: 'app_settings_scrappers_test', methods: ['POST'])]
public function scrapperTest(Request $request): JsonResponse
{
$contentSource = new ContentSource();
$form = $this->createForm(ContentSourceType::class, $contentSource);
$form->submit($request->request->all()['content_source']);
if ($form->isValid()) {
$mangaSlug = $request->request->get('mangaSlug');
$chapterNumber = $request->request->get('chapterNumber');
try {
$scrapedData = $this->mangaScraperService->testScraping($mangaSlug, $chapterNumber, $contentSource);
} catch (\Exception $e) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => $e->getMessage()]);
return new JsonResponse([
'success' => false,
'message' => $e->getMessage(),
]);
}
return new JsonResponse([
'success' => true,
'message' => 'Test successful',
'data' => $scrapedData
]);
} else {
return new JsonResponse([
'success' => false,
'message' => 'Invalid form submission',
'errors' => $this->getFormErrors($form)
]);
}
}
private function getFormErrors($form): array
{
$errors = [];
foreach ($form->getErrors(true) as $error) {
$errors[] = $error->getMessage();
}
return $errors;
}
#[Route('/settings/ui', name: 'app_settings_ui')]
public function ui(): Response
{
return $this->render('settings/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/settings/export_scrappers', name: 'app_settings_scrappers_export', methods: ['GET'])]
public function exportScrappers(): JsonResponse
{
$contentSources = $this->contentSourceRepository->findAll();
$data = [];
foreach ($contentSources as $source) {
$data[] = [
'baseUrl' => $source->getBaseUrl(),
'imageSelector' => $source->getImageSelector(),
'nextPageSelector' => $source->getNextPageSelector(),
'chapterUrlFormat' => $source->getChapterUrlFormat(),
'scrapingType' => $source->getScrapingType(),
'chapterSelector' => $source->getChapterSelector(), //TODO à renommer en chapterListSelector
];
}
return new JsonResponse($data);
}
#[Route('/settings/import_scrappers', name: 'app_settings_scrappers_import', methods: ['POST'])]
public function importScrappers(Request $request): JsonResponse
{
$content = $request->getContent();
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Invalid JSON data']);
return new JsonResponse(['error' => 'Invalid JSON data'], 400);
}
foreach ($data as $sourceData) {
$contentSource = new ContentSource();
$contentSource->setBaseUrl($sourceData['baseUrl']);
$contentSource->setImageSelector($sourceData['imageSelector']);
$contentSource->setNextPageSelector($sourceData['nextPageSelector']);
$contentSource->setChapterUrlFormat($sourceData['chapterUrlFormat']);
$contentSource->setScrapingType($sourceData['scrapingType']);
$this->entityManager->persist($contentSource);
}
$this->entityManager->flush();
return new JsonResponse(['message' => 'Content sources imported successfully']);
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SystemController extends AbstractController
{
#[Route('/system', name: 'app_system')]
public function index(): Response
{
return $this->render('system/index.html.twig', [
'controller_name' => 'SystemController',
]);
}
#[Route('/system/status', name: 'app_system_status')]
public function status(): Response
{
return $this->render('system/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/system/backup', name: 'app_system_backup')]
public function backup(): Response
{
return $this->render('system/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/system/logs', name: 'app_system_logs')]
public function logs(): Response
{
return $this->render('system/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
#[Route('/system/updates', name: 'app_system_updates')]
public function update(): Response
{
return $this->render('system/index.html.twig', [
'controller_name' => 'SettingsController',
]);
}
}

View File

@@ -1,100 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use App\Entity\Manga;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository;
use App\Service\ActivityService;
use App\Service\MangadexProvider;
use App\Service\MangaScraperService;
use App\Service\MangaUpdatesMetadataProvider;
use Doctrine\DBAL\Connection;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
class TestController extends AbstractController
{
private ImageManager $imageManager;
public function __construct(
private string $projectDir,
private SluggerInterface $slugger,
private MangaRepository $mangaRepository
) {
$this->imageManager = new ImageManager(new Driver());
}
#[Route('/test', name: 'test')]
public function test(): Response
{
$mangas = $this->mangaRepository->findAll();
$changed = 0;
foreach ($mangas as $manga) {
//si getImageUrl() retourne un lien sous la forme d'une URL (https ou http)
if ($manga->getImageUrl()) {
$imageUrls = $this->processAndSaveImage($manga->getImageUrl());
$manga->setThumbnailUrl($imageUrls['thumbnail']);
$this->mangaRepository->save($manga, true);
$changed++;
}
}
return new JsonResponse(['changed' => $changed]);
}
/**
* @throws GuzzleException
*/
private function processAndSaveImage(string $imageUrl): array
{
$image = file_get_contents($this->projectDir . '/public' .$imageUrl);
$tempImage = tmpfile();
fwrite($tempImage, $image);
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
// Générer un nom de fichier unique
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
$safeFilename = $this->slugger->slug($originalFilename);
$newFilename = $safeFilename . '-' . uniqid() . '.' . 'jpg';
try {
// Créer et sauvegarder la miniature
$thumbnail = $this->imageManager->read($tempImagePath);
$thumbnail->cover(300, 440);
$thumbnail->save($this->projectDir . '/public/images/thumbnails/' . $newFilename, quality: 85);
// Sauvegarder l'image en taille réelle
// $fullImage = $this->imageManager->read($tempImagePath);
// $fullImage->save($this->projectDir . '/public/images/full/' . $newFilename, quality: 90);
// Fermer et supprimer le fichier temporaire
fclose($tempImage);
return [
'full' => '/images/full/' . $newFilename,
'thumbnail' => '/images/thumbnails/' . $newFilename
];
} catch (FileException $e) {
// Fermer le fichier temporaire en cas d'erreur
fclose($tempImage);
throw $e;
}
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace App\DataFixtures;
use App\Factory\ApiTokenFactory;
use App\Factory\ChapterFactory;
use App\Factory\MangaFactory;
use App\Factory\PageFactory;
use App\Factory\UserFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
UserFactory::createMany(20);
ApiTokenFactory::createMany(60, function () {
return [
'ownedBy' => UserFactory::random()
];
});
$mangas = MangaFactory::createMany(25);
foreach ($mangas as $manga) {
for ($i = 1; $i <= 5; $i++) {
$manga->addChapter(ChapterFactory::createOne([
'manga' => $manga,
'number' => $i
])->object());
}
foreach ($manga->getChapters() as $chapter) {
for ($i = 1; $i <= 5; $i++) {
$chapter->addPagesLink(PageFactory::createOne([
'chapter' => $chapter,
'number' => $i
])->object());
}
}
}
}
}

View File

@@ -6,10 +6,8 @@ use App\Repository\ChapterRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: ChapterRepository::class)]
#[Broadcast()]
class Chapter
{
#[ORM\Id]

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
class PageScrappingProgressEvent extends Event
{
public const NAME = 'page.scrapping.progress';
private int $chapterId;
private int $pageIndex;
private int $totalPages;
public function __construct(int $chapterId, int $pageIndex, int $totalPages)
{
$this->chapterId = $chapterId;
$this->pageIndex = $pageIndex;
$this->totalPages = $totalPages;
}
public function getChapterId(): int
{
return $this->chapterId;
}
public function getPageIndex(): int
{
return $this->pageIndex;
}
public function getTotalPages(): int
{
return $this->totalPages;
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\EventListener;
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use ApiPlatform\Exception\FilterValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
class ExceptionListener
{
public function __construct(private LoggerInterface $logger)
{
}
public function onKernelException(ExceptionEvent $event): void
{
// $exception = $event->getThrowable();
//
// $response = match(true) {
// $exception instanceof FilterValidationException,
// $exception instanceof BadRequestException => $this->createResponse($exception, Response::HTTP_BAD_REQUEST),
// $exception instanceof NotFoundHttpException,
// $exception instanceof ItemNotFoundException => $this->createResponse($exception, Response::HTTP_NOT_FOUND),
// $exception instanceof AccessDeniedHttpException => $this->createResponse($exception, Response::HTTP_FORBIDDEN),
// $exception instanceof ValidationException,
// $exception instanceof NotNormalizableValueException => $this->createResponse($exception, Response::HTTP_UNPROCESSABLE_ENTITY),
// default => null,
// };
//
// if ($response) {
// $event->setResponse($response);
// }else{
// $this->logger->error($exception->getMessage(), ['exception' => $exception]);
// }
}
private function createResponse(\Throwable $exception, int $statusCode): Response
{
$this->logger->info($exception->getMessage(), ['exception' => $exception]);
return new Response(json_encode(['message' => $exception->getMessage()]), $statusCode, ['Content-Type' => 'application/json']);
}
}

View File

@@ -1,145 +0,0 @@
<?php
namespace App\EventSubscriber;
use App\Event\PageScrappingProgressEvent;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use App\Service\ActivityService;
use Doctrine\DBAL\Connection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent;
class QueueStatusSubscriber implements EventSubscriberInterface
{
public function __construct(
private ActivityService $activityService,
private Connection $connection,
private ChapterRepository $chapterRepository
) {
}
public static function getSubscribedEvents(): array
{
return [
WorkerMessageReceivedEvent::class => 'onMessageReceived',
WorkerMessageHandledEvent::class => 'onMessageHandled',
WorkerMessageFailedEvent::class => 'onMessageFailed',
PageScrappingProgressEvent::NAME => 'onPageScrapingProgress',
];
}
public function onMessageReceived(WorkerMessageReceivedEvent $event): void
{
$envelope = $event->getEnvelope();
$message = $envelope->getMessage();
if ($message instanceof DownloadChapter) {
$this->activityService->sendUpdate($this->getActivity());
}
}
public function onMessageHandled(WorkerMessageHandledEvent $event): void
{
$envelope = $event->getEnvelope();
$message = $envelope->getMessage();
if ($message instanceof DownloadChapter) {
$this->activityService->sendUpdate($this->getActivity());
}
}
public function onMessageFailed(WorkerMessageFailedEvent $event): void
{
$envelope = $event->getEnvelope();
$message = $envelope->getMessage();
if ($message instanceof DownloadChapter) {
$this->activityService->sendUpdate($this->getActivity());
}
}
public function onPageScrapingProgress(PageScrappingProgressEvent $event): void
{
$data = [
'status' => 'scrapping.progress',
'chapterId' => $event->getChapterId(),
'pageIndex' => $event->getPageIndex(),
'totalPages' => $event->getTotalPages(),
];
$this->activityService->sendUpdate($data);
}
private function getActivity(): array
{
$queueStatus = $this->getQueueStatus();
return [
'processing' => $this->buildStatusActivity($this->decodeMessages($queueStatus['processing'])),
'pending' => $this->buildStatusActivity($this->decodeMessages($queueStatus['pending']))
];
}
//TODO refactorer ce code avec celui du ActivityController
private function buildStatusActivity(array $activity): array
{
$status = [];
foreach ($activity as $envelope) {
$envelope = $envelope['body'];
if ($envelope instanceof Envelope) {
if (!$envelope->getMessage() instanceof DownloadChapter) {
continue;
}
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
$manga = $chapter->getManga();
$status[] = [
'manga' => $manga->getTitle(),
'volume' => $chapter->getVolume(),
'chapter' => $chapter->getNumber(),
'title' => $chapter->getTitle(),
];
}
}
return $status;
}
private function getQueueStatus(): array
{
// Requête pour récupérer les messages en attente
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
// Requête pour récupérer les messages en cours de traitement
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
return [
'pending' => $pending,
'processing' => $processing
];
}
private function decodeMessages(array $messages): array
{
$decodedMessages = [];
foreach ($messages as $message) {
$decodedMessages[] = [
'id' => $message['id'],
'body' => $this->decodeMessageBody($message['body']),
'headers' => json_decode($message['headers'], true),
];
}
return $decodedMessages;
}
private function decodeMessageBody(string $body)
{
return unserialize(stripcslashes($body));
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\ApiToken;
use App\Repository\ApiTokenRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<ApiToken>
*
* @method ApiToken|Proxy create(array|callable $attributes = [])
* @method static ApiToken|Proxy createOne(array $attributes = [])
* @method static ApiToken|Proxy find(object|array|mixed $criteria)
* @method static ApiToken|Proxy findOrCreate(array $attributes)
* @method static ApiToken|Proxy first(string $sortedField = 'id')
* @method static ApiToken|Proxy last(string $sortedField = 'id')
* @method static ApiToken|Proxy random(array $attributes = [])
* @method static ApiToken|Proxy randomOrCreate(array $attributes = [])
* @method static ApiTokenRepository|RepositoryProxy repository()
* @method static ApiToken[]|Proxy[] all()
* @method static ApiToken[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static ApiToken[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static ApiToken[]|Proxy[] findBy(array $attributes)
* @method static ApiToken[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static ApiToken[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class ApiTokenFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
return [
'ownedBy' => UserFactory::new(),
'scopes' => [],
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(ApiToken $apiToken): void {})
;
}
protected static function getClass(): string
{
return ApiToken::class;
}
}

View File

@@ -1,75 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\Chapter;
use App\Repository\ChapterRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<Chapter>
*
* @method Chapter|Proxy create(array|callable $attributes = [])
* @method static Chapter|Proxy createOne(array $attributes = [])
* @method static Chapter|Proxy find(object|array|mixed $criteria)
* @method static Chapter|Proxy findOrCreate(array $attributes)
* @method static Chapter|Proxy first(string $sortedField = 'id')
* @method static Chapter|Proxy last(string $sortedField = 'id')
* @method static Chapter|Proxy random(array $attributes = [])
* @method static Chapter|Proxy randomOrCreate(array $attributes = [])
* @method static ChapterRepository|RepositoryProxy repository()
* @method static Chapter[]|Proxy[] all()
* @method static Chapter[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static Chapter[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static Chapter[]|Proxy[] findBy(array $attributes)
* @method static Chapter[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static Chapter[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class ChapterFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
return [
'manga' => MangaFactory::new(),
'number' => self::faker()->randomFloat(2, 0, 999),
'volume' => self::faker()->optional()->numberBetween(1, 100),
'title' => self::faker()->optional()->sentence(3),
'localPath' => self::faker()->optional()->filePath(),
'externalId' => self::faker()->optional()->uuid(),
'cbzPath' => self::faker()->optional()->filePath(),
'visible' => true,
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(Chapter $chapter): void {})
;
}
protected static function getClass(): string
{
return Chapter::class;
}
}

View File

@@ -1,84 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\Manga;
use App\Repository\MangaRepository;
use Symfony\Component\String\Slugger\SluggerInterface;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<Manga>
*
* @method Manga|Proxy create(array|callable $attributes = [])
* @method static Manga|Proxy createOne(array $attributes = [])
* @method static Manga|Proxy find(object|array|mixed $criteria)
* @method static Manga|Proxy findOrCreate(array $attributes)
* @method static Manga|Proxy first(string $sortedField = 'id')
* @method static Manga|Proxy last(string $sortedField = 'id')
* @method static Manga|Proxy random(array $attributes = [])
* @method static Manga|Proxy randomOrCreate(array $attributes = [])
* @method static MangaRepository|RepositoryProxy repository()
* @method static Manga[]|Proxy[] all()
* @method static Manga[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static Manga[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static Manga[]|Proxy[] findBy(array $attributes)
* @method static Manga[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static Manga[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class MangaFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct(private SluggerInterface $slugger)
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
$title = self::faker()->words(rand(1, 3), true);
return [
'title' => $title,
'slug' => $this->slugger->slug($title)->lower(),
'imageUrl' => self::faker()->optional()->imageUrl(),
'publicationYear' => self::faker()->optional()->year(),
'description' => self::faker()->optional()->text(),
'genres' => self::faker()->optional()->words(rand(1, 5)),
'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'rating' => self::faker()->optional()->randomFloat(1, 0, 10),
'author' => self::faker()->optional()->name(),
'externalId' => self::faker()->optional()->uuid(),
'status' => self::faker()->optional()->randomElement(['ongoing', 'completed', 'hiatus']),
'thumbnailUrl' => self::faker()->optional()->imageUrl(150, 150),
'monitored' => self::faker()->boolean(),
'AlternativeSlugs' => self::faker()->optional()->words(3),
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(Manga $manga): void {})
;
}
protected static function getClass(): string
{
return Manga::class;
}
}

View File

@@ -1,71 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\Page;
use App\Repository\PageRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<Page>
*
* @method Page|Proxy create(array|callable $attributes = [])
* @method static Page|Proxy createOne(array $attributes = [])
* @method static Page|Proxy find(object|array|mixed $criteria)
* @method static Page|Proxy findOrCreate(array $attributes)
* @method static Page|Proxy first(string $sortedField = 'id')
* @method static Page|Proxy last(string $sortedField = 'id')
* @method static Page|Proxy random(array $attributes = [])
* @method static Page|Proxy randomOrCreate(array $attributes = [])
* @method static PageRepository|RepositoryProxy repository()
* @method static Page[]|Proxy[] all()
* @method static Page[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static Page[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static Page[]|Proxy[] findBy(array $attributes)
* @method static Page[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static Page[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class PageFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
return [
'chapter' => ChapterFactory::new(),
'imageLocalUrl' => 'https://placehold.co/770x1090',
'imageUrl' => 'https://placehold.co/770x1090',
'number' => self::faker()->randomNumber(2),
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(Page $page): void {})
;
}
protected static function getClass(): string
{
return Page::class;
}
}

View File

@@ -1,74 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\Source;
use App\Repository\SourceRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<Source>
*
* @method Source|Proxy create(array|callable $attributes = [])
* @method static Source|Proxy createOne(array $attributes = [])
* @method static Source|Proxy find(object|array|mixed $criteria)
* @method static Source|Proxy findOrCreate(array $attributes)
* @method static Source|Proxy first(string $sortedField = 'id')
* @method static Source|Proxy last(string $sortedField = 'id')
* @method static Source|Proxy random(array $attributes = [])
* @method static Source|Proxy randomOrCreate(array $attributes = [])
* @method static SourceRepository|RepositoryProxy repository()
* @method static Source[]|Proxy[] all()
* @method static Source[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static Source[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static Source[]|Proxy[] findBy(array $attributes)
* @method static Source[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static Source[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class SourceFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
return [
'name' => self::faker()->optional()->company(),
'description' => self::faker()->optional()->text(),
'baseUrl' => self::faker()->url(),
'scrappingParameters' => [],
'isActive' => self::faker()->boolean(),
'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'updatedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(Source $source): void {})
;
}
protected static function getClass(): string
{
return Source::class;
}
}

View File

@@ -1,79 +0,0 @@
<?php
namespace App\Factory;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<User>
*
* @method User|Proxy create(array|callable $attributes = [])
* @method static User|Proxy createOne(array $attributes = [])
* @method static User|Proxy find(object|array|mixed $criteria)
* @method static User|Proxy findOrCreate(array $attributes)
* @method static User|Proxy first(string $sortedField = 'id')
* @method static User|Proxy last(string $sortedField = 'id')
* @method static User|Proxy random(array $attributes = [])
* @method static User|Proxy randomOrCreate(array $attributes = [])
* @method static UserRepository|RepositoryProxy repository()
* @method static User[]|Proxy[] all()
* @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static User[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static User[]|Proxy[] findBy(array $attributes)
* @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static User[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class UserFactory extends ModelFactory
{
public const array FIRST_NAMES = ["ALAIN", "ALEXANDRE", "ANDRÉ", "ANNIE", "ANTHONY", "AUDREY", "AURÉLIE", "BERNARD", "BRIGITTE", "BRUNO", "CATHERINE", "CEDRIC", "CHANTAL", "CHRISTELLE", "CHRISTIAN", "CHRISTIANE", "CHRISTINE", "CHRISTOPHE", "CLAUDE", "CORINNE", "CÉLINE", "DANIEL", "DANIELLE", "DAVID", "DENISE", "DIDIER", "DOMINIQUE", "ELODIE", "EMILIE", "ENZO", "ERIC", "FABRICE", "FLORENCE", "FRANCK", "FRANÇOISE", "FRÉDÉRIC", "GEORGES", "GERMAINE", "GUILLAUME", "GUY", "GÉRARD", "HENRI", "ISABELLE", "JACQUELINE", "JACQUES", "JEAN", "JEAN-CLAUDE", "JEAN-PIERRE", "JEANNE", "JEANNINE", "JEREMY", "JEROME", "JONATHAN", "JOSEPH", "JULIE", "JULIEN", "KARINE", "KEVIN", "LAETITIA", "LAURA", "LAURENCE", "LAURENT", "LOUIS", "LUCAS", "LÉA", "MADELEINE", "MANON", "MARCEL", "MARCELLE", "MARGUERITE", "MARIE", "MARINE", "MARTINE", "MAURICE", "MAXIME", "MICHEL", "MICHÈLE", "MONIQUE", "NATHALIE", "NICOLAS", "NICOLE", "ODETTE", "OLIVIER", "PASCAL", "PASCALE", "PATRICIA", "PATRICK", "PAUL", "PAULETTE", "PHILIPPE", "PIERRE", "RENÉ", "ROBERT", "ROGER", "ROMAIN", "SANDRA", "SANDRINE", "SERGE", "SOPHIE", "STÉPHANE", "STÉPHANIE", "SUZANNE", "SYLVIE", "SÉBASTIEN", "THIERRY", "THOMAS", "THÉO", "VALÉRIE", "VIRGINIE", "VÉRONIQUE", "YVETTE", "YVONNE"];
public const array LAST_NAMES = ["Adam", "Andre", "Antoine", "Arnaud", "Aubert", "Aubry", "Bailly", "Barbier", "Baron", "Barre", "Barthelemy", "Benard", "Benoit", "Berger", "Bernard", "Bertin", "Bertrand", "Besson", "Blanc", "Blanchard", "Bonnet", "Boucher", "Bouchet", "Boulanger", "Bourgeois", "Bouvier", "Boyer", "Breton", "Brun", "Brunet", "Carlier", "Caron", "Carpentier", "Carre", "Charles", "Charpentier", "Chauvin", "Chevalier", "Chevallier", "Clement", "Colin", "Collet", "Collin", "Cordier", "Cousin", "Da Silva", "Daniel", "David", "Delaunay", "Denis", "Deschamps", "Dubois", "Dufour", "Dumas", "Dumont", "Dupont", "Dupuis", "Dupuy", "Durand", "Duval", "Etienne", "Fabre", "Faure", "Fernandez", "Fleury", "Fontaine", "Fournier", "Francois", "Gaillard", "Garcia", "Garnier", "Gauthier", "Gautier", "Gay", "Gerard", "Germain", "Gilbert", "Gillet", "Girard", "Giraud", "Gonzalez", "Grondin", "Guerin", "Guichard", "Guillaume", "Guillot", "Guyot", "Hamon", "Henry", "Herve", "Hoarau", "Hubert", "Huet", "Humbert", "Jacob", "Jacquet", "Jean", "Joly", "Julien", "Klein", "Lacroix", "Lambert", "Lamy", "Langlois", "Laporte", "Laurent", "Le Gall", "Le Goff", "Le Roux", "Leblanc", "Lebrun", "Leclerc", "Leclercq", "Lecomte", "Lefebvre", "Lefevre", "Leger", "Legrand", "Lejeune", "Lemaire", "Lemaitre", "Lemoine", "Leroux", "Leroy", "Leveque", "Lopez", "Louis", "Lucas", "Maillard", "Mallet", "Marchal", "Marchand", "Marechal", "Marie", "Martin", "Martinez", "Marty", "Masson", "Mathieu", "Menard", "Mercier", "Meunier", "Meyer", "Michaud", "Michel", "Millet", "Monnier", "Moreau", "Morel", "Morin", "Moulin", "Muller", "Nicolas", "Noel", "Olivier", "Paris", "Pasquier", "Payet", "Pelletier", "Perez", "Perret", "Perrier", "Perrin", "Perrot", "Petit", "Philippe", "Picard", "Pichon", "Pierre", "Poirier", "Poulain", "Prevost", "Remy", "Renard", "Renaud", "Renault", "Rey", "Reynaud", "Richard", "Riviere", "Robert", "Robin", "Roche", "Rodriguez", "Roger", "Rolland", "Rousseau", "Roussel", "Roux", "Roy", "Royer", "Sanchez", "Schmitt", "Schneider", "Simon", "Tessier", "Thomas", "Vasseur", "Vidal", "Vincent", "Weber"];
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
*/
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher)
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
*/
protected function getDefaults(): array
{
return [
'email' => self::faker()->unique()->email(),
'password' => 'password',
'roles' => [],
'firstName' => self::faker()->randomElement(self::FIRST_NAMES),
'lastName' => self::faker()->randomElement(self::LAST_NAMES),
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
->afterInstantiate(function (User $user): void {
$user->setPassword($this->passwordHasher->hashPassword(
$user,
$user->getPassword()
));
})
;
}
protected static function getClass(): string
{
return User::class;
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Form;
use App\Entity\AppSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AppSettingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('mangaDirectory', TextType::class, [
'label' => 'Manga Directory',
])
->add('imageDirectory', TextType::class, [
'label' => 'Image Directory',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AppSettings::class,
]);
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Form;
use App\Entity\ContentSource;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ContentSourceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('baseUrl', UrlType::class, [
'label' => 'Base URL',
])
->add('imageSelector', TextType::class, [
'label' => 'Image Selector',
])
->add('chapterUrlFormat', TextType::class, [
'label' => 'Chapter URL Format ({slug}, {chapterNumber})',
])
->add('nextPageSelector', TextType::class, [
'label' => 'Next Page Selector (let empty if vertical reader)',
'required' => false,
])
->add('ChapterSelector', TextType::class, [
'label' => 'Chapter Selector (required for Javascript scraping)',
'required' => false,
])
->add('scrapingType', ChoiceType::class, [
'label' => 'Scraping Type',
'choices' => [
'HTML' => 'html',
'JavaScript' => 'javascript'
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContentSource::class,
]);
}
}

View File

@@ -1,95 +0,0 @@
<?php
namespace App\Form;
use App\Entity\Manga;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MangaEditType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Titre',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']
])
->add('slug', TextType::class, [
'label' => 'Slug',
'attr' => [
'readonly' => true,
'class' => 'bg-gray-100 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'
],
])
->add('alternativeSlugs', CollectionType::class, [
'entry_type' => TextType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'label' => false,
'prototype' => true,
'entry_options' => ['attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'], 'label' => false],
'required' => false,
])
->add('publicationYear', NumberType::class, [
'label' => 'Année de publication',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']
])
->add('description', TextareaType::class, [
'label' => 'Description',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500', 'rows' => 8]
])
->add('genres', CollectionType::class, [
'entry_type' => TextType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'label' => 'Genres',
'entry_options' => ['attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']],
'required' => false,
])
->add('rating', NumberType::class, [
'label' => 'Note',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
'required' => false,
])
->add('author', TextType::class, [
'label' => 'Auteur',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
'required' => false,
])
->add('status', TextType::class, [
'label' => 'Statut',
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
'required' => false,
])
;
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
$manga = $event->getForm()->getData();
if ($manga && $manga->getSlug()) {
$data['slug'] = $manga->getSlug();
}
$event->setData($data);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Manga::class,
]);
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace App\Interface;
interface ClientInterface
{
public function get(string $endpoint, array $params = []): array;
public function post(string $endpoint, array $data): array;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Interface;
use App\Entity\Manga;
interface ContentProviderInterface
{
public function getAvailableContent(Manga $manga): array;
public function getContent(Manga $manga): array;
}

View File

@@ -1,10 +0,0 @@
<?php
namespace App\Interface;
use Doctrine\Common\Collections\Collection;
interface MetadataProviderInterface
{
public function search(string $title): Collection;
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Manager;
use App\Entity\AppSettings;
use Doctrine\ORM\EntityManagerInterface;
class AppSettingsManager
{
private const string DEFAULT_MANGA_DIRECTORY = '/manga_data';
private const string DEFAULT_IMAGE_DIRECTORY = '/image_data';
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getSettings(): AppSettings
{
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
if (!$settings) {
$settings = $this->createDefaultSettings();
}
return $settings;
}
public function updateSettings(AppSettings $newSettings): void
{
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
if (!$settings) {
$settings = new AppSettings();
}
$settings->setMangaDirectory($newSettings->getMangaDirectory());
$settings->setImageDirectory($newSettings->getImageDirectory());
$this->entityManager->persist($settings);
$this->entityManager->flush();
}
private function createDefaultSettings(): AppSettings
{
$settings = new AppSettings();
$settings->setMangaDirectory(self::DEFAULT_MANGA_DIRECTORY);
$settings->setImageDirectory(self::DEFAULT_IMAGE_DIRECTORY);
$this->entityManager->persist($settings);
$this->entityManager->flush();
return $settings;
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Manager;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\String\Slugger\SluggerInterface;
class FileSystemManager
{
private const string CBZ_DIRECTORY = 'public/cbz';
private const string UPLOADS_DIRECTORY = 'public/tmp';
private const string IMAGES_DIRECTORY = 'public/images';
private string $mangaDirectory;
private string $imageDirectory;
public function __construct(
private readonly string $projectDir,
private readonly Filesystem $filesystem,
private readonly SluggerInterface $slugger,
private readonly AppSettingsManager $appSettingsManager
) {
$this->loadSettings();
}
private function loadSettings(): void
{
$settings = $this->appSettingsManager->getSettings();
$this->mangaDirectory = $settings->getMangaDirectory();
$this->imageDirectory = $settings->getImageDirectory();
}
public function getMangaDirectory(): string
{
return $this->mangaDirectory;
}
public function getImageDirectory(): string
{
return $this->imageDirectory;
}
public function getImagePath(string $subDir = ''): string
{
if (!$this->filesystem->exists($this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : ''))) {
$this->filesystem->mkdir($this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : ''), 0755);
}
return $this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : '');
}
public function createMangaDirectory(string $mangaSlug, ?int $year): string
{
$year = $year ?? 'unknown';
$directoryPath = $this->projectDir.'/'.self::CBZ_DIRECTORY.'/'.ucfirst($mangaSlug)." ($year)";
$this->filesystem->mkdir($directoryPath, 0755);
return $directoryPath;
}
public function createVolumeDirectory(string $mangaDir, int $volume): string
{
$volumeDir = sprintf('%s/volume_%02d', $mangaDir, $volume);
$this->filesystem->mkdir($volumeDir, 0755);
return $volumeDir;
}
public function moveUploadedFile(string $sourcePath, string $destinationDir, string $originalFilename): string
{
$newFilename = $this->generateUniqueFilename($originalFilename);
$destinationPath = $destinationDir.'/'.$newFilename;
$this->filesystem->rename($sourcePath, $destinationPath, true);
return $destinationPath;
}
public function deleteFile(string $filePath): void
{
if ($this->filesystem->exists($filePath)) {
$this->filesystem->remove($filePath);
}
}
public function deleteDirectory(string $directoryPath): void
{
if ($this->filesystem->exists($directoryPath)) {
$this->filesystem->remove($directoryPath);
}
}
public function fileExists(string $filePath): bool
{
return $this->filesystem->exists($filePath);
}
public function moveFile(string $sourcePath, string $destinationPath): void
{
$this->filesystem->rename($sourcePath, $destinationPath, true);
}
public function getUploadsDirectory(): string
{
return $this->projectDir.'/'.self::UPLOADS_DIRECTORY;
}
private function generateUniqueFilename(string $originalFilename): string
{
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));
return $safeFilename.'-'.uniqid().'.'.pathinfo($originalFilename, PATHINFO_EXTENSION);
}
public function generateUniqueImageFilename(string $originalFilename): string
{
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));
return $safeFilename.'-'.uniqid().'.jpg';
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarButton;
use App\Manager\Toolbar\Element\ToolbarDivider;
class ActivityToolbar extends Toolbar
{
public function __construct(array $contextData = [])
{
$this
->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh', 'toolbar#refreshActivity'))
->addToLeftGroup(new ToolbarDivider())
->addToLeftGroup(new ToolbarButton('trash-can', 'Remove Selected', 'toolbar#removeActivity'))
->addToRightGroup(new ToolbarButton('th-large', 'Options', 'toolbar#optionActivity'))
;
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarButton;
use App\Manager\Toolbar\Element\ToolbarDivider;
class ChapterListToolbar extends Toolbar
{
public function __construct(array $contextData = [])
{
$monitoredTitle = $contextData['isMonitored'] ? 'Monitored' : 'Monitoring';
$monitoredColor = $contextData['isMonitored'] ? 'text-green-500' : 'text-white';
$this
->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh metadata', 'toolbar#refreshMetadata', $contextData))
->addToLeftGroup(new ToolbarDivider())
->addToLeftGroup(new ToolbarButton('keyboard', 'Rename chapters', 'toolbar#renameChapters'))
->addToLeftGroup(new ToolbarButton('file-zipper', 'Manage cbz', 'toolbar#manageCbz', $contextData))
->addToLeftGroup(new ToolbarButton('gear', 'Preferred Sources', 'toolbar#editPreferredSources', $contextData))
->addToRightGroup(new ToolbarButton('bookmark', $monitoredTitle, 'toolbar#monitoring', array_merge($contextData, ['buttonClass' => $monitoredColor])))
->addToRightGroup(new ToolbarButton('wrench', 'Edit', 'toolbar#editManga', $contextData))
->addToRightGroup(new ToolbarButton('trash-can', 'Delete', 'toolbar#deleteManga', $contextData))
->addToRightGroup(new ToolbarDivider())
->addToRightGroup(new ToolbarButton('chevron-down', 'Expand all', 'toolbar#expandAll'));
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarButton;
use App\Manager\Toolbar\Element\ToolbarDivider;
use App\Manager\Toolbar\Element\ToolbarDropdown;
class MangaListToolbar extends Toolbar
{
public function __construct(array $contextData = [])
{
$this->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh', 'toolbar#refreshMetadata'))
->addToLeftGroup(new ToolbarButton('search', 'Search', 'toolbar#searchLastChapter'))
->addToRightGroup(new ToolbarButton('th-large', 'Options', 'toolbar#options'))
->addToRightGroup(new ToolbarDivider())
->addToRightGroup(new ToolbarDropdown('eye', 'View', 'changeView', [
['text' => 'Poster View', 'action' => 'changeView', 'data' => ['view' => 'poster']],
['text' => 'Table View', 'action' => 'changeView', 'data' => ['view' => 'table']],
['text' => 'Resume View', 'action' => 'changeView', 'data' => ['view' => 'resume']]
]))
->addToRightGroup(new ToolbarDropdown('sort', 'Sort', 'sort', [
['text' => 'Par titre', 'action' => 'sort', 'data' => ['sort' => 'title']],
['text' => 'Par année de publication', 'action' => 'sort', 'data' => ['sort' => 'publicationYear']],
['text' => 'Par date d\'ajout', 'action' => 'sort', 'data' => ['sort' => 'createdAt']]
]))
->addToRightGroup(new ToolbarDropdown('filter', 'Filter', 'filter', [
['text' => 'Tous les mangas', 'action' => 'filter', 'data' => ['filter' => 'all']],
['text' => 'Mangas en cours', 'action' => 'filter', 'data' => ['filter' => 'ongoing']],
['text' => 'Mangas terminés', 'action' => 'filter', 'data' => ['filter' => 'completed']]
]))
;
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarButton;
use App\Manager\Toolbar\Element\ToolbarDivider;
class ScraperListToolbar extends Toolbar
{
public function __construct(array $contextData = [])
{
$this->addToRightGroup(new ToolbarButton('file-import', 'Import Json', 'toolbar#openImportModal'))
->addToRightGroup(new ToolbarDivider())
->addToRightGroup(new ToolbarButton('file-export', 'Export Json', 'toolbar#openExportModal'));
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarElement;
abstract class Toolbar
{
private array $leftGroup = [];
private array $rightGroup = [];
public function addToLeftGroup(ToolbarElement $element): self
{
$this->leftGroup[] = $element;
return $this;
}
public function addToRightGroup(ToolbarElement $element): self
{
$this->rightGroup[] = $element;
return $this;
}
public function getGroups(): array
{
return [
'leftGroup' => $this->leftGroup,
'rightGroup' => $this->rightGroup,
];
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Manager\Toolbar\Element;
abstract class AbstractToolbarElement implements ToolbarElement
{
protected string $icon;
protected string|array $text;
protected string $action;
public function __construct(string $icon, string|array $text, string $action)
{
$this->icon = $icon;
$this->text = $text;
$this->action = $action;
}
public function getIcon(): string
{
return $this->icon;
}
public function getText(): string|array
{
return $this->text;
}
public function getAction(): string
{
return $this->action;
}
public function getAdditionalProperties(): array
{
return [];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Manager\Toolbar\Element;
class ToolbarButton extends AbstractToolbarElement
{
protected array $data;
public function __construct(string $icon, string $label, string $action, array $data = [])
{
parent::__construct($icon, $label, $action);
$this->data = $data;
}
public function getType(): string
{
return 'button';
}
public function getAdditionalProperties(): array
{
return ['data' => $this->data];
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Manager\Toolbar\Element;
class ToolbarDivider extends AbstractToolbarElement
{
public function __construct()
{
parent::__construct('divider', '', '');
}
public function getType(): string
{
return 'divider';
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Manager\Toolbar\Element;
class ToolbarDropdown extends AbstractToolbarElement
{
private array $items;
public function __construct(string $icon, string $text, string $action, array $items)
{
parent::__construct($icon, $text, $action);
$this->items = $items;
}
public function getType(): string
{
return 'dropdown';
}
public function getAdditionalProperties(): array
{
return ['items' => $this->items];
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Manager\Toolbar\Element;
interface ToolbarElement
{
public function getIcon(): string;
public function getText(): string|array;
public function getAction(): string;
public function getType(): string;
public function getAdditionalProperties(): array;
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Manager\Toolbar\Factory;
use App\Manager\Toolbar\Definition\ActivityToolbar;
use App\Manager\Toolbar\Definition\ChapterListToolbar;
use App\Manager\Toolbar\Definition\MangaListToolbar;
use App\Manager\Toolbar\Definition\ScraperListToolbar;
use App\Manager\Toolbar\Definition\Toolbar;
class ToolbarFactory
{
public function createToolbar(string $type, array $context = []): Toolbar
{
return match ($type) {
'manga_list' => new MangaListToolbar(),
'chapter_list' => new ChapterListToolbar($context),
'activity' => new ActivityToolbar($context),
'scraper_list' => new ScraperListToolbar($context),
default => throw new \InvalidArgumentException("Unknown toolbar type: $type"),
};
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Message;
readonly class DownloadChapter
{
public function __construct(private int $chapterId)
{
}
public function getChapterId(): int
{
return $this->chapterId;
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Message;
final class RefreshAndDownloadChapters
{
/*
* Add whatever properties and methods you need
* to hold the data for this message class.
*/
// private $name;
// public function __construct(string $name)
// {
// $this->name = $name;
// }
// public function getName(): string
// {
// return $this->name;
// }
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Message;
readonly class RefreshMetadata
{
public function __construct(private int $mangaId)
{
}
public function getMangaId(): int
{
return $this->mangaId;
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\MessageHandler;
use App\Entity\ContentSource;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use App\Repository\ContentSourceRepository;
use App\Service\NotificationService;
use App\Service\Scraper\MangaScraperService;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class DownloadChapterHandler
{
public function __construct(
private ChapterRepository $chapterRepository,
private MangaScraperService $mangaScraperService,
private NotificationService $notificationService,
private ContentSourceRepository $contentSourceRepository
) {
}
/**
* @throws \Exception
*/
public function __invoke(DownloadChapter $message): void
{
$chapter = $this->chapterRepository->find($message->getChapterId());
if (!$chapter) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter not found.']);
throw new BadRequestHttpException('Chapter not found');
} elseif (null !== $chapter->getCbzPath()) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter already scraped.']);
throw new BadRequestHttpException('Chapter already downloaded');
}
$manga = $chapter->getManga();
$preferredSources = $manga->getPreferredSources()->toArray();
$allSources = $this->contentSourceRepository->findAll();
$filteredSources = array_udiff($allSources, $preferredSources, function ($a, $b) {
return $a->getId() - $b->getId();
});
$sources = array_merge($preferredSources, $filteredSources);
if (count($preferredSources) > 0) {
$sources = $preferredSources;
} else {
$sources = $allSources;
}
// $sources[] =
// (new ContentSource())
// ->setBaseUrl('https://api.mangadex.org/')
// ->setImageSelector('img')
// ->setChapterUrlFormat('at-home/server/%s')
// ->setScrapingType('mangadex');
// (new ContentSource())
// ->setBaseUrl('https://lelscans.net')
// ->setImageSelector('#image img')
// ->setChapterUrlFormat('https://lelscans.net/scan-%s/%s')
// ->setNextPageSelector('a[title="Suivant"]')
// ->setScrapingType('html'),
// (new ContentSource())
// ->setBaseUrl('https://darkscans.net/')
// ->setImageSelector('.reading-content img')
// ->setChapterUrlFormat('https://darkscans.net/mangas/%s/chapter-%s/')
// ->setNextPageSelector(null)
// ->setScrapingType('html')
$scrapedSuccessfully = false;
foreach ($sources as $source) {
try {
$this->mangaScraperService->scrapeChapter($chapter, $source);
$scrapedSuccessfully = true;
break;
} catch (\Exception $e) {
$this->notificationService->sendUpdate([
'status' => 'warning',
'message' => 'An error occurred while scraping with source: '.$source->getBaseUrl().'. Trying next source...',
]);
} catch (GuzzleException $e) {
}
}
if (!$scrapedSuccessfully) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'All sources failed to scrape the chapter '.$chapter->getManga()->getTitle().' '.$chapter->getNumber().'.',
]);
throw new \Exception('All sources failed to scrape the chapter '.$chapter->getManga()->getTitle().' '.$chapter->getNumber().'.');
}
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Chapter scraped successfully.']);
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\MessageHandler;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Message\DownloadChapter;
use App\Message\RefreshAndDownloadChapters;
use App\Repository\MangaRepository;
use App\Service\MangadexProvider;
use App\Service\NotificationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class RefreshAndDownloadChaptersHandler
{
public function __construct(
private MangaRepository $mangaRepository,
private MangadexProvider $mangadexProvider,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus
) {
}
public function __invoke(RefreshAndDownloadChapters $message): void
{
$mangas = $this->mangaRepository->findBy(['monitored' => true]);
foreach ($mangas as $manga) {
$chapters = $this->refreshMangas($manga);
if (empty($chapters)) {
continue;
}
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
}
}
}
private function refreshMangas(Manga $manga): array
{
$lastChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
foreach ($lastChapters as $chapter) {
$this->entityManager->persist($chapter);
}
$this->entityManager->persist($manga);
$this->entityManager->flush();
return $lastChapters;
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\MessageHandler;
use App\Message\RefreshMetadata;
use App\Repository\MangaRepository;
use App\Service\MangadexProvider;
use App\Service\NotificationService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class RefreshMetadataHandler
{
public function __construct(
private MangaRepository $mangaRepository,
private MangadexProvider $mangadexProvider,
private EntityManagerInterface $entityManager,
private NotificationService $notificationService
) {
}
public function __invoke(RefreshMetadata $message): void
{
$manga = $this->mangaRepository->find($message->getMangaId());
if (!$manga) {
return;
}
$lastChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
try {
foreach ($lastChapters as $chapter) {
$this->entityManager->persist($chapter);
}
$this->entityManager->persist($manga);
$this->entityManager->flush();
} catch (\Exception $e) {
if ($e instanceof UniqueConstraintViolationException) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while refreshing ' . $manga->getTitle() . '.']);
return;
}
}
$this->notificationService->sendUpdate(['status' => 'success', 'message' => $manga->getTitle() . ' refreshed, ' . count($lastChapters) . ' new chapters added.']);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Scheduler;
use App\Message\RefreshAndDownloadChapters;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
// Désactivé : remplacé par MonitoringSchedule (DDD) dans src/Domain/Manga/Infrastructure/Scheduler/
class MainSchedule implements ScheduleProviderInterface
{
public function __construct(private CacheInterface $cache)
{
}
#[\Override] public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::every('6 hours', new RefreshAndDownloadChapters())
)
->stateful($this->cache);
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Service;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
class ActivityService
{
public function __construct(private HubInterface $hub)
{
}
public function sendUpdate(mixed $data): void
{
$update = new Update('activity', json_encode($data));
$this->hub->publish($update);
}
}

View File

@@ -1,67 +0,0 @@
<?php
namespace App\Service;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
class CbrToCbzConverter
{
private string $tempDir;
private Filesystem $filesystem;
public function __construct(string $projectDir)
{
$this->tempDir = $projectDir . '/public/tmp';
$this->filesystem = new Filesystem();
}
public function convert(string $cbrPath): string
{
$tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_');
$this->filesystem->mkdir($tempDir);
$extractDir = $tempDir . '/extract';
$this->filesystem->mkdir($extractDir);
$process = new Process(['unrar-free', 'x', $cbrPath, $extractDir]);
$process->run();
// Si unrar échoue, essayer avec 7z
if (!$process->isSuccessful()) {
$process = new Process(['7z', 'x', $cbrPath, "-o$extractDir"]);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput());
}
}
// Créer le CBZ
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz';
$cbzPath = $this->tempDir . '/' . $cbzFileName;
$zip = new \ZipArchive();
if ($zip->open($cbzPath, \ZipArchive::CREATE) !== true) {
throw new \RuntimeException("Cannot create ZIP file");
}
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($extractDir),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
if (!$file->isDir()) {
$filePath = $file->getRealPath();
$relativePath = substr($filePath, strlen($extractDir) + 1);
$zip->addFile($filePath, $relativePath);
}
}
$zip->close();
$this->filesystem->remove($tempDir);
return $cbzPath;
}
}

View File

@@ -1,221 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Manga;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\String\Slugger\SluggerInterface;
class CbzService
{
public function __construct(private SluggerInterface $slugger)
{
}
/**
* @throws \Exception
*/
public function extractMetadata(string $filePath, string $originalFileName): array
{
$zip = new \ZipArchive();
$fileInfo = $this->extractInfoFromFileName($originalFileName);
$metadata['title'] = $fileInfo['title'];
$metadata['volume'] = null !== $fileInfo['volume'] ? (int) $fileInfo['volume'] : null;
$metadata['chapter'] = null !== $fileInfo['chapter'] ? (int) $fileInfo['chapter'] : null;
if (is_null($metadata['chapter'])) {
try {
$zip->open($filePath);
$chapterNumbers = [];
for ($i = 0; $i < $zip->numFiles; ++$i) {
$stat = $zip->statIndex($i);
$fileName = $stat['name'];
$chapterNumbers[] = $this->extractChapter($fileName);
}
$chapterNumbers = array_unique($chapterNumbers);
if (1 === count($chapterNumbers)) {
$metadata['chapter'] = '' === array_values($chapterNumbers)[0] ? null : (int) array_values($chapterNumbers)[0];
} elseif (count($chapterNumbers) > 1) {
$metadata['chapter'] = min($chapterNumbers);
}
$zip->close();
} catch (\Exception $e) {
throw new \Exception("Impossible d'ouvrir le fichier CBZ. ".$e->getMessage());
}
}
return $metadata;
}
public function getPageContent(string $cbzPath, int $pageNumber): ?string
{
$zip = new \ZipArchive();
if (true === $zip->open($cbzPath)) {
$images = $this->getImageList($zip);
if (isset($images[$pageNumber - 1])) {
$content = $zip->getFromName($images[$pageNumber - 1]);
$zip->close();
return $content;
}
$zip->close();
}
return null;
}
public function getPageCount(string $cbzPath): int
{
$zip = new \ZipArchive();
if (true === $zip->open($cbzPath)) {
$count = count($this->getImageList($zip));
$zip->close();
return $count;
}
return 0;
}
private function extractInfoFromFileName(string $fileName): array
{
$title = $this->extractTitle($fileName);
$volume = $this->extractVolume($fileName);
$chapter = $this->extractChapter($fileName);
return [
'title' => '' === $title ? null : $title,
'volume' => '' === $volume ? null : $volume,
'chapter' => '' === $chapter ? null : $chapter,
];
}
private function extractTitle(string $fileName): string
{
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol\.?|[Tt]|[Cc]hap(?:itre|ter)?)\s*\d+)/';
if (preg_match($titlePattern, $fileName, $matches)) {
return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString();
}
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString();
}
return $this->slugger->slug(pathinfo($fileName, PATHINFO_FILENAME), '-')->lower()->toString();
}
private function extractVolume(string $fileName): string
{
$volumePattern = '/(?:[Tt]ome|[Vv]ol\.?|[Tt])\s*(?P<volume>\d+)/';
if (preg_match($volumePattern, $fileName, $matches)) {
return str_pad($matches['volume'], 2, '0', STR_PAD_LEFT);
}
return '';
}
private function extractChapter(string $fileName): string
{
$chapterPattern = '/[Cc]hap(?:itre|ter)?\s*(?P<chapter>\d+)/';
if (preg_match($chapterPattern, $fileName, $matches)) {
return $matches['chapter'];
}
$newFormatPattern = '/_(?P<chapter>\d+)(?:\.\w+)?$/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return $matches['chapter'];
}
return '';
}
private function getImageList(\ZipArchive $zip): array
{
$images = [];
for ($i = 0; $i < $zip->numFiles; ++$i) {
$filename = $zip->getNameIndex($i);
if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $filename)) {
$images[] = $filename;
}
}
sort($images);
return $images;
}
public function createVolumeArchive(array $chapters): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'volume_cbz_');
$zip = new \ZipArchive();
if (true !== $zip->open($tempFile, \ZipArchive::CREATE)) {
throw new \RuntimeException('Impossible de créer le fichier ZIP temporaire.');
}
foreach ($chapters as $chapter) {
$chapterZip = new \ZipArchive();
if (true === $chapterZip->open($chapter->getCbzPath())) {
for ($i = 0; $i < $chapterZip->numFiles; ++$i) {
$filename = $chapterZip->getNameIndex($i);
$fileContent = $chapterZip->getFromIndex($i);
$zip->addFromString('Chapter '.$chapter->getNumber().'/'.$filename, $fileContent);
}
$chapterZip->close();
}
}
$zip->close();
return $tempFile;
}
public function generateFileName(Manga $manga, int $volume = null, float $chapterNumber = null): string
{
$sluggedTitle = $this->slugger->slug($manga->getTitle())->lower();
if (null !== $volume) {
return sprintf('%s_volume_%02d.cbz', $sluggedTitle, $volume);
} elseif (null !== $chapterNumber) {
return sprintf('%s_chapter_%s.cbz', $sluggedTitle, number_format($chapterNumber, 2));
} else {
throw new \InvalidArgumentException('Either volume or chapter number must be provided');
}
}
public function createBinaryFileResponse(string $filePath, string $fileName): BinaryFileResponse
{
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$fileName
);
return $response;
}
public function areAllChaptersCbzIdentical(array $chapters): bool
{
if (empty($chapters)) {
return false;
}
$firstCbzPath = $chapters[0]->getCbzPath();
return array_reduce($chapters, function ($carry, $chapter) use ($firstCbzPath) {
return $carry && $chapter->getCbzPath() === $firstCbzPath;
}, true);
}
public function doAllChaptersHaveCbz(array $chapters): bool
{
return array_reduce($chapters, function ($carry, $chapter) {
return $carry && null !== $chapter->getCbzPath();
}, true);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Service;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
class ChapterUrlGenerator
{
private string $chapterUrlFormat;
public function __construct(string $chapterUrlFormat)
{
$this->chapterUrlFormat = $chapterUrlFormat;
$this->validateUrlFormat($chapterUrlFormat);
}
public function getChapterUrl(string $mangaTitle, float $chapterNumber): string
{
$placeholders = [
'{chapterNumber}' => $chapterNumber,
'{slug}' => $mangaTitle,
];
return str_replace(array_keys($placeholders), array_values($placeholders), $this->chapterUrlFormat);
}
private function validateUrlFormat(string $format): void
{
if (!str_contains($format, '{slug}')) {
throw new InvalidArgumentException("The URL format must contain both {slug} and {chapterNumber} placeholders.");
}
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Manager\FileSystemManager;
use App\Repository\ChapterRepository;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\String\Slugger\SluggerInterface;
readonly class MangaImportService
{
public function __construct(
private FileSystemManager $fileSystemManager,
private EntityManagerInterface $entityManager,
private ChapterRepository $chapterRepository,
private SluggerInterface $slugger
) {
}
/**
* @throws Exception
*/
public function importFile(Manga $manga, ?int $volume, ?Chapter $chapter, string $tempFilePath): void
{
if ($chapter !== null) {
$this->importChapter($manga, $chapter, $tempFilePath);
} elseif ($volume !== null) {
$this->importVolume($manga, $volume, $tempFilePath);
} else {
throw new \RuntimeException("Impossible de déterminer s'il s'agit d'un volume ou d'un chapitre.");
}
}
/**
* @throws Exception
*/
private function importVolume(Manga $manga, int $volume, string $tempFilePath): void
{
$permanentFileName = $this->createPermanentFileName($manga, $volume);
$mangaDirectory = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
$volumeDirectory = $this->fileSystemManager->createVolumeDirectory($mangaDirectory, $volume);
$permanentFilePath = $volumeDirectory . '/' . $permanentFileName;
if ($this->fileSystemManager->fileExists($permanentFilePath)) {
throw new \RuntimeException("Un fichier pour ce volume existe déjà.");
}
$this->fileSystemManager->moveFile($tempFilePath, $permanentFilePath);
$this->updateVolumeChapters($manga, $volume, $permanentFilePath);
$this->entityManager->flush();
}
/**
* @throws Exception
*/
private function importChapter(Manga $manga, Chapter $chapter, string $tempFilePath): void
{
$volume = $chapter->getVolume();
$permanentFileName = $this->createPermanentFileName($manga, $volume, $chapter->getNumber());
$mangaDirectory = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
$volumeDirectory = $this->fileSystemManager->createVolumeDirectory($mangaDirectory, $chapter->getVolume());
$permanentFilePath = $volumeDirectory . '/' . $permanentFileName;
if ($this->fileSystemManager->fileExists($permanentFilePath)) {
throw new \RuntimeException("Un fichier pour ce chapitre existe déjà.");
}
$this->fileSystemManager->moveFile($tempFilePath, $permanentFilePath);
$chapter->setCbzPath($permanentFilePath);
$this->entityManager->flush();
}
private function createPermanentFileName(Manga $manga, int $volume, ?float $chapterNumber = null): string
{
$baseFileName = $this->slugger->slug($manga->getTitle()) . '_vol' . sprintf('%02d', $volume);
if ($chapterNumber !== null) {
$baseFileName .= '_ch' . $chapterNumber;
}
return $baseFileName . '.cbz';
}
private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void
{
$chapters = $this->chapterRepository->findBy([
'manga' => $manga,
'volume' => $volume
]);
if (empty($chapters)) {
throw new \RuntimeException("Aucun chapitre trouvé pour le volume $volume en base de données.");
}
foreach ($chapters as $chapter) {
$chapter->setCbzPath($cbzPath);
}
}
}

View File

@@ -1,625 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Entity\ContentSource;
use App\Event\PageScrappingProgressEvent;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Facebook\WebDriver\Remote\RemoteWebElement;
use Facebook\WebDriver\WebDriverExpectedCondition;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Panther\Client as PantherClient;
class MangaScraperService
{
public const string PUBLIC_CBZ = '/public/cbz';
public function __construct(
private readonly string $projectDir,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly EntityManagerInterface $entityManager,
private readonly MangaRepository $mangaRepository,
) {
}
private function extractMangaPageData(string $html, ContentSource $mangaSource): array
{
$crawler = new Crawler($html);
$imgUrl = $crawler->filter($mangaSource->getImageSelector())->attr('src')
?? $crawler->filter($mangaSource->getImageSelector())->attr('data-src');
// dd($imgUrl);
// if (empty($imgUrl)) {
// throw new \Exception('No valid image found on the page.');
// }
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
$nextUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null;
// Convert relative URLs to absolute URLs
if (!preg_match('/^https?:\/\//', $imgUrl)) {
$urlComponents = parse_url($mangaSource->getBaseUrl());
$scheme = $urlComponents['scheme'];
$host = $urlComponents['host'];
$imgUrl = $scheme . '://' . $host . '/' . ltrim($imgUrl, '/');
}
return [
'image_url' => $imgUrl,
'next_page_url' => $nextUrl,
];
}
/**
* @throws GuzzleException
*/
public function scrapeManga(Manga $manga, ContentSource $mangaSource): array
{
$allChaptersData = [];
foreach ($manga->getChapters() as $chapter) {
$chapterData = $this->scrapeChapter($chapter, $mangaSource);
if ($chapterData !== false) {
$allChaptersData[$chapter->getNumber()] = $chapterData;
}
}
return $allChaptersData;
}
/**
* @throws GuzzleException
* @throws Exception
*/
public function scrapeChapter(Chapter $chapter, ContentSource $mangaSource): array|bool
{
return match ($mangaSource->getScrapingType()) {
'html' => $this->scrapeChapterHtml($chapter->getManga(), $chapter, $mangaSource),
'javascript' => $this->scrapeChapterJavaScript($chapter->getManga(), $chapter, $mangaSource),
'mangadex' => $this->scrapeChapterMangadex($chapter, $mangaSource),
default => throw new Exception('Unsupported scraping type: ' . $mangaSource->getScrapingType()),
};
}
/**
* @throws GuzzleException
* @throws Exception
*/
private function scrapeChapterMangadex(Chapter $chapter, ContentSource $mangaSource): bool
{
$client = new Client();
$chapterUrl = $mangaSource->getBaseUrl() . sprintf($mangaSource->getChapterUrlFormat(), $chapter->getExternalId());
$manga = $chapter->getManga();
$pageData = [];
$response = $client->get($chapterUrl);
$results = json_decode($response->getBody()->getContents(), true);
if ($results['result'] !== 'ok' || count($results['chapter']['dataSaver']) === 0) {
throw new Exception('Error while fetching chapter data from Mangadex ' . $manga->getTitle() . ' ' . $chapter->getNumber());
}
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
mkdir($tempDir);
foreach ($results['chapter']['dataSaver'] as $index => $page) {
$pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page;
$imagePath = $tempDir . '/' . sprintf('%03d.%s', $index + 1, pathinfo($page, PATHINFO_EXTENSION));
$this->downloadAndSaveImage($pageUrl, $imagePath);
$event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, count($results['chapter']['dataSaver']));
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$pageData[] = [
'image_url' => $pageUrl,
'local_image_url' => $imagePath,
'page_number' => $index + 1,
];
}
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
$this->createCbzFile($tempDir, $pageData, $cbzFilePath);
$chapter->setCbzPath($cbzFilePath);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
// Nettoyage du répertoire temporaire
$this->cleanupTempFiles($tempDir);
return true;
}
private function scrapeChapterJavascript(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool
{
$pantherClient = PantherClient::createChromeClient();
$chapterUrl = $mangaSource->getChapterUrl($manga->getSlug(), $chapter->getNumber());
$pantherClient->request('GET', $chapterUrl);
// Sélection du chapitre dans le menu déroulant
try {
$crawler = $pantherClient->waitFor('body');
$select = $crawler->filter('#selectChapitres');
if ($select->count() > 0) {
$chapterNumber = $chapter->getNumber();
$options = $select->filter('option');
$targetindex = null;
/** @var RemoteWebElement $option */
foreach ($options->getIterator() as $index => $option) {
$optionText = $option->getText();
// Recherche plus flexible du numéro de chapitre
if (preg_match("/\b{$chapterNumber}\b/", $optionText)) {
$targetIndex = $index;
break;
}
}
if ($targetIndex !== null) {
$pantherClient->executeScript("
var select = document.querySelector('#selectChapitres');
select.selectedIndex = $targetIndex;
select.dispatchEvent(new Event('change'));
");
// Attendre que la page se mette à jour après la sélection
$pantherClient->wait(60000)->until( // 60 secondes de timeout
function ($driver) {
return $driver->executeScript("
var scansPlacement = document.querySelector('#scansPlacement');
if (!scansPlacement) return false;
var lazyImages = scansPlacement.querySelectorAll('img.lazy');
var loadingGif = scansPlacement.querySelector('img[src*=\"loading_scans.gif\"]');
// Vérifier que toutes les images lazy sont chargées et que le GIF de chargement n'est plus présent
var allImagesLoaded = Array.from(lazyImages).every(img => img.complete && img.naturalWidth > 0);
return lazyImages.length > 0 && allImagesLoaded && !loadingGif;
");
}
);
} else {
throw new \Exception("Chapitre $chapterNumber non trouvé dans le menu déroulant");
}
}
} catch (\Exception $e) {
// $this->logger->warning('Erreur lors de la sélection du chapitre : ' . $e->getMessage());
$pantherClient->close();
return false;
}
$pageData = [];
try {
if ($mangaSource->getNextPageSelector() === null) {
// Lecteur vertical
$pageData = $this->scrapeVerticalReaderJavascript($pantherClient, $mangaSource, $chapter);
} else {
// Lecteur horizontal
$pageData = $this->scrapeHorizontalReaderJavascript($pantherClient, $mangaSource, $chapter);
}
} catch (\Exception $e) {
throw $e;
// $this->logger->warning('Erreur lors du scraping du chapitre ' . $chapter->getNumber() . ' du manga ' . $manga->getTitle() . ': ' . $e->getMessage());
} finally {
$pantherClient->close();
}
return $pageData;
}
private function scrapeVerticalReaderJavascript(PantherClient $pantherClient, ContentSource $mangaSource, Chapter $chapter): array
{
$pageData = [];
$pageNumber = 1;
$crawler = $pantherClient->waitFor($mangaSource->getImageSelector());
$images = $crawler->filter($mangaSource->getImageSelector());
foreach ($images->getIterator() as $image) {
$imageUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
$pageData[] = [
'image_url' => $this->cleanImageUrl($imageUrl),
'page_number' => $pageNumber,
];
$event = new PageScrappingProgressEvent($chapter->getId(), $pageNumber, $images->count());
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$pageNumber++;
}
return $pageData;
}
private function scrapeHorizontalReaderJavascript(PantherClient $pantherClient, ContentSource $mangaSource, Chapter $chapter): array
{
$pageData = [];
$pageNumber = 1;
while (true) {
try {
$crawler = $pantherClient->waitFor($mangaSource->getImageSelector());
$imageElement = $crawler->filter($mangaSource->getImageSelector())->first();
if ($imageElement->count() === 0) {
break; // Fin du chapitre
}
$imageUrl = $imageElement->attr('src') ?: $imageElement->attr('data-src');
$pageData[] = [
'image_url' => $this->cleanImageUrl($imageUrl),
'page_number' => $pageNumber,
];
$event = new PageScrappingProgressEvent($chapter->getId(), $pageNumber, 0);
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
// Passer à la page suivante
$nextButton = $pantherCrawler->filter($mangaSource->getNextPageSelector());
if ($nextButton->count() === 0) {
break; // Pas de bouton suivant, fin du chapitre
}
$nextButton->click();
// Attendre que la page change
$pantherClient->waitFor($mangaSource->getImageSelector(), 10);
// Mettre à jour le crawler avec le nouveau contenu de la page
$pantherCrawler = $pantherClient->refreshCrawler();
$pageNumber++;
} catch (\Exception $e) {
throw $e;
// $this->logger->warning('Erreur lors du scraping de la page ' . $pageNumber . ' du chapitre ' . $chapter->getNumber() . ': ' . $e->getMessage());
break;
}
}
return $pageData;
}
private function fetchImagesUsingPuppeteer(string $url, string $imageSelector, string $nextButtonSelector): array
{
// Appeler le script Puppeteer avec les paramètres nécessaires
$output = [];
$command = sprintf('node puppeteer-script.js "%s" "%s" "%s" 2>&1', $url, $imageSelector, $nextButtonSelector); // Redirect stderr to stdout
// dump($command);
// exec($command, $output, $return_var);
// dd($command, $output);
// Convertir la sortie JSON en tableau PHP
return json_decode(implode("", $output), true);
}
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
return match ($contentSource->getScrapingType()) {
'html' => $this->testScrapingHtml($mangaSlug, $chapterNumber, $contentSource),
'javascript' => $this->testScrapingJavascript($mangaSlug, $chapterNumber, $contentSource),
default => throw new Exception('Unsupported scraping type: ' . $contentSource->getScrapingType()),
};
}
/**
* @throws Exception
*/
public function testScrapingJavascript(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
$chapter = $manga->getChapterByNumber($chapterNumber);
return $this->scrapeChapterJavascript($manga, $chapter, $contentSource);
}
/**
* @throws GuzzleException
*/
public function testScrapingHtml(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
$html = $this->fetchHtml($chapterUrl);
if ($contentSource->getNextPageSelector() === null) {
return $this->scrapeVerticalReader($html, $contentSource);
} else {
return $this->scrapeHorizontalReader($chapterUrl, $contentSource);
}
}
/**
* @throws GuzzleException
*/
private function scrapeChapterHtml(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool
{
$chapterUrl = $mangaSource->getChapterUrl($manga->getSlug(), $chapter->getNumber());
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
mkdir($tempDir);
$pageData = [];
if ($mangaSource->getNextPageSelector() === null) {
// Lecteur vertical
$html = $this->fetchHtml($chapterUrl);
$pageData = $this->scrapeVerticalReader($html, $mangaSource);
} else {
// Lecteur horizontal (paginé)
$pageData = $this->scrapeHorizontalReader($chapterUrl, $mangaSource);
}
// Télécharger et sauvegarder les images
foreach ($pageData as $index => &$page) {
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
$imagePath = $tempDir . '/' . $imageName;
$this->downloadAndSaveImage($page['image_url'], $imagePath);
$event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, count($pageData));
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$page['local_image_url'] = $imagePath;
}
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
$this->createCbzFile($tempDir, $pageData, $cbzFilePath);
$chapter->setCbzPath($cbzFilePath);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
// Nettoyage du répertoire temporaire
$this->cleanupTempFiles($tempDir);
return $pageData;
}
private function scrapeVerticalReader(string $html, ContentSource $contentSource): array
{
$crawler = new Crawler($html);
$images = $crawler->filter($contentSource->getImageSelector());
$pageData = [];
foreach ($images as $index => $image) {
if ($image->getAttribute('src') === '') {
$imgUrl = $image->getAttribute('data-src');
} else {
$imgUrl = $image->getAttribute('src');
}
$pageData[] = [
'image_url' => $this->cleanImageUrl($imgUrl),
'page_number' => $index + 1,
];
}
return $pageData;
}
/**
* @throws GuzzleException
*/
private function scrapeHorizontalReader(string $chapterUrl, ContentSource $contentSource): array
{
$pageData = [];
$currentPageUrl = $chapterUrl;
do {
$html = $this->fetchHtml($currentPageUrl);
$page = $this->extractMangaPageData($html, $contentSource);
$pageData[] = [
'image_url' => $this->cleanImageUrl($page['image_url']),
'page_number' => count($pageData) + 1,
];
$currentPageUrl = $page['next_page_url'];
} while ($currentPageUrl);
return $pageData;
}
/**
* Processes a single image
* @throws GuzzleException
*/
private function processImage(string $imgUrl, string $tempDir, array &$pageData, int $index, Chapter $chapter): void
{
$imgUrl = $this->cleanImageUrl($imgUrl);
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($imgUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
$imagePath = $tempDir . '/' . $imageName;
$this->downloadAndSaveImage($imgUrl, $imagePath);
// $event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, 0);
// $this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$pageData[] = [
'image_url' => $imgUrl,
'local_image_url' => $imagePath,
'page_number' => $index + 1,
];
}
private function cleanImageUrl(string $url): string
{
return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url));
}
/**
* @throws GuzzleException
* @throws Exception
*/
private function fetchHtml(string $url): string
{
$client = new Client();
try {
$response = $client->get($url, [
'http_errors' => true,
'allow_redirects' => false
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 300 && $statusCode < 400) {
throw new Exception('Chapter Not Found at ' . $url);
} elseif ($statusCode == 404) {
throw new Exception('Chapter Not Found at ' . $url);
}
return (string)$response->getBody();
} catch (Exception $e) {
throw new Exception('Bad Request: ' . $e->getMessage());
}
}
/**
* @throws GuzzleException
*/
private function downloadAndSaveImage(string $imageUrl, string $destinationPath): void
{
$client = new Client();
$startTime = microtime(true);
try {
$response = $client->get($imageUrl);
$endTime = microtime(true);
$contentType = $response->getHeaderLine('Content-Type');
$xCacheHeader = $response->getHeaderLine('X-Cache');
$isCached = str_starts_with($xCacheHeader, 'HIT');
$contentLength = $response->getHeaderLine('Content-Length');
if (str_starts_with($contentType, 'image/')) {
file_put_contents($destinationPath, $response->getBody()->getContents());
// if ($this->scrapingType === 'mangadex') {
// $this->sendReport($imageUrl, true, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000);
// }
} else {
// if ($this->scrapingType === 'mangadex') {
// $this->sendReport($imageUrl, false, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000);
// }
throw new \Exception('Le contenu récupéré n\'est pas une image. Type de contenu : ' . $contentType);
}
} catch (RequestException $e) {
throw new \Exception('Erreur lors de la récupération de l\'image : ' . $e->getMessage());
}
}
/**
* @throws GuzzleException
*/
private function isChapterAvailable(string $chapterUrl, float $chapterNumber, ContentSource $mangaSource): bool
{
$html = $this->fetchHtml($chapterUrl);
$crawler = new Crawler($html);
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
if ($nextLink->count() === 0) {
return false;
}
$nextUrl = $nextLink->attr('href');
$routeCollection = new RouteCollection();
$routeCollection->add('manga_chapter', new Route('/scan-{manga}/{chapter}/{page}'));
$context = new RequestContext('/');
$matcher = new UrlMatcher($routeCollection, $context);
$path = parse_url($nextUrl, PHP_URL_PATH);
$parameters = $matcher->match($path);
return (float)$parameters['chapter'] === $chapterNumber;
}
private function sendReport(string $imageUrl, bool $success, bool $cached, int $bytes, float $duration): void
{
$client = new Client();
try {
$client->post('https://api.mangadex.network/report', [
'headers' => [
'Content-Type' => 'application/json',
],
'json' => [
'url' => $imageUrl,
'success' => $success,
'cached' => $cached,
'bytes' => $bytes,
'duration' => $duration,
],
]);
} catch (RequestException $e) {
// Gérer les exceptions de requête pour le rapport
throw new \Exception('Erreur lors de l\'envoi du rapport : ' . $e->getMessage());
}
}
private function createCbzFile(string $tempDir, array $pageData, string $cbzFilePath): void
{
$zip = new \ZipArchive();
if ($zip->open($cbzFilePath, \ZipArchive::CREATE) === true) {
foreach ($pageData as $page) {
$zip->addFile($page['local_image_url'], basename($page['local_image_url']));
}
$zip->close();
}
}
private function generateCbzPath(Manga $manga, Chapter $chapter): string
{
$volumeDir = $this->createDirectories($manga, $chapter->getVolume());
$fileName = sprintf(
'%s_vol%d_ch%s.cbz',
$manga->getSlug(),
$chapter->getVolume(),
$chapter->getNumber()
);
return $volumeDir . '/' . $fileName;
}
private function createDirectories(Manga $manga, int $volume): string
{
$mangaYear = $manga->getPublicationYear() ?? 'unknown';
$mangaDir = sprintf('%s/%s (%s)', $this->projectDir . self::PUBLIC_CBZ, ucfirst($manga->getSlug()), $mangaYear);
$volumeDir = sprintf('%s/volume_%d', $mangaDir, sprintf('%02d', $volume));
if (!is_dir($volumeDir)) {
mkdir($volumeDir, 0755, true);
}
return $volumeDir;
}
private function cleanupTempFiles(string $directory): void
{
$files = glob($directory . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
rmdir($directory);
}
}

View File

@@ -1,77 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Manga;
use App\Interface\MetadataProviderInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\String\Slugger\SluggerInterface;
class MangaUpdatesMetadataProvider implements MetadataProviderInterface
{
private Client $client;
public function __construct(private readonly SluggerInterface $slugger)
{
$this->client = new Client();
}
/**
* @throws Exception
*/
public function search(string $title): Collection
{
try {
$response = $this->client->request('PUT', 'https://api.mangaupdates.com/v1/account/login', [
'json' => [
'username' => 'Colgora',
'password' => '7TK5jv33NDn*SLV',
]
])
->withHeader('Content-Type', 'application/json');
$jwt = json_decode($response->getBody()->getContents(), true)['context']['session_token'];
$results = $this->client->request('POST', 'https://api.mangaupdates.com/v1/series/search', [
'json' => [
'search' => $title,
'licensed' => 'yes',
'type' => ['Manga'],
'exclude_genre' => ['Doujinshi', 'Adult', 'Hentai', 'Ecchi', 'Yaoi', 'Yuri', 'Josei', 'Smut', 'Gender Bender'],
'orderby' => 'score',
]
])->withHeader('Authorization', 'Bearer ' . $jwt)
->withHeader('Content-Type', 'application/json')
->getBody()
->getContents();
$mangas = [];
foreach (json_decode($results, true)['results'] as $record) {
$record = $record['record'];
$genres = [];
foreach ($record['genres'] as $genre) {
$genres[] = $genre['genre'];
}
$mangas[] = (new Manga())
->setTitle($record['title'])
->setSlug($this->slugger->slug($record['title'])->lower())
->setDescription($record['description'])
->setImageUrl($record['image']['url']['original'])
->setGenres($genres)
->setPublicationYear((int)$record['year'])
->setRating((float)$record['bayesian_rating'])
;
}
return new ArrayCollection($mangas);
} catch (GuzzleException $e) {
throw new Exception($e->getMessage());
}
}
}

View File

@@ -1,252 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Interface\ClientInterface;
use App\Interface\MetadataProviderInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\String\Slugger\SluggerInterface;
readonly class MangadexProvider implements MetadataProviderInterface
{
public function __construct(private ClientInterface $client, private SluggerInterface $slugger, private NotificationService $notificationService)
{
}
public function search(?string $title): Collection
{
if (null === $title) {
return new ArrayCollection();
}
try {
$results = $this->client->get('/manga', [
'title' => $title,
'contentRating' => ['safe', 'suggestive', 'erotica'],
'includes' => ['cover_art', 'author'],
'limit' => 50,
]);
} catch (\Exception $e) {
$this->notificationService->sendUpdate('notification', ['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
return new ArrayCollection();
}
$mangas = [];
foreach ($results['data'] as $result) {
$mangas[] = (new Manga())
->setExternalId($result['id'])
->setTitle($result['attributes']['title']['en'])
->setSlug($this->slugger->slug($result['attributes']['title']['en'])->lower())
->setDescription($result['attributes']['description']['fr'] ?? $result['attributes']['description']['en'] ?? '')
->setPublicationYear($result['attributes']['year'])
->setStatus($result['attributes']['status']);
$tags = [];
foreach ($result['attributes']['tags'] as $tag) {
$tags[] = $tag['attributes']['name']['en'];
}
$mangas[count($mangas) - 1]->setGenres($tags);
foreach ($result['relationships'] as $relationship) {
if ('author' === $relationship['type']) {
$mangas[count($mangas) - 1]->setAuthor($relationship['attributes']['name']);
}
if ('cover_art' === $relationship['type']) {
$mangas[count($mangas) - 1]->setImageUrl('https://mangadex.org/covers/'.$result['id'].'/'.$relationship['attributes']['fileName']);
}
}
}
$test = array_map(fn ($manga) => $manga->getExternalId(), $mangas);
$ratings = $this->client->get('/statistics/manga', [
'manga' => $test,
]);
foreach ($mangas as $manga) {
$manga->setRating($ratings['statistics'][$manga->getExternalId()]['rating']['average']);
}
usort($mangas, fn ($a, $b) => $b->getRating() <=> $a->getRating());
return new ArrayCollection($mangas);
}
public function getFeed(Manga $manga): array
{
if (null === $manga->getExternalId()) {
return [];
}
$chapters = [];
$page = 0;
do {
$results = $this->getFeedWithPagination($manga->getExternalId(), $page);
if (isset($results['data'])) {
$chapters = array_merge($chapters, $results['data']);
} else {
break;
}
++$page;
} while (count($chapters) < $results['total']);
return $this->getChaptersFromFeed($chapters, $manga);
}
public function getLastFeed(Manga $manga, int $limit = 100): array
{
if (null === $manga->getExternalId()) {
return [];
}
$chapters = [];
try {
$results = $this->getFeedWithPagination($manga->getExternalId(), 0, $limit, 'desc');
if (isset($results['data'])) {
$chapters = $results['data'];
}
} catch (\Exception $e) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching recent chapters from Mangadex.']);
return [];
}
return $this->getChaptersFromFeed($chapters, $manga);
}
private function getFeedWithPagination(string $externalId, int $page, int $limit = 500, string $order = 'asc'): array
{
try {
$response = $this->client->get('/manga/'.$externalId.'/feed', [
'limit' => $limit,
'translatedLanguage' => ['en', 'fr'],
'order' => ['chapter' => $order],
'offset' => $page * $limit,
]);
} catch (\Exception $e) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
return [];
}
return $response;
}
public function getMangaAggregate(Manga $manga): array
{
if (null === $manga->getExternalId()) {
return [];
}
try {
$response = $this->client->get('/manga/'.$manga->getExternalId().'/aggregate');
} catch (\Exception $e) {
// $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
return [];
}
$chapterEntities = [];
if ('ok' === $response['result']) {
foreach ($response['volumes'] as $volume) {
$volumeNumber = 'none' === $volume['volume'] ? 0 : (float) $volume['volume'];
foreach ($volume['chapters'] as $chapter) {
$chapterEntity = new Chapter();
$chapterEntity->setNumber((float) $chapter['chapter'])
->setTitle('Chapter '.$chapter['chapter'])
->setVolume($volumeNumber)
->setExternalId('');
$chapterEntities[] = $chapterEntity;
// $manga->addChapter($chapterEntity);
}
}
}
return $chapterEntities;
}
public function getChaptersFromFeed(mixed $chapters, Manga $manga): array
{
$chapterEntities = [];
$uniqueChapterNumbers = [];
foreach ($chapters as $result) {
$chapterNumber = (float) $result['attributes']['chapter'];
// Vérifiez si le chapitre existe déjà dans la base de données
$chapterExists = $manga->getChapters()->exists(function ($key, $existingChapter) use ($chapterNumber) {
return $existingChapter->getNumber() === $chapterNumber;
});
// Si le chapitre existe déjà dans la base de données ou dans notre nouvelle liste, on skip
if ($chapterExists || in_array($chapterNumber, $uniqueChapterNumbers)) {
continue;
}
// Créez et ajoutez le nouveau chapitre
$chapter = new Chapter();
$chapter->setNumber($chapterNumber)
->setTitle($result['attributes']['title'])
->setVolume((int) $result['attributes']['volume'] ?? null)
->setExternalId($result['id']);
$chapterEntities[] = $chapter;
$uniqueChapterNumbers[] = $chapterNumber;
}
// Trier les chapitres par numéro
usort($chapterEntities, function ($a, $b) {
return $a->getNumber() <=> $b->getNumber();
});
return $chapterEntities;
}
public function addAllChaptersToManga(Manga $manga): array
{
$mangaFeed = $this->getFeed($manga);
$mangaAggregate = $this->getMangaAggregate($manga);
$allChapters = array_merge($mangaFeed, $mangaAggregate);
if (empty($allChapters)) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'No chapters found for this manga.',
]);
return [];
}
$mergedChapters = [];
foreach ($allChapters as $chapter) {
$number = $chapter->getNumber();
$existingChapter = $manga->getChapterByNumber($number);
if ($existingChapter) {
if ($existingChapter->getExternalId() !== $chapter->getExternalId() && is_null($existingChapter->getExternalId())) {
$this->updateChapter($existingChapter, $chapter);
$mergedChapters[$number] = $existingChapter;
}
} else {
// Add new chapter
$manga->addChapter($chapter);
$mergedChapters[$number] = $chapter;
}
}
return array_values($mergedChapters);
}
private function updateChapter(Chapter $existingChapter, Chapter $newChapter): void
{
$existingChapter->setVolume($newChapter->getVolume());
$existingChapter->setExternalId($newChapter->getExternalId());
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Service;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
class NotificationService
{
public function __construct(private HubInterface $hub)
{
}
public function sendUpdate(mixed $data): void
{
$update = new Update('notification', json_encode($data));
$this->hub->publish($update);
}
}

View File

@@ -1,160 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use App\Entity\Manga;
use App\Event\PageScrappingProgressEvent;
use App\Manager\FileSystemManager;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
abstract class AbstractScraper implements ScraperInterface
{
protected Client $httpClient;
public function __construct(
protected FileSystemManager $fileSystemManager,
protected EventDispatcherInterface $eventDispatcher,
protected EntityManagerInterface $entityManager
) {
$this->httpClient = new Client();
}
protected function getValidChapterUrl(ContentSource $contentSource, Manga $manga, float $chapterNumber): ?string
{
$slugs = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs() ?? []);
foreach ($slugs as $slug) {
$url = $contentSource->getChapterUrl($slug, $chapterNumber);
if ($this->isChapterUrlValid($url)) {
return $url;
}
}
return null;
}
protected function isChapterUrlValid(string $url): bool
{
try {
$response = $this->httpClient->head($url);
return 200 === $response->getStatusCode();
} catch (RequestException $e) {
return false;
}
}
protected function generateCbzPath(Manga $manga, Chapter $chapter): string
{
$mangaDir = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
$volumeDir = $this->fileSystemManager->createVolumeDirectory($mangaDir, $chapter->getVolume());
$fileName = sprintf(
'%s_vol%d_ch%s.cbz',
$manga->getSlug(),
$chapter->getVolume(),
$chapter->getNumber()
);
return $volumeDir.'/'.$fileName;
}
protected function createCbzFile(array $pageData, string $cbzFilePath): void
{
$zip = new \ZipArchive();
if (true === $zip->open($cbzFilePath, \ZipArchive::CREATE)) {
foreach ($pageData as $page) {
$zip->addFile($page['local_image_url'], basename($page['local_image_url']));
}
$zip->close();
}
}
protected function cleanupTempFiles(string $directory): void
{
$this->fileSystemManager->deleteDirectory($directory);
}
protected function cleanImageUrl(string $url): string
{
return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url));
}
protected function dispatchProgressEvent(Chapter $chapter, int $currentPage, int $totalPages): void
{
$event = new PageScrappingProgressEvent($chapter->getId(), $currentPage, $totalPages);
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
}
/**
* @throws GuzzleException
* @throws \Exception
*/
protected function downloadAndSaveImage(string $imageUrl, string $destinationPath): string
{
try {
$response = $this->httpClient->get($imageUrl);
$contentType = $response->getHeaderLine('Content-Type');
if (!str_starts_with($contentType, 'image/')) {
throw new \Exception('Le contenu récupéré n\'est pas une image. Type de contenu : '.$contentType);
}
$imageData = $response->getBody()->getContents();
$tempFilePath = $this->saveTempFile($imageData);
$image = $this->createImageResource($tempFilePath, $contentType);
if (false === $image) {
throw new \Exception('Échec de la création de la ressource image.');
}
$destinationPath = $this->ensureJpgExtension($destinationPath);
if (!imagejpeg($image, $destinationPath)) {
imagedestroy($image);
unlink($tempFilePath);
throw new \Exception('Échec de la sauvegarde de l\'image en JPG.');
}
imagedestroy($image);
unlink($tempFilePath);
return $destinationPath;
} catch (\Exception $e) {
throw new \Exception('Erreur lors de la récupération de l\'image : '.$e->getMessage());
}
}
private function saveTempFile(string $data): string
{
$tempFilePath = tempnam(sys_get_temp_dir(), 'manga_img_');
file_put_contents($tempFilePath, $data);
return $tempFilePath;
}
/**
* @throws \Exception
*/
private function createImageResource(string $filePath, string $contentType)
{
return match ($contentType) {
'image/webp' => imagecreatefromwebp($filePath),
'image/png' => imagecreatefrompng($filePath),
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($filePath),
default => throw new \Exception('Format d\'image non pris en charge : '.$contentType),
};
}
private function ensureJpgExtension(string $path): string
{
$info = pathinfo($path);
return $info['dirname'].'/'.$info['filename'].'.jpg';
}
}

View File

@@ -1,170 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\DomCrawler\Crawler;
class HtmlScraper extends AbstractScraper
{
/**
* @throws \Exception
* @throws GuzzleException
*/
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
{
$manga = $chapter->getManga();
$chapterUrl = $this->getValidChapterUrl($contentSource, $manga, $chapter->getNumber());
if (!$chapterUrl) {
throw new \Exception("Aucune URL valide trouvée pour le chapitre {$chapter->getNumber()} du manga {$manga->getTitle()}");
}
$tempDir = sys_get_temp_dir().'/'.uniqid('manga_scraper_');
mkdir($tempDir);
$pageData = [];
if (null === $contentSource->getNextPageSelector()) {
// Lecteur vertical
$html = $this->fetchHtml($chapterUrl);
$pageData = $this->scrapeVerticalReader($html, $contentSource);
} else {
// Lecteur horizontal (paginé)
$pageData = $this->scrapeHorizontalReader($chapterUrl, $contentSource);
}
// Télécharger et sauvegarder les images
foreach ($pageData as $index => &$page) {
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
$imagePath = $tempDir.'/'.$imageName;
$destinationPath = $this->downloadAndSaveImage($page['image_url'], $imagePath);
$this->dispatchProgressEvent($chapter, $index + 1, count($pageData));
$page['local_image_url'] = $destinationPath;
}
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
$this->createCbzFile($pageData, $cbzFilePath);
$chapter->setCbzPath($cbzFilePath);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
$this->cleanupTempFiles($tempDir);
return $pageData;
}
/**
* @throws \Exception
*/
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
if (!$this->isChapterUrlValid($chapterUrl)) {
throw new \Exception('Invalid URL, check format and slug');
}
$html = $this->fetchHtml($chapterUrl);
if (null === $contentSource->getNextPageSelector()) {
return $this->scrapeVerticalReader($html, $contentSource);
} else {
return $this->scrapeHorizontalReader($chapterUrl, $contentSource);
}
}
public function supports(string $scrapingType): bool
{
return 'html' === $scrapingType;
}
private function scrapeVerticalReader(string $html, ContentSource $contentSource): array
{
$crawler = new Crawler($html);
$images = $crawler->filter($contentSource->getImageSelector());
$pageData = [];
foreach ($images as $index => $image) {
$imgUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
$pageData[] = [
'image_url' => $this->cleanImageUrl($imgUrl),
'page_number' => $index + 1,
];
}
return $pageData;
}
/**
* @throws \Exception
*/
private function scrapeHorizontalReader(string $chapterUrl, ContentSource $contentSource): array
{
$pageData = [];
$currentPageUrl = $chapterUrl;
do {
$html = $this->fetchHtml($currentPageUrl);
$page = $this->extractMangaPageData($html, $contentSource);
$pageData[] = [
'image_url' => $this->cleanImageUrl($page['image_url']),
'page_number' => count($pageData) + 1,
];
$currentPageUrl = $page['next_page_url'];
} while ($currentPageUrl);
return $pageData;
}
private function fetchHtml(string $url): string
{
try {
$response = $this->httpClient->get($url, [
'http_errors' => true,
'allow_redirects' => false,
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 300 && $statusCode < 400 || 404 == $statusCode) {
throw new \Exception('Chapter Not Found at '.$url);
}
return (string) $response->getBody();
} catch (\Exception $e) {
throw new \Exception('Bad Request: '.$e->getMessage());
}
}
private function extractMangaPageData(string $html, ContentSource $mangaSource): array
{
$crawler = new Crawler($html);
$imgUrl = $crawler->filter($mangaSource->getImageSelector())->attr('src')
?? $crawler->filter($mangaSource->getImageSelector())->attr('data-src');
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
$nextUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null;
// Convert relative URLs to absolute URLs
if (!preg_match('/^https?:\/\//', $imgUrl)) {
$urlComponents = parse_url($mangaSource->getBaseUrl());
$scheme = $urlComponents['scheme'];
$host = $urlComponents['host'];
$imgUrl = $scheme.'://'.$host.'/'.ltrim($imgUrl, '/');
}
return [
'image_url' => $imgUrl,
'next_page_url' => $nextUrl,
];
}
}

View File

@@ -1,190 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\Panther\Client as PantherClient;
class JavascriptScraper extends AbstractScraper
{
/**
* @throws Exception
*/
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
{
$manga = $chapter->getManga();
$pantherClient = PantherClient::createChromeClient();
$chapterUrl = $this->getValidChapterUrl($contentSource, $manga, $chapter->getNumber());
if (!$chapterUrl) {
throw new Exception("Aucune URL valide trouvée pour le chapitre {$chapter->getNumber()} du manga {$manga->getTitle()}");
}
$pantherClient->request('GET', $chapterUrl);
try {
$this->selectChapter($pantherClient, $chapter, $contentSource);
$pageData = $contentSource->getNextPageSelector() === null
? $this->scrapeVerticalReaderJavascript($pantherClient, $contentSource, $chapter)
: $this->scrapeHorizontalReaderJavascript($pantherClient, $contentSource, $chapter);
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
mkdir($tempDir);
// Télécharger et sauvegarder les images
foreach ($pageData as $index => &$page) {
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
$imagePath = $tempDir . '/' . $imageName;
$destinationPath = $this->downloadAndSaveImage($page['image_url'], $imagePath);
$this->dispatchProgressEvent($chapter, $index + 1, count($pageData));
$page['local_image_url'] = $destinationPath;
}
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
$this->createCbzFile($pageData, $cbzFilePath);
$chapter->setCbzPath($cbzFilePath);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
$this->cleanupTempFiles($tempDir);
return $pageData;
} finally {
$pantherClient->close();
}
}
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
if (!$this->isChapterUrlValid($chapterUrl)) {
throw new \Exception("Invalid URL, check format and slug");
}
$pantherClient = PantherClient::createChromeClient();
$pantherClient->request('GET', $chapterUrl);
try {
$chapter = new Chapter();
$chapter->setNumber((float)$chapterNumber);
$this->selectChapter($pantherClient, $chapter, $contentSource);
return $contentSource->getNextPageSelector() === null
? $this->scrapeVerticalReaderJavascript($pantherClient, $contentSource, $chapter)
: $this->scrapeHorizontalReaderJavascript($pantherClient, $contentSource, $chapter);
} catch (Exception $e) {
throw $e;
} finally {
$pantherClient->close();
}
}
public function supports(string $scrapingType): bool
{
return $scrapingType === 'javascript';
}
private function selectChapter(PantherClient $pantherClient, Chapter $chapter, ContentSource $contentSource): void
{
$chapterSelector = $contentSource->getChapterSelector();
if (!$chapterSelector) {
return;
}
$crawler = $pantherClient->waitFor($chapterSelector);
$select = $crawler->filter($chapterSelector);
if ($select->count() > 0) {
$chapterNumber = $chapter->getNumber();
$options = $select->filter('option');
$targetIndex = null;
foreach ($options as $index => $option) {
if (preg_match("/\b{$chapterNumber}\b/", $option->getText())) {
$targetIndex = $index;
break;
}
}
if ($targetIndex !== null) {
$pantherClient->executeScript("
var select = document.querySelector('$chapterSelector');
select.selectedIndex = $targetIndex;
select.dispatchEvent(new Event('change'));
");
$this->waitForImagesLoaded($pantherClient, $contentSource);
} else {
throw new Exception("Chapitre $chapterNumber non trouvé dans le menu déroulant");
}
}
}
private function waitForImagesLoaded(PantherClient $pantherClient, ContentSource $contentSource): void
{
$imageSelector = $contentSource->getImageSelector();
$pantherClient->wait(30)->until(
function ($driver) use ($imageSelector) {
return $driver->executeScript("
return new Promise((resolve) => {
let lastImageCount = 0;
let stableCount = 0;
const stableThreshold = 10;
function checkImages() {
const images = document.querySelectorAll('$imageSelector');
const loadedImages = Array.from(images).filter(img => img.complete && img.naturalWidth > 0);
if (loadedImages.length === lastImageCount) {
stableCount++;
} else {
stableCount = 0;
lastImageCount = loadedImages.length;
}
if (stableCount >= stableThreshold) {
resolve(true);
} else {
setTimeout(checkImages, 200);
}
}
checkImages();
});
");
}
);
}
private function scrapeVerticalReaderJavascript(PantherClient $pantherClient, ContentSource $contentSource, Chapter $chapter): array
{
$pageData = [];
$crawler = $pantherClient->waitFor($contentSource->getImageSelector());
$images = $crawler->filter($contentSource->getImageSelector());
foreach ($images as $index => $image) {
$imageUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
$pageData[] = [
'image_url' => $this->cleanImageUrl($imageUrl),
'page_number' => $index + 1,
];
}
return $pageData;
}
private function scrapeHorizontalReaderJavascript(PantherClient $pantherClient, ContentSource $contentSource, Chapter $chapter): array
{
$pageData = [];
return $pageData;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
class MangaScraperService
{
private ScraperFactory $scraperFactory;
public function __construct(ScraperFactory $scraperFactory)
{
$this->scraperFactory = $scraperFactory;
}
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
{
$scraper = $this->scraperFactory->createScraper($contentSource);
return $scraper->scrapeChapter($chapter, $contentSource);
}
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
$scraper = $this->scraperFactory->createScraper($contentSource);
return $scraper->testScraping($mangaSlug, $chapterNumber, $contentSource);
}
}

View File

@@ -1,72 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Client;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class MangadexScraper extends AbstractScraper
{
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
{
$chapterUrl = $contentSource->getBaseUrl() . sprintf($contentSource->getChapterUrlFormat(), $chapter->getExternalId());
$manga = $chapter->getManga();
$pageData = [];
try {
$response = $this->httpClient->get($chapterUrl);
$results = json_decode($response->getBody()->getContents(), true);
if ($results['result'] !== 'ok' || count($results['chapter']['dataSaver']) === 0) {
throw new \Exception('Error while fetching chapter data from Mangadex ' . $manga->getTitle() . ' ' . $chapter->getNumber());
}
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
mkdir($tempDir);
foreach ($results['chapter']['dataSaver'] as $index => $page) {
$pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page;
$imagePath = $tempDir . '/' . sprintf('%03d.%s', $index + 1, pathinfo($page, PATHINFO_EXTENSION));
$this->downloadAndSaveImage($pageUrl, $imagePath);
$this->dispatchProgressEvent($chapter, $index + 1, count($results['chapter']['dataSaver']));
$pageData[] = [
'image_url' => $pageUrl,
'local_image_url' => $imagePath,
'page_number' => $index + 1,
];
}
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
$this->createCbzFile($pageData, $cbzFilePath);
$chapter->setCbzPath($cbzFilePath);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
$this->cleanupTempFiles($tempDir);
return $pageData;
} catch (\Exception $e) {
// Log the error
return false;
}
}
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
{
// For Mangadex, we need the chapter's external ID, which we don't have in this context.
// We could potentially fetch it first, but for simplicity, let's return an empty array.
return [];
}
public function supports(string $scrapingType): bool
{
return $scrapingType === 'mangadex';
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\ContentSource;
class ScraperFactory
{
private array $scrapers;
public function __construct(iterable $scrapers)
{
$this->scrapers = iterator_to_array($scrapers);
}
public function createScraper(ContentSource $contentSource): ScraperInterface
{
foreach ($this->scrapers as $scraper) {
if ($scraper->supports($contentSource->getScrapingType())) {
return $scraper;
}
}
throw new \InvalidArgumentException('Unsupported scraping type: '.$contentSource->getScrapingType());
}
}

View File

@@ -1,13 +0,0 @@
<?php
namespace App\Service\Scraper;
use App\Entity\Chapter;
use App\Entity\ContentSource;
interface ScraperInterface
{
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool;
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array;
public function supports(string $scrapingType): bool;
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Entity\Manga;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class AddMangaModalComponent
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public ?Manga $manga;
public function open(Manga $manga): void
{
$this->manga = $manga;
}
public function close(): void
{
$this->manga = null;
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class BootstrapModal
{
public ?string $id = null;
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class DownloadChapter
{
use DefaultActionTrait;
public ?string $mangaSlug = '';
public float $chapter;
public function __construct()
{
}
public function downloadChapter(MangaRepository $mangaRepository, ChapterRepository $chapterRepository): int
{
// $mangaSlug = $this->mangaSlug;
// $chapter = $this->chapter;
// $manga = $mangaRepository->findOneBy(['slug' => $mangaSlug]);
// $chapter = $chapterRepository->findOneBy(['manga' => $manga, 'number' => $chapter]);
return 0;
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class DropdownMenu
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public ?array $items = null;
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Service\MangadexProvider;
use Doctrine\Common\Collections\Collection;
use Exception;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class MangaSearch
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public ?string $query = null;
public function __construct(private readonly MangadexProvider $mangadexProvider)
{
}
/**
* @throws Exception
*/
public function getMangas(): Collection|null
{
if ($this->query === null || $this->query === '') {
return null;
}
return $this->mangadexProvider->search($this->query);
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Repository\MangaRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Search
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public ?string $query = null;
public function __construct(private readonly MangaRepository $mangaRepository)
{
}
public function getMangas(): array
{
return $this->query ? $this->mangaRepository->findByTitle($this->query) : [];
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class ToolBarButton
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public ?array $data = null;
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('get_placeholder', [$this, 'getPlaceholder']),
];
}
public function getPlaceholder(string $fieldName): string
{
return match ($fieldName) {
'baseUrl' => 'https://example.com',
'imageSelector' => '.manga-image img',
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter-{number}',
'nextPageSelector' => '.next-page',
'scrapingType' => 'Select scraping type',
default => '',
};
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class TruncateExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('truncate', [$this, 'truncate']),
];
}
public function truncate(?string $value, int $limit): string
{
if ($value === null) {
return '';
}
return strlen($value) > $limit ? substr($value, 0, $limit) . '...' : $value;
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Twig\Runtime;
use Twig\Extension\RuntimeExtensionInterface;
class TruncateExtensionRuntime implements RuntimeExtensionInterface
{
public function __construct()
{
// Inject dependencies if needed
}
public function doSomething($value)
{
// ...
}
}