refactor: supprimer tout le code legacy MVC/Twig/Stimulus
Supprime toutes les couches pré-DDD pour ne garder que l'architecture hexagonale (src/Domain/), les entités Doctrine et le front Vue.js SPA. Supprimé : - src/Controller/ (9 controllers Twig, garde SecurityController) - src/Service/, src/Message/, src/MessageHandler/ (services et messages legacy) - src/Manager/, src/Twig/, src/Form/ (UI legacy) - src/Event/, src/EventListener/, src/EventSubscriber/QueueStatusSubscriber - src/Client/MangadexClient.php (doublon du Domain) - src/Interface/, src/Factory/, src/DataFixtures/, src/Scheduler/MainSchedule - templates/ (tous sauf vue/ et base retiré — SecurityController = pur JSON) - assets/controllers/ (20 Stimulus controllers), app.js, bootstrap.js, controllers.json Modifié : - config/routes.yaml : suppression du chargement des controllers legacy - config/packages/messenger.yaml : suppression des routes legacy - config/services.yaml : suppression des bindings legacy + entrées Domain\Import fantômes - webpack.config.js : suppression entry 'app' et enableStimulusBridge - src/Entity/Chapter.php : suppression #[Broadcast] (Turbo Streams legacy) Déplacé : - src/Factory/*.php → tests/Factory/ (namespace App\Tests\Factory)
This commit is contained in:
parent
d7e6bf56d0
commit
5a0888eb28
@@ -1,17 +0,0 @@
|
||||
import './bootstrap.js';
|
||||
|
||||
import '@fortawesome/fontawesome-free/js/all.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
*
|
||||
* We recommend including the built version of this JavaScript file
|
||||
* (and its CSS file) in your base layout (base.html.twig).
|
||||
*/
|
||||
|
||||
// any CSS you import will output into a single css file (app.css in this case)
|
||||
import './styles/app.scss';
|
||||
|
||||
// start the Stimulus application
|
||||
import './bootstrap';
|
||||
|
||||
// La ligne registerReactControllerComponents a déjà été commentée
|
||||
35
assets/bootstrap.js
vendored
35
assets/bootstrap.js
vendored
@@ -1,35 +0,0 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bridge';
|
||||
|
||||
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
|
||||
export const app = startStimulusApp(require.context(
|
||||
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
|
||||
true,
|
||||
/\.[jt]sx?$/
|
||||
));
|
||||
|
||||
// register any custom, 3rd party controllers here
|
||||
// app.register('some_controller_name', SomeImportedController);
|
||||
|
||||
//DEBUG TURBO
|
||||
// import * as Turbo from "@hotwired/turbo"
|
||||
//
|
||||
// Turbo.session.drive = false
|
||||
// Turbo.start()
|
||||
//
|
||||
// // Écouteurs existants
|
||||
// document.addEventListener("turbo:before-stream-render", (event) => {
|
||||
// console.log("Before stream render", event.target);
|
||||
// });
|
||||
//
|
||||
// document.addEventListener("turbo:stream-render", (event) => {
|
||||
// console.log("Stream rendered", event.target);
|
||||
// });
|
||||
//
|
||||
// // Nouvel écouteur pour les événements de création
|
||||
// document.addEventListener("turbo:before-fetch-request", (event) => {
|
||||
// console.log("Before fetch request", event.detail.fetchOptions);
|
||||
// });
|
||||
//
|
||||
// document.addEventListener("turbo:before-fetch-response", (event) => {
|
||||
// console.log("Before fetch response", event.detail.fetchResponse);
|
||||
// });
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"controllers": {
|
||||
"@symfony/ux-live-component": {
|
||||
"live": {
|
||||
"enabled": true,
|
||||
"fetch": "eager",
|
||||
"autoimport": {
|
||||
"@symfony/ux-live-component/dist/live.min.css": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@symfony/ux-react": {
|
||||
"react": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
}
|
||||
},
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
}
|
||||
@@ -1,54 +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 = ['activity']
|
||||
|
||||
// ...
|
||||
async connect() {
|
||||
try {
|
||||
const response = await fetch(`/activity/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Handle the response data as needed
|
||||
this.activityTarget.innerHTML = data.length;
|
||||
if (data.length > 0) {
|
||||
this.activityTarget.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
|
||||
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.processing !== undefined && data.pending !== undefined) {
|
||||
let totalActivities = data.processing.length + data.pending.length;
|
||||
this.activityTarget.innerHTML = totalActivities;
|
||||
if (totalActivities > 0) {
|
||||
this.activityTarget.classList.remove('hidden');
|
||||
}else if (totalActivities === 0) {
|
||||
this.activityTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource
|
||||
.onerror = (event) => {
|
||||
console.error('EventSource failed:', event);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// assets/controllers/addmanga_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
index: Number
|
||||
}
|
||||
|
||||
openModal(event) {
|
||||
event.preventDefault()
|
||||
const openEvent = new CustomEvent(`openAddMangaModal${this.indexValue}`)
|
||||
document.dispatchEvent(openEvent)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +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 = ['alert', 'icon', 'message']
|
||||
|
||||
connect() {
|
||||
window.addEventListener('alert:show', this.showAlert.bind(this));
|
||||
}
|
||||
|
||||
// ...
|
||||
showAlert(event) {
|
||||
const detail = event.detail;
|
||||
const message = detail.message;
|
||||
const level = detail.level;
|
||||
|
||||
let alertClass = "";
|
||||
let iconClass = "";
|
||||
switch (level) {
|
||||
case 'success':
|
||||
alertClass = "bg-green-500";
|
||||
iconClass = "fa-circle-check";
|
||||
break;
|
||||
case 'warning':
|
||||
alertClass = "bg-yellow-500";
|
||||
iconClass = "fa-circle-exclamation";
|
||||
break;
|
||||
case 'error':
|
||||
alertClass = "bg-red-500";
|
||||
iconClass = "fa-circle-xmark";
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
alertClass = "bg-blue-500";
|
||||
iconClass = "fa-circle-info";
|
||||
break;
|
||||
}
|
||||
|
||||
this.messageTarget.innerHTML = message;
|
||||
this.alertTarget.classList.add(alertClass);
|
||||
this.iconTarget.classList.add(iconClass);
|
||||
this.alertTarget.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
this.alertTarget.style.opacity = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
this.alertTarget.style.display = 'none';
|
||||
this.alertTarget.classList.remove(alertClass);
|
||||
this.alertTarget.style.opacity = 1;
|
||||
this.iconTarget.classList.remove(iconClass);
|
||||
this.messageTarget.innerHTML = message;
|
||||
}, 1000);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
||||
|
||||
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 === "scrapping.progress" && data.chapterId === this.chapterIdValue) {
|
||||
this.handleProgressUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
handleProgressUpdate(data) {
|
||||
this.currentPage = data.pageIndex;
|
||||
this.totalPages = data.totalPages;
|
||||
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progress = (this.currentPage / this.totalPages) * 100;
|
||||
this.progressBarTarget.style.width = `${progress}%`;
|
||||
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +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 = ['container', 'template', 'item'];
|
||||
|
||||
connect() {
|
||||
this.index = this.itemTargets.length;
|
||||
}
|
||||
|
||||
add(event) {
|
||||
event.preventDefault();
|
||||
const template = this.templateTarget.innerHTML.replace(/__name__/g, this.index);
|
||||
this.containerTarget.insertAdjacentHTML('beforeend', template);
|
||||
this.index++;
|
||||
}
|
||||
|
||||
remove(event) {
|
||||
event.preventDefault();
|
||||
event.target.closest('.collection-item').remove();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// assets/controllers/dropdown_controller.js
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
import {useClickOutside} from "stimulus-use"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "menu"]
|
||||
|
||||
connect() {
|
||||
useClickOutside(this)
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
this.menuTarget.classList.toggle('hidden')
|
||||
if (!this.menuTarget.classList.contains('hidden')) {
|
||||
this.positionMenu()
|
||||
}
|
||||
}
|
||||
|
||||
clickOutside(event) {
|
||||
this.menuTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
positionMenu() {
|
||||
const buttonRect = this.buttonTarget.getBoundingClientRect()
|
||||
const menuRect = this.menuTarget.getBoundingClientRect()
|
||||
const spaceRight = window.innerWidth - buttonRect.right
|
||||
const spaceBottom = window.innerHeight - buttonRect.bottom
|
||||
|
||||
if (spaceRight < menuRect.width && buttonRect.left > menuRect.width) {
|
||||
this.menuTarget.style.left = 'auto'
|
||||
this.menuTarget.style.right = '0'
|
||||
} else {
|
||||
this.menuTarget.style.left = '0'
|
||||
this.menuTarget.style.right = 'auto'
|
||||
}
|
||||
|
||||
if (spaceBottom < menuRect.height && buttonRect.top > menuRect.height) {
|
||||
this.menuTarget.style.top = 'auto'
|
||||
this.menuTarget.style.bottom = '100%'
|
||||
} else {
|
||||
this.menuTarget.style.top = '100%'
|
||||
this.menuTarget.style.bottom = 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* This is an example Stimulus controller!
|
||||
*
|
||||
* Any element with a data-controller="hello" attribute will cause
|
||||
* this controller to be executed. The name "hello" comes from the filename:
|
||||
* hello_controller.js -> "hello"
|
||||
*
|
||||
* Delete this file or adapt it for your use!
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// assets/controllers/loading_button_controller.js
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["text", "loader"];
|
||||
static values = {form: String};
|
||||
|
||||
startLoading(event) {
|
||||
event.preventDefault();
|
||||
this.textTarget.classList.add("hidden");
|
||||
this.loaderTarget.classList.remove("hidden");
|
||||
this.element.disabled = true;
|
||||
|
||||
if (this.hasFormValue) {
|
||||
const form = document.getElementById(this.formValue);
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// assets/controllers/menu_controller.js
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["sidebar"]
|
||||
|
||||
toggleMenu() {
|
||||
this.sidebarTarget.classList.toggle('-translate-x-full')
|
||||
}
|
||||
}
|
||||
@@ -1,33 +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 {
|
||||
// ...
|
||||
connect() {
|
||||
const topic = this.data.get('topic');
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`, {withCredentials: true});
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Received Mercure update:', data);
|
||||
|
||||
this.dispatchAlert(data.message, data.status);
|
||||
};
|
||||
|
||||
eventSource.onerror = (event) => {
|
||||
console.error('EventSource failed:', event);
|
||||
};
|
||||
}
|
||||
|
||||
dispatchAlert(message, level) {
|
||||
const event = new CustomEvent('alert:show', {
|
||||
detail: { message: message, level: level }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// assets/controllers/modal_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal"]
|
||||
static values = {
|
||||
openTrigger: String,
|
||||
closeTrigger: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.hasOpenTriggerValue) {
|
||||
document.addEventListener(this.openTriggerValue, this.open.bind(this))
|
||||
}
|
||||
if (this.hasCloseTriggerValue) {
|
||||
document.addEventListener(this.closeTriggerValue, this.close.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hasOpenTriggerValue) {
|
||||
document.removeEventListener(this.openTriggerValue, this.open.bind(this))
|
||||
}
|
||||
if (this.hasCloseTriggerValue) {
|
||||
document.removeEventListener(this.closeTriggerValue, this.close.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
console.log("Opening modal...")
|
||||
this.modalTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// assets/controllers/preferred-sources_controller.js
|
||||
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["preferredList", "availableList"]
|
||||
static values = {
|
||||
mangaId: Number,
|
||||
preferredSources: Array,
|
||||
allSources: Array
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.initSortable()
|
||||
}
|
||||
|
||||
initSortable() {
|
||||
new Sortable(this.preferredListTarget, {
|
||||
animation: 150,
|
||||
ghostClass: 'bg-gray-300',
|
||||
onEnd: this.handleDragEnd.bind(this)
|
||||
})
|
||||
}
|
||||
|
||||
handleDragEnd() {
|
||||
this.updatePreferredSources()
|
||||
}
|
||||
|
||||
addSource(event) {
|
||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
||||
if (!this.preferredSourcesValue.includes(sourceId)) {
|
||||
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
|
||||
this.updateLists()
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
removeSource(event) {
|
||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
||||
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
|
||||
this.updateLists()
|
||||
this.save()
|
||||
}
|
||||
|
||||
updatePreferredSources() {
|
||||
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
|
||||
this.save()
|
||||
}
|
||||
|
||||
updateLists() {
|
||||
this.preferredListTarget.innerHTML = this.preferredSourcesValue
|
||||
.map(id => this.allSourcesValue.find(s => s.id === id))
|
||||
.map(source => this.sourceTemplate(source, true))
|
||||
.join('')
|
||||
|
||||
this.availableListTarget.innerHTML = this.allSourcesValue
|
||||
.filter(source => !this.preferredSourcesValue.includes(source.id))
|
||||
.map(source => this.sourceTemplate(source, false))
|
||||
.join('')
|
||||
|
||||
this.initSortable()
|
||||
}
|
||||
|
||||
sourceTemplate(source, isPreferred) {
|
||||
return `
|
||||
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
|
||||
<span>${source.name}</span>
|
||||
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
|
||||
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
|
||||
</button>
|
||||
</li>
|
||||
`
|
||||
}
|
||||
|
||||
async save() {
|
||||
try {
|
||||
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preferredSources: this.preferredSourcesValue
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Preferred sources saved successfully')
|
||||
// Optionally show a success message
|
||||
} else {
|
||||
console.error('Error saving preferred sources')
|
||||
// Optionally show an error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
// Optionally show an error message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton']
|
||||
static values = {
|
||||
mangaSlug: String,
|
||||
chapterNumber: Number,
|
||||
totalPages: Number,
|
||||
currentPage: { type: Number, default: 1 },
|
||||
readingMode: { type: String, default: 'horizontal' }
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.loadChapters();
|
||||
this.loadPages();
|
||||
}
|
||||
|
||||
async loadChapters() {
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${this.mangaSlugValue}`);
|
||||
const chapters = await response.json();
|
||||
|
||||
this.chapterSelectTarget.innerHTML = chapters.map(chapter =>
|
||||
`<option value="${chapter.number}" ${chapter.number === this.chapterNumberValue ? 'selected' : ''}>
|
||||
Chapitre ${chapter.number}
|
||||
</option>`
|
||||
).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPages() {
|
||||
this.pageContainerTarget.innerHTML = '';
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
await this.loadPage(this.currentPageValue);
|
||||
} else {
|
||||
for (let i = 1; i <= this.totalPagesValue; i++) {
|
||||
await this.loadPage(i, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadPage(pageNumber, isVertical = false) {
|
||||
const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`);
|
||||
const pageContent = await response.text();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `data:image/jpeg;base64,${pageContent}`;
|
||||
img.alt = `Page ${pageNumber}`;
|
||||
img.classList.add('shadow-lg', 'w-full', 'h-auto');
|
||||
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
img.classList.add('cursor-pointer');
|
||||
img.dataset.action = 'click->reader#pageClick';
|
||||
this.pageContainerTarget.innerHTML = '';
|
||||
}
|
||||
|
||||
if (isVertical) {
|
||||
img.loading = 'lazy';
|
||||
img.classList.add('mb-4');
|
||||
}
|
||||
|
||||
this.pageContainerTarget.appendChild(img);
|
||||
|
||||
if (!isVertical) {
|
||||
this.currentPageTarget.textContent = pageNumber;
|
||||
this.currentPageValue = pageNumber;
|
||||
}
|
||||
}
|
||||
|
||||
pageClick(event) {
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
const pageWidth = event.target.offsetWidth;
|
||||
const clickX = event.offsetX;
|
||||
|
||||
if (clickX < pageWidth / 2) {
|
||||
this.previousPage();
|
||||
} else {
|
||||
this.nextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
if (this.currentPageValue > 1) {
|
||||
this.loadPage(this.currentPageValue - 1);
|
||||
} else {
|
||||
this.previousChapter();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPageValue < this.totalPagesValue) {
|
||||
this.loadPage(this.currentPageValue + 1);
|
||||
} else {
|
||||
this.nextChapter();
|
||||
}
|
||||
}
|
||||
|
||||
async previousChapter() {
|
||||
const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
||||
const previousChapter = await response.json();
|
||||
if (previousChapter) {
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`;
|
||||
}
|
||||
}
|
||||
|
||||
async nextChapter() {
|
||||
const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
||||
const nextChapter = await response.json();
|
||||
if (nextChapter) {
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`;
|
||||
}
|
||||
}
|
||||
|
||||
changeChapter(event) {
|
||||
const selectedChapterNumber = event.target.value;
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`;
|
||||
}
|
||||
|
||||
toggleReadingMode() {
|
||||
this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal';
|
||||
this.loadPages();
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['form', 'testForm', 'imageSelector', 'nextPageSelector', 'testResults', 'scrapingType']
|
||||
|
||||
connect() {
|
||||
}
|
||||
|
||||
async saveConfiguration(event) {
|
||||
event.preventDefault();
|
||||
this.formTarget.submit();
|
||||
}
|
||||
|
||||
async testConfiguration(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(this.formTarget);
|
||||
const testFormData = new FormData(this.testFormTarget);
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
const cleanKey = key.replace(/^content_source\[(.+)]$/, '$1');
|
||||
testFormData.append(`content_source[${cleanKey}]`, value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.testFormTarget.action, {
|
||||
method: 'POST',
|
||||
body: testFormData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.displayTestResults(result.data);
|
||||
} else {
|
||||
this.displayError(result.message, result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
this.displayError('An error occurred while testing the configuration');
|
||||
}
|
||||
}
|
||||
|
||||
displayTestResults(data) {
|
||||
let html = '<h3 class="text-xl font-semibold mb-4">Test Results</h3>';
|
||||
html += '<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">';
|
||||
data.forEach(page => {
|
||||
html += `
|
||||
<div class="border rounded-lg p-2 flex flex-col items-center">
|
||||
<img src="${page.image_url}" alt="Page ${page.page_number}" class="w-full h-48 object-cover mb-2">
|
||||
<p class="text-sm font-medium">Page ${page.page_number}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
this.testResultsTarget.innerHTML = html;
|
||||
}
|
||||
|
||||
displayError(message, errors = []) {
|
||||
let errorHtml = `
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<strong class="font-bold">Error:</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
`;
|
||||
|
||||
if (errors.length > 0) {
|
||||
errorHtml += '<ul class="list-disc list-inside mt-2">';
|
||||
errors.forEach(error => {
|
||||
errorHtml += `<li>${error}</li>`;
|
||||
});
|
||||
errorHtml += '</ul>';
|
||||
}
|
||||
|
||||
errorHtml += '</div>';
|
||||
this.testResultsTarget.innerHTML = errorHtml;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +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 = ["textarea", "submitButton"]
|
||||
|
||||
connect() {
|
||||
document.addEventListener('openImportModal', this.prepareImportModal.bind(this));
|
||||
document.addEventListener('openExportModal', this.prepareExportModal.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener('openImportModal', this.prepareImportModal.bind(this));
|
||||
document.removeEventListener('openExportModal', this.prepareExportModal.bind(this));
|
||||
}
|
||||
|
||||
async prepareExportModal() {
|
||||
try {
|
||||
const response = await fetch('/settings/export_scrappers');
|
||||
const data = await response.json();
|
||||
this.textareaTarget.value = JSON.stringify(data, null, 2);
|
||||
this.submitButtonTarget.textContent = 'Copy to Clipboard';
|
||||
this.submitButtonTarget.dataset.action = 'scrapper-import#copyToClipboard';
|
||||
this.openModal('Export Scrapper Configurations');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
prepareImportModal() {
|
||||
this.textareaTarget.value = '';
|
||||
this.submitButtonTarget.textContent = 'Import';
|
||||
this.submitButtonTarget.dataset.action = 'scrapper-import#submitImport';
|
||||
this.openModal('Import Scrapper Configurations');
|
||||
}
|
||||
|
||||
openModal(title) {
|
||||
const event = new CustomEvent('openScrapperModal', { detail: { title: title } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async submitImport() {
|
||||
const jsonData = this.textareaTarget.value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/import_scrappers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
console.log(result.message);
|
||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
||||
window.location.reload();
|
||||
} else {
|
||||
console.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard() {
|
||||
navigator.clipboard.writeText(this.textareaTarget.value).then(() => {
|
||||
console.log('Copied to clipboard');
|
||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
||||
}, (err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +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 = ['input']
|
||||
|
||||
clearSearch() {
|
||||
this.inputTarget.value = '';
|
||||
this.inputTarget.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +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 = ["body", "toggleIcon"]
|
||||
static values = { open: Boolean }
|
||||
|
||||
connect() {
|
||||
if (!this.openValue) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.bodyTarget.style.display === "none") {
|
||||
this.open()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.bodyTarget.style.display = "block"
|
||||
this.toggleIconTarget.classList.replace("fa-chevron-down", "fa-chevron-up")
|
||||
}
|
||||
|
||||
close() {
|
||||
this.bodyTarget.style.display = "none"
|
||||
this.toggleIconTarget.classList.replace("fa-chevron-up", "fa-chevron-down")
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// assets/controllers/toolbar_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { visit } from "@hotwired/turbo"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["dropdown", "icon", "text"]
|
||||
static values = {
|
||||
currentSort: String,
|
||||
currentOrder: String,
|
||||
currentStatus: String,
|
||||
mangaId: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
window.addEventListener('alert:show', this.stopLoading.bind(this));
|
||||
}
|
||||
|
||||
stopLoading(event) {
|
||||
if(event.currentTarget.dataset !== undefined){
|
||||
this.iconTarget.classList.remove('fa-spin');
|
||||
}
|
||||
}
|
||||
|
||||
refreshMetadata(event) {
|
||||
const mangaId = event.currentTarget.dataset.mangaid;
|
||||
const url = `/refresh_metadata`;
|
||||
|
||||
this.iconTarget.classList.add('fa-spin');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mangaId: mangaId })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
searchLastChapter() {
|
||||
console.log("Searching last chapter...");
|
||||
}
|
||||
|
||||
import() {
|
||||
console.log("Importing...");
|
||||
}
|
||||
|
||||
monitoring(event){
|
||||
const mangaId = event.currentTarget.dataset.mangaid;
|
||||
const currentTarget = event.currentTarget;
|
||||
|
||||
const url = `/toggle_monitored`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mangaId: mangaId })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
}).then(data => {
|
||||
if(data.isMonitored === true){
|
||||
currentTarget.classList.remove('text-white');
|
||||
currentTarget.classList.add('text-green-500');
|
||||
this.textTarget.innerHTML = "Monitored";
|
||||
}else if(data.isMonitored === false){
|
||||
currentTarget.classList.remove('text-green-500');
|
||||
currentTarget.classList.add('text-white');
|
||||
this.textTarget.innerHTML = "Monitoring";
|
||||
}
|
||||
// console.log(data.isMonitored);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
editMangas() {
|
||||
console.log("Editing mangas...");
|
||||
}
|
||||
|
||||
editManga() {
|
||||
const event = new CustomEvent('openEditModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
editPreferredSources() {
|
||||
const event = new CustomEvent('openPreferredSourcesModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
openImportModal() {
|
||||
const importEvent = new CustomEvent('openImportModal');
|
||||
document.dispatchEvent(importEvent);
|
||||
}
|
||||
|
||||
openExportModal() {
|
||||
const exportEvent = new CustomEvent('openExportModal');
|
||||
document.dispatchEvent(exportEvent);
|
||||
}
|
||||
|
||||
deleteMangas() {
|
||||
console.log("Deleting mangas...");
|
||||
}
|
||||
|
||||
deleteManga() {
|
||||
const event = new CustomEvent('openDeleteModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
confirmDelete(event) {
|
||||
event.preventDefault();
|
||||
const url = `/manga/delete/${this.mangaIdValue}`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
visit('/', {});
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
// Show error message to user
|
||||
});
|
||||
}
|
||||
|
||||
showOptions() {
|
||||
console.log("Showing options...");
|
||||
}
|
||||
|
||||
expandAll() {
|
||||
console.log("Expanding all...");
|
||||
}
|
||||
|
||||
changeView(event) {
|
||||
event.preventDefault();
|
||||
const viewOption = event.currentTarget.dataset.view;
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view', viewOption);
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
sort(event) {
|
||||
event.preventDefault()
|
||||
const sortOption = event.currentTarget.dataset.sort;
|
||||
let order = 'asc';
|
||||
|
||||
if (sortOption === this.currentSortValue && this.currentOrderValue === 'asc') {
|
||||
order = 'desc';
|
||||
}
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('sort', sortOption);
|
||||
url.searchParams.set('order', order);
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
filter(event) {
|
||||
event.preventDefault();
|
||||
const filterOption = event.currentTarget.dataset.filter;
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('status', filterOption);
|
||||
|
||||
// Réinitialiser la page à 1 si on utilise la pagination
|
||||
// url.searchParams.set('page', '1');
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,10 +37,6 @@ framework:
|
||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
||||
|
||||
# Legacy messages (à garder si nécessaire)
|
||||
'App\Message\DownloadChapter': commands
|
||||
'App\Message\RefreshMetadata': commands
|
||||
'App\Message\RefreshAndDownloadChapters': commands
|
||||
|
||||
# when@test:
|
||||
# framework:
|
||||
|
||||
@@ -7,8 +7,3 @@ vue_app:
|
||||
requirements:
|
||||
req: "^(?!api/|legacy).*"
|
||||
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
|
||||
@@ -26,10 +26,6 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventListener\ExceptionListener:
|
||||
tags:
|
||||
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
|
||||
|
||||
GuzzleHttp\Client:
|
||||
class: GuzzleHttp\Client
|
||||
arguments:
|
||||
@@ -43,63 +39,11 @@ services:
|
||||
protocols: [ 'http', 'https' ]
|
||||
track_redirects: true
|
||||
|
||||
App\Service\MangaScraperService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Controller\TestController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Domain\Conversion\Infrastructure\Service\ConversionService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Service\CbrToCbzConverter:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Manager\FileSystemManager:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\EventSubscriber\QueueStatusSubscriber:
|
||||
tags:
|
||||
- { name: kernel.event_subscriber }
|
||||
|
||||
App\Client\MangadexClient:
|
||||
arguments:
|
||||
$httpClient: '@GuzzleHttp\Client'
|
||||
$clientId: '%env(MANGADEX_CLIENT_ID)%'
|
||||
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
|
||||
$username: '%env(MANGADEX_USERNAME)%'
|
||||
$password: '%env(MANGADEX_PASSWORD)%'
|
||||
|
||||
App\Service\MangadexProvider:
|
||||
arguments:
|
||||
$client: '@App\Client\MangadexClient'
|
||||
|
||||
# Scraper Service
|
||||
App\Service\Scraper\HtmlScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
App\Service\Scraper\JavascriptScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
App\Service\Scraper\MangadexScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
# Scraper Factory
|
||||
App\Service\Scraper\ScraperFactory:
|
||||
arguments:
|
||||
$scrapers: !tagged_iterator app.scraper
|
||||
|
||||
# Manga Scraper Service
|
||||
App\Service\Scraper\MangaScraperService:
|
||||
arguments:
|
||||
$scraperFactory: '@App\Service\Scraper\ScraperFactory'
|
||||
|
||||
# New Scrapers Factory for Domain Layer
|
||||
# Scrapers Factory for Domain Layer
|
||||
App\Domain\Scraping\Infrastructure\Service\ScraperFactory:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -187,20 +131,6 @@ services:
|
||||
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
|
||||
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||
|
||||
# Import Domain Services
|
||||
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
||||
|
||||
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
|
||||
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
|
||||
|
||||
# Import Domain Query/Command Handlers
|
||||
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
|
||||
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
|
||||
|
||||
# Import Domain API Platform Services
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
||||
|
||||
# System Domain
|
||||
App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
|
||||
alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
|
||||
|
||||
0
src/ApiResource/.gitignore
vendored
0
src/ApiResource/.gitignore
vendored
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Client;
|
||||
|
||||
use App\Interface\ClientInterface;
|
||||
use GuzzleHttp\ClientInterface as GuzzleInterface;
|
||||
|
||||
class MangadexClient implements ClientInterface
|
||||
{
|
||||
private const AUTHENTICATION_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
|
||||
private const API_URL = 'https://api.mangadex.org';
|
||||
private GuzzleInterface $httpClient;
|
||||
private string $clientId;
|
||||
private string $clientSecret;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private ?string $accessToken = null;
|
||||
private ?string $refreshToken = null;
|
||||
|
||||
public function __construct(GuzzleInterface $httpClient, string $clientId, string $clientSecret, string $username, string $password)
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
$this->clientId = $clientId;
|
||||
$this->clientSecret = $clientSecret;
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->authenticate();
|
||||
}
|
||||
|
||||
public function authenticate(): void
|
||||
{
|
||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
||||
'form_params' => [
|
||||
'grant_type' => 'password',
|
||||
'username' => $this->username,
|
||||
'password' => $this->password,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$this->accessToken = $data['access_token'];
|
||||
$this->refreshToken = $data['refresh_token'];
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
||||
'form_params' => [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$this->accessToken = $data['access_token'];
|
||||
}
|
||||
|
||||
private function request(string $method, string $endpoint, array $options = []): array
|
||||
{
|
||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
||||
|
||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
||||
|
||||
if ($response->getStatusCode() === 429) {
|
||||
$this->refresh();
|
||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
||||
}
|
||||
|
||||
return json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
public function get(string $endpoint, array $params = []): array
|
||||
{
|
||||
return $this->request('GET', $endpoint, ['query' => $params]);
|
||||
}
|
||||
|
||||
public function post(string $endpoint, array $data): array
|
||||
{
|
||||
return $this->request('POST', $endpoint, ['json' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
||||
use App\Manager\ToolbarManager;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Repository\ChapterRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class ActivityController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ChapterRepository $chapterRepository,
|
||||
private readonly ToolbarFactory $toolbarFactory
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/activity', name: 'app_activity')]
|
||||
public function index(): Response
|
||||
{
|
||||
$queueStatus = $this->getQueueStatus();
|
||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
||||
|
||||
$status = array_merge(
|
||||
$this->buildStatusActivity($decodedPending),
|
||||
$this->buildStatusActivity($decodedProcessing)
|
||||
);
|
||||
|
||||
return $this->render('activity/index.html.twig', [
|
||||
'controller_name' => 'ActivityController',
|
||||
'status' => $status,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('activity')->getGroups(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/activity/status', name: 'app_activity_status', methods: ['GET'])]
|
||||
public function getStatus(): JsonResponse
|
||||
{
|
||||
$queueStatus = $this->getQueueStatus();
|
||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
||||
$status = array_merge(
|
||||
$this->buildStatusActivity($decodedPending),
|
||||
$this->buildStatusActivity($decodedProcessing)
|
||||
);
|
||||
|
||||
return new JsonResponse($status);
|
||||
}
|
||||
|
||||
// TODO refactorer ce code avec celui du QueueStatusSubscriber
|
||||
private function getQueueStatus(): array
|
||||
{
|
||||
// Requête pour récupérer les messages en attente
|
||||
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
|
||||
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
|
||||
|
||||
// Requête pour récupérer les messages en cours de traitement
|
||||
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
|
||||
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
|
||||
|
||||
return [
|
||||
'pending' => $pending,
|
||||
'processing' => $processing
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStatusActivity(array $activity): array
|
||||
{
|
||||
$status = [];
|
||||
foreach ($activity as $envelope) {
|
||||
$envelope = $envelope['body'];
|
||||
if ($envelope instanceof Envelope) {
|
||||
if (!$envelope->getMessage() instanceof DownloadChapter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
|
||||
$manga = $chapter->getManga();
|
||||
$status[] = [
|
||||
'manga' => $manga->getTitle(),
|
||||
'volume' => $chapter->getVolume(),
|
||||
'chapter' => $chapter->getNumber(),
|
||||
'chapterId' => $chapter->getId(),
|
||||
'title' => $chapter->getTitle(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function decodeMessages(array $messages): array
|
||||
{
|
||||
$decodedMessages = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$decodedMessages[] = [
|
||||
'id' => $message['id'],
|
||||
'body' => $this->decodeMessageBody($message['body']),
|
||||
'headers' => json_decode($message['headers'], true),
|
||||
];
|
||||
}
|
||||
|
||||
return $decodedMessages;
|
||||
}
|
||||
|
||||
private function decodeMessageBody(string $body)
|
||||
{
|
||||
return unserialize(stripcslashes($body));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class CalendarController extends AbstractController
|
||||
{
|
||||
#[Route('/calendar', name: 'app_calendar')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('calendar/index.html.twig', [
|
||||
'controller_name' => 'CalendarController',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Manager\FileSystemManager;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\CbrToCbzConverter;
|
||||
use App\Service\CbzService;
|
||||
use App\Service\MangaImportService;
|
||||
use App\Service\NotificationService;
|
||||
use Exception;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class ImportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileSystemManager $fileSystemManager,
|
||||
private readonly CbzService $cbzService,
|
||||
private readonly MangaImportService $mangaImportService,
|
||||
private readonly NotificationService $notificationService,
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
private readonly CbrToCbzConverter $cbrToCbzConverter
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/manga/import', name: 'app_manga_import')]
|
||||
public function index(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
if ($request->isMethod('POST')) {
|
||||
$files = $request->files->get('files');
|
||||
if ($files) {
|
||||
$importFiles = [];
|
||||
foreach ($files as $file) {
|
||||
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
|
||||
$originalFileName = $file->getClientOriginalName();
|
||||
|
||||
try {
|
||||
$tmpPath = $this->fileSystemManager->moveUploadedFile(
|
||||
$file->getPathname(),
|
||||
$this->fileSystemManager->getUploadsDirectory(),
|
||||
$file->getClientOriginalName()
|
||||
);
|
||||
$importFiles[] = [
|
||||
'id' => uniqid(),
|
||||
'path' => $tmpPath,
|
||||
'original_name' => $originalFileName,
|
||||
];
|
||||
} catch (FileException $e) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'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é.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('import/index.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route('/import/match', name: 'import_match')]
|
||||
public function match(SessionInterface $session): Response
|
||||
{
|
||||
$files = $session->get('import_files', []);
|
||||
if (empty($files)) {
|
||||
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);
|
||||
$mangas = $this->mangaRepository->findBySlug($metadata['title']);
|
||||
|
||||
$mangaOptions = [];
|
||||
foreach ($mangas as $manga) {
|
||||
$mangaOptions[] = [
|
||||
'slug' => $manga->getSlug(),
|
||||
'title' => $manga->getTitle(),
|
||||
'author' => $manga->getAuthor(),
|
||||
'publicationYear' => $manga->getPublicationYear(),
|
||||
'genres' => $manga->getGenres(),
|
||||
'description' => $manga->getDescription()
|
||||
];
|
||||
}
|
||||
|
||||
$processedFiles[] = [
|
||||
'id' => $fileId,
|
||||
'originalFileName' => $originalFileName,
|
||||
'fileSize' => $this->formatBytes(filesize($filePath)),
|
||||
'metadata' => $metadata,
|
||||
'mangaOptions' => $mangaOptions
|
||||
];
|
||||
}
|
||||
|
||||
$session->set('import_files', $files);
|
||||
|
||||
return $this->render('import/match.html.twig', [
|
||||
'files' => $processedFiles
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$files = $session->get('import_files', []);
|
||||
$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;
|
||||
}
|
||||
|
||||
$file = $files[$fileId];
|
||||
$mangaSlug = $mangaSlugs[$fileId] ?? null;
|
||||
$volume = $volumes[$fileId] ?? null;
|
||||
$chapter = $chapters[$fileId] ?? null;
|
||||
|
||||
try {
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw new \Exception('Manga non trouvé.');
|
||||
}
|
||||
|
||||
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 $file) {
|
||||
$this->fileSystemManager->deleteFile($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([
|
||||
'status' => 'success',
|
||||
'message' => $successMessage
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$errorMessage = implode("\n", $errors);
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $errorMessage
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_manga');
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
<?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('/legacy', name: 'app_legacy')]
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\CbzService;
|
||||
use App\Service\NotificationService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ReaderController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
private readonly CbzService $cbzService,
|
||||
private readonly NotificationService $notificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/read/{mangaSlug}/{chapterNumber}', name: 'app_reader')]
|
||||
public function read(string $mangaSlug, float $chapterNumber): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
||||
if (!$chapter) {
|
||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
||||
}
|
||||
|
||||
if (is_null($chapter->getCbzPath())) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Le chapitre demandé n\'est pas encore disponible.',
|
||||
]);
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
|
||||
}
|
||||
|
||||
$totalPages = $this->cbzService->getPageCount($chapter->getCbzPath());
|
||||
|
||||
return $this->render('reader/index.html.twig', [
|
||||
'manga' => $manga,
|
||||
'chapter' => $chapter,
|
||||
'totalPages' => $totalPages,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_reader_page')]
|
||||
public function getPage(string $mangaSlug, float $chapterNumber, int $pageNumber): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
||||
if (!$chapter) {
|
||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
|
||||
if (!$pageContent) {
|
||||
throw $this->createNotFoundException("La page demandée n'existe pas.");
|
||||
}
|
||||
|
||||
return new Response(base64_encode($pageContent), 200, ['Content-Type' => 'text/plain']);
|
||||
}
|
||||
|
||||
#[Route('/api/chapters/{mangaSlug}', name: 'app_reader_chapters')]
|
||||
public function getChapters(string $mangaSlug): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapters = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && !is_null($chapter->getCbzPath()))
|
||||
->toArray();
|
||||
|
||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
||||
|
||||
$chapters = array_values(array_map(fn ($chapter) => [
|
||||
'number' => $chapter->getNumber(),
|
||||
'title' => $chapter->getTitle(),
|
||||
], $chapters));
|
||||
|
||||
return $this->json($chapters);
|
||||
}
|
||||
|
||||
#[Route('/api/previous-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_previous_chapter')]
|
||||
public function getPreviousChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapters = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber)
|
||||
->toArray();
|
||||
|
||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
||||
|
||||
$previousChapter = reset($chapters) ?: null;
|
||||
|
||||
return $this->json($previousChapter ? [
|
||||
'number' => $previousChapter->getNumber(),
|
||||
'title' => $previousChapter->getTitle(),
|
||||
] : null);
|
||||
}
|
||||
|
||||
#[Route('/api/next-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_next_chapter')]
|
||||
public function getNextChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$nextChapter = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() > $currentChapterNumber)
|
||||
->toArray();
|
||||
|
||||
usort($nextChapter, fn ($a, $b) => $a->getNumber() <=> $b->getNumber());
|
||||
|
||||
$nextChapter = reset($nextChapter) ?: null;
|
||||
|
||||
return $this->json($nextChapter ? [
|
||||
'number' => $nextChapter->getNumber(),
|
||||
'title' => $nextChapter->getTitle(),
|
||||
] : null);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ContentSource;
|
||||
use App\Form\AppSettingsType;
|
||||
use App\Form\ContentSourceType;
|
||||
use App\Manager\AppSettingsManager;
|
||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
||||
use App\Repository\ContentSourceRepository;
|
||||
use App\Service\NotificationService;
|
||||
use App\Service\Scraper\MangaScraperService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private MangaScraperService $mangaScraperService,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotificationService $notificationService,
|
||||
private ContentSourceRepository $contentSourceRepository
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/settings', name: 'app_settings')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/general', name: 'app_settings_general')]
|
||||
public function general(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/folders', name: 'app_settings_folders')]
|
||||
public function folders(Request $request, AppSettingsManager $settingsManager): Response
|
||||
{
|
||||
$currentSettings = $settingsManager->getSettings();
|
||||
|
||||
$form = $this->createForm(AppSettingsType::class, $currentSettings);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$newSettings = $form->getData();
|
||||
$settingsManager->updateSettings($newSettings);
|
||||
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Settings updated successfully.']);
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
return $this->render('settings/folders.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/scrappers/list', name: 'app_settings_scrappers_list')]
|
||||
public function list(ContentSourceRepository $repository, ToolbarFactory $toolbarFactory): Response
|
||||
{
|
||||
$contentSources = $repository->findAll();
|
||||
|
||||
return $this->render('settings/scrapper_list.html.twig', [
|
||||
'contentSources' => $contentSources,
|
||||
'toolbar' => $toolbarFactory->createToolbar('scraper_list')->getGroups(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/scrappers/{id}', name: 'app_settings_scrappers', defaults: ['id' => null])]
|
||||
public function scrappers(Request $request, ?ContentSource $contentSource): Response
|
||||
{
|
||||
$isNew = $contentSource === null;
|
||||
$contentSource = $contentSource ?? new ContentSource();
|
||||
|
||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($contentSource);
|
||||
$this->entityManager->flush();
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => ($isNew ? 'New scrapper configuration saved' : 'Scrapper configuration updated') . ' successfully.']);
|
||||
return $this->redirectToRoute('app_settings_scrappers_list');
|
||||
}
|
||||
|
||||
return $this->render('settings/scrappers.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'isNew' => $isNew,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
#[Route('/settings/scrappers_test', name: 'app_settings_scrappers_test', methods: ['POST'])]
|
||||
public function scrapperTest(Request $request): JsonResponse
|
||||
{
|
||||
$contentSource = new ContentSource();
|
||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
||||
$form->submit($request->request->all()['content_source']);
|
||||
|
||||
if ($form->isValid()) {
|
||||
$mangaSlug = $request->request->get('mangaSlug');
|
||||
$chapterNumber = $request->request->get('chapterNumber');
|
||||
|
||||
try {
|
||||
$scrapedData = $this->mangaScraperService->testScraping($mangaSlug, $chapterNumber, $contentSource);
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Test successful',
|
||||
'data' => $scrapedData
|
||||
]);
|
||||
} else {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Invalid form submission',
|
||||
'errors' => $this->getFormErrors($form)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getFormErrors($form): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$errors[] = $error->getMessage();
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
#[Route('/settings/ui', name: 'app_settings_ui')]
|
||||
public function ui(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/export_scrappers', name: 'app_settings_scrappers_export', methods: ['GET'])]
|
||||
public function exportScrappers(): JsonResponse
|
||||
{
|
||||
$contentSources = $this->contentSourceRepository->findAll();
|
||||
$data = [];
|
||||
|
||||
foreach ($contentSources as $source) {
|
||||
$data[] = [
|
||||
'baseUrl' => $source->getBaseUrl(),
|
||||
'imageSelector' => $source->getImageSelector(),
|
||||
'nextPageSelector' => $source->getNextPageSelector(),
|
||||
'chapterUrlFormat' => $source->getChapterUrlFormat(),
|
||||
'scrapingType' => $source->getScrapingType(),
|
||||
'chapterSelector' => $source->getChapterSelector(), //TODO à renommer en chapterListSelector
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse($data);
|
||||
}
|
||||
|
||||
#[Route('/settings/import_scrappers', name: 'app_settings_scrappers_import', methods: ['POST'])]
|
||||
public function importScrappers(Request $request): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Invalid JSON data']);
|
||||
return new JsonResponse(['error' => 'Invalid JSON data'], 400);
|
||||
}
|
||||
|
||||
foreach ($data as $sourceData) {
|
||||
$contentSource = new ContentSource();
|
||||
$contentSource->setBaseUrl($sourceData['baseUrl']);
|
||||
$contentSource->setImageSelector($sourceData['imageSelector']);
|
||||
$contentSource->setNextPageSelector($sourceData['nextPageSelector']);
|
||||
$contentSource->setChapterUrlFormat($sourceData['chapterUrlFormat']);
|
||||
$contentSource->setScrapingType($sourceData['scrapingType']);
|
||||
|
||||
$this->entityManager->persist($contentSource);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['message' => 'Content sources imported successfully']);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SystemController extends AbstractController
|
||||
{
|
||||
#[Route('/system', name: 'app_system')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SystemController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/status', name: 'app_system_status')]
|
||||
public function status(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/backup', name: 'app_system_backup')]
|
||||
public function backup(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/logs', name: 'app_system_logs')]
|
||||
public function logs(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/updates', name: 'app_system_updates')]
|
||||
public function update(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
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 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 string $projectDir,
|
||||
private SluggerInterface $slugger,
|
||||
private MangaRepository $mangaRepository
|
||||
) {
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/test', name: 'test')]
|
||||
public function test(): Response
|
||||
{
|
||||
$mangas = $this->mangaRepository->findAll();
|
||||
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new JsonResponse(['changed' => $changed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function processAndSaveImage(string $imageUrl): array
|
||||
{
|
||||
$image = file_get_contents($this->projectDir . '/public' .$imageUrl);
|
||||
$tempImage = tmpfile();
|
||||
fwrite($tempImage, $image);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Factory\ApiTokenFactory;
|
||||
use App\Factory\ChapterFactory;
|
||||
use App\Factory\MangaFactory;
|
||||
use App\Factory\PageFactory;
|
||||
use App\Factory\UserFactory;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
UserFactory::createMany(20);
|
||||
ApiTokenFactory::createMany(60, function () {
|
||||
return [
|
||||
'ownedBy' => UserFactory::random()
|
||||
];
|
||||
});
|
||||
|
||||
$mangas = MangaFactory::createMany(25);
|
||||
|
||||
foreach ($mangas as $manga) {
|
||||
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$manga->addChapter(ChapterFactory::createOne([
|
||||
'manga' => $manga,
|
||||
'number' => $i
|
||||
])->object());
|
||||
}
|
||||
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$chapter->addPagesLink(PageFactory::createOne([
|
||||
'chapter' => $chapter,
|
||||
'number' => $i
|
||||
])->object());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@ use App\Repository\ChapterRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\UX\Turbo\Attribute\Broadcast;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ChapterRepository::class)]
|
||||
#[Broadcast()]
|
||||
class Chapter
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class PageScrappingProgressEvent extends Event
|
||||
{
|
||||
public const NAME = 'page.scrapping.progress';
|
||||
|
||||
private int $chapterId;
|
||||
private int $pageIndex;
|
||||
private int $totalPages;
|
||||
|
||||
public function __construct(int $chapterId, int $pageIndex, int $totalPages)
|
||||
{
|
||||
$this->chapterId = $chapterId;
|
||||
$this->pageIndex = $pageIndex;
|
||||
$this->totalPages = $totalPages;
|
||||
}
|
||||
|
||||
public function getChapterId(): int
|
||||
{
|
||||
return $this->chapterId;
|
||||
}
|
||||
|
||||
public function getPageIndex(): int
|
||||
{
|
||||
return $this->pageIndex;
|
||||
}
|
||||
|
||||
public function getTotalPages(): int
|
||||
{
|
||||
return $this->totalPages;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use ApiPlatform\Exception\ItemNotFoundException;
|
||||
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use ApiPlatform\Exception\FilterValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
|
||||
|
||||
class ExceptionListener
|
||||
{
|
||||
public function __construct(private LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
// $exception = $event->getThrowable();
|
||||
//
|
||||
// $response = match(true) {
|
||||
// $exception instanceof FilterValidationException,
|
||||
// $exception instanceof BadRequestException => $this->createResponse($exception, Response::HTTP_BAD_REQUEST),
|
||||
// $exception instanceof NotFoundHttpException,
|
||||
// $exception instanceof ItemNotFoundException => $this->createResponse($exception, Response::HTTP_NOT_FOUND),
|
||||
// $exception instanceof AccessDeniedHttpException => $this->createResponse($exception, Response::HTTP_FORBIDDEN),
|
||||
// $exception instanceof ValidationException,
|
||||
// $exception instanceof NotNormalizableValueException => $this->createResponse($exception, Response::HTTP_UNPROCESSABLE_ENTITY),
|
||||
// default => null,
|
||||
// };
|
||||
//
|
||||
// if ($response) {
|
||||
// $event->setResponse($response);
|
||||
// }else{
|
||||
// $this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||
// }
|
||||
}
|
||||
|
||||
private function createResponse(\Throwable $exception, int $statusCode): Response
|
||||
{
|
||||
$this->logger->info($exception->getMessage(), ['exception' => $exception]);
|
||||
return new Response(json_encode(['message' => $exception->getMessage()]), $statusCode, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Event\PageScrappingProgressEvent;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Service\ActivityService;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
|
||||
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
|
||||
use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent;
|
||||
|
||||
class QueueStatusSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ActivityService $activityService,
|
||||
private Connection $connection,
|
||||
private ChapterRepository $chapterRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
WorkerMessageReceivedEvent::class => 'onMessageReceived',
|
||||
WorkerMessageHandledEvent::class => 'onMessageHandled',
|
||||
WorkerMessageFailedEvent::class => 'onMessageFailed',
|
||||
PageScrappingProgressEvent::NAME => 'onPageScrapingProgress',
|
||||
];
|
||||
}
|
||||
|
||||
public function onMessageReceived(WorkerMessageReceivedEvent $event): void
|
||||
{
|
||||
$envelope = $event->getEnvelope();
|
||||
$message = $envelope->getMessage();
|
||||
|
||||
if ($message instanceof DownloadChapter) {
|
||||
$this->activityService->sendUpdate($this->getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
public function onMessageHandled(WorkerMessageHandledEvent $event): void
|
||||
{
|
||||
$envelope = $event->getEnvelope();
|
||||
$message = $envelope->getMessage();
|
||||
|
||||
if ($message instanceof DownloadChapter) {
|
||||
$this->activityService->sendUpdate($this->getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
public function onMessageFailed(WorkerMessageFailedEvent $event): void
|
||||
{
|
||||
$envelope = $event->getEnvelope();
|
||||
$message = $envelope->getMessage();
|
||||
|
||||
if ($message instanceof DownloadChapter) {
|
||||
$this->activityService->sendUpdate($this->getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
public function onPageScrapingProgress(PageScrappingProgressEvent $event): void
|
||||
{
|
||||
$data = [
|
||||
'status' => 'scrapping.progress',
|
||||
'chapterId' => $event->getChapterId(),
|
||||
'pageIndex' => $event->getPageIndex(),
|
||||
'totalPages' => $event->getTotalPages(),
|
||||
];
|
||||
$this->activityService->sendUpdate($data);
|
||||
}
|
||||
|
||||
private function getActivity(): array
|
||||
{
|
||||
$queueStatus = $this->getQueueStatus();
|
||||
return [
|
||||
'processing' => $this->buildStatusActivity($this->decodeMessages($queueStatus['processing'])),
|
||||
'pending' => $this->buildStatusActivity($this->decodeMessages($queueStatus['pending']))
|
||||
];
|
||||
}
|
||||
|
||||
//TODO refactorer ce code avec celui du ActivityController
|
||||
private function buildStatusActivity(array $activity): array
|
||||
{
|
||||
$status = [];
|
||||
foreach ($activity as $envelope) {
|
||||
$envelope = $envelope['body'];
|
||||
if ($envelope instanceof Envelope) {
|
||||
if (!$envelope->getMessage() instanceof DownloadChapter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
|
||||
$manga = $chapter->getManga();
|
||||
$status[] = [
|
||||
'manga' => $manga->getTitle(),
|
||||
'volume' => $chapter->getVolume(),
|
||||
'chapter' => $chapter->getNumber(),
|
||||
'title' => $chapter->getTitle(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function getQueueStatus(): array
|
||||
{
|
||||
// Requête pour récupérer les messages en attente
|
||||
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
|
||||
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
|
||||
|
||||
// Requête pour récupérer les messages en cours de traitement
|
||||
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
|
||||
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
|
||||
|
||||
return [
|
||||
'pending' => $pending,
|
||||
'processing' => $processing
|
||||
];
|
||||
}
|
||||
|
||||
private function decodeMessages(array $messages): array
|
||||
{
|
||||
$decodedMessages = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$decodedMessages[] = [
|
||||
'id' => $message['id'],
|
||||
'body' => $this->decodeMessageBody($message['body']),
|
||||
'headers' => json_decode($message['headers'], true),
|
||||
];
|
||||
}
|
||||
|
||||
return $decodedMessages;
|
||||
}
|
||||
|
||||
private function decodeMessageBody(string $body)
|
||||
{
|
||||
return unserialize(stripcslashes($body));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\AppSettings;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class AppSettingsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('mangaDirectory', TextType::class, [
|
||||
'label' => 'Manga Directory',
|
||||
])
|
||||
->add('imageDirectory', TextType::class, [
|
||||
'label' => 'Image Directory',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AppSettings::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\ContentSource;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ContentSourceType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('baseUrl', UrlType::class, [
|
||||
'label' => 'Base URL',
|
||||
])
|
||||
->add('imageSelector', TextType::class, [
|
||||
'label' => 'Image Selector',
|
||||
])
|
||||
->add('chapterUrlFormat', TextType::class, [
|
||||
'label' => 'Chapter URL Format ({slug}, {chapterNumber})',
|
||||
])
|
||||
->add('nextPageSelector', TextType::class, [
|
||||
'label' => 'Next Page Selector (let empty if vertical reader)',
|
||||
'required' => false,
|
||||
])
|
||||
->add('ChapterSelector', TextType::class, [
|
||||
'label' => 'Chapter Selector (required for Javascript scraping)',
|
||||
'required' => false,
|
||||
])
|
||||
->add('scrapingType', ChoiceType::class, [
|
||||
'label' => 'Scraping Type',
|
||||
'choices' => [
|
||||
'HTML' => 'html',
|
||||
'JavaScript' => 'javascript'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ContentSource::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class MangaEditType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('title', TextType::class, [
|
||||
'label' => 'Titre',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']
|
||||
])
|
||||
->add('slug', TextType::class, [
|
||||
'label' => 'Slug',
|
||||
'attr' => [
|
||||
'readonly' => true,
|
||||
'class' => 'bg-gray-100 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'
|
||||
],
|
||||
])
|
||||
->add('alternativeSlugs', CollectionType::class, [
|
||||
'entry_type' => TextType::class,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'label' => false,
|
||||
'prototype' => true,
|
||||
'entry_options' => ['attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'], 'label' => false],
|
||||
'required' => false,
|
||||
])
|
||||
->add('publicationYear', NumberType::class, [
|
||||
'label' => 'Année de publication',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']
|
||||
])
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'Description',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500', 'rows' => 8]
|
||||
])
|
||||
->add('genres', CollectionType::class, [
|
||||
'entry_type' => TextType::class,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'label' => 'Genres',
|
||||
'entry_options' => ['attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500']],
|
||||
'required' => false,
|
||||
])
|
||||
->add('rating', NumberType::class, [
|
||||
'label' => 'Note',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
|
||||
'required' => false,
|
||||
])
|
||||
->add('author', TextType::class, [
|
||||
'label' => 'Auteur',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
|
||||
'required' => false,
|
||||
])
|
||||
->add('status', TextType::class, [
|
||||
'label' => 'Statut',
|
||||
'attr' => ['class' => 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500'],
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
|
||||
$data = $event->getData();
|
||||
$manga = $event->getForm()->getData();
|
||||
|
||||
if ($manga && $manga->getSlug()) {
|
||||
$data['slug'] = $manga->getSlug();
|
||||
}
|
||||
|
||||
$event->setData($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Manga::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interface;
|
||||
|
||||
interface ClientInterface
|
||||
{
|
||||
public function get(string $endpoint, array $params = []): array;
|
||||
public function post(string $endpoint, array $data): array;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interface;
|
||||
|
||||
use App\Entity\Manga;
|
||||
|
||||
interface ContentProviderInterface
|
||||
{
|
||||
public function getAvailableContent(Manga $manga): array;
|
||||
public function getContent(Manga $manga): array;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interface;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
interface MetadataProviderInterface
|
||||
{
|
||||
public function search(string $title): Collection;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager;
|
||||
|
||||
use App\Entity\AppSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class AppSettingsManager
|
||||
{
|
||||
private const string DEFAULT_MANGA_DIRECTORY = '/manga_data';
|
||||
private const string DEFAULT_IMAGE_DIRECTORY = '/image_data';
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getSettings(): AppSettings
|
||||
{
|
||||
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
|
||||
if (!$settings) {
|
||||
$settings = $this->createDefaultSettings();
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function updateSettings(AppSettings $newSettings): void
|
||||
{
|
||||
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
|
||||
if (!$settings) {
|
||||
$settings = new AppSettings();
|
||||
}
|
||||
|
||||
$settings->setMangaDirectory($newSettings->getMangaDirectory());
|
||||
$settings->setImageDirectory($newSettings->getImageDirectory());
|
||||
|
||||
$this->entityManager->persist($settings);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function createDefaultSettings(): AppSettings
|
||||
{
|
||||
$settings = new AppSettings();
|
||||
$settings->setMangaDirectory(self::DEFAULT_MANGA_DIRECTORY);
|
||||
$settings->setImageDirectory(self::DEFAULT_IMAGE_DIRECTORY);
|
||||
|
||||
$this->entityManager->persist($settings);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class FileSystemManager
|
||||
{
|
||||
private const string CBZ_DIRECTORY = 'public/cbz';
|
||||
private const string UPLOADS_DIRECTORY = 'public/tmp';
|
||||
private const string IMAGES_DIRECTORY = 'public/images';
|
||||
|
||||
private string $mangaDirectory;
|
||||
private string $imageDirectory;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly Filesystem $filesystem,
|
||||
private readonly SluggerInterface $slugger,
|
||||
private readonly AppSettingsManager $appSettingsManager
|
||||
) {
|
||||
$this->loadSettings();
|
||||
}
|
||||
|
||||
private function loadSettings(): void
|
||||
{
|
||||
$settings = $this->appSettingsManager->getSettings();
|
||||
$this->mangaDirectory = $settings->getMangaDirectory();
|
||||
$this->imageDirectory = $settings->getImageDirectory();
|
||||
}
|
||||
|
||||
public function getMangaDirectory(): string
|
||||
{
|
||||
return $this->mangaDirectory;
|
||||
}
|
||||
|
||||
public function getImageDirectory(): string
|
||||
{
|
||||
return $this->imageDirectory;
|
||||
}
|
||||
|
||||
public function getImagePath(string $subDir = ''): string
|
||||
{
|
||||
if (!$this->filesystem->exists($this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : ''))) {
|
||||
$this->filesystem->mkdir($this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : ''), 0755);
|
||||
}
|
||||
|
||||
return $this->projectDir.'/'.self::IMAGES_DIRECTORY.($subDir ? "/$subDir" : '');
|
||||
}
|
||||
|
||||
public function createMangaDirectory(string $mangaSlug, ?int $year): string
|
||||
{
|
||||
$year = $year ?? 'unknown';
|
||||
$directoryPath = $this->projectDir.'/'.self::CBZ_DIRECTORY.'/'.ucfirst($mangaSlug)." ($year)";
|
||||
$this->filesystem->mkdir($directoryPath, 0755);
|
||||
|
||||
return $directoryPath;
|
||||
}
|
||||
|
||||
public function createVolumeDirectory(string $mangaDir, int $volume): string
|
||||
{
|
||||
$volumeDir = sprintf('%s/volume_%02d', $mangaDir, $volume);
|
||||
$this->filesystem->mkdir($volumeDir, 0755);
|
||||
|
||||
return $volumeDir;
|
||||
}
|
||||
|
||||
public function moveUploadedFile(string $sourcePath, string $destinationDir, string $originalFilename): string
|
||||
{
|
||||
$newFilename = $this->generateUniqueFilename($originalFilename);
|
||||
$destinationPath = $destinationDir.'/'.$newFilename;
|
||||
$this->filesystem->rename($sourcePath, $destinationPath, true);
|
||||
|
||||
return $destinationPath;
|
||||
}
|
||||
|
||||
public function deleteFile(string $filePath): void
|
||||
{
|
||||
if ($this->filesystem->exists($filePath)) {
|
||||
$this->filesystem->remove($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteDirectory(string $directoryPath): void
|
||||
{
|
||||
if ($this->filesystem->exists($directoryPath)) {
|
||||
$this->filesystem->remove($directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function fileExists(string $filePath): bool
|
||||
{
|
||||
return $this->filesystem->exists($filePath);
|
||||
}
|
||||
|
||||
public function moveFile(string $sourcePath, string $destinationPath): void
|
||||
{
|
||||
$this->filesystem->rename($sourcePath, $destinationPath, true);
|
||||
}
|
||||
|
||||
public function getUploadsDirectory(): string
|
||||
{
|
||||
return $this->projectDir.'/'.self::UPLOADS_DIRECTORY;
|
||||
}
|
||||
|
||||
private function generateUniqueFilename(string $originalFilename): string
|
||||
{
|
||||
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));
|
||||
|
||||
return $safeFilename.'-'.uniqid().'.'.pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
public function generateUniqueImageFilename(string $originalFilename): string
|
||||
{
|
||||
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));
|
||||
|
||||
return $safeFilename.'-'.uniqid().'.jpg';
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Definition;
|
||||
|
||||
use App\Manager\Toolbar\Element\ToolbarButton;
|
||||
use App\Manager\Toolbar\Element\ToolbarDivider;
|
||||
|
||||
class ActivityToolbar extends Toolbar
|
||||
{
|
||||
public function __construct(array $contextData = [])
|
||||
{
|
||||
$this
|
||||
->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh', 'toolbar#refreshActivity'))
|
||||
->addToLeftGroup(new ToolbarDivider())
|
||||
->addToLeftGroup(new ToolbarButton('trash-can', 'Remove Selected', 'toolbar#removeActivity'))
|
||||
|
||||
->addToRightGroup(new ToolbarButton('th-large', 'Options', 'toolbar#optionActivity'))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Definition;
|
||||
|
||||
use App\Manager\Toolbar\Element\ToolbarButton;
|
||||
use App\Manager\Toolbar\Element\ToolbarDivider;
|
||||
|
||||
class ChapterListToolbar extends Toolbar
|
||||
{
|
||||
public function __construct(array $contextData = [])
|
||||
{
|
||||
$monitoredTitle = $contextData['isMonitored'] ? 'Monitored' : 'Monitoring';
|
||||
$monitoredColor = $contextData['isMonitored'] ? 'text-green-500' : 'text-white';
|
||||
|
||||
$this
|
||||
->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh metadata', 'toolbar#refreshMetadata', $contextData))
|
||||
->addToLeftGroup(new ToolbarDivider())
|
||||
->addToLeftGroup(new ToolbarButton('keyboard', 'Rename chapters', 'toolbar#renameChapters'))
|
||||
->addToLeftGroup(new ToolbarButton('file-zipper', 'Manage cbz', 'toolbar#manageCbz', $contextData))
|
||||
->addToLeftGroup(new ToolbarButton('gear', 'Preferred Sources', 'toolbar#editPreferredSources', $contextData))
|
||||
|
||||
|
||||
->addToRightGroup(new ToolbarButton('bookmark', $monitoredTitle, 'toolbar#monitoring', array_merge($contextData, ['buttonClass' => $monitoredColor])))
|
||||
->addToRightGroup(new ToolbarButton('wrench', 'Edit', 'toolbar#editManga', $contextData))
|
||||
->addToRightGroup(new ToolbarButton('trash-can', 'Delete', 'toolbar#deleteManga', $contextData))
|
||||
->addToRightGroup(new ToolbarDivider())
|
||||
->addToRightGroup(new ToolbarButton('chevron-down', 'Expand all', 'toolbar#expandAll'));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Definition;
|
||||
|
||||
use App\Manager\Toolbar\Element\ToolbarButton;
|
||||
use App\Manager\Toolbar\Element\ToolbarDivider;
|
||||
use App\Manager\Toolbar\Element\ToolbarDropdown;
|
||||
|
||||
class MangaListToolbar extends Toolbar
|
||||
{
|
||||
public function __construct(array $contextData = [])
|
||||
{
|
||||
$this->addToLeftGroup(new ToolbarButton('arrows-rotate', 'Refresh', 'toolbar#refreshMetadata'))
|
||||
->addToLeftGroup(new ToolbarButton('search', 'Search', 'toolbar#searchLastChapter'))
|
||||
|
||||
->addToRightGroup(new ToolbarButton('th-large', 'Options', 'toolbar#options'))
|
||||
->addToRightGroup(new ToolbarDivider())
|
||||
->addToRightGroup(new ToolbarDropdown('eye', 'View', 'changeView', [
|
||||
['text' => 'Poster View', 'action' => 'changeView', 'data' => ['view' => 'poster']],
|
||||
['text' => 'Table View', 'action' => 'changeView', 'data' => ['view' => 'table']],
|
||||
['text' => 'Resume View', 'action' => 'changeView', 'data' => ['view' => 'resume']]
|
||||
]))
|
||||
->addToRightGroup(new ToolbarDropdown('sort', 'Sort', 'sort', [
|
||||
['text' => 'Par titre', 'action' => 'sort', 'data' => ['sort' => 'title']],
|
||||
['text' => 'Par année de publication', 'action' => 'sort', 'data' => ['sort' => 'publicationYear']],
|
||||
['text' => 'Par date d\'ajout', 'action' => 'sort', 'data' => ['sort' => 'createdAt']]
|
||||
]))
|
||||
->addToRightGroup(new ToolbarDropdown('filter', 'Filter', 'filter', [
|
||||
['text' => 'Tous les mangas', 'action' => 'filter', 'data' => ['filter' => 'all']],
|
||||
['text' => 'Mangas en cours', 'action' => 'filter', 'data' => ['filter' => 'ongoing']],
|
||||
['text' => 'Mangas terminés', 'action' => 'filter', 'data' => ['filter' => 'completed']]
|
||||
]))
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Definition;
|
||||
|
||||
use App\Manager\Toolbar\Element\ToolbarButton;
|
||||
use App\Manager\Toolbar\Element\ToolbarDivider;
|
||||
|
||||
class ScraperListToolbar extends Toolbar
|
||||
{
|
||||
public function __construct(array $contextData = [])
|
||||
{
|
||||
$this->addToRightGroup(new ToolbarButton('file-import', 'Import Json', 'toolbar#openImportModal'))
|
||||
->addToRightGroup(new ToolbarDivider())
|
||||
->addToRightGroup(new ToolbarButton('file-export', 'Export Json', 'toolbar#openExportModal'));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Definition;
|
||||
|
||||
use App\Manager\Toolbar\Element\ToolbarElement;
|
||||
|
||||
abstract class Toolbar
|
||||
{
|
||||
private array $leftGroup = [];
|
||||
private array $rightGroup = [];
|
||||
|
||||
public function addToLeftGroup(ToolbarElement $element): self
|
||||
{
|
||||
$this->leftGroup[] = $element;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addToRightGroup(ToolbarElement $element): self
|
||||
{
|
||||
$this->rightGroup[] = $element;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGroups(): array
|
||||
{
|
||||
return [
|
||||
'leftGroup' => $this->leftGroup,
|
||||
'rightGroup' => $this->rightGroup,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Element;
|
||||
|
||||
abstract class AbstractToolbarElement implements ToolbarElement
|
||||
{
|
||||
protected string $icon;
|
||||
protected string|array $text;
|
||||
protected string $action;
|
||||
|
||||
public function __construct(string $icon, string|array $text, string $action)
|
||||
{
|
||||
$this->icon = $icon;
|
||||
$this->text = $text;
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return $this->icon;
|
||||
}
|
||||
|
||||
public function getText(): string|array
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
public function getAdditionalProperties(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Element;
|
||||
|
||||
class ToolbarButton extends AbstractToolbarElement
|
||||
{
|
||||
protected array $data;
|
||||
|
||||
public function __construct(string $icon, string $label, string $action, array $data = [])
|
||||
{
|
||||
parent::__construct($icon, $label, $action);
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'button';
|
||||
}
|
||||
|
||||
public function getAdditionalProperties(): array
|
||||
{
|
||||
return ['data' => $this->data];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Element;
|
||||
|
||||
class ToolbarDivider extends AbstractToolbarElement
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('divider', '', '');
|
||||
}
|
||||
public function getType(): string
|
||||
{
|
||||
return 'divider';
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Element;
|
||||
|
||||
class ToolbarDropdown extends AbstractToolbarElement
|
||||
{
|
||||
private array $items;
|
||||
|
||||
public function __construct(string $icon, string $text, string $action, array $items)
|
||||
{
|
||||
parent::__construct($icon, $text, $action);
|
||||
$this->items = $items;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'dropdown';
|
||||
}
|
||||
|
||||
public function getAdditionalProperties(): array
|
||||
{
|
||||
return ['items' => $this->items];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Element;
|
||||
|
||||
interface ToolbarElement
|
||||
{
|
||||
public function getIcon(): string;
|
||||
public function getText(): string|array;
|
||||
public function getAction(): string;
|
||||
public function getType(): string;
|
||||
public function getAdditionalProperties(): array;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Manager\Toolbar\Factory;
|
||||
|
||||
use App\Manager\Toolbar\Definition\ActivityToolbar;
|
||||
use App\Manager\Toolbar\Definition\ChapterListToolbar;
|
||||
use App\Manager\Toolbar\Definition\MangaListToolbar;
|
||||
use App\Manager\Toolbar\Definition\ScraperListToolbar;
|
||||
use App\Manager\Toolbar\Definition\Toolbar;
|
||||
|
||||
class ToolbarFactory
|
||||
{
|
||||
public function createToolbar(string $type, array $context = []): Toolbar
|
||||
{
|
||||
return match ($type) {
|
||||
'manga_list' => new MangaListToolbar(),
|
||||
'chapter_list' => new ChapterListToolbar($context),
|
||||
'activity' => new ActivityToolbar($context),
|
||||
'scraper_list' => new ScraperListToolbar($context),
|
||||
default => throw new \InvalidArgumentException("Unknown toolbar type: $type"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
readonly class DownloadChapter
|
||||
{
|
||||
public function __construct(private int $chapterId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getChapterId(): int
|
||||
{
|
||||
return $this->chapterId;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class RefreshAndDownloadChapters
|
||||
{
|
||||
/*
|
||||
* Add whatever properties and methods you need
|
||||
* to hold the data for this message class.
|
||||
*/
|
||||
|
||||
// private $name;
|
||||
|
||||
// public function __construct(string $name)
|
||||
// {
|
||||
// $this->name = $name;
|
||||
// }
|
||||
|
||||
// public function getName(): string
|
||||
// {
|
||||
// return $this->name;
|
||||
// }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
readonly class RefreshMetadata
|
||||
{
|
||||
public function __construct(private int $mangaId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMangaId(): int
|
||||
{
|
||||
return $this->mangaId;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\ContentSource;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\ContentSourceRepository;
|
||||
use App\Service\NotificationService;
|
||||
use App\Service\Scraper\MangaScraperService;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class DownloadChapterHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ChapterRepository $chapterRepository,
|
||||
private MangaScraperService $mangaScraperService,
|
||||
private NotificationService $notificationService,
|
||||
private ContentSourceRepository $contentSourceRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __invoke(DownloadChapter $message): void
|
||||
{
|
||||
$chapter = $this->chapterRepository->find($message->getChapterId());
|
||||
if (!$chapter) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter not found.']);
|
||||
throw new BadRequestHttpException('Chapter not found');
|
||||
} elseif (null !== $chapter->getCbzPath()) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter already scraped.']);
|
||||
throw new BadRequestHttpException('Chapter already downloaded');
|
||||
}
|
||||
|
||||
$manga = $chapter->getManga();
|
||||
$preferredSources = $manga->getPreferredSources()->toArray();
|
||||
$allSources = $this->contentSourceRepository->findAll();
|
||||
|
||||
$filteredSources = array_udiff($allSources, $preferredSources, function ($a, $b) {
|
||||
return $a->getId() - $b->getId();
|
||||
});
|
||||
|
||||
$sources = array_merge($preferredSources, $filteredSources);
|
||||
|
||||
if (count($preferredSources) > 0) {
|
||||
$sources = $preferredSources;
|
||||
} else {
|
||||
$sources = $allSources;
|
||||
}
|
||||
|
||||
// $sources[] =
|
||||
// (new ContentSource())
|
||||
// ->setBaseUrl('https://api.mangadex.org/')
|
||||
// ->setImageSelector('img')
|
||||
// ->setChapterUrlFormat('at-home/server/%s')
|
||||
// ->setScrapingType('mangadex');
|
||||
|
||||
// (new ContentSource())
|
||||
// ->setBaseUrl('https://lelscans.net')
|
||||
// ->setImageSelector('#image img')
|
||||
// ->setChapterUrlFormat('https://lelscans.net/scan-%s/%s')
|
||||
// ->setNextPageSelector('a[title="Suivant"]')
|
||||
// ->setScrapingType('html'),
|
||||
// (new ContentSource())
|
||||
// ->setBaseUrl('https://darkscans.net/')
|
||||
// ->setImageSelector('.reading-content img')
|
||||
// ->setChapterUrlFormat('https://darkscans.net/mangas/%s/chapter-%s/')
|
||||
// ->setNextPageSelector(null)
|
||||
// ->setScrapingType('html')
|
||||
|
||||
$scrapedSuccessfully = false;
|
||||
|
||||
foreach ($sources as $source) {
|
||||
try {
|
||||
$this->mangaScraperService->scrapeChapter($chapter, $source);
|
||||
$scrapedSuccessfully = true;
|
||||
break;
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'warning',
|
||||
'message' => 'An error occurred while scraping with source: '.$source->getBaseUrl().'. Trying next source...',
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!$scrapedSuccessfully) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'All sources failed to scrape the chapter '.$chapter->getManga()->getTitle().' '.$chapter->getNumber().'.',
|
||||
]);
|
||||
throw new \Exception('All sources failed to scrape the chapter '.$chapter->getManga()->getTitle().' '.$chapter->getNumber().'.');
|
||||
}
|
||||
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Chapter scraped successfully.']);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Message\RefreshAndDownloadChapters;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\MangadexProvider;
|
||||
use App\Service\NotificationService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final readonly class RefreshAndDownloadChaptersHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepository $mangaRepository,
|
||||
private MangadexProvider $mangadexProvider,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MessageBusInterface $bus
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public function __invoke(RefreshAndDownloadChapters $message): void
|
||||
{
|
||||
$mangas = $this->mangaRepository->findBy(['monitored' => true]);
|
||||
|
||||
foreach ($mangas as $manga) {
|
||||
$chapters = $this->refreshMangas($manga);
|
||||
|
||||
if (empty($chapters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var Chapter $chapter */
|
||||
foreach ($chapters as $chapter) {
|
||||
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function refreshMangas(Manga $manga): array
|
||||
{
|
||||
$lastChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
|
||||
|
||||
foreach ($lastChapters as $chapter) {
|
||||
$this->entityManager->persist($chapter);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $lastChapters;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\RefreshMetadata;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\MangadexProvider;
|
||||
use App\Service\NotificationService;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class RefreshMetadataHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepository $mangaRepository,
|
||||
private MangadexProvider $mangadexProvider,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotificationService $notificationService
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RefreshMetadata $message): void
|
||||
{
|
||||
$manga = $this->mangaRepository->find($message->getMangaId());
|
||||
if (!$manga) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
|
||||
|
||||
try {
|
||||
foreach ($lastChapters as $chapter) {
|
||||
$this->entityManager->persist($chapter);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
} catch (\Exception $e) {
|
||||
if ($e instanceof UniqueConstraintViolationException) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while refreshing ' . $manga->getTitle() . '.']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => $manga->getTitle() . ' refreshed, ' . count($lastChapters) . ' new chapters added.']);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Scheduler;
|
||||
|
||||
use App\Message\RefreshAndDownloadChapters;
|
||||
use Symfony\Component\Scheduler\RecurringMessage;
|
||||
use Symfony\Component\Scheduler\Schedule;
|
||||
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
// Désactivé : remplacé par MonitoringSchedule (DDD) dans src/Domain/Manga/Infrastructure/Scheduler/
|
||||
class MainSchedule implements ScheduleProviderInterface
|
||||
{
|
||||
public function __construct(private CacheInterface $cache)
|
||||
{
|
||||
}
|
||||
|
||||
#[\Override] public function getSchedule(): Schedule
|
||||
{
|
||||
return (new Schedule())->add(
|
||||
RecurringMessage::every('6 hours', new RefreshAndDownloadChapters())
|
||||
)
|
||||
->stateful($this->cache);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
public function __construct(private HubInterface $hub)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function sendUpdate(mixed $data): void
|
||||
{
|
||||
$update = new Update('activity', json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class CbzService
|
||||
{
|
||||
public function __construct(private SluggerInterface $slugger)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function extractMetadata(string $filePath, string $originalFileName): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
$fileInfo = $this->extractInfoFromFileName($originalFileName);
|
||||
|
||||
$metadata['title'] = $fileInfo['title'];
|
||||
$metadata['volume'] = null !== $fileInfo['volume'] ? (int) $fileInfo['volume'] : null;
|
||||
$metadata['chapter'] = null !== $fileInfo['chapter'] ? (int) $fileInfo['chapter'] : null;
|
||||
|
||||
if (is_null($metadata['chapter'])) {
|
||||
try {
|
||||
$zip->open($filePath);
|
||||
$chapterNumbers = [];
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$fileName = $stat['name'];
|
||||
|
||||
$chapterNumbers[] = $this->extractChapter($fileName);
|
||||
}
|
||||
|
||||
$chapterNumbers = array_unique($chapterNumbers);
|
||||
|
||||
if (1 === count($chapterNumbers)) {
|
||||
$metadata['chapter'] = '' === array_values($chapterNumbers)[0] ? null : (int) array_values($chapterNumbers)[0];
|
||||
} elseif (count($chapterNumbers) > 1) {
|
||||
$metadata['chapter'] = min($chapterNumbers);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception("Impossible d'ouvrir le fichier CBZ. ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
public function getPageContent(string $cbzPath, int $pageNumber): ?string
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if (true === $zip->open($cbzPath)) {
|
||||
$images = $this->getImageList($zip);
|
||||
if (isset($images[$pageNumber - 1])) {
|
||||
$content = $zip->getFromName($images[$pageNumber - 1]);
|
||||
$zip->close();
|
||||
|
||||
return $content;
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPageCount(string $cbzPath): int
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if (true === $zip->open($cbzPath)) {
|
||||
$count = count($this->getImageList($zip));
|
||||
$zip->close();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function extractInfoFromFileName(string $fileName): array
|
||||
{
|
||||
$title = $this->extractTitle($fileName);
|
||||
$volume = $this->extractVolume($fileName);
|
||||
$chapter = $this->extractChapter($fileName);
|
||||
|
||||
return [
|
||||
'title' => '' === $title ? null : $title,
|
||||
'volume' => '' === $volume ? null : $volume,
|
||||
'chapter' => '' === $chapter ? null : $chapter,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractTitle(string $fileName): string
|
||||
{
|
||||
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol\.?|[Tt]|[Cc]hap(?:itre|ter)?)\s*\d+)/';
|
||||
if (preg_match($titlePattern, $fileName, $matches)) {
|
||||
return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString();
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$volumePattern = '/(?:[Tt]ome|[Vv]ol\.?|[Tt])\s*(?P<volume>\d+)/';
|
||||
if (preg_match($volumePattern, $fileName, $matches)) {
|
||||
return str_pad($matches['volume'], 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractChapter(string $fileName): string
|
||||
{
|
||||
$chapterPattern = '/[Cc]hap(?:itre|ter)?\s*(?P<chapter>\d+)/';
|
||||
if (preg_match($chapterPattern, $fileName, $matches)) {
|
||||
return $matches['chapter'];
|
||||
}
|
||||
|
||||
$newFormatPattern = '/_(?P<chapter>\d+)(?:\.\w+)?$/';
|
||||
if (preg_match($newFormatPattern, $fileName, $matches)) {
|
||||
return $matches['chapter'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getImageList(\ZipArchive $zip): array
|
||||
{
|
||||
$images = [];
|
||||
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||
$filename = $zip->getNameIndex($i);
|
||||
if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $filename)) {
|
||||
$images[] = $filename;
|
||||
}
|
||||
}
|
||||
sort($images);
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
public function createVolumeArchive(array $chapters): string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'volume_cbz_');
|
||||
$zip = new \ZipArchive();
|
||||
if (true !== $zip->open($tempFile, \ZipArchive::CREATE)) {
|
||||
throw new \RuntimeException('Impossible de créer le fichier ZIP temporaire.');
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$chapterZip = new \ZipArchive();
|
||||
if (true === $chapterZip->open($chapter->getCbzPath())) {
|
||||
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 (null !== $volume) {
|
||||
return sprintf('%s_volume_%02d.cbz', $sluggedTitle, $volume);
|
||||
} elseif (null !== $chapterNumber) {
|
||||
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 && null !== $chapter->getCbzPath();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
|
||||
|
||||
class ChapterUrlGenerator
|
||||
{
|
||||
private string $chapterUrlFormat;
|
||||
|
||||
public function __construct(string $chapterUrlFormat)
|
||||
{
|
||||
$this->chapterUrlFormat = $chapterUrlFormat;
|
||||
$this->validateUrlFormat($chapterUrlFormat);
|
||||
}
|
||||
|
||||
public function getChapterUrl(string $mangaTitle, float $chapterNumber): string
|
||||
{
|
||||
$placeholders = [
|
||||
'{chapterNumber}' => $chapterNumber,
|
||||
'{slug}' => $mangaTitle,
|
||||
];
|
||||
|
||||
return str_replace(array_keys($placeholders), array_values($placeholders), $this->chapterUrlFormat);
|
||||
}
|
||||
|
||||
private function validateUrlFormat(string $format): void
|
||||
{
|
||||
if (!str_contains($format, '{slug}')) {
|
||||
throw new InvalidArgumentException("The URL format must contain both {slug} and {chapterNumber} placeholders.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Manager\FileSystemManager;
|
||||
use App\Repository\ChapterRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
readonly class MangaImportService
|
||||
{
|
||||
public function __construct(
|
||||
private FileSystemManager $fileSystemManager,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private ChapterRepository $chapterRepository,
|
||||
private SluggerInterface $slugger
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function importFile(Manga $manga, ?int $volume, ?Chapter $chapter, string $tempFilePath): void
|
||||
{
|
||||
if ($chapter !== null) {
|
||||
$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.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function importVolume(Manga $manga, int $volume, string $tempFilePath): void
|
||||
{
|
||||
$permanentFileName = $this->createPermanentFileName($manga, $volume);
|
||||
$mangaDirectory = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
|
||||
$volumeDirectory = $this->fileSystemManager->createVolumeDirectory($mangaDirectory, $volume);
|
||||
$permanentFilePath = $volumeDirectory . '/' . $permanentFileName;
|
||||
|
||||
if ($this->fileSystemManager->fileExists($permanentFilePath)) {
|
||||
throw new \RuntimeException("Un fichier pour ce volume existe déjà.");
|
||||
}
|
||||
|
||||
$this->fileSystemManager->moveFile($tempFilePath, $permanentFilePath);
|
||||
|
||||
$this->updateVolumeChapters($manga, $volume, $permanentFilePath);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function importChapter(Manga $manga, Chapter $chapter, string $tempFilePath): void
|
||||
{
|
||||
$volume = $chapter->getVolume();
|
||||
$permanentFileName = $this->createPermanentFileName($manga, $volume, $chapter->getNumber());
|
||||
$mangaDirectory = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
|
||||
$volumeDirectory = $this->fileSystemManager->createVolumeDirectory($mangaDirectory, $chapter->getVolume());
|
||||
$permanentFilePath = $volumeDirectory . '/' . $permanentFileName;
|
||||
|
||||
if ($this->fileSystemManager->fileExists($permanentFilePath)) {
|
||||
throw new \RuntimeException("Un fichier pour ce chapitre existe déjà.");
|
||||
}
|
||||
|
||||
$this->fileSystemManager->moveFile($tempFilePath, $permanentFilePath);
|
||||
|
||||
$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);
|
||||
if ($chapterNumber !== null) {
|
||||
$baseFileName .= '_ch' . $chapterNumber;
|
||||
}
|
||||
return $baseFileName . '.cbz';
|
||||
}
|
||||
|
||||
private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void
|
||||
{
|
||||
$chapters = $this->chapterRepository->findBy([
|
||||
'manga' => $manga,
|
||||
'volume' => $volume
|
||||
]);
|
||||
|
||||
if (empty($chapters)) {
|
||||
throw new \RuntimeException("Aucun chapitre trouvé pour le volume $volume en base de données.");
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$chapter->setCbzPath($cbzPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Entity\ContentSource;
|
||||
use App\Event\PageScrappingProgressEvent;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Facebook\WebDriver\Remote\RemoteWebElement;
|
||||
use Facebook\WebDriver\WebDriverExpectedCondition;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\Routing\Matcher\UrlMatcher;
|
||||
use Symfony\Component\Routing\RequestContext;
|
||||
use Symfony\Component\Routing\Route;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Panther\Client as PantherClient;
|
||||
|
||||
class MangaScraperService
|
||||
{
|
||||
public const string PUBLIC_CBZ = '/public/cbz';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
private function extractMangaPageData(string $html, ContentSource $mangaSource): array
|
||||
{
|
||||
$crawler = new Crawler($html);
|
||||
$imgUrl = $crawler->filter($mangaSource->getImageSelector())->attr('src')
|
||||
?? $crawler->filter($mangaSource->getImageSelector())->attr('data-src');
|
||||
|
||||
// dd($imgUrl);
|
||||
|
||||
// if (empty($imgUrl)) {
|
||||
// throw new \Exception('No valid image found on the page.');
|
||||
// }
|
||||
|
||||
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
|
||||
$nextUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null;
|
||||
|
||||
// Convert relative URLs to absolute URLs
|
||||
if (!preg_match('/^https?:\/\//', $imgUrl)) {
|
||||
$urlComponents = parse_url($mangaSource->getBaseUrl());
|
||||
$scheme = $urlComponents['scheme'];
|
||||
$host = $urlComponents['host'];
|
||||
$imgUrl = $scheme . '://' . $host . '/' . ltrim($imgUrl, '/');
|
||||
}
|
||||
|
||||
return [
|
||||
'image_url' => $imgUrl,
|
||||
'next_page_url' => $nextUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function scrapeManga(Manga $manga, ContentSource $mangaSource): array
|
||||
{
|
||||
$allChaptersData = [];
|
||||
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
$chapterData = $this->scrapeChapter($chapter, $mangaSource);
|
||||
if ($chapterData !== false) {
|
||||
$allChaptersData[$chapter->getNumber()] = $chapterData;
|
||||
}
|
||||
}
|
||||
|
||||
return $allChaptersData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $mangaSource): array|bool
|
||||
{
|
||||
return match ($mangaSource->getScrapingType()) {
|
||||
'html' => $this->scrapeChapterHtml($chapter->getManga(), $chapter, $mangaSource),
|
||||
'javascript' => $this->scrapeChapterJavaScript($chapter->getManga(), $chapter, $mangaSource),
|
||||
'mangadex' => $this->scrapeChapterMangadex($chapter, $mangaSource),
|
||||
default => throw new Exception('Unsupported scraping type: ' . $mangaSource->getScrapingType()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
* @throws Exception
|
||||
*/
|
||||
private function scrapeChapterMangadex(Chapter $chapter, ContentSource $mangaSource): bool
|
||||
{
|
||||
$client = new Client();
|
||||
$chapterUrl = $mangaSource->getBaseUrl() . sprintf($mangaSource->getChapterUrlFormat(), $chapter->getExternalId());
|
||||
$manga = $chapter->getManga();
|
||||
$pageData = [];
|
||||
|
||||
$response = $client->get($chapterUrl);
|
||||
$results = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($results['result'] !== 'ok' || count($results['chapter']['dataSaver']) === 0) {
|
||||
throw new Exception('Error while fetching chapter data from Mangadex ' . $manga->getTitle() . ' ' . $chapter->getNumber());
|
||||
}
|
||||
|
||||
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
|
||||
mkdir($tempDir);
|
||||
|
||||
foreach ($results['chapter']['dataSaver'] as $index => $page) {
|
||||
$pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page;
|
||||
$imagePath = $tempDir . '/' . sprintf('%03d.%s', $index + 1, pathinfo($page, PATHINFO_EXTENSION));
|
||||
|
||||
$this->downloadAndSaveImage($pageUrl, $imagePath);
|
||||
|
||||
$event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, count($results['chapter']['dataSaver']));
|
||||
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $pageUrl,
|
||||
'local_image_url' => $imagePath,
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
|
||||
$this->createCbzFile($tempDir, $pageData, $cbzFilePath);
|
||||
|
||||
$chapter->setCbzPath($cbzFilePath);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Nettoyage du répertoire temporaire
|
||||
$this->cleanupTempFiles($tempDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function scrapeChapterJavascript(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool
|
||||
{
|
||||
$pantherClient = PantherClient::createChromeClient();
|
||||
$chapterUrl = $mangaSource->getChapterUrl($manga->getSlug(), $chapter->getNumber());
|
||||
|
||||
$pantherClient->request('GET', $chapterUrl);
|
||||
|
||||
// Sélection du chapitre dans le menu déroulant
|
||||
try {
|
||||
$crawler = $pantherClient->waitFor('body');
|
||||
$select = $crawler->filter('#selectChapitres');
|
||||
|
||||
if ($select->count() > 0) {
|
||||
$chapterNumber = $chapter->getNumber();
|
||||
$options = $select->filter('option');
|
||||
$targetindex = null;
|
||||
|
||||
/** @var RemoteWebElement $option */
|
||||
foreach ($options->getIterator() as $index => $option) {
|
||||
$optionText = $option->getText();
|
||||
// Recherche plus flexible du numéro de chapitre
|
||||
if (preg_match("/\b{$chapterNumber}\b/", $optionText)) {
|
||||
$targetIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($targetIndex !== null) {
|
||||
$pantherClient->executeScript("
|
||||
var select = document.querySelector('#selectChapitres');
|
||||
select.selectedIndex = $targetIndex;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
");
|
||||
|
||||
// Attendre que la page se mette à jour après la sélection
|
||||
$pantherClient->wait(60000)->until( // 60 secondes de timeout
|
||||
function ($driver) {
|
||||
return $driver->executeScript("
|
||||
var scansPlacement = document.querySelector('#scansPlacement');
|
||||
if (!scansPlacement) return false;
|
||||
|
||||
var lazyImages = scansPlacement.querySelectorAll('img.lazy');
|
||||
var loadingGif = scansPlacement.querySelector('img[src*=\"loading_scans.gif\"]');
|
||||
|
||||
// Vérifier que toutes les images lazy sont chargées et que le GIF de chargement n'est plus présent
|
||||
var allImagesLoaded = Array.from(lazyImages).every(img => img.complete && img.naturalWidth > 0);
|
||||
|
||||
return lazyImages.length > 0 && allImagesLoaded && !loadingGif;
|
||||
");
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw new \Exception("Chapitre $chapterNumber non trouvé dans le menu déroulant");
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// $this->logger->warning('Erreur lors de la sélection du chapitre : ' . $e->getMessage());
|
||||
$pantherClient->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
$pageData = [];
|
||||
|
||||
try {
|
||||
if ($mangaSource->getNextPageSelector() === null) {
|
||||
// Lecteur vertical
|
||||
$pageData = $this->scrapeVerticalReaderJavascript($pantherClient, $mangaSource, $chapter);
|
||||
} else {
|
||||
// Lecteur horizontal
|
||||
$pageData = $this->scrapeHorizontalReaderJavascript($pantherClient, $mangaSource, $chapter);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw $e;
|
||||
// $this->logger->warning('Erreur lors du scraping du chapitre ' . $chapter->getNumber() . ' du manga ' . $manga->getTitle() . ': ' . $e->getMessage());
|
||||
} finally {
|
||||
$pantherClient->close();
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function scrapeVerticalReaderJavascript(PantherClient $pantherClient, ContentSource $mangaSource, Chapter $chapter): array
|
||||
{
|
||||
$pageData = [];
|
||||
$pageNumber = 1;
|
||||
|
||||
$crawler = $pantherClient->waitFor($mangaSource->getImageSelector());
|
||||
$images = $crawler->filter($mangaSource->getImageSelector());
|
||||
|
||||
foreach ($images->getIterator() as $image) {
|
||||
$imageUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($imageUrl),
|
||||
'page_number' => $pageNumber,
|
||||
];
|
||||
|
||||
$event = new PageScrappingProgressEvent($chapter->getId(), $pageNumber, $images->count());
|
||||
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
|
||||
$pageNumber++;
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function scrapeHorizontalReaderJavascript(PantherClient $pantherClient, ContentSource $mangaSource, Chapter $chapter): array
|
||||
{
|
||||
$pageData = [];
|
||||
$pageNumber = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$crawler = $pantherClient->waitFor($mangaSource->getImageSelector());
|
||||
|
||||
$imageElement = $crawler->filter($mangaSource->getImageSelector())->first();
|
||||
if ($imageElement->count() === 0) {
|
||||
break; // Fin du chapitre
|
||||
}
|
||||
|
||||
$imageUrl = $imageElement->attr('src') ?: $imageElement->attr('data-src');
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($imageUrl),
|
||||
'page_number' => $pageNumber,
|
||||
];
|
||||
|
||||
$event = new PageScrappingProgressEvent($chapter->getId(), $pageNumber, 0);
|
||||
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
|
||||
// Passer à la page suivante
|
||||
$nextButton = $pantherCrawler->filter($mangaSource->getNextPageSelector());
|
||||
if ($nextButton->count() === 0) {
|
||||
break; // Pas de bouton suivant, fin du chapitre
|
||||
}
|
||||
|
||||
$nextButton->click();
|
||||
|
||||
// Attendre que la page change
|
||||
$pantherClient->waitFor($mangaSource->getImageSelector(), 10);
|
||||
|
||||
// Mettre à jour le crawler avec le nouveau contenu de la page
|
||||
$pantherCrawler = $pantherClient->refreshCrawler();
|
||||
|
||||
$pageNumber++;
|
||||
} catch (\Exception $e) {
|
||||
throw $e;
|
||||
// $this->logger->warning('Erreur lors du scraping de la page ' . $pageNumber . ' du chapitre ' . $chapter->getNumber() . ': ' . $e->getMessage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function fetchImagesUsingPuppeteer(string $url, string $imageSelector, string $nextButtonSelector): array
|
||||
{
|
||||
// Appeler le script Puppeteer avec les paramètres nécessaires
|
||||
$output = [];
|
||||
$command = sprintf('node puppeteer-script.js "%s" "%s" "%s" 2>&1', $url, $imageSelector, $nextButtonSelector); // Redirect stderr to stdout
|
||||
// dump($command);
|
||||
// exec($command, $output, $return_var);
|
||||
|
||||
// dd($command, $output);
|
||||
|
||||
// Convertir la sortie JSON en tableau PHP
|
||||
return json_decode(implode("", $output), true);
|
||||
}
|
||||
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
return match ($contentSource->getScrapingType()) {
|
||||
'html' => $this->testScrapingHtml($mangaSlug, $chapterNumber, $contentSource),
|
||||
'javascript' => $this->testScrapingJavascript($mangaSlug, $chapterNumber, $contentSource),
|
||||
default => throw new Exception('Unsupported scraping type: ' . $contentSource->getScrapingType()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testScrapingJavascript(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
||||
|
||||
return $this->scrapeChapterJavascript($manga, $chapter, $contentSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function testScrapingHtml(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
|
||||
$html = $this->fetchHtml($chapterUrl);
|
||||
|
||||
if ($contentSource->getNextPageSelector() === null) {
|
||||
return $this->scrapeVerticalReader($html, $contentSource);
|
||||
} else {
|
||||
return $this->scrapeHorizontalReader($chapterUrl, $contentSource);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function scrapeChapterHtml(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool
|
||||
{
|
||||
$chapterUrl = $mangaSource->getChapterUrl($manga->getSlug(), $chapter->getNumber());
|
||||
|
||||
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
|
||||
mkdir($tempDir);
|
||||
|
||||
$pageData = [];
|
||||
|
||||
if ($mangaSource->getNextPageSelector() === null) {
|
||||
// Lecteur vertical
|
||||
$html = $this->fetchHtml($chapterUrl);
|
||||
$pageData = $this->scrapeVerticalReader($html, $mangaSource);
|
||||
} else {
|
||||
// Lecteur horizontal (paginé)
|
||||
$pageData = $this->scrapeHorizontalReader($chapterUrl, $mangaSource);
|
||||
}
|
||||
|
||||
// Télécharger et sauvegarder les images
|
||||
foreach ($pageData as $index => &$page) {
|
||||
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
|
||||
$imagePath = $tempDir . '/' . $imageName;
|
||||
|
||||
$this->downloadAndSaveImage($page['image_url'], $imagePath);
|
||||
|
||||
$event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, count($pageData));
|
||||
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
|
||||
$page['local_image_url'] = $imagePath;
|
||||
}
|
||||
|
||||
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
|
||||
$this->createCbzFile($tempDir, $pageData, $cbzFilePath);
|
||||
|
||||
$chapter->setCbzPath($cbzFilePath);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Nettoyage du répertoire temporaire
|
||||
$this->cleanupTempFiles($tempDir);
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function scrapeVerticalReader(string $html, ContentSource $contentSource): array
|
||||
{
|
||||
$crawler = new Crawler($html);
|
||||
$images = $crawler->filter($contentSource->getImageSelector());
|
||||
|
||||
$pageData = [];
|
||||
foreach ($images as $index => $image) {
|
||||
if ($image->getAttribute('src') === '') {
|
||||
$imgUrl = $image->getAttribute('data-src');
|
||||
} else {
|
||||
$imgUrl = $image->getAttribute('src');
|
||||
}
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($imgUrl),
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function scrapeHorizontalReader(string $chapterUrl, ContentSource $contentSource): array
|
||||
{
|
||||
$pageData = [];
|
||||
$currentPageUrl = $chapterUrl;
|
||||
|
||||
do {
|
||||
$html = $this->fetchHtml($currentPageUrl);
|
||||
$page = $this->extractMangaPageData($html, $contentSource);
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($page['image_url']),
|
||||
'page_number' => count($pageData) + 1,
|
||||
];
|
||||
|
||||
$currentPageUrl = $page['next_page_url'];
|
||||
} while ($currentPageUrl);
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single image
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function processImage(string $imgUrl, string $tempDir, array &$pageData, int $index, Chapter $chapter): void
|
||||
{
|
||||
$imgUrl = $this->cleanImageUrl($imgUrl);
|
||||
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($imgUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
|
||||
$imagePath = $tempDir . '/' . $imageName;
|
||||
|
||||
$this->downloadAndSaveImage($imgUrl, $imagePath);
|
||||
|
||||
// $event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, 0);
|
||||
// $this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $imgUrl,
|
||||
'local_image_url' => $imagePath,
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
private function cleanImageUrl(string $url): string
|
||||
{
|
||||
return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
* @throws Exception
|
||||
*/
|
||||
private function fetchHtml(string $url): string
|
||||
{
|
||||
$client = new Client();
|
||||
|
||||
try {
|
||||
$response = $client->get($url, [
|
||||
'http_errors' => true,
|
||||
'allow_redirects' => false
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400) {
|
||||
throw new Exception('Chapter Not Found at ' . $url);
|
||||
} elseif ($statusCode == 404) {
|
||||
throw new Exception('Chapter Not Found at ' . $url);
|
||||
}
|
||||
|
||||
return (string)$response->getBody();
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Bad Request: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function downloadAndSaveImage(string $imageUrl, string $destinationPath): void
|
||||
{
|
||||
$client = new Client();
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$response = $client->get($imageUrl);
|
||||
$endTime = microtime(true);
|
||||
$contentType = $response->getHeaderLine('Content-Type');
|
||||
$xCacheHeader = $response->getHeaderLine('X-Cache');
|
||||
$isCached = str_starts_with($xCacheHeader, 'HIT');
|
||||
$contentLength = $response->getHeaderLine('Content-Length');
|
||||
|
||||
if (str_starts_with($contentType, 'image/')) {
|
||||
file_put_contents($destinationPath, $response->getBody()->getContents());
|
||||
// if ($this->scrapingType === 'mangadex') {
|
||||
// $this->sendReport($imageUrl, true, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000);
|
||||
// }
|
||||
} else {
|
||||
// if ($this->scrapingType === 'mangadex') {
|
||||
// $this->sendReport($imageUrl, false, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000);
|
||||
// }
|
||||
throw new \Exception('Le contenu récupéré n\'est pas une image. Type de contenu : ' . $contentType);
|
||||
}
|
||||
} catch (RequestException $e) {
|
||||
throw new \Exception('Erreur lors de la récupération de l\'image : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function isChapterAvailable(string $chapterUrl, float $chapterNumber, ContentSource $mangaSource): bool
|
||||
{
|
||||
$html = $this->fetchHtml($chapterUrl);
|
||||
$crawler = new Crawler($html);
|
||||
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
|
||||
|
||||
if ($nextLink->count() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextUrl = $nextLink->attr('href');
|
||||
$routeCollection = new RouteCollection();
|
||||
$routeCollection->add('manga_chapter', new Route('/scan-{manga}/{chapter}/{page}'));
|
||||
$context = new RequestContext('/');
|
||||
$matcher = new UrlMatcher($routeCollection, $context);
|
||||
$path = parse_url($nextUrl, PHP_URL_PATH);
|
||||
$parameters = $matcher->match($path);
|
||||
|
||||
return (float)$parameters['chapter'] === $chapterNumber;
|
||||
}
|
||||
|
||||
private function sendReport(string $imageUrl, bool $success, bool $cached, int $bytes, float $duration): void
|
||||
{
|
||||
$client = new Client();
|
||||
|
||||
try {
|
||||
$client->post('https://api.mangadex.network/report', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'url' => $imageUrl,
|
||||
'success' => $success,
|
||||
'cached' => $cached,
|
||||
'bytes' => $bytes,
|
||||
'duration' => $duration,
|
||||
],
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
// Gérer les exceptions de requête pour le rapport
|
||||
throw new \Exception('Erreur lors de l\'envoi du rapport : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createCbzFile(string $tempDir, array $pageData, string $cbzFilePath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($cbzFilePath, \ZipArchive::CREATE) === true) {
|
||||
foreach ($pageData as $page) {
|
||||
$zip->addFile($page['local_image_url'], basename($page['local_image_url']));
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCbzPath(Manga $manga, Chapter $chapter): string
|
||||
{
|
||||
$volumeDir = $this->createDirectories($manga, $chapter->getVolume());
|
||||
$fileName = sprintf(
|
||||
'%s_vol%d_ch%s.cbz',
|
||||
$manga->getSlug(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->getNumber()
|
||||
);
|
||||
return $volumeDir . '/' . $fileName;
|
||||
}
|
||||
|
||||
private function createDirectories(Manga $manga, int $volume): string
|
||||
{
|
||||
$mangaYear = $manga->getPublicationYear() ?? 'unknown';
|
||||
$mangaDir = sprintf('%s/%s (%s)', $this->projectDir . self::PUBLIC_CBZ, ucfirst($manga->getSlug()), $mangaYear);
|
||||
$volumeDir = sprintf('%s/volume_%d', $mangaDir, sprintf('%02d', $volume));
|
||||
|
||||
if (!is_dir($volumeDir)) {
|
||||
mkdir($volumeDir, 0755, true);
|
||||
}
|
||||
|
||||
return $volumeDir;
|
||||
}
|
||||
|
||||
private function cleanupTempFiles(string $directory): void
|
||||
{
|
||||
$files = glob($directory . '/*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use App\Interface\MetadataProviderInterface;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class MangaUpdatesMetadataProvider implements MetadataProviderInterface
|
||||
{
|
||||
private Client $client;
|
||||
|
||||
public function __construct(private readonly SluggerInterface $slugger)
|
||||
{
|
||||
$this->client = new Client();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function search(string $title): Collection
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('PUT', 'https://api.mangaupdates.com/v1/account/login', [
|
||||
'json' => [
|
||||
'username' => 'Colgora',
|
||||
'password' => '7TK5jv33NDn*SLV',
|
||||
]
|
||||
])
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
|
||||
$jwt = json_decode($response->getBody()->getContents(), true)['context']['session_token'];
|
||||
|
||||
$results = $this->client->request('POST', 'https://api.mangaupdates.com/v1/series/search', [
|
||||
'json' => [
|
||||
'search' => $title,
|
||||
'licensed' => 'yes',
|
||||
'type' => ['Manga'],
|
||||
'exclude_genre' => ['Doujinshi', 'Adult', 'Hentai', 'Ecchi', 'Yaoi', 'Yuri', 'Josei', 'Smut', 'Gender Bender'],
|
||||
'orderby' => 'score',
|
||||
]
|
||||
])->withHeader('Authorization', 'Bearer ' . $jwt)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->getBody()
|
||||
->getContents();
|
||||
|
||||
$mangas = [];
|
||||
foreach (json_decode($results, true)['results'] as $record) {
|
||||
$record = $record['record'];
|
||||
|
||||
$genres = [];
|
||||
foreach ($record['genres'] as $genre) {
|
||||
$genres[] = $genre['genre'];
|
||||
}
|
||||
|
||||
$mangas[] = (new Manga())
|
||||
->setTitle($record['title'])
|
||||
->setSlug($this->slugger->slug($record['title'])->lower())
|
||||
->setDescription($record['description'])
|
||||
->setImageUrl($record['image']['url']['original'])
|
||||
->setGenres($genres)
|
||||
->setPublicationYear((int)$record['year'])
|
||||
->setRating((float)$record['bayesian_rating'])
|
||||
;
|
||||
}
|
||||
|
||||
return new ArrayCollection($mangas);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Interface\ClientInterface;
|
||||
use App\Interface\MetadataProviderInterface;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
readonly class MangadexProvider implements MetadataProviderInterface
|
||||
{
|
||||
public function __construct(private ClientInterface $client, private SluggerInterface $slugger, private NotificationService $notificationService)
|
||||
{
|
||||
}
|
||||
|
||||
public function search(?string $title): Collection
|
||||
{
|
||||
if (null === $title) {
|
||||
return new ArrayCollection();
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->client->get('/manga', [
|
||||
'title' => $title,
|
||||
'contentRating' => ['safe', 'suggestive', 'erotica'],
|
||||
'includes' => ['cover_art', 'author'],
|
||||
'limit' => 50,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate('notification', ['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
|
||||
|
||||
return new ArrayCollection();
|
||||
}
|
||||
|
||||
$mangas = [];
|
||||
foreach ($results['data'] as $result) {
|
||||
$mangas[] = (new Manga())
|
||||
->setExternalId($result['id'])
|
||||
->setTitle($result['attributes']['title']['en'])
|
||||
->setSlug($this->slugger->slug($result['attributes']['title']['en'])->lower())
|
||||
->setDescription($result['attributes']['description']['fr'] ?? $result['attributes']['description']['en'] ?? '')
|
||||
->setPublicationYear($result['attributes']['year'])
|
||||
->setStatus($result['attributes']['status']);
|
||||
$tags = [];
|
||||
foreach ($result['attributes']['tags'] as $tag) {
|
||||
$tags[] = $tag['attributes']['name']['en'];
|
||||
}
|
||||
|
||||
$mangas[count($mangas) - 1]->setGenres($tags);
|
||||
|
||||
foreach ($result['relationships'] as $relationship) {
|
||||
if ('author' === $relationship['type']) {
|
||||
$mangas[count($mangas) - 1]->setAuthor($relationship['attributes']['name']);
|
||||
}
|
||||
|
||||
if ('cover_art' === $relationship['type']) {
|
||||
$mangas[count($mangas) - 1]->setImageUrl('https://mangadex.org/covers/'.$result['id'].'/'.$relationship['attributes']['fileName']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$test = array_map(fn ($manga) => $manga->getExternalId(), $mangas);
|
||||
|
||||
$ratings = $this->client->get('/statistics/manga', [
|
||||
'manga' => $test,
|
||||
]);
|
||||
|
||||
foreach ($mangas as $manga) {
|
||||
$manga->setRating($ratings['statistics'][$manga->getExternalId()]['rating']['average']);
|
||||
}
|
||||
|
||||
usort($mangas, fn ($a, $b) => $b->getRating() <=> $a->getRating());
|
||||
|
||||
return new ArrayCollection($mangas);
|
||||
}
|
||||
|
||||
public function getFeed(Manga $manga): array
|
||||
{
|
||||
if (null === $manga->getExternalId()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chapters = [];
|
||||
$page = 0;
|
||||
|
||||
do {
|
||||
$results = $this->getFeedWithPagination($manga->getExternalId(), $page);
|
||||
if (isset($results['data'])) {
|
||||
$chapters = array_merge($chapters, $results['data']);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
++$page;
|
||||
} while (count($chapters) < $results['total']);
|
||||
|
||||
return $this->getChaptersFromFeed($chapters, $manga);
|
||||
}
|
||||
|
||||
public function getLastFeed(Manga $manga, int $limit = 100): array
|
||||
{
|
||||
if (null === $manga->getExternalId()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chapters = [];
|
||||
|
||||
try {
|
||||
$results = $this->getFeedWithPagination($manga->getExternalId(), 0, $limit, 'desc');
|
||||
if (isset($results['data'])) {
|
||||
$chapters = $results['data'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching recent chapters from Mangadex.']);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getChaptersFromFeed($chapters, $manga);
|
||||
}
|
||||
|
||||
private function getFeedWithPagination(string $externalId, int $page, int $limit = 500, string $order = 'asc'): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->get('/manga/'.$externalId.'/feed', [
|
||||
'limit' => $limit,
|
||||
'translatedLanguage' => ['en', 'fr'],
|
||||
'order' => ['chapter' => $order],
|
||||
'offset' => $page * $limit,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function getMangaAggregate(Manga $manga): array
|
||||
{
|
||||
if (null === $manga->getExternalId()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->get('/manga/'.$manga->getExternalId().'/aggregate');
|
||||
} catch (\Exception $e) {
|
||||
// $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'An error occurred while fetching data from Mangadex.']);
|
||||
return [];
|
||||
}
|
||||
|
||||
$chapterEntities = [];
|
||||
if ('ok' === $response['result']) {
|
||||
foreach ($response['volumes'] as $volume) {
|
||||
$volumeNumber = 'none' === $volume['volume'] ? 0 : (float) $volume['volume'];
|
||||
foreach ($volume['chapters'] as $chapter) {
|
||||
$chapterEntity = new Chapter();
|
||||
$chapterEntity->setNumber((float) $chapter['chapter'])
|
||||
->setTitle('Chapter '.$chapter['chapter'])
|
||||
->setVolume($volumeNumber)
|
||||
->setExternalId('');
|
||||
|
||||
$chapterEntities[] = $chapterEntity;
|
||||
// $manga->addChapter($chapterEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $chapterEntities;
|
||||
}
|
||||
|
||||
public function getChaptersFromFeed(mixed $chapters, Manga $manga): array
|
||||
{
|
||||
$chapterEntities = [];
|
||||
$uniqueChapterNumbers = [];
|
||||
|
||||
foreach ($chapters as $result) {
|
||||
$chapterNumber = (float) $result['attributes']['chapter'];
|
||||
|
||||
// Vérifiez si le chapitre existe déjà dans la base de données
|
||||
$chapterExists = $manga->getChapters()->exists(function ($key, $existingChapter) use ($chapterNumber) {
|
||||
return $existingChapter->getNumber() === $chapterNumber;
|
||||
});
|
||||
|
||||
// Si le chapitre existe déjà dans la base de données ou dans notre nouvelle liste, on skip
|
||||
if ($chapterExists || in_array($chapterNumber, $uniqueChapterNumbers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Créez et ajoutez le nouveau chapitre
|
||||
$chapter = new Chapter();
|
||||
$chapter->setNumber($chapterNumber)
|
||||
->setTitle($result['attributes']['title'])
|
||||
->setVolume((int) $result['attributes']['volume'] ?? null)
|
||||
->setExternalId($result['id']);
|
||||
|
||||
$chapterEntities[] = $chapter;
|
||||
$uniqueChapterNumbers[] = $chapterNumber;
|
||||
}
|
||||
|
||||
// Trier les chapitres par numéro
|
||||
usort($chapterEntities, function ($a, $b) {
|
||||
return $a->getNumber() <=> $b->getNumber();
|
||||
});
|
||||
|
||||
return $chapterEntities;
|
||||
}
|
||||
|
||||
public function addAllChaptersToManga(Manga $manga): array
|
||||
{
|
||||
$mangaFeed = $this->getFeed($manga);
|
||||
$mangaAggregate = $this->getMangaAggregate($manga);
|
||||
|
||||
$allChapters = array_merge($mangaFeed, $mangaAggregate);
|
||||
|
||||
if (empty($allChapters)) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'No chapters found for this manga.',
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$mergedChapters = [];
|
||||
foreach ($allChapters as $chapter) {
|
||||
$number = $chapter->getNumber();
|
||||
$existingChapter = $manga->getChapterByNumber($number);
|
||||
if ($existingChapter) {
|
||||
if ($existingChapter->getExternalId() !== $chapter->getExternalId() && is_null($existingChapter->getExternalId())) {
|
||||
$this->updateChapter($existingChapter, $chapter);
|
||||
$mergedChapters[$number] = $existingChapter;
|
||||
}
|
||||
} else {
|
||||
// Add new chapter
|
||||
$manga->addChapter($chapter);
|
||||
$mergedChapters[$number] = $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($mergedChapters);
|
||||
}
|
||||
|
||||
private function updateChapter(Chapter $existingChapter, Chapter $newChapter): void
|
||||
{
|
||||
$existingChapter->setVolume($newChapter->getVolume());
|
||||
$existingChapter->setExternalId($newChapter->getExternalId());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public function __construct(private HubInterface $hub)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function sendUpdate(mixed $data): void
|
||||
{
|
||||
$update = new Update('notification', json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
use App\Entity\Manga;
|
||||
use App\Event\PageScrappingProgressEvent;
|
||||
use App\Manager\FileSystemManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
abstract class AbstractScraper implements ScraperInterface
|
||||
{
|
||||
protected Client $httpClient;
|
||||
|
||||
public function __construct(
|
||||
protected FileSystemManager $fileSystemManager,
|
||||
protected EventDispatcherInterface $eventDispatcher,
|
||||
protected EntityManagerInterface $entityManager
|
||||
) {
|
||||
$this->httpClient = new Client();
|
||||
}
|
||||
|
||||
protected function getValidChapterUrl(ContentSource $contentSource, Manga $manga, float $chapterNumber): ?string
|
||||
{
|
||||
$slugs = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs() ?? []);
|
||||
|
||||
foreach ($slugs as $slug) {
|
||||
$url = $contentSource->getChapterUrl($slug, $chapterNumber);
|
||||
if ($this->isChapterUrlValid($url)) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function isChapterUrlValid(string $url): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->head($url);
|
||||
|
||||
return 200 === $response->getStatusCode();
|
||||
} catch (RequestException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function generateCbzPath(Manga $manga, Chapter $chapter): string
|
||||
{
|
||||
$mangaDir = $this->fileSystemManager->createMangaDirectory($manga->getSlug(), $manga->getPublicationYear());
|
||||
$volumeDir = $this->fileSystemManager->createVolumeDirectory($mangaDir, $chapter->getVolume());
|
||||
$fileName = sprintf(
|
||||
'%s_vol%d_ch%s.cbz',
|
||||
$manga->getSlug(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->getNumber()
|
||||
);
|
||||
|
||||
return $volumeDir.'/'.$fileName;
|
||||
}
|
||||
|
||||
protected function createCbzFile(array $pageData, string $cbzFilePath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if (true === $zip->open($cbzFilePath, \ZipArchive::CREATE)) {
|
||||
foreach ($pageData as $page) {
|
||||
$zip->addFile($page['local_image_url'], basename($page['local_image_url']));
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
protected function cleanupTempFiles(string $directory): void
|
||||
{
|
||||
$this->fileSystemManager->deleteDirectory($directory);
|
||||
}
|
||||
|
||||
protected function cleanImageUrl(string $url): string
|
||||
{
|
||||
return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url));
|
||||
}
|
||||
|
||||
protected function dispatchProgressEvent(Chapter $chapter, int $currentPage, int $totalPages): void
|
||||
{
|
||||
$event = new PageScrappingProgressEvent($chapter->getId(), $currentPage, $totalPages);
|
||||
$this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function downloadAndSaveImage(string $imageUrl, string $destinationPath): string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->get($imageUrl);
|
||||
$contentType = $response->getHeaderLine('Content-Type');
|
||||
|
||||
if (!str_starts_with($contentType, 'image/')) {
|
||||
throw new \Exception('Le contenu récupéré n\'est pas une image. Type de contenu : '.$contentType);
|
||||
}
|
||||
|
||||
$imageData = $response->getBody()->getContents();
|
||||
$tempFilePath = $this->saveTempFile($imageData);
|
||||
|
||||
$image = $this->createImageResource($tempFilePath, $contentType);
|
||||
if (false === $image) {
|
||||
throw new \Exception('Échec de la création de la ressource image.');
|
||||
}
|
||||
|
||||
$destinationPath = $this->ensureJpgExtension($destinationPath);
|
||||
if (!imagejpeg($image, $destinationPath)) {
|
||||
imagedestroy($image);
|
||||
unlink($tempFilePath);
|
||||
throw new \Exception('Échec de la sauvegarde de l\'image en JPG.');
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
unlink($tempFilePath);
|
||||
|
||||
return $destinationPath;
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Erreur lors de la récupération de l\'image : '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function saveTempFile(string $data): string
|
||||
{
|
||||
$tempFilePath = tempnam(sys_get_temp_dir(), 'manga_img_');
|
||||
file_put_contents($tempFilePath, $data);
|
||||
|
||||
return $tempFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function createImageResource(string $filePath, string $contentType)
|
||||
{
|
||||
return match ($contentType) {
|
||||
'image/webp' => imagecreatefromwebp($filePath),
|
||||
'image/png' => imagecreatefrompng($filePath),
|
||||
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($filePath),
|
||||
default => throw new \Exception('Format d\'image non pris en charge : '.$contentType),
|
||||
};
|
||||
}
|
||||
|
||||
private function ensureJpgExtension(string $path): string
|
||||
{
|
||||
$info = pathinfo($path);
|
||||
|
||||
return $info['dirname'].'/'.$info['filename'].'.jpg';
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class HtmlScraper extends AbstractScraper
|
||||
{
|
||||
/**
|
||||
* @throws \Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
|
||||
{
|
||||
$manga = $chapter->getManga();
|
||||
$chapterUrl = $this->getValidChapterUrl($contentSource, $manga, $chapter->getNumber());
|
||||
|
||||
if (!$chapterUrl) {
|
||||
throw new \Exception("Aucune URL valide trouvée pour le chapitre {$chapter->getNumber()} du manga {$manga->getTitle()}");
|
||||
}
|
||||
|
||||
$tempDir = sys_get_temp_dir().'/'.uniqid('manga_scraper_');
|
||||
mkdir($tempDir);
|
||||
|
||||
$pageData = [];
|
||||
|
||||
if (null === $contentSource->getNextPageSelector()) {
|
||||
// Lecteur vertical
|
||||
$html = $this->fetchHtml($chapterUrl);
|
||||
$pageData = $this->scrapeVerticalReader($html, $contentSource);
|
||||
} else {
|
||||
// Lecteur horizontal (paginé)
|
||||
$pageData = $this->scrapeHorizontalReader($chapterUrl, $contentSource);
|
||||
}
|
||||
|
||||
// Télécharger et sauvegarder les images
|
||||
foreach ($pageData as $index => &$page) {
|
||||
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
|
||||
$imagePath = $tempDir.'/'.$imageName;
|
||||
|
||||
$destinationPath = $this->downloadAndSaveImage($page['image_url'], $imagePath);
|
||||
|
||||
$this->dispatchProgressEvent($chapter, $index + 1, count($pageData));
|
||||
|
||||
$page['local_image_url'] = $destinationPath;
|
||||
}
|
||||
|
||||
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
|
||||
$this->createCbzFile($pageData, $cbzFilePath);
|
||||
|
||||
$chapter->setCbzPath($cbzFilePath);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->cleanupTempFiles($tempDir);
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
|
||||
|
||||
if (!$this->isChapterUrlValid($chapterUrl)) {
|
||||
throw new \Exception('Invalid URL, check format and slug');
|
||||
}
|
||||
|
||||
$html = $this->fetchHtml($chapterUrl);
|
||||
|
||||
if (null === $contentSource->getNextPageSelector()) {
|
||||
return $this->scrapeVerticalReader($html, $contentSource);
|
||||
} else {
|
||||
return $this->scrapeHorizontalReader($chapterUrl, $contentSource);
|
||||
}
|
||||
}
|
||||
|
||||
public function supports(string $scrapingType): bool
|
||||
{
|
||||
return 'html' === $scrapingType;
|
||||
}
|
||||
|
||||
private function scrapeVerticalReader(string $html, ContentSource $contentSource): array
|
||||
{
|
||||
$crawler = new Crawler($html);
|
||||
$images = $crawler->filter($contentSource->getImageSelector());
|
||||
|
||||
$pageData = [];
|
||||
foreach ($images as $index => $image) {
|
||||
$imgUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($imgUrl),
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function scrapeHorizontalReader(string $chapterUrl, ContentSource $contentSource): array
|
||||
{
|
||||
$pageData = [];
|
||||
$currentPageUrl = $chapterUrl;
|
||||
|
||||
do {
|
||||
$html = $this->fetchHtml($currentPageUrl);
|
||||
$page = $this->extractMangaPageData($html, $contentSource);
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($page['image_url']),
|
||||
'page_number' => count($pageData) + 1,
|
||||
];
|
||||
|
||||
$currentPageUrl = $page['next_page_url'];
|
||||
} while ($currentPageUrl);
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function fetchHtml(string $url): string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->get($url, [
|
||||
'http_errors' => true,
|
||||
'allow_redirects' => false,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400 || 404 == $statusCode) {
|
||||
throw new \Exception('Chapter Not Found at '.$url);
|
||||
}
|
||||
|
||||
return (string) $response->getBody();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Bad Request: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function extractMangaPageData(string $html, ContentSource $mangaSource): array
|
||||
{
|
||||
$crawler = new Crawler($html);
|
||||
$imgUrl = $crawler->filter($mangaSource->getImageSelector())->attr('src')
|
||||
?? $crawler->filter($mangaSource->getImageSelector())->attr('data-src');
|
||||
|
||||
$nextLink = $crawler->filter($mangaSource->getNextPageSelector());
|
||||
$nextUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null;
|
||||
|
||||
// Convert relative URLs to absolute URLs
|
||||
if (!preg_match('/^https?:\/\//', $imgUrl)) {
|
||||
$urlComponents = parse_url($mangaSource->getBaseUrl());
|
||||
$scheme = $urlComponents['scheme'];
|
||||
$host = $urlComponents['host'];
|
||||
$imgUrl = $scheme.'://'.$host.'/'.ltrim($imgUrl, '/');
|
||||
}
|
||||
|
||||
return [
|
||||
'image_url' => $imgUrl,
|
||||
'next_page_url' => $nextUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Component\Panther\Client as PantherClient;
|
||||
|
||||
class JavascriptScraper extends AbstractScraper
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
|
||||
{
|
||||
$manga = $chapter->getManga();
|
||||
$pantherClient = PantherClient::createChromeClient();
|
||||
$chapterUrl = $this->getValidChapterUrl($contentSource, $manga, $chapter->getNumber());
|
||||
|
||||
if (!$chapterUrl) {
|
||||
throw new Exception("Aucune URL valide trouvée pour le chapitre {$chapter->getNumber()} du manga {$manga->getTitle()}");
|
||||
}
|
||||
|
||||
$pantherClient->request('GET', $chapterUrl);
|
||||
|
||||
try {
|
||||
$this->selectChapter($pantherClient, $chapter, $contentSource);
|
||||
|
||||
$pageData = $contentSource->getNextPageSelector() === null
|
||||
? $this->scrapeVerticalReaderJavascript($pantherClient, $contentSource, $chapter)
|
||||
: $this->scrapeHorizontalReaderJavascript($pantherClient, $contentSource, $chapter);
|
||||
|
||||
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
|
||||
mkdir($tempDir);
|
||||
|
||||
// Télécharger et sauvegarder les images
|
||||
foreach ($pageData as $index => &$page) {
|
||||
$imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
|
||||
$imagePath = $tempDir . '/' . $imageName;
|
||||
|
||||
$destinationPath = $this->downloadAndSaveImage($page['image_url'], $imagePath);
|
||||
$this->dispatchProgressEvent($chapter, $index + 1, count($pageData));
|
||||
|
||||
$page['local_image_url'] = $destinationPath;
|
||||
}
|
||||
|
||||
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
|
||||
$this->createCbzFile($pageData, $cbzFilePath);
|
||||
|
||||
$chapter->setCbzPath($cbzFilePath);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->cleanupTempFiles($tempDir);
|
||||
|
||||
return $pageData;
|
||||
} finally {
|
||||
$pantherClient->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
$chapterUrl = $contentSource->getChapterUrl($mangaSlug, $chapterNumber);
|
||||
|
||||
if (!$this->isChapterUrlValid($chapterUrl)) {
|
||||
throw new \Exception("Invalid URL, check format and slug");
|
||||
}
|
||||
|
||||
$pantherClient = PantherClient::createChromeClient();
|
||||
$pantherClient->request('GET', $chapterUrl);
|
||||
|
||||
try {
|
||||
$chapter = new Chapter();
|
||||
$chapter->setNumber((float)$chapterNumber);
|
||||
|
||||
$this->selectChapter($pantherClient, $chapter, $contentSource);
|
||||
|
||||
return $contentSource->getNextPageSelector() === null
|
||||
? $this->scrapeVerticalReaderJavascript($pantherClient, $contentSource, $chapter)
|
||||
: $this->scrapeHorizontalReaderJavascript($pantherClient, $contentSource, $chapter);
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
$pantherClient->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function supports(string $scrapingType): bool
|
||||
{
|
||||
return $scrapingType === 'javascript';
|
||||
}
|
||||
|
||||
private function selectChapter(PantherClient $pantherClient, Chapter $chapter, ContentSource $contentSource): void
|
||||
{
|
||||
$chapterSelector = $contentSource->getChapterSelector();
|
||||
if (!$chapterSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
$crawler = $pantherClient->waitFor($chapterSelector);
|
||||
$select = $crawler->filter($chapterSelector);
|
||||
|
||||
if ($select->count() > 0) {
|
||||
$chapterNumber = $chapter->getNumber();
|
||||
$options = $select->filter('option');
|
||||
$targetIndex = null;
|
||||
|
||||
foreach ($options as $index => $option) {
|
||||
if (preg_match("/\b{$chapterNumber}\b/", $option->getText())) {
|
||||
$targetIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetIndex !== null) {
|
||||
$pantherClient->executeScript("
|
||||
var select = document.querySelector('$chapterSelector');
|
||||
select.selectedIndex = $targetIndex;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
");
|
||||
|
||||
$this->waitForImagesLoaded($pantherClient, $contentSource);
|
||||
} else {
|
||||
throw new Exception("Chapitre $chapterNumber non trouvé dans le menu déroulant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForImagesLoaded(PantherClient $pantherClient, ContentSource $contentSource): void
|
||||
{
|
||||
$imageSelector = $contentSource->getImageSelector();
|
||||
$pantherClient->wait(30)->until(
|
||||
function ($driver) use ($imageSelector) {
|
||||
return $driver->executeScript("
|
||||
return new Promise((resolve) => {
|
||||
let lastImageCount = 0;
|
||||
let stableCount = 0;
|
||||
const stableThreshold = 10;
|
||||
|
||||
function checkImages() {
|
||||
const images = document.querySelectorAll('$imageSelector');
|
||||
const loadedImages = Array.from(images).filter(img => img.complete && img.naturalWidth > 0);
|
||||
|
||||
if (loadedImages.length === lastImageCount) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastImageCount = loadedImages.length;
|
||||
}
|
||||
|
||||
if (stableCount >= stableThreshold) {
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(checkImages, 200);
|
||||
}
|
||||
}
|
||||
|
||||
checkImages();
|
||||
});
|
||||
");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private function scrapeVerticalReaderJavascript(PantherClient $pantherClient, ContentSource $contentSource, Chapter $chapter): array
|
||||
{
|
||||
$pageData = [];
|
||||
$crawler = $pantherClient->waitFor($contentSource->getImageSelector());
|
||||
$images = $crawler->filter($contentSource->getImageSelector());
|
||||
|
||||
foreach ($images as $index => $image) {
|
||||
$imageUrl = $image->getAttribute('src') ?: $image->getAttribute('data-src');
|
||||
$pageData[] = [
|
||||
'image_url' => $this->cleanImageUrl($imageUrl),
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $pageData;
|
||||
}
|
||||
|
||||
private function scrapeHorizontalReaderJavascript(PantherClient $pantherClient, ContentSource $contentSource, Chapter $chapter): array
|
||||
{
|
||||
$pageData = [];
|
||||
return $pageData;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
|
||||
class MangaScraperService
|
||||
{
|
||||
private ScraperFactory $scraperFactory;
|
||||
|
||||
public function __construct(ScraperFactory $scraperFactory)
|
||||
{
|
||||
$this->scraperFactory = $scraperFactory;
|
||||
}
|
||||
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
|
||||
{
|
||||
$scraper = $this->scraperFactory->createScraper($contentSource);
|
||||
return $scraper->scrapeChapter($chapter, $contentSource);
|
||||
}
|
||||
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
$scraper = $this->scraperFactory->createScraper($contentSource);
|
||||
return $scraper->testScraping($mangaSlug, $chapterNumber, $contentSource);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\Client;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class MangadexScraper extends AbstractScraper
|
||||
{
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool
|
||||
{
|
||||
$chapterUrl = $contentSource->getBaseUrl() . sprintf($contentSource->getChapterUrlFormat(), $chapter->getExternalId());
|
||||
$manga = $chapter->getManga();
|
||||
$pageData = [];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->get($chapterUrl);
|
||||
$results = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($results['result'] !== 'ok' || count($results['chapter']['dataSaver']) === 0) {
|
||||
throw new \Exception('Error while fetching chapter data from Mangadex ' . $manga->getTitle() . ' ' . $chapter->getNumber());
|
||||
}
|
||||
|
||||
$tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_');
|
||||
mkdir($tempDir);
|
||||
|
||||
foreach ($results['chapter']['dataSaver'] as $index => $page) {
|
||||
$pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page;
|
||||
$imagePath = $tempDir . '/' . sprintf('%03d.%s', $index + 1, pathinfo($page, PATHINFO_EXTENSION));
|
||||
|
||||
$this->downloadAndSaveImage($pageUrl, $imagePath);
|
||||
|
||||
$this->dispatchProgressEvent($chapter, $index + 1, count($results['chapter']['dataSaver']));
|
||||
|
||||
$pageData[] = [
|
||||
'image_url' => $pageUrl,
|
||||
'local_image_url' => $imagePath,
|
||||
'page_number' => $index + 1,
|
||||
];
|
||||
}
|
||||
|
||||
$cbzFilePath = $this->generateCbzPath($manga, $chapter);
|
||||
$this->createCbzFile($pageData, $cbzFilePath);
|
||||
|
||||
$chapter->setCbzPath($cbzFilePath);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->cleanupTempFiles($tempDir);
|
||||
|
||||
return $pageData;
|
||||
} catch (\Exception $e) {
|
||||
// Log the error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array
|
||||
{
|
||||
// For Mangadex, we need the chapter's external ID, which we don't have in this context.
|
||||
// We could potentially fetch it first, but for simplicity, let's return an empty array.
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supports(string $scrapingType): bool
|
||||
{
|
||||
return $scrapingType === 'mangadex';
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\ContentSource;
|
||||
|
||||
class ScraperFactory
|
||||
{
|
||||
private array $scrapers;
|
||||
|
||||
public function __construct(iterable $scrapers)
|
||||
{
|
||||
$this->scrapers = iterator_to_array($scrapers);
|
||||
}
|
||||
|
||||
public function createScraper(ContentSource $contentSource): ScraperInterface
|
||||
{
|
||||
foreach ($this->scrapers as $scraper) {
|
||||
if ($scraper->supports($contentSource->getScrapingType())) {
|
||||
return $scraper;
|
||||
}
|
||||
}
|
||||
throw new \InvalidArgumentException('Unsupported scraping type: '.$contentSource->getScrapingType());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Scraper;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
|
||||
interface ScraperInterface
|
||||
{
|
||||
public function scrapeChapter(Chapter $chapter, ContentSource $contentSource): array|bool;
|
||||
public function testScraping(string $mangaSlug, string $chapterNumber, ContentSource $contentSource): array;
|
||||
public function supports(string $scrapingType): bool;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
class AddMangaModalComponent
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?Manga $manga;
|
||||
|
||||
public function open(Manga $manga): void
|
||||
{
|
||||
$this->manga = $manga;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->manga = null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
|
||||
#[AsTwigComponent]
|
||||
class BootstrapModal
|
||||
{
|
||||
public ?string $id = null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
final class DownloadChapter
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
public ?string $mangaSlug = '';
|
||||
public float $chapter;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function downloadChapter(MangaRepository $mangaRepository, ChapterRepository $chapterRepository): int
|
||||
{
|
||||
// $mangaSlug = $this->mangaSlug;
|
||||
// $chapter = $this->chapter;
|
||||
// $manga = $mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
// $chapter = $chapterRepository->findOneBy(['manga' => $manga, 'number' => $chapter]);
|
||||
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
class DropdownMenu
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?array $items = null;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use App\Service\MangadexProvider;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Exception;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
class MangaSearch
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?string $query = null;
|
||||
|
||||
public function __construct(private readonly MangadexProvider $mangadexProvider)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getMangas(): Collection|null
|
||||
{
|
||||
if ($this->query === null || $this->query === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->mangadexProvider->search($this->query);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use App\Repository\MangaRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
final class Search
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?string $query = null;
|
||||
|
||||
public function __construct(private readonly MangaRepository $mangaRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMangas(): array
|
||||
{
|
||||
return $this->query ? $this->mangaRepository->findByTitle($this->query) : [];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
final class ToolBarButton
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
#[LiveProp(writable: true)]
|
||||
public ?array $data = null;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Extension;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class AppExtension extends AbstractExtension
|
||||
{
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('get_placeholder', [$this, 'getPlaceholder']),
|
||||
];
|
||||
}
|
||||
|
||||
public function getPlaceholder(string $fieldName): string
|
||||
{
|
||||
return match ($fieldName) {
|
||||
'baseUrl' => 'https://example.com',
|
||||
'imageSelector' => '.manga-image img',
|
||||
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter-{number}',
|
||||
'nextPageSelector' => '.next-page',
|
||||
'scrapingType' => 'Select scraping type',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Extension;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
class TruncateExtension extends AbstractExtension
|
||||
{
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
new TwigFilter('truncate', [$this, 'truncate']),
|
||||
];
|
||||
}
|
||||
|
||||
public function truncate(?string $value, int $limit): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
return strlen($value) > $limit ? substr($value, 0, $limit) . '...' : $value;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Runtime;
|
||||
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
|
||||
class TruncateExtensionRuntime implements RuntimeExtensionInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Inject dependencies if needed
|
||||
}
|
||||
|
||||
public function doSomething($value)
|
||||
{
|
||||
// ...
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block toolbar %}
|
||||
{% if toolbar is defined %}
|
||||
<twig:Toolbar toolbar="{{ toolbar }}"/>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div {{ turbo_stream_listen('App\\Entity\\Chapter') }}></div>
|
||||
<div class="container mx-auto mt-8 p-2">
|
||||
<div class="bg-white overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600">
|
||||
</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Manga</th>
|
||||
<th class="w-1/12 py-3 px-4 text-left">Volume</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Chapitre</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Titre</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Progress</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
{% for manga in status %}
|
||||
<tr id="activity-{{ manga.chapterId }}" 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">
|
||||
</td>
|
||||
<td class="py-4 px-4 font-medium">{{ manga.manga }}</td>
|
||||
<td class="py-4 px-4">{{ manga.volume }}</td>
|
||||
<td class="py-4 px-4">
|
||||
{{ manga.chapter }}
|
||||
|
||||
</td>
|
||||
<td class="py-4 px-4">{{ manga.title }}</td>
|
||||
<td class="py-4 px-4">
|
||||
<div class="mt-2"
|
||||
data-controller="chapter-progress"
|
||||
data-chapter-progress-chapter-id-value="{{ manga.chapterId }}">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div data-chapter-progress-target="progressBar" class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out" style="width: 0"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white" data-chapter-progress-target="progressText">
|
||||
0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<button class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="py-4 px-4 text-center text-gray-500">Aucune activité en cours.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,86 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>{% block title %}Mangarr{% endblock %}</title>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
|
||||
{% block stylesheets %}
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
{% endblock %}
|
||||
{% block javascripts %}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{# {{ encore_entry_script_tags('turbo') }} #}
|
||||
{% endblock %}
|
||||
<meta name="turbo-refresh-scroll" content="preserve">
|
||||
</head>
|
||||
<body class="bg-gray-50 h-full overflow-hidden" data-controller="menu">
|
||||
<div data-controller="mercure" data-mercure-topic="notification"></div>
|
||||
|
||||
<div data-controller="alert" class="fixed right-0 z-[60] flex justify-center w-full">
|
||||
<div data-alert-target="alert" style="display: none;"
|
||||
class="mt-8 max-w-fit p-4 rounded-lg shadow-lg text-white border-2 border-gray-200 transition-opacity duration-1000 ease-out">
|
||||
<i data-alert-target="icon" class="fa text-xl mr-2"></i>
|
||||
<span data-alert-target="message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-green-600 h-16 flex items-center fixed w-full z-50">
|
||||
<div class="flex justify-center ml-4">
|
||||
<a class="flex flex-row justify-start" href="{{ path('app_legacy') }}">
|
||||
{# <div class="flex items-center"> #}
|
||||
{# <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" #}
|
||||
{# x="0px" y="0px" width="50" height="50" viewBox="-40 -90 500 500"> #}
|
||||
{# <path cx="100" cy="100" r="150" fill="white" stroke="green" stroke-width="15" #}
|
||||
{# d="M448 160A240 240 0 0 1 208 400A240 240 0 0 1 -32 160A240 240 0 0 1 448 160z"/> #}
|
||||
{# <g transform="translate(28, -15) scale(0.9)"> #}
|
||||
{# <g> #}
|
||||
{# <path style="fill:#16A34A; stroke:black; stroke-width:3;" #}
|
||||
{# d="M68.955 285.752c45.294 0.882 84.544 7.654 113.51 19.587a4.037 4.037 0 0 0 5.571 -3.73l0.016 -216.52a4.032 4.032 0 0 0 -2.544 -3.749L70.525 35.586a4.032 4.032 0 0 0 -5.525 3.749v242.384a4.032 4.032 0 0 0 3.955 4.034"/> #}
|
||||
{# <path style="fill:green; stroke:black; stroke-width:3;" #}
|
||||
{# d="M398.374 74.04a4.048 4.048 0 0 0 -3.565 -0.63l-40.667 12.158a4.032 4.032 0 0 0 -2.88 3.864v209.165s0.181 3.299 -3.293 3.299h-6.592c-32.806 0 -93.773 3.405 -133.496 26.213l-7.883 4.514 -7.883 -4.514c-39.723 -22.806 -100.688 -26.213 -133.496 -26.213H50.208c-1.568 0 -1.472 -1.885 -1.472 -1.883V89.434a4.032 4.032 0 0 0 -2.878 -3.864L5.189 73.411A4.032 4.032 0 0 0 0 77.274V330.336c0 1.138 0.482 2.224 1.325 2.99a4.032 4.032 0 0 0 3.104 1.026c12.086 -1.186 30.984 -2.597 52.363 -2.597 46.957 0 84.691 6.858 109.146 19.837 3.592 1.885 24.422 13.109 34.064 13.109s30.47 -11.224 34.062 -13.109c24.454 -12.979 62.19 -19.837 109.146 -19.837 21.378 0 40.275 1.411 52.362 2.597a4.032 4.032 0 0 0 4.429 -4.014V77.274a4.048 4.048 0 0 0 -1.626 -3.234"/> #}
|
||||
{# <path style="fill:#16A34A; stroke:black; stroke-width:3;" #}
|
||||
{# d="M213.75 304.962a4.048 4.048 0 0 0 3.782 0.378c28.965 -11.933 68.216 -18.707 113.51 -19.587a4.032 4.032 0 0 0 3.957 -4.032V39.334a4.032 4.032 0 0 0 -5.525 -3.749l-114.986 45.755a4.032 4.032 0 0 0 -2.542 3.749l0.016 216.52a4.032 4.032 0 0 0 1.789 3.352"/> #}
|
||||
{# </g> #}
|
||||
{# </g> #}
|
||||
{# </svg> #}
|
||||
{# </div> #}
|
||||
<img src="{{ asset('img/mangarr_logo.png') }}" alt="Mangarr" class="w-32 shadow-xl">
|
||||
</a>
|
||||
</div>
|
||||
<button data-action="click->menu#toggleMenu" class="ml-4 text-white p-2 md:hidden">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="ml-4 w-60 relative">
|
||||
<twig:Search/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex h-full pt-16">
|
||||
<!-- Sidebar -->
|
||||
<nav data-menu-target="sidebar"
|
||||
class="w-60 bg-white h-full overflow-y-auto fixed left-0 transform -translate-x-full transition-transform duration-200 ease-in-out md:translate-x-0 z-40">
|
||||
{% include 'menu/menu.html.twig' %}
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden md:ml-60 w-full">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-white shadow z-20 w-full">
|
||||
{% block toolbar %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50 w-full">
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
{% block create %}
|
||||
<turbo-stream action="prepend" target="volume-{{ entity.volume }}">
|
||||
<template>
|
||||
{% include 'manga/_chapter_row.html.twig' with { chapter: entity, manga: entity.manga } %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endblock %}
|
||||
|
||||
{% block update %}
|
||||
<turbo-stream action="remove" target="activity-{{ entity.id }}"></turbo-stream>
|
||||
|
||||
<turbo-stream action="replace" target="chapter-{{ entity.id }}">
|
||||
<template>
|
||||
{% include 'manga/_chapter_row.html.twig' with { chapter: entity, manga: entity.manga } %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block remove %}
|
||||
{% endblock %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Page non trouvée{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Page non trouvée</h1>
|
||||
<p>La page que vous cherchez n'existe pas.</p>
|
||||
{% endblock %}
|
||||
@@ -1,20 +0,0 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Hello CalendarController!{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<style>
|
||||
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
|
||||
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
|
||||
</style>
|
||||
|
||||
<div class="example-wrapper">
|
||||
<h1>Hello {{ controller_name }}! ✅</h1>
|
||||
|
||||
This friendly message is coming from:
|
||||
<ul>
|
||||
<li>Your controller at <code><a href="{{ '/app/src/Controller/CalendarController.php'|file_link(0) }}">src/Controller/CalendarController.php</a></code></li>
|
||||
<li>Your template at <code><a href="{{ '/app/templates/calendar/index.html.twig'|file_link(0) }}">templates/calendar/index.html.twig</a></code></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{# templates/components/manga_modal.html.twig #}
|
||||
<div id="manga-modal" style="display: {{ manga ? 'block' : 'none' }};">
|
||||
<div class="modal-content">
|
||||
<span class="close-button" data-action="live#action" data-live-action-param="close">×</span>
|
||||
{% if manga %}
|
||||
<h2>{{ manga.title }}</h2>
|
||||
<p><strong>Year:</strong> {{ manga.publicationYear }}</p>
|
||||
<p>{{ manga.description }}</p>
|
||||
<button data-action="live#action" data-live-action-param="saveManga">Save Manga</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user