From 4484be4d4e8431df7cc7d2829dfd98e81224cb31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guillot?= Date: Tue, 23 Jul 2024 15:30:05 +0200 Subject: [PATCH] Added: - Updated Reader - fix image download for JavascriptScraper.php --- assets/controllers/reader_controller.js | 127 +++++++++++++++++ src/Controller/MangaController.php | 64 ++++----- src/Controller/ReaderController.php | 130 ++++++++++++++++++ src/MessageHandler/DownloadChapterHandler.php | 1 + src/Service/Scraper/AbstractScraper.php | 21 +++ src/Service/Scraper/HtmlScraper.php | 21 +-- src/Service/Scraper/JavascriptScraper.php | 9 +- src/Service/Scraper/MangadexScraper.php | 6 - templates/manga/_chapter_list.html.twig | 4 +- templates/manga/_chapter_row.html.twig | 4 +- templates/reader/index.html.twig | 31 +++++ 11 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 assets/controllers/reader_controller.js create mode 100644 src/Controller/ReaderController.php create mode 100644 templates/reader/index.html.twig diff --git a/assets/controllers/reader_controller.js b/assets/controllers/reader_controller.js new file mode 100644 index 0000000..771244c --- /dev/null +++ b/assets/controllers/reader_controller.js @@ -0,0 +1,127 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton'] + static values = { + mangaSlug: String, + chapterNumber: Number, + totalPages: Number, + currentPage: { type: Number, default: 1 }, + readingMode: { type: String, default: 'horizontal' } + } + + connect() { + this.loadChapters(); + this.loadPages(); + } + + async loadChapters() { + try { + const response = await fetch(`/api/chapters/${this.mangaSlugValue}`); + const chapters = await response.json(); + + this.chapterSelectTarget.innerHTML = chapters.map(chapter => + `` + ).join(''); + } catch (error) { + console.error('Error loading chapters:', error); + } + } + + async loadPages() { + this.pageContainerTarget.innerHTML = ''; + if (this.readingModeValue === 'horizontal') { + await this.loadPage(this.currentPageValue); + } else { + for (let i = 1; i <= this.totalPagesValue; i++) { + await this.loadPage(i, true); + } + } + } + + async loadPage(pageNumber, isVertical = false) { + const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`); + const pageContent = await response.text(); + + const img = document.createElement('img'); + img.src = `data:image/jpeg;base64,${pageContent}`; + img.alt = `Page ${pageNumber}`; + img.classList.add('shadow-lg', 'w-full', 'h-auto'); + + if (this.readingModeValue === 'horizontal') { + img.classList.add('cursor-pointer'); + img.dataset.action = 'click->reader#pageClick'; + this.pageContainerTarget.innerHTML = ''; + } + + if (isVertical) { + img.loading = 'lazy'; + img.classList.add('mb-4'); + } + + this.pageContainerTarget.appendChild(img); + + if (!isVertical) { + this.currentPageTarget.textContent = pageNumber; + this.currentPageValue = pageNumber; + } + } + + pageClick(event) { + if (this.readingModeValue === 'horizontal') { + const pageWidth = event.target.offsetWidth; + const clickX = event.offsetX; + + if (clickX < pageWidth / 2) { + this.previousPage(); + } else { + this.nextPage(); + } + } + } + + previousPage() { + if (this.currentPageValue > 1) { + this.loadPage(this.currentPageValue - 1); + } else { + this.previousChapter(); + } + } + + nextPage() { + if (this.currentPageValue < this.totalPagesValue) { + this.loadPage(this.currentPageValue + 1); + } else { + this.nextChapter(); + } + } + + async previousChapter() { + const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`); + const previousChapter = await response.json(); + if (previousChapter) { + window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`; + } + } + + async nextChapter() { + const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`); + const nextChapter = await response.json(); + if (nextChapter) { + window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`; + } + } + + changeChapter(event) { + const selectedChapterNumber = event.target.value; + window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`; + } + + toggleReadingMode() { + this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal'; + this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal'; + this.loadPages(); + } +} diff --git a/src/Controller/MangaController.php b/src/Controller/MangaController.php index da49dd9..4a788d9 100644 --- a/src/Controller/MangaController.php +++ b/src/Controller/MangaController.php @@ -213,38 +213,38 @@ class MangaController extends AbstractController return new JsonResponse(['success' => 'Chapter hidden.'], 200); } - #[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')] - public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): 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())) { - throw $this->createNotFoundException("Le chapitre demandé n'a pas été scrapé."); - } - - $pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber); - if (!$pageContent) { - throw $this->createNotFoundException("La page demandée n'existe pas."); - } - - $totalPages = $this->cbzService->getPageCount($chapter->getCbzPath()); - - return $this->render('manga/manga_reader.html.twig', [ - 'manga' => $manga, - 'chapter' => $chapter, - 'currentPage' => $pageNumber, - 'totalPages' => $totalPages, - 'pageContent' => base64_encode($pageContent), - ]); - } +// #[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')] +// public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): 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())) { +// throw $this->createNotFoundException("Le chapitre demandé n'a pas été scrapé."); +// } +// +// $pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber); +// if (!$pageContent) { +// throw $this->createNotFoundException("La page demandée n'existe pas."); +// } +// +// $totalPages = $this->cbzService->getPageCount($chapter->getCbzPath()); +// +// return $this->render('manga/manga_reader.html.twig', [ +// 'manga' => $manga, +// 'chapter' => $chapter, +// 'currentPage' => $pageNumber, +// 'totalPages' => $totalPages, +// 'pageContent' => base64_encode($pageContent), +// ]); +// } #[Route('/manga/search/{query}', name: 'app_manga_search')] public function search(string $query = ''): Response diff --git a/src/Controller/ReaderController.php b/src/Controller/ReaderController.php new file mode 100644 index 0000000..97a2b64 --- /dev/null +++ b/src/Controller/ReaderController.php @@ -0,0 +1,130 @@ +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())) { + throw $this->createNotFoundException("Le chapitre demandé n'a pas été scrapé."); + } + + $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."); + } + + $previousChapter = $manga->getChapters() + ->filter(fn($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber) + ->last(); + + 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); + } +} diff --git a/src/MessageHandler/DownloadChapterHandler.php b/src/MessageHandler/DownloadChapterHandler.php index 5595870..460dcf4 100644 --- a/src/MessageHandler/DownloadChapterHandler.php +++ b/src/MessageHandler/DownloadChapterHandler.php @@ -57,6 +57,7 @@ readonly class DownloadChapterHandler ->setChapterUrlFormat('at-home/server/%s') ->setScrapingType('mangadex'); + // (new ContentSource()) // ->setBaseUrl('https://lelscans.net') // ->setImageSelector('#image img') diff --git a/src/Service/Scraper/AbstractScraper.php b/src/Service/Scraper/AbstractScraper.php index 108eaff..d0844f6 100644 --- a/src/Service/Scraper/AbstractScraper.php +++ b/src/Service/Scraper/AbstractScraper.php @@ -7,6 +7,7 @@ use App\Entity\ContentSource; use App\Entity\Manga; use App\Event\PageScrappingProgressEvent; use Doctrine\ORM\EntityManagerInterface; +use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; @@ -107,4 +108,24 @@ abstract class AbstractScraper implements ScraperInterface $event = new PageScrappingProgressEvent($chapter->getId(), $currentPage, $totalPages); $this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME); } + + /** + * @throws GuzzleException + * @throws Exception + */ + protected function downloadAndSaveImage(string $imageUrl, string $destinationPath): void + { + try { + $response = $this->httpClient->get($imageUrl); + $contentType = $response->getHeaderLine('Content-Type'); + + if (str_starts_with($contentType, 'image/')) { + file_put_contents($destinationPath, $response->getBody()->getContents()); + } else { + throw new Exception('Le contenu récupéré n\'est pas une image. Type de contenu : ' . $contentType); + } + } catch (Exception $e) { + throw new Exception('Erreur lors de la récupération de l\'image : ' . $e->getMessage()); + } + } } diff --git a/src/Service/Scraper/HtmlScraper.php b/src/Service/Scraper/HtmlScraper.php index 2cd2f81..db7e706 100644 --- a/src/Service/Scraper/HtmlScraper.php +++ b/src/Service/Scraper/HtmlScraper.php @@ -7,6 +7,7 @@ use App\Entity\ContentSource; use Doctrine\ORM\EntityManagerInterface; use Exception; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\DomCrawler\Crawler; @@ -25,6 +26,7 @@ class HtmlScraper extends AbstractScraper /** * @throws Exception + * @throws GuzzleException */ public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool { @@ -116,6 +118,9 @@ class HtmlScraper extends AbstractScraper return $pageData; } + /** + * @throws Exception + */ private function scrapeHorizontalReader(string $chapterUrl, ContentSource $contentSource): array { $pageData = []; @@ -156,22 +161,6 @@ class HtmlScraper extends AbstractScraper } } - private function downloadAndSaveImage(string $imageUrl, string $destinationPath): void - { - try { - $response = $this->client->get($imageUrl); - $contentType = $response->getHeaderLine('Content-Type'); - - if (str_starts_with($contentType, 'image/')) { - file_put_contents($destinationPath, $response->getBody()->getContents()); - } else { - throw new Exception('Le contenu récupéré n\'est pas une image. Type de contenu : ' . $contentType); - } - } catch (Exception $e) { - throw new Exception('Erreur lors de la récupération de l\'image : ' . $e->getMessage()); - } - } - private function extractMangaPageData(string $html, ContentSource $mangaSource): array { $crawler = new Crawler($html); diff --git a/src/Service/Scraper/JavascriptScraper.php b/src/Service/Scraper/JavascriptScraper.php index 22e8e13..6bd820d 100644 --- a/src/Service/Scraper/JavascriptScraper.php +++ b/src/Service/Scraper/JavascriptScraper.php @@ -5,10 +5,14 @@ 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(); @@ -36,7 +40,7 @@ class JavascriptScraper extends AbstractScraper $imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION)); $imagePath = $tempDir . '/' . $imageName; - file_put_contents($imagePath, file_get_contents($page['image_url'])); + $this->downloadAndSaveImage($page['image_url'], $imagePath); $this->dispatchProgressEvent($chapter, $index + 1, count($pageData)); $page['local_image_url'] = $imagePath; @@ -52,9 +56,6 @@ class JavascriptScraper extends AbstractScraper $this->cleanupTempFiles($tempDir); return $pageData; - } catch (Exception $e) { - // Log the error - return false; } finally { $pantherClient->close(); } diff --git a/src/Service/Scraper/MangadexScraper.php b/src/Service/Scraper/MangadexScraper.php index d60d75a..0badefc 100644 --- a/src/Service/Scraper/MangadexScraper.php +++ b/src/Service/Scraper/MangadexScraper.php @@ -80,10 +80,4 @@ class MangadexScraper extends AbstractScraper { return $scrapingType === 'mangadex'; } - - private function downloadAndSaveImage(string $imageUrl, string $destinationPath): void - { - $response = $this->client->get($imageUrl); - file_put_contents($destinationPath, $response->getBody()->getContents()); - } } diff --git a/templates/manga/_chapter_list.html.twig b/templates/manga/_chapter_list.html.twig index b485d49..ec44adf 100644 --- a/templates/manga/_chapter_list.html.twig +++ b/templates/manga/_chapter_list.html.twig @@ -67,12 +67,12 @@ {% if all_chapters_same_cbz and volume_cbz_path is not null %} - + {{ '%02d'|format(volume) }} - + Volume {{ '%02d'|format(volume) }} diff --git a/templates/manga/_chapter_row.html.twig b/templates/manga/_chapter_row.html.twig index a57e3e4..1045d2d 100644 --- a/templates/manga/_chapter_row.html.twig +++ b/templates/manga/_chapter_row.html.twig @@ -3,7 +3,7 @@ {% if chapter.cbzPath is not null %} + href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}"> {{ chapter.number < 10 ? '0' ~ chapter.number : chapter.number }} @@ -14,7 +14,7 @@ {% if chapter.cbzPath is not null %} + href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}"> {{ chapter.title ?? 'No title' }} {% else %} diff --git a/templates/reader/index.html.twig b/templates/reader/index.html.twig new file mode 100644 index 0000000..45fb441 --- /dev/null +++ b/templates/reader/index.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ manga.title }} - Chapitre {{ chapter.number }}{% endblock %} + +{% block body %} +
+

{{ manga.title }} - Chapitre {{ chapter.number }}

+ +
+ + + +
+ +
+ +
+ +
+ +
+ +
+ Page 1 sur {{ totalPages }} +
+
+{% endblock %}