476 lines
18 KiB
PHP
476 lines
18 KiB
PHP
<?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('/', name: 'app_manga')]
|
|
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;
|
|
}
|
|
}
|