diff --git a/.gitignore b/.gitignore index 5f1456d..a2c57c7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ yarn-error.log /public/manga-export/ /public/manga-images/ /public/cbz/ +/public/images/ diff --git a/Dockerfile b/Dockerfile index 5afb41a..930f6a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN set -eux; \ intl \ opcache \ zip \ + gd \ ; # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser diff --git a/Makefile b/Makefile index 55fe6a2..0e37c0c 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,7 @@ consume: ## Consume messages ## —— Webpack Encore ————————————————————————————————————————————————————————————— npm-install: ## Install npm dependencies - @$(DOCKER_COMP) exec node npm install + @$(DOCKER_COMP) exec node npm install --force npm-run: ## Run the dev server @$(DOCKER_COMP) exec node npm run dev @@ -156,3 +156,6 @@ npm-add: ## Add a package as a dependency make npm-add p=package-name npm-add-dev: ## Add a package as a dev dependency make npm-add-dev p=package-name @$(DOCKER_COMP) exec node npm install $(p) --save-dev + +npm-remove: ## Remove a package make npm-remove p=package-name + @$(DOCKER_COMP) exec node npm uninstall $(p) diff --git a/assets/controllers.json b/assets/controllers.json index b980571..ed1f20f 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -8,6 +8,16 @@ "@symfony/ux-live-component/dist/live.min.css": true } } + }, + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager" + }, + "mercure-turbo-stream": { + "enabled": false, + "fetch": "eager" + } } }, "entrypoints": [] diff --git a/assets/controllers/activity_controller.js b/assets/controllers/activity_controller.js index e050ae0..84abf4e 100644 --- a/assets/controllers/activity_controller.js +++ b/assets/controllers/activity_controller.js @@ -35,11 +35,14 @@ export default class extends Controller { eventSource.onmessage = (event) => { const data = JSON.parse(event.data); + console.log(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'); + }else if (totalActivities === 0) { + this.activityTarget.classList.add('hidden'); } } }; diff --git a/assets/controllers/chapter_progress_controller.js b/assets/controllers/chapter_progress_controller.js new file mode 100644 index 0000000..903749c --- /dev/null +++ b/assets/controllers/chapter_progress_controller.js @@ -0,0 +1,50 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['progressBar', 'progressText'] + static values = { + chapterId: Number + } + + connect() { + this.currentPage = 0; + this.totalPages = 0; + this.progressBarElement = this.progressBarTarget.querySelector('.bg-blue-600'); + + const mercureHubUrl = 'https://localhost/.well-known/mercure'; + this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`); + + this.eventSource.onmessage = this.handleMessage.bind(this); + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + } + } + + handleMessage(event) { + const data = JSON.parse(event.data); + if (data.status === "Page Scrapping progress" && data.chapterId === this.chapterIdValue) { + this.handleProgressUpdate(data); + } + } + + handleProgressUpdate(data) { + this.currentPage = data.pageIndex + 1; + this.totalPages = data.totalPages; + + if (this.currentPage > 1) { + this.progressBarTarget.classList.remove('hidden'); + } + + this.updateProgressBar(); + } + + updateProgressBar() { + const progress = (this.currentPage / this.totalPages) * 100; + this.progressBarElement.style.width = `${progress}%`; + this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`; + } +} diff --git a/assets/controllers/download_chapter_controller.js b/assets/controllers/download_chapter_controller.js deleted file mode 100644 index 91725c5..0000000 --- a/assets/controllers/download_chapter_controller.js +++ /dev/null @@ -1,56 +0,0 @@ -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 = ['icon'] - - connect() { - this.defaultIconClass = this.iconTarget.classList.value; - } - - async handleClick(event) { - event.preventDefault(); - - const button = event.currentTarget; - const url = button.dataset.url; - - // Change the icon to a loader - this.iconTarget.classList.remove("fa-search"); - this.iconTarget.classList.add("fa-spinner"); - this.iconTarget.classList.add("fa-spin"); - - try { - const response = await fetch(`${url}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest' - } - }); - - const data = await response.json(); - // Handle the response data as needed - if(data.error){ - this.dispatchAlert(data.error, 'error'); - }else if(data.success) { - this.dispatchAlert(data.success, 'success'); - } - } catch (error) { - console.error('Error:', error); - } finally { - // Revert the icon back to the original one - this.iconTarget.classList.value = this.defaultIconClass; - } - } - - dispatchAlert(message, level) { - const event = new CustomEvent('alert:show', { - detail: { message: message, level: level } - }); - window.dispatchEvent(event); - } -} diff --git a/assets/controllers/download_controller.js b/assets/controllers/download_controller.js new file mode 100644 index 0000000..685c4b9 --- /dev/null +++ b/assets/controllers/download_controller.js @@ -0,0 +1,69 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['icon'] + static values = { + url: String + } + + connect() { + this.defaultIconClass = this.iconTarget.classList.value; + } + + async download(event) { + event.preventDefault(); + + // Change the icon to a loader + this.iconTarget.classList.remove("fa-download", "fa-search"); + this.iconTarget.classList.add("fa-spinner", "fa-spin"); + + try { + const response = await fetch(this.urlValue, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.includes("application/json")) { + const data = await response.json(); + if (data.error) { + this.dispatchAlert(data.error, 'error'); + } else if (data.success) { + this.dispatchAlert(data.success, 'success'); + } + } else { + // C'est un fichier à télécharger + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + const contentDisposition = response.headers.get('Content-Disposition'); + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(contentDisposition); + let filename = 'download'; + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + } + } finally { + // Revert the icon back to the original one + this.iconTarget.classList.value = this.defaultIconClass; + } + } + + dispatchAlert(message, level) { + const event = new CustomEvent('alert:show', { + detail: { message: message, level: level } + }); + window.dispatchEvent(event); + } +} diff --git a/composer.json b/composer.json index e31efb2..554973a 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^2.17", "guzzlehttp/guzzle": "^7.8", + "intervention/image": "^3.7", "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.25", @@ -42,6 +43,7 @@ "symfony/stimulus-bundle": "^2.17", "symfony/twig-bundle": "7.0.*", "symfony/ux-live-component": "^2.17", + "symfony/ux-turbo": "^2.18", "symfony/validator": "7.0.*", "symfony/webpack-encore-bundle": "^2.1", "symfony/yaml": "7.0.*", diff --git a/composer.lock b/composer.lock index 763ceb8..a5472ee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d52c83bad4e4c116ba33e0f33b9cfd7b", + "content-hash": "821989b8a12af699869b3885cb6c3660", "packages": [ { "name": "api-platform/core", @@ -1820,6 +1820,142 @@ ], "time": "2023-12-03T20:05:35+00:00" }, + { + "name": "intervention/gif", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3", + "reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^10.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.1.0" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + } + ], + "time": "2024-03-26T17:23:47+00:00" + }, + { + "name": "intervention/image", + "version": "3.7.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/5451ff9f909c2fc836722e5ed6831b9f9a6db68c", + "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.1", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^10.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP image manipulation", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.7.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + } + ], + "time": "2024-07-05T13:35:01+00:00" + }, { "name": "lcobucci/jwt", "version": "5.3.0", @@ -7358,6 +7494,103 @@ ], "time": "2024-04-22T18:53:03+00:00" }, + { + "name": "symfony/ux-turbo", + "version": "v2.18.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-turbo.git", + "reference": "e447231ddcc09ab68d29047f47d31a524837dc7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/e447231ddcc09ab68d29047f47d31a524837dc7a", + "reference": "e447231ddcc09ab68d29047f47d31a524837dc7a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9.1" + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "require-dev": { + "dbrekelmans/bdi": "dev-main", + "doctrine/doctrine-bundle": "^2.4.3", + "doctrine/orm": "^2.8 | 3.0", + "phpstan/phpstan": "^1.10", + "symfony/debug-bundle": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/mercure-bundle": "^0.3.7", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/panther": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|6.3.*|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0", + "symfony/webpack-encore-bundle": "^2.1.1" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Turbo\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Hotwire Turbo integration for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "hotwire", + "javascript", + "mercure", + "symfony-ux", + "turbo", + "turbo-stream" + ], + "support": { + "source": "https://github.com/symfony/ux-turbo/tree/v2.18.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-06-01T17:56:14+00:00" + }, { "name": "symfony/ux-twig-component", "version": "v2.17.0", diff --git a/config/bundles.php b/config/bundles.php index 9851a32..c087d61 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -19,4 +19,5 @@ return [ Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], + Symfony\UX\Turbo\TurboBundle::class => ['all' => true], ]; diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 8add6da..a9d5277 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -9,7 +9,7 @@ framework: cookie_secure: true #esi: true - #fragments: true + fragments: true when@test: framework: diff --git a/config/services.yaml b/config/services.yaml index 02dbb01..5dc95d6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -51,10 +51,14 @@ services: App\Controller\ImportController: arguments: $projectDir: '%kernel.project_dir%' - - App\EventListener\MangaScrapedListener: - tags: - - { name: kernel.event_listener, event: 'manga.scraped', method: 'onMangaScraped' } + + App\Controller\TestController: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Controller\MangaController: + arguments: + $projectDir: '%kernel.project_dir%' App\EventSubscriber\QueueStatusSubscriber: tags: diff --git a/migrations/Version20240706171902.php b/migrations/Version20240706171902.php new file mode 100644 index 0000000..3e44f94 --- /dev/null +++ b/migrations/Version20240706171902.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE manga ADD thumbnail_url VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE manga DROP thumbnail_url'); + } +} diff --git a/package-lock.json b/package-lock.json index bcf8c50..52f9d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,10 @@ "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.1.1 || ^8.0", "@symfony/stimulus-bridge": "^3.2.0", "@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets", + "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/webpack-encore": "^4.0.0", "core-js": "^3.23.0", "daisyui": "^4.4.2", @@ -1781,6 +1783,15 @@ "@hotwired/stimulus": ">= 3.0" } }, + "node_modules/@hotwired/turbo": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.4.tgz", + "integrity": "sha512-mlZEFUZrJnpfj+g/XeCWWuokvQyN68WvM78JM+0jfSFc98wegm259vCbC1zSllcspRwbgXK31ibehCy5PA78/Q==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2171,6 +2182,10 @@ "resolved": "vendor/symfony/ux-live-component/assets", "link": true }, + "node_modules/@symfony/ux-turbo": { + "resolved": "vendor/symfony/ux-turbo/assets", + "link": true + }, "node_modules/@symfony/webpack-encore": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/@symfony/webpack-encore/-/webpack-encore-4.6.1.tgz", @@ -10644,6 +10659,19 @@ "peerDependencies": { "@hotwired/stimulus": "^3.0.0" } + }, + "vendor/symfony/ux-turbo/assets": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.1.0 || ^8.0" + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.1.1 || ^8.0" + } } } } diff --git a/package.json b/package.json index 553c6e8..34fbca9 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.1.1 || ^8.0", "@symfony/stimulus-bridge": "^3.2.0", "@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets", + "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/webpack-encore": "^4.0.0", "core-js": "^3.23.0", "daisyui": "^4.4.2", diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 42f76d7..9a6fcae 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -89,6 +89,7 @@ class ActivityController extends AbstractController 'manga' => $manga->getTitle(), 'volume' => $chapter->getVolume(), 'chapter' => $chapter->getNumber(), + 'chapterId' => $chapter->getId(), 'title' => $chapter->getTitle(), ]; } diff --git a/src/Controller/MangaController.php b/src/Controller/MangaController.php index f737f76..410b18e 100644 --- a/src/Controller/MangaController.php +++ b/src/Controller/MangaController.php @@ -15,31 +15,42 @@ 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\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\String\Slugger\SluggerInterface; class MangaController extends AbstractController { + private ImageManager $imageManager; + public function __construct( + private readonly string $projectDir, 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 EntityManagerInterface $entityManager, + private readonly NotificationService $notificationService, + private readonly SluggerInterface $slugger ) { + $this->imageManager = new ImageManager(new Driver()); } - #[Route('/manga', name: 'app_manga')] + #[Route('/', name: 'app_manga')] public function index(Request $request): Response { $sort = $request->query->get('sort', 'title'); @@ -57,11 +68,8 @@ class MangaController extends AbstractController ]); } - /** - * @throws NonUniqueResultException - */ #[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')] - public function showChapters(string $mangaSlug): Response + public function showChapters(string $mangaSlug, Request $request): Response { // $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]); $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]); @@ -70,6 +78,16 @@ class MangaController extends AbstractController throw new NotFoundHttpException("Le manga demandé n'existe pas."); } + return $this->render('manga/show_chapters.html.twig', [ + 'manga' => $manga, + 'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId()])->getGroups(), + ]); + } + + + public function _chaptersByManga(int $id): Response + { + $manga = $this->mangaRepository->find($id); $chaptersByVolume = []; foreach ($manga->getChapters() as $chapter) { $volume = $chapter->getVolume() ?? 'Not Found'; @@ -92,10 +110,10 @@ class MangaController extends AbstractController } return $b <=> $a; }); - return $this->render('manga/show_chapters.html.twig', [ - 'chapters_by_volume' => $chaptersByVolume, + + return $this->render('manga/_chapter_list.html.twig', [ 'manga' => $manga, - 'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId()])->getGroups(), + 'chapters_by_volume' => $chaptersByVolume ]); } @@ -154,11 +172,15 @@ class MangaController extends AbstractController #[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')) - ->setImageUrl($request->request->get('imageUrl')) ->setStatus($request->request->get('status')) ->setGenres(explode(',', $request->request->get('genres'))) ->setAuthor($request->request->get('author')) @@ -166,6 +188,16 @@ class MangaController extends AbstractController ->setRating($request->request->get('rating')) ->setExternalId($request->request->get('externalId')); + // Traitement de l'image + $imageUrl = $request->request->get('imageUrl'); + try { + $imageUrls = $this->processAndSaveImage($imageUrl); + $manga->setImageUrl($imageUrls['full']); + $manga->setThumbnailUrl($imageUrls['thumbnail']); + } catch (\Exception $e) { + // Gérer l'exception (par exemple, logger l'erreur) + } + $mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga); if (empty($mergedChapters)) { @@ -189,7 +221,48 @@ class MangaController extends AbstractController return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]); } - #[Route('/addChapter/{id}', name: 'add_chapter')] + /** + * @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); + $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; + } + } + + #[Route('/searchChapter/{id}', name: 'search_chapter')] public function addChapterMessenger(int $id): JsonResponse { $chapter = $this->chapterRepository->find($id); @@ -204,36 +277,85 @@ class MangaController extends AbstractController 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 + ]); + + if (empty($volumeChapters)) { + $this->notificationService->sendUpdate(['error' => '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 + public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse { $chapter = $this->chapterRepository->find($chapterId); if (!$chapter) { - throw $this->createNotFoundException("Le chapitre demandé n'existe pas."); + $this->notificationService->sendUpdate(['error' => 'Chapitre non trouvé.']); + return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200); } $cbzPath = $chapter->getCbzPath(); if (!$cbzPath || !file_exists($cbzPath)) { - throw $this->createNotFoundException("Le fichier CBZ n'existe pas."); + $this->notificationService->sendUpdate(['error' => 'Le fichier CBZ n\'existe pas.']); + return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200); } - $response = new BinaryFileResponse($cbzPath); - - // Vérifier si c'est un volume complet ou un chapitre individuel $isFullVolume = $this->isFullVolume($chapter); + $fileName = $isFullVolume + ? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume()) + : $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber()); - if ($isFullVolume) { - $fileName = sprintf("%s_volume_%02d.cbz", $chapter->getManga()->getSlug(), $chapter->getVolume()); - } else { - $fileName = sprintf("%s_chapter_%s.cbz", $chapter->getManga()->getSlug(), number_format($chapter->getNumber(), 2)); + 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 + ]); + + if (empty($volumeChapters)) { + $this->notificationService->sendUpdate(['error' => 'Aucun chapitre trouvé pour ce volume.']); } - $response->setContentDisposition( - ResponseHeaderBag::DISPOSITION_ATTACHMENT, - $fileName - ); + if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) { + $this->notificationService->sendUpdate(['error' => '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); + } - return $response; + $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')] diff --git a/src/Controller/TestController.php b/src/Controller/TestController.php index 2eef6b8..1d1f22c 100644 --- a/src/Controller/TestController.php +++ b/src/Controller/TestController.php @@ -8,83 +8,94 @@ 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 App\Service\SushiScanProviderService; 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 MangadexProvider $mangadexProvider, - private MangaRepository $mangaRepository, - private MessageBusInterface $bus, - private Connection $connection, - private SerializerInterface $serializer, - private readonly ChapterRepository $chapterRepository + private string $projectDir, + private SluggerInterface $slugger, + private MangaRepository $mangaRepository ) { + $this->imageManager = new ImageManager(new Driver()); } #[Route('/test', name: 'test')] public function test(): Response { - $sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue'; - $pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']); + $mangas = $this->mangaRepository->findAll(); -// // 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(), - ]; + $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++; } } -// $this->bus->dispatch(new DownloadChapter(1)); - dd($status); + return new JsonResponse(['changed' => $changed]); } - private function decodeMessages(array $messages): array + /** + * @throws GuzzleException + */ + private function processAndSaveImage(string $imageUrl): array { - $decodedMessages = []; + $image = file_get_contents($this->projectDir . '/public' .$imageUrl); + $tempImage = tmpfile(); + fwrite($tempImage, $image); + $tempImagePath = stream_get_meta_data($tempImage)['uri']; - foreach ($messages as $message) { - $decodedMessages[] = [ - 'id' => $message['id'], - 'body' => $this->decodeMessageBody($message['body']), - 'headers' => json_decode($message['headers'], true), + // 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; } - - return $decodedMessages; } - - private function decodeMessageBody(string $body) - { - return unserialize(stripcslashes($body)); - } - - } diff --git a/src/Entity/ContentSource.php b/src/Entity/ContentSource.php index a55fe74..cc05e8c 100644 --- a/src/Entity/ContentSource.php +++ b/src/Entity/ContentSource.php @@ -16,10 +16,10 @@ class ContentSource #[ORM\Column(length: 255)] private ?string $baseUrl = null; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, nullable: true)] private ?string $imageSelector = null; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, nullable: true)] private ?string $NextPageSelector = null; #[ORM\Column(length: 255)] diff --git a/src/Entity/Manga.php b/src/Entity/Manga.php index 21ee559..9cdc515 100644 --- a/src/Entity/Manga.php +++ b/src/Entity/Manga.php @@ -52,6 +52,9 @@ class Manga #[ORM\Column(length: 255, nullable: true)] private ?string $status = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $thumbnailUrl = null; + public function __construct() { $this->chapters = new ArrayCollection(); @@ -234,4 +237,16 @@ class Manga return $this; } + + public function getThumbnailUrl(): ?string + { + return $this->thumbnailUrl; + } + + public function setThumbnailUrl(?string $thumbnailUrl): static + { + $this->thumbnailUrl = $thumbnailUrl; + + return $this; + } } diff --git a/src/EventListener/MangaScrapedListener.php b/src/EventListener/MangaScrapedListener.php deleted file mode 100644 index e4e5062..0000000 --- a/src/EventListener/MangaScrapedListener.php +++ /dev/null @@ -1,55 +0,0 @@ -entityManager = $entityManager; - } - - public function onMangaScraped(MangaScrapedEvent $event): void - { - $mangaData = $event->getMangaData(); - $manga = $this->entityManager->getRepository(Manga::class)->findOneBy(['title' => $mangaData['title']]); - if (!$manga) { - $manga = new Manga(); - $manga->setTitle($mangaData['title']); - $this->entityManager->persist($manga); - } - - $chapter = $manga->getChapterByNumber($mangaData['chapter']); - if (!$chapter) { - $chapter = (new Chapter()) - ->setNumber($mangaData['chapter']); - $manga->addChapter($chapter); - $this->entityManager->persist($chapter); - $this->entityManager->persist($manga); - } - - $chapter->setLocalPath($mangaData['directory']); - - foreach ($mangaData['pages'] as $pageData) { - $page = $chapter->getPageByNumber($pageData['page_number']); - if (!$page) { - $page = (new Page()) - ->setNumber($pageData['page_number']) - ->setImageUrl($pageData['image_url']) - ->setImageLocalUrl($pageData['local_image_url']); - - $chapter->addPagesLink($page); - $this->entityManager->persist($chapter); - $this->entityManager->persist($page); - } - } - $this->entityManager->flush(); - } -} diff --git a/src/EventSubscriber/MangaScrapedEvent.php b/src/EventSubscriber/MangaScrapedEvent.php deleted file mode 100644 index 3dbba5b..0000000 --- a/src/EventSubscriber/MangaScrapedEvent.php +++ /dev/null @@ -1,33 +0,0 @@ -mangaTitle = $mangaTitle; - $this->chapterNumber = $chapterNumber; - $this->pagesData = $pagesData; - $this->chapterDirectory = $chapterDirectory; - } - - public function getMangaData(): array - { - return [ - 'title' => $this->mangaTitle, - 'chapter' => $this->chapterNumber, - 'pages' => $this->pagesData, - 'directory' => $this->chapterDirectory - ]; - } -} diff --git a/src/Service/CbzService.php b/src/Service/CbzService.php index 29eb9f6..456e44f 100644 --- a/src/Service/CbzService.php +++ b/src/Service/CbzService.php @@ -2,7 +2,10 @@ namespace App\Service; +use App\Entity\Manga; use Exception; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\String\Slugger\SluggerInterface; use ZipArchive; @@ -133,4 +136,68 @@ class CbzService sort($images); return $images; } + + public function createVolumeArchive(array $chapters): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'volume_cbz_'); + $zip = new ZipArchive(); + if ($zip->open($tempFile, ZipArchive::CREATE) !== TRUE) { + throw new \RuntimeException("Impossible de créer le fichier ZIP temporaire."); + } + + foreach ($chapters as $chapter) { + $chapterZip = new ZipArchive(); + if ($chapterZip->open($chapter->getCbzPath()) === TRUE) { + 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 ($volume !== null) { + return sprintf("%s_volume_%02d.cbz", $sluggedTitle, $volume); + } elseif ($chapterNumber !== null) { + 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 && $chapter->getCbzPath() !== null; + }, true); + } } diff --git a/src/Service/MangaScraperService.php b/src/Service/MangaScraperService.php index d29907c..20186f1 100644 --- a/src/Service/MangaScraperService.php +++ b/src/Service/MangaScraperService.php @@ -6,7 +6,6 @@ use App\Entity\Chapter; use App\Entity\Manga; use App\Entity\ContentSource; use App\Event\PageScrappingProgressEvent; -use App\EventSubscriber\MangaScrapedEvent; use Doctrine\ORM\EntityManagerInterface; use Exception; use GuzzleHttp\Client; diff --git a/symfony.lock b/symfony.lock index 288263c..3ce88f9 100644 --- a/symfony.lock +++ b/symfony.lock @@ -258,6 +258,9 @@ "config/routes/ux_live_component.yaml" ] }, + "symfony/ux-turbo": { + "version": "v2.18.0" + }, "symfony/ux-twig-component": { "version": "2.17", "recipe": { diff --git a/templates/activity/index.html.twig b/templates/activity/index.html.twig index 574423b..777c725 100644 --- a/templates/activity/index.html.twig +++ b/templates/activity/index.html.twig @@ -23,13 +23,24 @@ {% for manga in status %} - + {{ manga.manga }} {{ manga.volume }} - {{ manga.chapter }} + + {{ manga.chapter }} + + {{ manga.title }}
- +
-