- activity on menu
- starting activity page
This commit is contained in:
2024-06-17 22:36:37 +02:00
parent 671551c7f8
commit f7bb7b9148
14 changed files with 524 additions and 33 deletions

View File

@@ -0,0 +1,52 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['activity']
// ...
async connect() {
try {
const response = await fetch(`/activity/status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
// Handle the response data as needed
this.activityTarget.innerHTML = data.length;
if (data.length > 0) {
this.activityTarget.classList.remove('hidden');
}
} catch (error) {
console.error('Error:', error);
}
const mercureHubUrl = 'https://localhost/.well-known/mercure';
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.processing !== undefined && data.pending !== undefined) {
let totalActivities = data.processing.length + data.pending.length;
this.activityTarget.innerHTML = totalActivities;
if (totalActivities > 0) {
this.activityTarget.classList.remove('hidden');
}
}
};
eventSource
.onerror = (event) => {
console.error('EventSource failed:', event);
};
}
}

View File

@@ -56,6 +56,10 @@ services:
App\EventListener\MangaScrapedListener: App\EventListener\MangaScrapedListener:
tags: tags:
- { name: kernel.event_listener, event: 'manga.scraped', method: 'onMangaScraped' } - { name: kernel.event_listener, event: 'manga.scraped', method: 'onMangaScraped' }
App\EventSubscriber\QueueStatusSubscriber:
tags:
- { name: kernel.event_subscriber }
App\Controller\MenuController: App\Controller\MenuController:
tags: [ 'controller.service_arguments' ] tags: [ 'controller.service_arguments' ]

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Controller;
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 Connection $connection, private readonly ChapterRepository $chapterRepository)
{
}
#[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,
]);
}
#[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(),
'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

@@ -5,26 +5,86 @@ namespace App\Controller;
use App\Entity\Chapter; use App\Entity\Chapter;
use App\Entity\ContentSource; use App\Entity\ContentSource;
use App\Entity\Manga; use App\Entity\Manga;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository; use App\Repository\MangaRepository;
use App\Service\MangadexProvider; use App\Service\MangadexProvider;
use App\Service\MangaScraperService; use App\Service\MangaScraperService;
use App\Service\MangaUpdatesMetadataProvider; use App\Service\MangaUpdatesMetadataProvider;
use App\Service\SushiScanProviderService; use App\Service\SushiScanProviderService;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class TestController extends AbstractController class TestController extends AbstractController
{ {
public function __construct(private MangadexProvider $mangadexProvider, private MangaRepository $mangaRepository) public function __construct(
private MangadexProvider $mangadexProvider,
private MangaRepository $mangaRepository,
private MessageBusInterface $bus,
private Connection $connection,
private SerializerInterface $serializer,
private readonly ChapterRepository $chapterRepository
)
{ {
} }
#[Route('/test', name: 'test')] #[Route('/test', name: 'test')]
public function test(): Response public function test(): Response
{ {
$manga = $this->mangaRepository->find(8); $sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue';
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
dd($this->mangadexProvider->getFeed($manga)); // // 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']);
// dd($pending);
$decoded = $this->decodeMessages($pending);
$status = [];
foreach($decoded as $message) {
$message = $message['body'];
if($message instanceof Envelope) {
$chapter = $this->chapterRepository->find($message->getMessage()->getChapterId());
$manga = $chapter->getManga();
$status[] = [
'manga' => $manga->getTitle(),
'volume' => $chapter->getVolume(),
'chapter' => $chapter->getNumber(),
'title' => $chapter->getTitle(),
];
}
}
// $this->bus->dispatch(new DownloadChapter(1));
dd($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

@@ -116,19 +116,19 @@ class Chapter
return $this; return $this;
} }
public function getPageByNumber(int $number): ?Page public function getPageByNumber(int $number): ?Page
{ {
/** /**
* @var Page $page * @var Page $page
*/ */
foreach ($this->pagesLink as $page) { foreach ($this->pagesLink as $page) {
if ($page->getNumber() === $number) { if ($page->getNumber() === $number) {
return $page; return $page;
} }
} }
return null; return null;
} }
public function getVolume(): ?int public function getVolume(): ?int
{ {

View File

@@ -0,0 +1,36 @@
<?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

@@ -0,0 +1,146 @@
<?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' => 'Page scraping 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

@@ -33,10 +33,10 @@ readonly class DownloadChapterHandler
{ {
$chapter = $this->chapterRepository->find($message->getChapterId()); $chapter = $this->chapterRepository->find($message->getChapterId());
if (!$chapter) { if (!$chapter) {
$this->notificationService->sendUpdate('notification', ['status' => 'error', 'message' => 'Chapter not found.']); $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter not found.']);
throw new BadRequestHttpException('Chapter not found'); throw new BadRequestHttpException('Chapter not found');
} elseif ($chapter->getLocalPath() !== null) { } elseif ($chapter->getLocalPath() !== null) {
$this->notificationService->sendUpdate('notification', ['status' => 'error', 'message' => 'Chapter already scraped.']); $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter already scraped.']);
throw new BadRequestHttpException('Chapter already downloaded'); throw new BadRequestHttpException('Chapter already downloaded');
} }
@@ -64,7 +64,7 @@ readonly class DownloadChapterHandler
$scrapedSuccessfully = true; $scrapedSuccessfully = true;
break; break;
} catch (Exception $e) { } catch (Exception $e) {
$this->notificationService->sendUpdate('notification', [ $this->notificationService->sendUpdate([
'status' => 'warning', 'status' => 'warning',
'message' => 'An error occurred while scraping with source: ' . $source->getBaseUrl() . '. Trying next source...' 'message' => 'An error occurred while scraping with source: ' . $source->getBaseUrl() . '. Trying next source...'
]); ]);
@@ -74,13 +74,13 @@ readonly class DownloadChapterHandler
} }
if (!$scrapedSuccessfully) { if (!$scrapedSuccessfully) {
$this->notificationService->sendUpdate('notification', [ $this->notificationService->sendUpdate([
'status' => 'error', 'status' => 'error',
'message' => 'All sources failed to scrape the chapter ' . $chapter->getManga()->getTitle() . ' ' . $chapter->getNumber() . '.' '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() . '.'); throw new Exception('All sources failed to scrape the chapter ' . $chapter->getManga()->getTitle() . ' ' . $chapter->getNumber() . '.');
} }
$this->notificationService->sendUpdate('notification', ['status' => 'success', 'message' => 'Chapter scraped successfully.']); $this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Chapter scraped successfully.']);
} }
} }

View File

@@ -0,0 +1,20 @@
<?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

@@ -5,6 +5,7 @@ namespace App\Service;
use App\Entity\Chapter; use App\Entity\Chapter;
use App\Entity\Manga; use App\Entity\Manga;
use App\Entity\ContentSource; use App\Entity\ContentSource;
use App\Event\PageScrappingProgressEvent;
use App\EventSubscriber\MangaScrapedEvent; use App\EventSubscriber\MangaScrapedEvent;
use Exception; use Exception;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@@ -143,6 +144,9 @@ class MangaScraperService
$this->downloadAndSaveImage($pageUrl, $imagePath); $this->downloadAndSaveImage($pageUrl, $imagePath);
$event = new PageScrappingProgressEvent($chapter->getId(), count($pageData) + 1, count($results['chapter']['dataSaver']));
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$pageData[] = [ $pageData[] = [
'image_url' => $pageUrl, 'image_url' => $pageUrl,
'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName), 'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName),
@@ -215,6 +219,9 @@ class MangaScraperService
$this->downloadAndSaveImage($page['image_url'], $imagePath); $this->downloadAndSaveImage($page['image_url'], $imagePath);
$event = new PageScrappingProgressEvent($chapter->getId(), count($pageData) + 1, 0);
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
$pageData[] = [ $pageData[] = [
'image_url' => $page['image_url'], 'image_url' => $page['image_url'],
'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName), 'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName),

View File

@@ -12,7 +12,7 @@ use Symfony\Component\String\Slugger\SluggerInterface;
readonly class MangadexProvider implements MetadataProviderInterface readonly class MangadexProvider implements MetadataProviderInterface
{ {
public function __construct(private ClientInterface $client, private SluggerInterface $slugger) public function __construct(private ClientInterface $client, private SluggerInterface $slugger, private NotificationService $notificationService)
{ {
} }
@@ -22,12 +22,17 @@ readonly class MangadexProvider implements MetadataProviderInterface
return new ArrayCollection(); return new ArrayCollection();
} }
$results = $this->client->get('/manga', [ try{
'title' => $title, $results = $this->client->get('/manga', [
'contentRating' => ['safe', 'suggestive'], 'title' => $title,
'includes' => ['cover_art', 'author'], 'contentRating' => ['safe', 'suggestive'],
'limit' => 25 'includes' => ['cover_art', 'author'],
]); 'limit' => 25
]);
}catch(\Exception $e){
$this->notificationService->sendUpdate('notification', ['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
return new ArrayCollection();
}
$mangas = []; $mangas = [];
foreach ($results['data'] as $result) { foreach ($results['data'] as $result) {

View File

@@ -12,9 +12,9 @@ class NotificationService
} }
public function sendUpdate(string $topic, mixed $data): void public function sendUpdate(mixed $data): void
{ {
$update = new Update($topic, json_encode($data)); $update = new Update('notification', json_encode($data));
$this->hub->publish($update); $this->hub->publish($update);
} }
} }

View File

@@ -0,0 +1,39 @@
{% extends 'base.html.twig' %}
{% block body %}
{# TODO styliser cette page #}
<table class="min-w-full bg-white">
<thead class="bg-gray-800 text-white">
<tr>
<th class="w-1/12 py-2 px-4">
<input type="checkbox">
</th>
<th class="w-2/12 py-2 px-4 text-left">Manga</th>
<th class="w-1/12 py-2 px-4 text-left">Volume</th>
<th class="w-4/12 py-2 px-4 text-left">Chapter</th>
<th class="w-1/12 py-2 px-4 text-left">Title</th>
<th class="w-1/12 py-2 px-4 text-left">Actions</th>
<th class="w-1/12 py-2 px-4"></th>
</tr>
</thead>
<tbody class="text-gray-700">
{% for manga in status %}
<tr class="border-b">
<td class="py-2 px-4 text-center">
<input type="checkbox">
</td>
<td class="py-2 px-4">{{ manga.manga }}</td>
<td class="py-2 px-4">{{ manga.volume }}</td>
<td class="py-2 px-4">{{ manga.chapter }}</td>
<td class="py-2 px-4">{{ manga.title }}</td>
<td class="py-2 px-4 text-center">
<button class="text-red-500 hover:text-red-700">&times;</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-4">
<span>Total records: {{ status|length }}</span>
</div>
{% endblock %}

View File

@@ -21,9 +21,18 @@
<i class="fas fa-calendar-alt mr-2"></i> <i class="fas fa-calendar-alt mr-2"></i>
<span>Calendrier</span> <span>Calendrier</span>
</li> </li>
<li class="mb-4 pl-8 flex items-center hover:text-green-600"> <li class="mb-4 pl-8 hover:text-green-600">
<i class="fas fa-clock mr-2"></i> <a href="{{ path('app_activity') }}">
<span>Activité</span> <div data-controller="activity" class="flex flew-row justify-between">
<div class="flex flex-row">
<i class="fas fa-clock mr-2"></i>
<span>Activité</span>
</div>
{# TODO le texte doit être blanc au survol #}
<span data-activity-target="activity"
class="bg-green-500 rounded px-2 mr-4 hover:text-white hidden"></span>
</div>
</a>
</li> </li>
<li class="mb-4 pl-8 flex items-center hover:text-green-600"> <li class="mb-4 pl-8 flex items-center hover:text-green-600">
<i class="fas fa-cog mr-2"></i> <i class="fas fa-cog mr-2"></i>