- CbrToCbzConverter.php
- import now convert .cbr to .cbz
- import improvement, multiple files
This commit is contained in:
Jérémy Guillot
2024-07-24 14:10:28 +02:00
parent 4484be4d4e
commit 7068bd1a34
14 changed files with 547 additions and 238 deletions

View File

@@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
file \ file \
gettext \ gettext \
git \ git \
unrar-free \
p7zip-full \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \

View File

@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["checkbox", "modal", "modalContent"]
toggleAllCheckboxes(event) {
this.checkboxTargets.forEach(checkbox => {
checkbox.checked = event.target.checked;
});
}
updateMangaInfo(event) {
const select = event.target;
const selectedOption = select.options[select.selectedIndex];
const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo);
}
showDetails(event) {
const fileId = event.currentTarget.dataset.fileId;
const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`);
const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo);
this.modalContentTarget.innerHTML = `
<h3 class="text-lg leading-6 font-medium text-gray-900">${mangaInfo.title}</h3>
<div class="mt-2">
<p><strong>Author:</strong> ${mangaInfo.author || 'N/A'}</p>
<p><strong>Publication Year:</strong> ${mangaInfo.publicationYear || 'N/A'}</p>
<p><strong>Genres:</strong> ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}</p>
<p><strong>Description:</strong> ${this.truncate(mangaInfo.description || 'N/A', 200)}</p>
</div>
`;
this.modalTarget.classList.remove('hidden');
}
closeModal() {
this.modalTarget.classList.add('hidden');
}
confirmSelected(event) {
const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value);
if (selectedFiles.length === 0) {
event.preventDefault();
alert('Veuillez sélectionner au moins un fichier à importer.');
}
}
truncate(str, length) {
return str.length > length ? str.substring(0, length) + '...' : str;
}
}

View File

@@ -61,6 +61,10 @@ services:
arguments: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
App\Service\CbrToCbzConverter:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\QueueStatusSubscriber: App\EventSubscriber\QueueStatusSubscriber:
tags: tags:
- { name: kernel.event_subscriber } - { name: kernel.event_subscriber }

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Repository\ChapterRepository; use App\Repository\ChapterRepository;
use App\Repository\MangaRepository; use App\Repository\MangaRepository;
use App\Service\CbrToCbzConverter;
use App\Service\CbzService; use App\Service\CbzService;
use App\Service\MangaImportService; use App\Service\MangaImportService;
use App\Service\NotificationService; use App\Service\NotificationService;
@@ -18,16 +19,15 @@ use Symfony\Component\String\Slugger\SluggerInterface;
class ImportController extends AbstractController class ImportController extends AbstractController
{ {
private const UPLOADS_DIRECTORY = 'public/uploads'; private const string UPLOADS_DIRECTORY = 'public/uploads';
public function __construct( public function __construct(
private readonly string $projectDir, private readonly string $projectDir,
private readonly CbzService $cbzService, private readonly CbzService $cbzService,
private readonly MangaImportService $mangaImportService, private readonly MangaImportService $mangaImportService,
// private SluggerInterface $slugger, private readonly NotificationService $notificationService,
private NotificationService $notificationService, private readonly MangaRepository $mangaRepository,
private MangaRepository $mangaRepository, private readonly CbrToCbzConverter $cbrToCbzConverter
private ChapterRepository $chapterRepository
) )
{ {
@@ -36,27 +36,44 @@ class ImportController extends AbstractController
#[Route('/manga/import', name: 'app_manga_import')] #[Route('/manga/import', name: 'app_manga_import')]
public function index(Request $request, SessionInterface $session): Response public function index(Request $request, SessionInterface $session): Response
{ {
if ($request->isMethod('post')) { if ($request->isMethod('POST')) {
$file = $request->files->get('file'); $files = $request->files->get('files');
if ($file && $file->getClientOriginalExtension() === 'cbz') { if ($files) {
$originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); $importFiles = [];
foreach ($files as $file) {
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
$originalFileName = $file->getClientOriginalName();
$filename = uniqid() . '.' . $file->getClientOriginalExtension(); $filename = uniqid() . '.' . $file->getClientOriginalExtension();
try { try {
$file->move($this->projectDir . '/' . self::UPLOADS_DIRECTORY, $filename); $file->move($this->projectDir . '/' . self::UPLOADS_DIRECTORY, $filename);
$session->set('import_file_path', $this->projectDir . '/' .self::UPLOADS_DIRECTORY . '/' . $filename); $importFiles[] = [
$session->set('import_original_file_name', $originalFileName); 'id' => uniqid(),
return $this->redirectToRoute('import_match'); 'path' => $this->projectDir . '/' . self::UPLOADS_DIRECTORY . '/' . $filename,
'original_name' => $originalFileName,
];
} catch (FileException $e) { } catch (FileException $e) {
$this->notificationService->sendUpdate([ $this->notificationService->sendUpdate([
'status' => 'error', 'status' => 'error',
'message' => 'Une erreur est survenue lors de l\'import du fichier.' 'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
]); ]);
} }
} else { } else {
$this->notificationService->sendUpdate([ $this->notificationService->sendUpdate([
'status' => 'error', 'status' => 'error',
'message' => 'Le fichier doit être au format CBZ.' 'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.',
]);
}
}
if (!empty($importFiles)) {
$session->set('import_files', $importFiles);
return $this->redirectToRoute('import_match');
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Aucun fichier n\'a été sélectionné.',
]); ]);
} }
} }
@@ -70,128 +87,134 @@ class ImportController extends AbstractController
#[Route('/import/match', name: 'import_match')] #[Route('/import/match', name: 'import_match')]
public function match(Request $request, SessionInterface $session): Response public function match(Request $request, SessionInterface $session): Response
{ {
$filePath = $session->get('import_file_path'); $files = $session->get('import_files', []);
$originalFileName = $session->get('import_original_file_name'); if (empty($files)) {
if (!$filePath || !$originalFileName) {
return $this->redirectToRoute('app_manga_import'); return $this->redirectToRoute('app_manga_import');
} }
$processedFiles = [];
foreach ($files as $fileId => $fileInfo) {
$filePath = $fileInfo['path'];
$originalFileName = $fileInfo['original_name'];
$fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
if (strtolower($fileExtension) === 'cbr') {
$cbzPath = $this->cbrToCbzConverter->convert($filePath);
$filePath = $cbzPath;
$originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz';
$files[$fileId]['path'] = $filePath;
$files[$fileId]['original_name'] = $originalFileName;
}
$metadata = $this->cbzService->extractMetadata($filePath, $originalFileName); $metadata = $this->cbzService->extractMetadata($filePath, $originalFileName);
if($metadata['title'] === '' || is_null($metadata['title'])){
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Impossible de détecter le titre du manga.'
]);
return $this->redirectToRoute('app_manga_import');
}
$mangas = $this->mangaRepository->findBySlug($metadata['title']); $mangas = $this->mangaRepository->findBySlug($metadata['title']);
$mangasChapters = []; $mangaOptions = [];
foreach ($mangas as $manga) { foreach ($mangas as $manga) {
if(!is_null($metadata['chapter'])){ $mangaOptions[] = [
$chapters = $this->chapterRepository->findBy([ 'slug' => $manga->getSlug(),
'manga' => $manga, 'title' => $manga->getTitle(),
'number' => $metadata['chapter'] 'author' => $manga->getAuthor(),
]); 'publicationYear' => $manga->getPublicationYear(),
$chapters = [$chapters[0]->getVolume() => $chapters]; 'genres' => $manga->getGenres(),
}else{ 'description' => $manga->getDescription()
$chapters = $this->chapterRepository->findBy([ ];
'manga' => $manga,
'volume' => (int) $metadata['volume']
]);
$chapters = [$metadata['volume'] => $chapters];
}
$mangasChapters[$manga->getSlug()] = $chapters;
} }
if(empty($mangas)) { $processedFiles[] = [
$this->notificationService->sendUpdate([ 'id' => $fileId,
'status' => 'error', 'originalFileName' => $originalFileName,
'message' => 'Aucun manga trouvé avec ce titre.' 'fileSize' => $this->formatBytes(filesize($filePath)),
]); 'metadata' => $metadata,
return $this->redirectToRoute('app_manga_search', ['query' => $metadata['title']]); 'mangaOptions' => $mangaOptions
];
} }
if ($request->isMethod('post')) { $session->set('import_files', $files);
$session->set('import_metadata', $request->request->all());
return $this->redirectToRoute('import_confirm');
}
return $this->render('import/match.html.twig', [ return $this->render('import/match.html.twig', [
'mangas' => $mangas, 'files' => $processedFiles
'volume' => $metadata['volume'],
'chapters' => $mangasChapters
]); ]);
} }
#[Route('/import/confirm', name: 'import_confirm')] private function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
public function confirm(Request $request, SessionInterface $session): Response public function confirm(Request $request, SessionInterface $session): Response
{ {
if (!$request->isMethod('POST')) { $files = $session->get('import_files', []);
return $this->redirectToRoute('app_manga_import'); $selectedFiles = $request->request->all('selected');
$mangaSlugs = $request->request->all('manga_slug');
$volumes = $request->request->all('volume');
$chapters = $request->request->all('chapter');
$importedFiles = [];
$errors = [];
foreach ($selectedFiles as $fileId) {
if (!isset($files[$fileId])) {
continue;
} }
$action = $request->request->get('action'); $file = $files[$fileId];
$mangaSlug = $request->request->get('manga_slug'); $mangaSlug = $mangaSlugs[$fileId] ?? null;
$volume = $request->request->get('volume'); $volume = $volumes[$fileId] ?? null;
$chapter = $chapters[$fileId] ?? null;
if ($action === 'confirm') {
// Logique de confirmation
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Manga non trouvé.'
]);
return $this->redirectToRoute('app_manga_import');
}
$filePath = $session->get('import_file_path');
if (!$filePath) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Fichier d\'import non trouvé.'
]);
return $this->redirectToRoute('app_manga_import');
}
$originalFileName = $session->get('import_original_file_name');
// Ici, vous pouvez ajouter la logique pour importer effectivement le fichier
// Par exemple :
// $this->mangaImportService->importVolume($manga, $volume, $filePath);
try { try {
$this->mangaImportService->importVolume($manga, (int)$volume, $filePath, $originalFileName); $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
} catch (\Exception $e) { if (!$manga) {
$this->notificationService->sendUpdate([ throw new \Exception('Manga non trouvé.');
'status' => 'error',
'message' => 'Erreur lors de l\'import : ' . $e->getMessage()
]);
} }
if(!is_null($chapter)){
$chapter = $manga->getChapterByNumber($chapter);
if (!$chapter) {
throw new \Exception('Chapitre non trouvé.');
}
}
$importedFiles[] = $file['original_name'];
$this->mangaImportService->importFile($manga, $volume, $chapter,$file['path']);
} catch (\Exception $e) {
$errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage();
}
}
// Nettoyer les fichiers temporaires non importés
foreach ($files as $fileId => $file) {
if (!in_array($fileId, (array)$selectedFiles) && file_exists($file['path'])) {
unlink($file['path']);
}
}
// Nettoyer la session
$session->remove('import_files');
// Préparer le message de notification
if (!empty($importedFiles)) {
$successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles);
$this->notificationService->sendUpdate([ $this->notificationService->sendUpdate([
'status' => 'success', 'status' => 'success',
'message' => 'Import confirmé avec succès.' 'message' => $successMessage
]); ]);
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
} elseif ($action === 'refuse') {
// Logique de refus
$filePath = $session->get('import_file_path');
if ($filePath && file_exists($filePath)) {
unlink($filePath); // Supprime le fichier temporaire
} }
$session->remove('import_file_path');
$session->remove('import_original_file_name');
if (!empty($errors)) {
$errorMessage = implode("\n", $errors);
$this->notificationService->sendUpdate([ $this->notificationService->sendUpdate([
'status' => 'info', 'status' => 'error',
'message' => 'Import refusé. Le fichier a été supprimé.' 'message' => $errorMessage
]); ]);
} }
return $this->redirectToRoute('app_manga_import'); return $this->redirectToRoute('app_manga');
} }
} }

View File

@@ -66,7 +66,7 @@ class MangaRepository extends ServiceEntityRepository
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
$resultSet = $stmt->executeQuery([ $resultSet = $stmt->executeQuery([
'slug' => $slug, 'slug' => $slug,
'max_distance' => strlen($slug) / 3 'max_distance' => intval(ceil(strlen($slug) / 3))
]); ]);
$results = $resultSet->fetchAllAssociative(); $results = $resultSet->fetchAllAssociative();

View File

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

View File

@@ -103,7 +103,13 @@ class CbzService
if (preg_match($titlePattern, $fileName, $matches)) { if (preg_match($titlePattern, $fileName, $matches)) {
return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString(); return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString();
} }
return '';
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString();
}
return $this->slugger->slug(pathinfo($fileName, PATHINFO_FILENAME), '-')->lower()->toString();
} }
private function extractVolume(string $fileName): string private function extractVolume(string $fileName): string
@@ -121,6 +127,12 @@ class CbzService
if (preg_match($chapterPattern, $fileName, $matches)) { if (preg_match($chapterPattern, $fileName, $matches)) {
return $matches['chapter']; return $matches['chapter'];
} }
$newFormatPattern = '/_(?P<chapter>\d+)(?:\.\w+)?$/';
if (preg_match($newFormatPattern, $fileName, $matches)) {
return $matches['chapter'];
}
return ''; return '';
} }

View File

@@ -2,6 +2,7 @@
namespace App\Service; namespace App\Service;
use App\Entity\Chapter;
use App\Entity\Manga; use App\Entity\Manga;
use App\Repository\ChapterRepository; use App\Repository\ChapterRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -18,7 +19,6 @@ class MangaImportService
private readonly string $projectDir, private readonly string $projectDir,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ChapterRepository $chapterRepository, private readonly ChapterRepository $chapterRepository,
private readonly CbzService $cbzService,
private readonly Filesystem $filesystem, private readonly Filesystem $filesystem,
private readonly SluggerInterface $slugger private readonly SluggerInterface $slugger
) )
@@ -28,42 +28,65 @@ class MangaImportService
/** /**
* @throws Exception * @throws Exception
*/ */
#[NoReturn] public function importVolume(Manga $manga, int $volume, string $tempFilePath, string $originalFileName): void public function importFile(Manga $manga, ?int $volume, ?Chapter $chapter, string $tempFilePath): void
{ {
// Extraire les métadonnées du fichier CBZ if ($chapter !== null) {
$metadata = $this->cbzService->extractMetadata($tempFilePath, $originalFileName); $this->importChapter($manga, $chapter, $tempFilePath);
} elseif ($volume !== null) {
$this->importVolume($manga, $volume, $tempFilePath);
} else {
throw new \RuntimeException("Impossible de déterminer s'il s'agit d'un volume ou d'un chapitre.");
}
}
// Créer le nom de fichier et le chemin pour le stockage permanent /**
$permanentFileName = $this->createPermanentFileName($manga, $volume, $metadata); * @throws Exception
*/
private function importVolume(Manga $manga, int $volume, string $tempFilePath): void
{
$permanentFileName = $this->createPermanentFileName($manga, $volume);
$mangaDirectory = $this->createMangaDirectory($manga); $mangaDirectory = $this->createMangaDirectory($manga);
$permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName; $permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName;
// Vérifier si le fichier existe déjà
if ($this->filesystem->exists($permanentFilePath)) { if ($this->filesystem->exists($permanentFilePath)) {
throw new \RuntimeException("Un fichier pour ce volume/chapitre existe déjà."); throw new \RuntimeException("Un fichier pour ce volume existe déjà.");
} }
// Déplacer le fichier vers l'emplacement permanent
$this->filesystem->mkdir(dirname($permanentFilePath), 0755); $this->filesystem->mkdir(dirname($permanentFilePath), 0755);
$this->filesystem->rename($tempFilePath, $permanentFilePath, true); $this->filesystem->rename($tempFilePath, $permanentFilePath, true);
// Mettre à jour ou créer les entités Chapter
if (isset($metadata['chapter'])) {
// Si c'est un chapitre spécifique
$this->updateChapter($manga, $volume, $metadata['chapter'], $permanentFilePath);
} else {
// Si c'est un volume entier, mettre à jour tous les chapitres du volume
$this->updateVolumeChapters($manga, $volume, $permanentFilePath); $this->updateVolumeChapters($manga, $volume, $permanentFilePath);
}
$this->entityManager->flush(); $this->entityManager->flush();
} }
private function createPermanentFileName(Manga $manga, int $volume, array $metadata): string /**
* @throws Exception
*/
private function importChapter(Manga $manga, Chapter $chapter, string $tempFilePath): void
{
$volume = $chapter->getVolume();
$permanentFileName = $this->createPermanentFileName($manga, $volume, $chapter->getNumber());
$mangaDirectory = $this->createMangaDirectory($manga);
$permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName;
if ($this->filesystem->exists($permanentFilePath)) {
throw new \RuntimeException("Un fichier pour ce chapitre existe déjà.");
}
$this->filesystem->mkdir(dirname($permanentFilePath), 0755);
$this->filesystem->rename($tempFilePath, $permanentFilePath, true);
$chapter->setCbzPath($permanentFilePath);
$this->entityManager->flush();
}
private function createPermanentFileName(Manga $manga, int $volume, ?float $chapterNumber = null): string
{ {
$baseFileName = $this->slugger->slug($manga->getTitle()) . '_vol' . sprintf('%02d', $volume); $baseFileName = $this->slugger->slug($manga->getTitle()) . '_vol' . sprintf('%02d', $volume);
if (isset($metadata['chapter'])) { if ($chapterNumber !== null) {
$baseFileName .= '_ch' . $metadata['chapter']; $baseFileName .= '_ch' . $chapterNumber;
} }
return $baseFileName . '.cbz'; return $baseFileName . '.cbz';
} }
@@ -77,21 +100,6 @@ class MangaImportService
return $directoryPath; return $directoryPath;
} }
private function updateChapter(Manga $manga, int $volume, float $chapterNumber, string $cbzPath): void
{
$chapter = $this->chapterRepository->findOneBy([
'manga' => $manga,
'volume' => $volume,
'number' => $chapterNumber
]);
if (!$chapter) {
throw new \RuntimeException("Le chapitre $chapterNumber du volume $volume n'existe pas en base de données.");
}
$chapter->setCbzPath($cbzPath);
}
private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void
{ {
$chapters = $this->chapterRepository->findBy([ $chapters = $this->chapterRepository->findBy([

View File

@@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="container mx-auto p-4">
<div class="bg-white shadow-lg rounded-sm overflow-hidden">
<div class="bg-gray-800 text-white p-4">
<h1 class="text-2xl font-bold">
<i class="fas fa-exchange-alt mr-2"></i>Convertir CBR en CBZ
</h1>
</div>
<div class="p-6">
<form method="post" enctype="multipart/form-data" action="{{ path('app_convert') }}" data-turbo="false">
<div class="mb-4">
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2">
Choisir un fichier CBR
</label>
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div class="space-y-1 text-center">
<i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i>
<div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500">
<span>Sélectionner un fichier</span>
<input id="file-upload" name="file" type="file" class="sr-only" accept=".cbr" required>
</label>
<p class="pl-1">ou glisser-déposer</p>
</div>
<p class="text-xs text-gray-500">
CBR jusqu'à 100MB
</p>
</div>
</div>
</div>
<div class="mt-6">
<button type="submit" class="w-full flex items-center justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
<span class="mr-2">Convertir en CBZ</span>
<i class="fas fa-arrow-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -5,27 +5,27 @@
<div class="bg-white shadow-lg rounded-sm overflow-hidden"> <div class="bg-white shadow-lg rounded-sm overflow-hidden">
<div class="bg-gray-800 text-white p-4"> <div class="bg-gray-800 text-white p-4">
<h1 class="text-2xl font-bold"> <h1 class="text-2xl font-bold">
<i class="fas fa-file-import mr-2"></i>Importer un Manga <i class="fas fa-file-import mr-2"></i>Importer des Mangas
</h1> </h1>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<div class="mb-4"> <div class="mb-4">
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2"> <label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2">
Choisir un fichier CBZ Choisir des fichiers CBZ ou CBR
</label> </label>
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"> <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i> <i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i>
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"> <label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500">
<span>Sélectionner un fichier</span> <span>Sélectionner des fichiers</span>
<input id="file-upload" name="file" type="file" class="sr-only" accept=".cbz" required> <input id="file-upload" name="files[]" type="file" class="sr-only" accept=".cbz,.cbr" multiple required>
</label> </label>
<p class="pl-1">ou glisser-déposer</p> <p class="pl-1">ou glisser-déposer</p>
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
CBZ jusqu'à 100MB CBZ ou CBR jusqu'à 100MB chacun
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,81 +1,103 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
<div class="container mx-auto p-4"> <form method="post" action="{{ path('import_confirm') }}" data-controller="import-match">
<h1 class="text-2xl font-bold mb-6">Correspondances trouvées :</h1> <div class="container mx-auto mt-8 p-2">
<div class="bg-white overflow-hidden">
{% if mangas %} <div class="overflow-x-auto">
{% for manga in mangas %} <table class="min-w-full bg-white">
<div class="bg-white shadow-lg rounded-lg overflow-hidden mb-8"> <thead>
<div class="flex bg-gray-100 p-4"> <tr class="bg-gray-200 text-gray-800">
<div class="flex-none w-48"> <th class="w-1/12 py-3 px-4 text-left">
<img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}" alt="{{ manga.title }}" <input type="checkbox" class="form-checkbox h-5 w-5 text-green-600"
class="w-full h-full object-cover rounded" style="width: 150px; height: 220px;"> data-action="change->import-match#toggleAllCheckboxes">
</th>
<th class="w-4/12 py-3 px-4 text-left">Original File</th>
<th class="w-4/12 py-3 px-4 text-left">Manga</th>
<th class="w-3/12 py-3 px-4 text-left">Content</th>
<th class="w-2/12 py-3 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody class="text-gray-700">
{% for file in files %}
<tr class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out">
<td class="py-4 px-4 text-center">
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600"
name="selected[]"
value="{{ file.id }}" data-import-match-target="checkbox">
</td>
<td>
<div class="py-4 px-4">
<div class="text-sm font-medium text-gray-900">{{ file.originalFileName }}</div>
<div class="text-sm text-gray-500">{{ file.fileSize }}</div>
</div> </div>
<div class="flex-grow ml-4"> </td>
<h2 class="text-xl font-bold text-gray-900">{{ manga.title }} <span class="text-gray-500">({{ manga.publicationYear }})</span></h2> <td class="py-4 px-4">
<div class="mt-2"> <select name="manga_slug[{{ file.id }}]"
{% for genre in manga.genres %} class="w-full bg-white border border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
<span class="inline-block bg-gray-200 text-gray-800 text-xs font-semibold mr-2 mb-2 px-2.5 py-0.5 rounded"> data-action="change->import-match#updateMangaInfo"
{{ genre }} data-file-id="{{ file.id }}">
</span> {% for manga in file.mangaOptions %}
<option value="{{ manga.slug }}" {% if loop.first %}selected{% endif %}
data-manga-info="{{ manga|json_encode }}">{{ manga.title }}</option>
{% endfor %} {% endfor %}
</div> </select>
<p class="text-gray-700 text-sm mt-2">{{ manga.description|truncate(150) }}</p> </td>
<div class="mt-2"> <td class="py-4 px-4">
<span class="text-gray-600 text-sm"> {% if file.metadata.chapter %}
<i class="fas fa-star text-yellow-500"></i> Chapter {{ file.metadata.chapter }}
{{ manga.rating }} <input type="hidden" name="chapter[{{ file.id }}]"
</span> value="{{ file.metadata.chapter }}">
</div> {% else %}
</div> Volume {{ file.metadata.volume }}
</div> <input type="hidden" name="volume[{{ file.id }}]"
value="{{ file.metadata.volume }}">
{% if chapters[manga.slug] is iterable %} {% endif %}
{% for volume, volumeChapters in chapters[manga.slug] %} </td>
{% set is_first = loop.first %} <td class="py-4 px-4">
<div data-controller="table"> <button type="button"
<div class="border-t border-gray-200"> class="text-blue-500 hover:text-blue-700 transition duration-150 ease-in-out"
<div class="bg-gray-50 px-4 py-3 flex justify-between items-center cursor-pointer" data-action="click->table#toggle"> data-action="click->import-match#showDetails" data-file-id="{{ file.id }}">
<h3 class="text-lg font-semibold">Volume {{ '%02d'|format(volume) }}</h3> <i class="fas fa-info-circle"></i>
<div class="flex items-center"> </button>
<span class="text-gray-600 mr-2">{{ volumeChapters|length }} Chapitres</span> </td>
<i data-table-target="toggleIcon" class="fas fa-chevron-down"></i>
</div>
</div>
<div data-table-target="body" class="" style="display: none;">
<table class="min-w-full divide-y divide-gray-200">
<tbody class="bg-white divide-y divide-gray-200">
{% for chapter in volumeChapters %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ chapter.number }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ chapter.title ?? 'Sans titre' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
{% endfor %}
{% endif %}
<div class="mt-6 flex justify-end">
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
data-action="import-match#confirmSelected">
Importer les fichiers sélectionnés
</button>
</div>
</div>
<div class="fixed z-10 inset-0 overflow-y-auto hidden" data-import-match-target="modal">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div data-import-match-target="modalContent">
<!-- Will be filled by JavaScript -->
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<form method="post" action="{{ path('import_confirm') }}" class="mt-4 sm:mt-0"> <button type="button"
<input type="hidden" name="manga_slug" value="{{ manga.slug }}"> class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
<input type="hidden" name="volume" value="{{ volume }}"> data-action="click->import-match#closeModal">
<button type="submit" name="action" value="confirm" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"> Close
Confirmer
</button>
<button type="submit" name="action" value="refuse" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Refuser
</button> </button>
</div>
</div>
</div>
</div>
</form> </form>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-gray-700">Aucune correspondance trouvée.</p>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@@ -67,19 +67,25 @@
{% if all_chapters_same_cbz and volume_cbz_path is not null %} {% if all_chapters_same_cbz and volume_cbz_path is not null %}
<tr class="border-t hover:bg-green-100"> <tr class="border-t hover:bg-green-100">
<td class="px-4 py-2 text-green-500"> <td class="px-4 py-2 text-green-500">
<a data-turbo-frame="_top" href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> <a data-turbo-frame="_top"
href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
{{ '%02d'|format(volume) }} {{ '%02d'|format(volume) }}
</a> </a>
</td> </td>
<td class="px-4 py-2 w-full text-left"> <td class="px-4 py-2 w-full text-left">
<a data-turbo-frame="_top" href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> <a data-turbo-frame="_top"
href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
Volume {{ '%02d'|format(volume) }} Volume {{ '%02d'|format(volume) }}
</a> </a>
</td> </td>
<td class="px-4 py-2 flex justify-end gap-2"> <td class="px-4 py-2 flex justify-end gap-2">
<a href="{{ path('download_cbz', {chapterId: chapters|first.id}) }}" <a href="#"
class="text-gray-500 hover:text-green-500"> data-controller="download"
<i class="fas fa-download"></i> data-action="download#download"
data-download-url-value="{{ path('download_cbz', {chapterId: chapters|first.id}) }}"
class="w-8 text-center">
<i data-download-target="icon"
class="fas fa-download text-gray-500 hover:text-green-500"></i>
</a> </a>
</td> </td>
</tr> </tr>

View File

@@ -14,6 +14,12 @@
</ul> </ul>
{% endif %} {% endif %}
</li> </li>
<li class="{{ app.request.get('_route') == 'app_convert' ? 'border-l-4 border-green-600' : '' }}">
<a href="{{ path('app_convert') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_convert' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}">
<i class="fas fa-exchange-alt mr-2"></i>
<span>Convertir CBR en CBZ</span>
</a>
</li>
<li class="{{ app.request.get('_route') == 'app_calendar' ? 'border-l-4 border-green-600' : '' }}"> <li class="{{ app.request.get('_route') == 'app_calendar' ? 'border-l-4 border-green-600' : '' }}">
<a href="{{ path('app_calendar') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_calendar' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> <a href="{{ path('app_calendar') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_calendar' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}">
<i class="fas fa-calendar-alt mr-2"></i> <i class="fas fa-calendar-alt mr-2"></i>