4 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
01b6628fa6 fix(scraping): corriger les 403 sur les images avec protection anti-hotlink
- Ajouter le header Referer (origin de l'image) dans ImageDownloader pour les téléchargements backend
- Ajouter referrerpolicy="no-referrer" sur les <img> de la modale de test pour les previews navigateur
2026-03-16 00:09:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
ae7a485195 feat(scraping): implémenter le health check de tous les scrapers
- Commande CheckAllScrapersHealth + handler avec ports dédiés
- Value Object ContentSourceHealthCheckData
- Resource API Platform et State Processor
- Adapters InMemory et tests unitaires + fonctionnels
2026-03-16 00:09:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
734dea569c feat(setting): étendre ContentSource avec champs de test et domain model
- Ajouter testSlug, testChapterNumber, baseUrl sur ContentSource (entité, domain model, migration)
- Exposer ces champs dans les Resources, Processors, Providers et Mapper
- Mettre à jour store Pinia, repository API et composants Vue (form, card, liste)
2026-03-16 00:09:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
71d6bb5ee9 feat(ui): harmoniser les pages Scrapers sur le design system Mangarr
- Layout canonique px-6 py-8 + sections border-t (suppression container mx-auto)
- Toolbar : label titre + bouton retour (ScrapperEdit) + boutons actions (ScrapperConfigurations)
- Bouton submit déplacé dans la toolbar droite via defineExpose/ref
- ContentSourceForm aplati (suppression du wrapper carte et du header)
- Séparation des sections du formulaire par border-t
- Suppression de tous les rounded-* sur les 4 composants
- Suppression du bloc debug "aucune source" et du h1 volant
2026-03-16 00:09:19 +01:00
50 changed files with 432 additions and 1985 deletions

View File

@@ -1,17 +1,13 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { Job } from '../../domain/entities/job';
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository'; import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository(); const jobRepository = new ApiJobRepository();
const ACTIVE_STATUSES = ['pending', 'in_progress'];
export const useActivityStore = defineStore('activity', { export const useActivityStore = defineStore('activity', {
state: () => ({ state: () => ({
jobs: [], jobs: [],
loading: false, loading: false,
error: null, error: null,
mercureEventSource: null,
// Pagination // Pagination
currentPage: 1, currentPage: 1,
totalPages: 0, totalPages: 0,
@@ -19,15 +15,21 @@ export const useActivityStore = defineStore('activity', {
limit: 20, limit: 20,
hasNextPage: false, hasNextPage: false,
hasPreviousPage: false, hasPreviousPage: false,
// Tri // Filtres
sortBy: 'createdAt', filter: {
sortOrder: 'DESC', status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
sortBy: 'createdAt',
sortOrder: 'DESC'
}
}), }),
getters: { getters: {
activeJobs: state => state.jobs.filter(job => job.isActive()), activeJobs: state => state.jobs.filter(job => job.isActive()),
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
failedJobs: state => state.jobs.filter(job => job.hasError()),
isLoading: state => state.loading, isLoading: state => state.loading,
hasError: state => !!state.error, hasError: state => !!state.error,
// Getters pour la pagination
paginationInfo: state => ({ paginationInfo: state => ({
currentPage: state.currentPage, currentPage: state.currentPage,
totalPages: state.totalPages, totalPages: state.totalPages,
@@ -39,25 +41,44 @@ export const useActivityStore = defineStore('activity', {
}, },
actions: { actions: {
/**
* Charge la liste des jobs selon les filtres actuels
* @param {number} page - Numéro de page optionnel
*/
async loadJobs(page = null) { async loadJobs(page = null) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
const jobCollection = await jobRepository.getJobs({ const options = {
page: page || this.currentPage, page: page || this.currentPage,
limit: this.limit, limit: this.limit,
sortBy: this.sortBy, sortBy: this.filter.sortBy,
sortOrder: this.sortOrder, sortOrder: this.filter.sortOrder,
status: ACTIVE_STATUSES, status: this.filter.status
}); };
const jobCollection = await jobRepository.getJobs(options);
// Mettre à jour les données
this.jobs = jobCollection.items; this.jobs = jobCollection.items;
this.currentPage = jobCollection.page; this.currentPage = jobCollection.page;
this.total = jobCollection.total; this.total = jobCollection.total;
this.hasNextPage = jobCollection.hasNextPage; this.hasNextPage = jobCollection.hasNextPage;
this.hasPreviousPage = jobCollection.hasPreviousPage; this.hasPreviousPage = jobCollection.hasPreviousPage;
// Calculer le nombre total de pages
this.totalPages = Math.ceil(this.total / this.limit); this.totalPages = Math.ceil(this.total / this.limit);
console.log('Store updated with:', {
jobs: this.jobs.length,
currentPage: this.currentPage,
total: this.total,
limit: this.limit,
totalPages: this.totalPages,
hasNextPage: this.hasNextPage,
hasPreviousPage: this.hasPreviousPage
});
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
console.error('Error loading jobs:', error); console.error('Error loading jobs:', error);
@@ -66,6 +87,10 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
/**
* Va à une page spécifique
* @param {number} page
*/
async goToPage(page) { async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) { if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page; this.currentPage = page;
@@ -73,26 +98,39 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
async updateSort(sortBy, sortOrder) { /**
this.sortBy = sortBy; * Met à jour les filtres et recharge la liste
this.sortOrder = sortOrder; * @param {Object} filter
this.currentPage = 1; */
async updateFilter(filter) {
this.filter = { ...this.filter, ...filter };
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
await this.loadJobs(1); await this.loadJobs(1);
}, },
/**
* Met à jour la limite par page
* @param {number} limit
*/
async updateLimit(limit) { async updateLimit(limit) {
this.limit = limit; this.limit = limit;
this.currentPage = 1; this.currentPage = 1; // Retourner à la première page
await this.loadJobs(1); await this.loadJobs(1);
}, },
/**
* Supprime un job par son ID
* @param {string} id
*/
async deleteJob(id) { async deleteJob(id) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
await jobRepository.deleteJob(id); await jobRepository.deleteJob(id);
// Supprimer le job de la liste locale
this.jobs = this.jobs.filter(job => job.id !== id); this.jobs = this.jobs.filter(job => job.id !== id);
// Recharger la page courante pour avoir les bons totaux
await this.loadJobs(this.currentPage); await this.loadJobs(this.currentPage);
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
@@ -102,75 +140,17 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
updateJobProgress(jobId, progress) { /**
const job = this.jobs.find(j => j.id === jobId); * Supprime tous les jobs correspondant aux critères
if (job) job.progress = progress; * @param {Object} criteria
}, */
handleJobCreated(data) {
const alreadyExists = this.jobs.some(j => j.id === data.id);
if (alreadyExists) return;
const job = Job.create({
id: data.id,
type: data.type_job,
status: data.status,
createdAt: data.createdAt,
context: data.context,
attempts: data.attempts,
maxAttempts: data.maxAttempts,
});
this.jobs.unshift(job);
this.total += 1;
this.totalPages = Math.ceil(this.total / this.limit);
},
handleJobStatusChange(jobId, newStatus) {
const job = this.jobs.find(j => j.id === jobId);
if (!job) return;
if (newStatus === 'in_progress') {
job.status = 'in_progress';
} else {
setTimeout(() => {
this.jobs = this.jobs.filter(j => j.id !== jobId);
this.total = Math.max(0, this.total - 1);
this.totalPages = Math.ceil(this.total / this.limit);
}, 1500);
}
},
subscribeMercure() {
if (this.mercureEventSource) return;
const url = new URL('/.well-known/mercure', window.location.origin);
url.searchParams.append('topic', 'jobs/activity');
this.mercureEventSource = new EventSource(url.toString());
this.mercureEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job.created') {
this.handleJobCreated(data);
} else if (data.type === 'job.progress_updated') {
this.updateJobProgress(data.jobId, data.progress);
} else if (data.type === 'job.status_changed') {
this.handleJobStatusChange(data.jobId, data.status);
}
};
},
unsubscribeMercure() {
if (this.mercureEventSource) {
this.mercureEventSource.close();
this.mercureEventSource = null;
}
},
async deleteJobs(criteria = {}) { async deleteJobs(criteria = {}) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
const deleted = await jobRepository.deleteJobs(criteria); const deleted = await jobRepository.deleteJobs(criteria);
// Recharger la liste après suppression
await this.loadJobs(this.currentPage); await this.loadJobs(this.currentPage);
return deleted; return deleted;
} catch (error) { } catch (error) {
@@ -180,5 +160,26 @@ export const useActivityStore = defineStore('activity', {
this.loading = false; this.loading = false;
} }
}, },
/**
* Supprime tous les jobs terminés
*/
async deleteCompletedJobs() {
return this.deleteJobs({ status: ['COMPLETED'] });
},
/**
* Supprime tous les jobs en erreur
*/
async deleteFailedJobs() {
return this.deleteJobs({ status: ['ERROR'] });
},
/**
* Supprime tous les jobs
*/
async deleteAllJobs() {
return this.deleteJobs({});
}
} }
}); });

View File

@@ -10,8 +10,6 @@ export class Job {
failureReason = null, failureReason = null,
createdAt = new Date().toISOString(), createdAt = new Date().toISOString(),
updatedAt = new Date().toISOString(), updatedAt = new Date().toISOString(),
startedAt = null,
completedAt = null,
attempts = 0, attempts = 0,
maxAttempts = 1, maxAttempts = 1,
context = {} context = {}
@@ -25,8 +23,6 @@ export class Job {
this.error = failureReason ?? error; this.error = failureReason ?? error;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
this.startedAt = startedAt;
this.completedAt = completedAt;
this.attempts = attempts; this.attempts = attempts;
this.maxAttempts = maxAttempts; this.maxAttempts = maxAttempts;
this.context = context; this.context = context;

View File

@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
* @returns {Promise<JobCollection>} Collection de jobs * @returns {Promise<JobCollection>} Collection de jobs
*/ */
async getJobs(options = {}) { async getJobs(options = {}) {
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = options; const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
try { try {
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`; let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
@@ -23,11 +23,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
url += `&status=${status.join(',')}`; url += `&status=${status.join(',')}`;
} }
// Ajouter le filtre de type si fourni
if (type) {
url += `&type=${type}`;
}
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {

View File

@@ -1,153 +1,169 @@
<template> <template>
<div class="flex flex-col h-full"> <div class="overflow-y-auto h-full">
<Toolbar :config="toolbarConfig" /> <Toolbar :config="toolbarConfig" class="mb-6" />
<div class="overflow-y-auto flex-1"> <div v-if="activityStore.loading" class="flex justify-center py-8">
<!-- Loading --> <div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
<div v-if="loading" class="flex justify-center py-12"> </div>
<div class="animate-spin h-10 w-10 border-b-2 border-indigo-500 rounded-full"></div>
</div>
<!-- Error --> <div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
<div v-else-if="activityStore.error" class="px-6 py-8"> <p>{{ activityStore.error }}</p>
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4"> </div>
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
</div>
</div>
<!-- Content --> <div v-else class="container mx-auto p-2">
<section v-else class="border-t border-gray-200 dark:border-gray-700"> <div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<!-- Empty --> <div class="overflow-x-auto">
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500"> <table class="min-w-full bg-white dark:bg-gray-800">
<ClockIcon class="w-12 h-12 mb-3" />
<p class="text-base">Aucun job en cours ou en attente.</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto">
<table class="min-w-full">
<thead> <thead>
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider"> <tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
<th class="w-2/11 py-3 px-6 text-left">Type</th> <th class="w-1/12 py-3 px-4 text-left">
<th class="w-2/11 py-3 px-4 text-left">Statut</th> <input
<th class="w-3/11 py-3 px-4 text-left">Informations</th> type="checkbox"
<th class="w-3/11 py-3 px-4 text-left">Progression</th> class="form-checkbox h-5 w-5 text-green-600"
<th class="w-1/11 py-3 px-4 text-left">Actions</th> @change="toggleSelectAll" />
</th>
<th class="w-2/12 py-3 px-4 text-left">Type</th>
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300"> <tbody class="text-gray-700 dark:text-gray-300">
<JobItem <template v-if="activityStore.jobs.length === 0">
v-for="job in activityStore.jobs" <tr>
:key="job.id" <td colspan="6" class="py-8 px-4 text-center text-gray-500">
:job="job" <div class="flex flex-col items-center">
@delete="deleteJob" /> <ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
</div>
</td>
</tr>
</template>
<template v-else>
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<Pagination <Pagination
v-if="total > activityStore.limit" v-if="activityStore.total > activityStore.limit"
:current-page="activityStore.currentPage" :current-page="activityStore.currentPage"
:total-pages="activityStore.totalPages" :total-pages="activityStore.totalPages"
:total="total" :total="activityStore.total"
:limit="activityStore.limit" :limit="activityStore.limit"
:has-next-page="activityStore.hasNextPage" :has-next-page="activityStore.hasNextPage"
:has-previous-page="activityStore.hasPreviousPage" :has-previous-page="activityStore.hasPreviousPage"
@page-change="changePage" /> @page-change="changePage" />
</section> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline'; import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia'; import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue'; import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useActivityStore } from '../../application/store/activityStore'; import { useActivityStore } from '../../application/store/activityStore';
import JobItem from '../components/JobItem.vue'; import JobItem from '../components/JobItem.vue';
const activityStore = useActivityStore(); const activityStore = useActivityStore();
const selectedAll = ref(false);
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore); // Statuts disponibles pour le filtre
const statusOptions = [
{ value: ['pending', 'in_progress'], label: 'Actifs' },
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
{ value: ['completed'], label: 'Terminés' },
{ value: ['failed'], label: 'En erreur' },
{ value: ['pending'], label: 'En attente' },
{ value: ['in_progress'], label: 'En cours' }
];
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order; // Index du statut actif (par défaut "Actifs")
const activeStatusIndex = ref(0);
const toolbarConfig = computed(() => ({ // Configuration de la toolbar réactive
leftSection: [ const toolbarConfig = computed(() => ({
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' }, leftSection: [
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' }, {
], icon: FunnelIcon,
rightSection: [ type: 'dropdown',
{ label: statusOptions[activeStatusIndex.value].label,
type: 'dropdown', active: false,
icon: BarsArrowDownIcon, items: statusOptions.map((option, index) => ({
label: 'Trier', label: option.label,
items: [ isSelected: index === activeStatusIndex.value,
{ onClick: () => setStatusFilter(index)
label: 'Plus récent', }))
isSelected: isSortSelected('createdAt', 'DESC'), }
onClick: () => activityStore.updateSort('createdAt', 'DESC'), ],
}, rightSection: [
{ {
label: 'Plus ancien', icon: ArrowPathIcon,
isSelected: isSortSelected('createdAt', 'ASC'), type: 'button',
onClick: () => activityStore.updateSort('createdAt', 'ASC'), label: 'Rafraîchir',
}, onClick: refreshJobs
{ },
label: 'Par type', {
isSelected: isSortSelected('type', 'ASC'), icon: TrashIcon,
onClick: () => activityStore.updateSort('type', 'ASC'), type: 'button',
}, label: 'Supprimer visibles',
{ onClick: deleteVisibleJobs
label: 'Par statut', }
isSelected: isSortSelected('status', 'ASC'), ]
onClick: () => activityStore.updateSort('status', 'ASC'), }));
},
],
},
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: loading.value,
onClick: () => activityStore.loadJobs(),
},
{
type: 'button',
icon: TrashIcon,
label: 'Supprimer visibles',
disabled: loading.value || total.value === 0,
onClick: deleteVisibleJobs,
},
],
}));
onMounted(() => { onMounted(() => {
activityStore.loadJobs(); loadJobs();
activityStore.subscribeMercure(); });
});
onUnmounted(() => { function loadJobs() {
activityStore.unsubscribeMercure(); activityStore.loadJobs();
});
function changePage(page) {
activityStore.goToPage(page);
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
} }
}
function deleteVisibleJobs() { function refreshJobs() {
if (activityStore.jobs.length === 0) return; loadJobs();
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) { }
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
function changePage(page) {
activityStore.goToPage(page);
}
function toggleSelectAll() {
selectedAll.value = !selectedAll.value;
// La logique pour sélectionner tous les jobs serait ajoutée ici
}
function setStatusFilter(index) {
if (index >= 0 && index < statusOptions.length) {
activeStatusIndex.value = index;
activityStore.updateFilter({ status: statusOptions[index].value });
}
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function deleteVisibleJobs() {
if (activityStore.jobs.length === 0) {
return;
}
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
activityStore.deleteJobs({ status: activityStore.filter.status });
}
} }
}
</script> </script>

View File

@@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue';
}); });
}; };
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
const getPlaceholderHeight = (page) => { const getPlaceholderHeight = (page) => {
const dims = page?.dimensions; const dims = page?.dimensions;
if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom); if (!dims?.width || !dims?.height) return 800;
const displayWidth = windowWidth.value < 1200 const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95) ? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200); : Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth * props.zoom); return Math.round((dims.height / dims.width) * displayWidth);
}; };
const setupObservers = () => { const setupObservers = () => {

View File

@@ -87,9 +87,13 @@ import { useReaderStore } from '../../application/store/readerStore';
const store = useReaderStore(); const store = useReaderStore();
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll) // En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
const containerStyle = computed(() => { const containerStyle = computed(() => {
return { zoom: props.zoom }; if (store.readingMode === 'single') {
return { zoom: props.zoom };
}
return { transform: `scale(${props.zoom})` };
}); });
const imageRef = ref(null); const imageRef = ref(null);

View File

@@ -28,10 +28,6 @@ export const useContentSourceStore = defineStore('contentSource', {
// Health check state // Health check state
checkingHealth: false, checkingHealth: false,
checkHealthError: null, checkHealthError: null,
// Delete state
deleting: false,
deleteError: null,
}), }),
getters: { getters: {
@@ -176,28 +172,6 @@ export const useContentSourceStore = defineStore('contentSource', {
} }
}, },
// Delete a source
async deleteSource(id) {
if (this.deleting) return;
this.deleting = true;
this.deleteError = null;
try {
await contentSourceRepository.delete(id);
this.sources = this.sources.filter(source => source.id !== id);
if (this.currentSource && this.currentSource.id === id) {
this.currentSource = null;
}
} catch (error) {
this.deleteError = error.message;
console.error('Erreur lors de la suppression de la source:', error);
throw error;
} finally {
this.deleting = false;
}
},
// Clear current source // Clear current source
clearCurrentSource() { clearCurrentSource() {
this.currentSource = null; this.currentSource = null;

View File

@@ -93,17 +93,6 @@ export class ApiContentSourceRepository {
} }
} }
/**
* Supprime une source de contenu
*/
async delete(id) {
try {
await this.apiClient.delete(`/content-sources/${id}`);
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la suppression de la source');
}
}
/** /**
* Teste une configuration de scraper * Teste une configuration de scraper
*/ */

View File

@@ -1,123 +0,0 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
Supprimer la source de contenu
</DialogTitle>
</div>
<!-- Error state -->
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{{ error }}
</div>
<!-- Warning message -->
<div class="mb-6">
<div class="flex items-center mb-4">
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Êtes-vous sûr de vouloir supprimer la source <strong>{{ source?.baseUrl }}</strong> ?
</p>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
<div class="flex">
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
Attention
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
<p>Cette source ne pourra plus être utilisée pour le scraping des chapitres.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="closeModal"
:disabled="isLoading"
>
Annuler
</button>
<button
type="button"
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="confirmDelete"
:disabled="isLoading"
>
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
source: {
type: Object,
default: null
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['close', 'confirm']);
const closeModal = () => {
emit('close');
};
const confirmDelete = () => {
emit('confirm');
};
</script>

View File

@@ -176,15 +176,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Modal -->
<ContentSourceDeleteModal
:is-open="isDeleteModalOpen"
:source="currentSource"
:is-loading="isDeleting"
:error="deleteError"
@close="isDeleteModalOpen = false"
@confirm="confirmDeleteSource" />
</div> </div>
</template> </template>
@@ -194,7 +185,6 @@ import {
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
PencilSquareIcon, PencilSquareIcon,
TrashIcon,
XCircleIcon, XCircleIcon,
XMarkIcon XMarkIcon
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
@@ -204,7 +194,6 @@ import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore'; import { useContentSourceStore } from '../../application/store/contentSourceStore';
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository'; import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
import ContentSourceDeleteModal from '../components/ContentSourceDeleteModal.vue';
import ContentSourceForm from '../components/ContentSourceForm.vue'; import ContentSourceForm from '../components/ContentSourceForm.vue';
const route = useRoute(); const route = useRoute();
@@ -228,9 +217,6 @@ const showTestResults = ref(false);
const showSuccessMessage = ref(false); const showSuccessMessage = ref(false);
const testResults = ref({}); const testResults = ref({});
const testingConfiguration = ref(false); const testingConfiguration = ref(false);
const isDeleteModalOpen = ref(false);
const isDeleting = ref(false);
const deleteError = ref(null);
const isEditing = computed(() => !!route.params.id); const isEditing = computed(() => !!route.params.id);
@@ -252,7 +238,6 @@ const toolbarConfig = computed(() => ({
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' }, { type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
], ],
rightSection: [ rightSection: [
...(isEditing.value ? [{ type: 'button', icon: TrashIcon, label: 'Supprimer', onClick: () => { isDeleteModalOpen.value = true; }, class: 'text-red-600 hover:text-red-700' }, { type: 'divider' }] : []),
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value }, { type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
], ],
})); }));
@@ -343,21 +328,6 @@ const handleImageLoad = (event) => {
event.target.style.display = 'block'; event.target.style.display = 'block';
}; };
const confirmDeleteSource = async () => {
isDeleting.value = true;
deleteError.value = null;
try {
await contentSourceStore.deleteSource(route.params.id);
isDeleteModalOpen.value = false;
await router.push({ name: 'scrapper-configurations' });
} catch (error) {
deleteError.value = error.message;
} finally {
isDeleting.value = false;
}
};
const formatErrorType = (type) => { const formatErrorType = (type) => {
const typeMap = { const typeMap = {
'selector_error': 'Erreur sélecteur', 'selector_error': 'Erreur sélecteur',

View File

@@ -1,110 +0,0 @@
import { defineStore } from 'pinia';
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository();
// Statuts disponibles par filtre
const STATUS_MAP = {
failed: ['failed'],
completed: ['completed'],
all: ['failed', 'completed'],
};
export const useLogsStore = defineStore('logs', {
state: () => ({
logs: [],
loading: false,
error: null,
currentPage: 1,
totalPages: 0,
total: 0,
limit: 50,
hasNextPage: false,
hasPreviousPage: false,
sortBy: 'createdAt',
sortOrder: 'DESC',
statusFilter: 'failed', // 'failed' | 'completed' | 'all'
}),
getters: {
isLoading: state => state.loading,
hasError: state => !!state.error,
},
actions: {
async loadLogs(page = null) {
this.loading = true;
this.error = null;
try {
const collection = await jobRepository.getJobs({
page: page || this.currentPage,
limit: this.limit,
sortBy: this.sortBy,
sortOrder: this.sortOrder,
status: STATUS_MAP[this.statusFilter],
type: 'scraping_job',
});
this.logs = collection.items;
this.currentPage = collection.page;
this.total = collection.total;
this.hasNextPage = collection.hasNextPage;
this.hasPreviousPage = collection.hasPreviousPage;
this.totalPages = Math.ceil(this.total / this.limit);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
await this.loadLogs(page);
}
},
async updateSort(sortBy, sortOrder) {
this.sortBy = sortBy;
this.sortOrder = sortOrder;
this.currentPage = 1;
await this.loadLogs(1);
},
async setStatusFilter(filter) {
this.statusFilter = filter;
this.currentPage = 1;
await this.loadLogs(1);
},
async deleteLog(id) {
try {
await jobRepository.deleteJob(id);
this.logs = this.logs.filter(log => log.id !== id);
this.total = Math.max(0, this.total - 1);
this.totalPages = Math.ceil(this.total / this.limit);
} catch (error) {
this.error = error.message;
}
},
async deleteAllLogs() {
this.loading = true;
this.error = null;
try {
await jobRepository.deleteJobs({
status: STATUS_MAP[this.statusFilter].join(','),
type: 'scraping_job',
});
await this.loadLogs(1);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
},
});

View File

@@ -1,26 +0,0 @@
import { defineStore } from 'pinia';
import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
const statusRepository = new ApiStatusRepository();
export const useStatusStore = defineStore('system-status', {
state: () => ({
status: null,
loading: false,
error: null,
}),
actions: {
async loadStatus() {
this.loading = true;
this.error = null;
try {
this.status = await statusRepository.getStatus();
} catch (e) {
this.error = e.message ?? 'Erreur lors du chargement du statut système';
} finally {
this.loading = false;
}
},
},
});

View File

@@ -1,13 +0,0 @@
export class ApiStatusRepository {
async getStatus() {
const response = await fetch('/api/system/status', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status}`);
}
return response.json();
}
}

View File

@@ -1,36 +0,0 @@
<template>
<StatusCard title="Chapitres" :icon="DocumentTextIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalChapters }}</span>
<span class="text-sm text-gray-500">total</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.downloadedChapters }} téléchargés</span>
<span>{{ downloadedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 rounded-full transition-all"
:style="{ width: downloadedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">{{ status.pendingChapters }} en attente</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { DocumentTextIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const downloadedPercent = computed(() => {
if (!props.status.totalChapters) return 0;
return Math.round((props.status.downloadedChapters / props.status.totalChapters) * 100);
});
</script>

View File

@@ -1,104 +0,0 @@
<template>
<StatusCard title="Jobs" :icon="CpuChipIcon">
<!-- Onglets -->
<div class="flex gap-1 mb-3 border-b border-gray-200 dark:border-gray-700">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === tab.key
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'">
{{ tab.label }}
</button>
</div>
<!-- Contenu -->
<template v-if="activeTab === 'global'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobs" />
<Metric label="En cours" :value="status.inProgressJobs" color="blue" />
<Metric label="Terminés" :value="status.completedJobs" color="green" />
<Metric label="En attente" :value="status.pendingJobs" color="yellow" />
<Metric label="Échoués" :value="status.failedJobs" color="red" />
</div>
</template>
<template v-else-if="activeTab === '24h'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast24h" />
<Metric label="Terminés" :value="status.completedJobsLast24h" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast24h" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast24h)">
{{ status.successRateLast24h }}%
</span>
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast7d" />
<Metric label="Terminés" :value="status.completedJobsLast7d" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast7d" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast7d)">
{{ status.successRateLast7d }}%
</span>
</div>
</div>
</template>
</StatusCard>
</template>
<script setup>
import { ref } from 'vue';
import { CpuChipIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
defineProps({
status: {
type: Object,
required: true,
},
});
const activeTab = ref('global');
const tabs = [
{ key: 'global', label: 'Global' },
{ key: '24h', label: '24h' },
{ key: '7j', label: '7 jours' },
];
function rateColor(rate) {
if (rate >= 80) return 'text-green-600 dark:text-green-400';
if (rate >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
}
const Metric = {
props: {
label: String,
value: Number,
color: { type: String, default: 'gray' },
},
template: `
<div>
<p class="text-xs text-gray-500">{{ label }}</p>
<p class="text-lg font-semibold"
:class="{
'text-gray-900 dark:text-white': color === 'gray',
'text-green-600 dark:text-green-400': color === 'green',
'text-red-600 dark:text-red-400': color === 'red',
'text-yellow-600 dark:text-yellow-400': color === 'yellow',
'text-blue-600 dark:text-blue-400': color === 'blue',
}">{{ value }}</p>
</div>
`,
};
</script>

View File

@@ -1,131 +0,0 @@
<template>
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
<!-- Ligne 1 : Titre manga + chapitre + badge statut + date + bouton supprimer -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-baseline gap-2 min-w-0">
<span class="font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ log.context?.mangaTitle ?? 'Manga inconnu' }}
</span>
<span class="text-gray-400 dark:text-gray-500 text-sm shrink-0"></span>
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">
Chapitre {{ log.context?.chapterNumber ?? '?' }}
</span>
<span
:class="[
'px-1.5 py-0.5 text-xs font-medium shrink-0',
log.status === 'completed'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
]">
{{ log.status === 'completed' ? 'Terminé' : 'Échec' }}
</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ formattedDate }}</span>
<button
@click="$emit('delete', log.id)"
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
title="Supprimer ce log">
<TrashIcon class="w-4 h-4" />
</button>
</div>
</div>
<!-- Ligne 2 : Source + slug + durée -->
<div class="flex items-center justify-between mt-1 gap-4">
<div class="flex items-center gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
<!-- Domaine de la source (lien vers la page d'édition) -->
<RouterLink
v-if="source"
:to="{ name: 'scrapper-edit', params: { id: source.id } }"
class="flex items-center gap-1 hover:text-blue-500 dark:hover:text-blue-400 transition-colors shrink-0">
<GlobeAltIcon class="w-3.5 h-3.5" />
<span class="font-mono">{{ cleanDomain }}</span>
</RouterLink>
<span v-else class="font-mono shrink-0">
ID {{ log.context?.sourceId ?? '-' }}
</span>
<!-- Badge type de scraping -->
<span
v-if="source?.scrapingType"
:class="[
'px-1.5 py-0.5 text-xs font-medium shrink-0',
source.scrapingType === 'Javascript'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
]">
{{ source.scrapingType }}
</span>
<!-- Slug utilisé -->
<span v-if="log.context?.slug" class="truncate text-gray-400 dark:text-gray-500">
slug : <span class="font-mono">{{ log.context.slug }}</span>
</span>
</div>
<span v-if="duration !== null" class="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{{ duration }}
</span>
</div>
<!-- Ligne 3 : Message d'erreur -->
<div v-if="log.error" class="mt-2">
<p
:class="[
'text-sm font-mono text-red-600 dark:text-red-400',
!expanded && isLong ? 'line-clamp-1' : ''
]">
{{ log.error }}
</p>
<button
v-if="isLong"
@click="expanded = !expanded"
class="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
{{ expanded ? 'voir moins' : 'voir plus' }}
</button>
</div>
</div>
</template>
<script setup>
import { GlobeAltIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
const props = defineProps({
log: {
type: Object,
required: true,
},
source: {
type: Object,
default: null,
},
});
defineEmits(['delete']);
const expanded = ref(false);
const isLong = computed(() => props.log.error && props.log.error.length > 120);
const cleanDomain = computed(() => {
if (!props.source?.baseUrl) return null;
return props.source.baseUrl.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
});
const formattedDate = computed(() => {
if (!props.log.createdAt) return '';
const d = new Date(props.log.createdAt);
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
});
const duration = computed(() => {
if (!props.log.startedAt || !props.log.completedAt) return null;
const ms = new Date(props.log.completedAt) - new Date(props.log.startedAt);
if (ms < 0) return null;
return `${(ms / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })}s`;
});
</script>

View File

@@ -1,33 +0,0 @@
<template>
<StatusCard title="Mangas" :icon="BookOpenIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalMangas }}</span>
<span class="text-sm text-gray-500">total</span>
<span class="ml-auto text-sm text-blue-600 dark:text-blue-400">{{ status.monitoredMangas }} suivis</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, label) in status.mangasByStatus"
:key="label"
class="px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400">
{{ label }}: {{ count }}
</span>
<span v-if="!hasStatuses" class="text-xs text-gray-400">Aucun statut disponible</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { BookOpenIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasStatuses = computed(() => Object.keys(props.status.mangasByStatus ?? {}).length > 0);
</script>

View File

@@ -1,41 +0,0 @@
<template>
<StatusCard title="Sources" :icon="GlobeAltIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalSources }}</span>
<span class="text-sm text-gray-500">sources configurées</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, health) in status.sourcesByHealth"
:key="health"
class="px-2 py-0.5 text-xs rounded-full"
:class="healthBadgeClass(health)">
{{ health }}: {{ count }}
</span>
<span v-if="!hasSources" class="text-xs text-gray-400">Aucune source</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasSources = computed(() => Object.keys(props.status.sourcesByHealth ?? {}).length > 0);
function healthBadgeClass(health) {
switch (health) {
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'unhealthy': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
}
</script>

View File

@@ -1,22 +0,0 @@
<template>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<component :is="icon" v-if="icon" class="w-5 h-5 text-blue-500 shrink-0" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ title }}</h3>
</div>
<slot />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
icon: {
type: [Object, Function],
default: null,
},
});
</script>

View File

@@ -1,40 +0,0 @@
<template>
<StatusCard title="Stockage" :icon="CircleStackIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.storageUsedHuman }}</span>
<span class="text-sm text-gray-500">utilisés</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.storageFreeHuman }} libres</span>
<span>{{ usedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="usedPercent > 90 ? 'bg-red-500' : 'bg-blue-500'"
:style="{ width: usedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">Total : {{ status.storageTotalHuman }}</p>
<p class="mt-1 text-xs text-gray-400 truncate" :title="status.storagePath">{{ status.storagePath }}</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const diskUsedBytes = computed(() => props.status.storageTotalBytes - props.status.storageFreeBytes);
const usedPercent = computed(() => {
if (!props.status.storageTotalBytes) return 0;
return Math.round((diskUsedBytes.value / props.status.storageTotalBytes) * 100);
});
</script>

View File

@@ -1,32 +0,0 @@
<template>
<StatusCard title="Informations système" :icon="ServerIcon">
<dl class="space-y-2">
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Version PHP</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ status.phpVersion }}</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Généré le</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ formattedDate }}</dd>
</div>
</dl>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { ServerIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const formattedDate = computed(() => {
if (!props.status.generatedAt) return '';
return new Date(props.status.generatedAt).toLocaleString('fr-FR');
});
</script>

View File

@@ -1,165 +0,0 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<section class="border-t border-gray-200 dark:border-gray-700">
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center py-12">
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
</div>
<!-- Error -->
<div v-else-if="hasError" class="px-6 py-8">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
<div class="flex items-center">
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
@click="logsStore.loadLogs()"
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
Réessayer
</button>
</div>
</div>
<!-- Empty -->
<div v-else-if="!isLoading && logs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
<ExclamationCircleIcon class="w-12 h-12 mb-3" />
<p class="text-base">Aucune erreur de scraping</p>
</div>
<!-- List -->
<template v-else>
<LogItem
v-for="log in logs"
:key="log.id"
:log="log"
:source="getSource(log)"
@delete="handleDelete" />
</template>
</section>
<!-- Pagination -->
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
:total-pages="totalPages"
:total="total"
:limit="limit"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@page-change="logsStore.goToPage" />
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { BarsArrowDownIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import { useContentSourceStore } from '../../../setting/application/store/contentSourceStore';
import { useLogsStore } from '../../application/store/logsStore';
import LogItem from '../components/LogItem.vue';
const logsStore = useLogsStore();
const contentSourceStore = useContentSourceStore();
const { sources } = storeToRefs(contentSourceStore);
const {
logs,
loading: isLoading,
error,
currentPage,
totalPages,
total,
limit,
hasNextPage,
hasPreviousPage,
sortBy,
sortOrder,
statusFilter,
} = storeToRefs(logsStore);
const hasError = computed(() => !!error.value);
onMounted(() => {
logsStore.loadLogs();
contentSourceStore.loadSources();
});
function getSource(log) {
const sourceId = log.context?.sourceId;
if (!sourceId) return null;
// eslint-disable-next-line eqeqeq
return sources.value.find(s => s.id == sourceId) ?? null;
}
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
const STATUS_FILTERS = [
{ key: 'failed', label: 'Échecs' },
{ key: 'completed', label: 'Terminés' },
{ key: 'all', label: 'Tous' },
];
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
],
rightSection: [
...STATUS_FILTERS.map(f => ({
type: 'button',
label: f.label,
active: statusFilter.value === f.key,
onClick: () => logsStore.setStatusFilter(f.key),
})),
{ type: 'divider' },
{
type: 'dropdown',
icon: BarsArrowDownIcon,
label: 'Trier',
items: [
{
label: 'Plus récent',
isSelected: isSortSelected('createdAt', 'DESC'),
onClick: () => logsStore.updateSort('createdAt', 'DESC'),
},
{
label: 'Plus ancien',
isSelected: isSortSelected('createdAt', 'ASC'),
onClick: () => logsStore.updateSort('createdAt', 'ASC'),
},
],
},
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: isLoading.value,
onClick: () => logsStore.loadLogs(),
},
{
type: 'button',
icon: TrashIcon,
label: 'Tout supprimer',
disabled: isLoading.value || total.value === 0,
onClick: handleDeleteAll,
},
],
}));
async function handleDelete(id) {
await logsStore.deleteLog(id);
}
async function handleDeleteAll() {
if (!confirm('Supprimer tous les logs d\'erreur ? Cette action est irréversible.')) return;
await logsStore.deleteAllLogs();
}
</script>

View File

@@ -1,71 +0,0 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
</div>
<!-- Error -->
<div v-else-if="error" class="px-6 py-8">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 rounded">
<div class="flex items-center">
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
@click="statusStore.loadStatus()"
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded">
Réessayer
</button>
</div>
</div>
<!-- Données -->
<div v-else-if="status" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
<MangasStatusCard :status="status" />
<ChaptersStatusCard :status="status" />
<JobsStatusCard :status="status" />
<StorageStatusCard :status="status" />
<SourcesStatusCard :status="status" />
<SystemInfoCard :status="status" />
</div>
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, ExclamationCircleIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useStatusStore } from '../../application/store/statusStore';
import ChaptersStatusCard from '../components/ChaptersStatusCard.vue';
import JobsStatusCard from '../components/JobsStatusCard.vue';
import MangasStatusCard from '../components/MangasStatusCard.vue';
import SourcesStatusCard from '../components/SourcesStatusCard.vue';
import StorageStatusCard from '../components/StorageStatusCard.vue';
import SystemInfoCard from '../components/SystemInfoCard.vue';
const statusStore = useStatusStore();
const { status, loading, error } = storeToRefs(statusStore);
onMounted(() => statusStore.loadStatus());
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Statut système', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: loading.value,
onClick: () => statusStore.loadStatus(),
},
],
}));
</script>

View File

@@ -10,10 +10,24 @@ import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue'; import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue'; import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue'; import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
import Layout from '../shared/components/layout/Layout.vue'; import Layout from '../shared/components/layout/Layout.vue';
// Placeholder component for new routes
const PlaceholderComponent = {
props: {
title: {
type: String,
required: true
}
},
template: `
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
</div>
`
};
const routes = [ const routes = [
{ {
path: '/', path: '/',
@@ -51,6 +65,13 @@ const routes = [
name: 'import', name: 'import',
component: NewImportPage component: NewImportPage
}, },
// Pages placeholder avec chargement différé
{
path: '/manga/import',
name: 'manga-import',
component: PlaceholderComponent,
props: { title: 'Import de bibliothèque' }
},
{ {
path: '/manga/discover', path: '/manga/discover',
name: 'discover', name: 'discover',
@@ -69,7 +90,21 @@ const routes = [
// Paramètres // Paramètres
{ {
path: '/settings', path: '/settings',
redirect: '/settings/scrappers', name: 'settings',
component: PlaceholderComponent,
props: { title: 'Paramètres' }
},
{
path: '/settings/general',
name: 'settings-general',
component: PlaceholderComponent,
props: { title: 'Paramètres généraux' }
},
{
path: '/settings/folders',
name: 'settings-folders',
component: PlaceholderComponent,
props: { title: 'Gestion des dossiers' }
}, },
{ {
path: '/settings/scrappers', path: '/settings/scrappers',
@@ -94,18 +129,34 @@ const routes = [
// Système // Système
{ {
path: '/system', path: '/system',
redirect: '/system/status', name: 'system',
component: PlaceholderComponent,
props: { title: 'Système' }
}, },
{ {
path: '/system/status', path: '/system/status',
name: 'system-status', name: 'system-status',
component: StatusPage, component: PlaceholderComponent,
props: { title: 'Status du système' }
},
{
path: '/system/backup',
name: 'system-backup',
component: PlaceholderComponent,
props: { title: 'Sauvegarde' }
}, },
{ {
path: '/system/logs', path: '/system/logs',
name: 'system-logs', name: 'system-logs',
component: LogsPage, component: PlaceholderComponent,
props: { title: 'Journaux système' }
}, },
{
path: '/system/updates',
name: 'system-updates',
component: PlaceholderComponent,
props: { title: 'Mises à jour' }
}
] ]
} }
]; ];

View File

@@ -78,9 +78,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{ {
icon: Cog6ToothIcon, icon: Cog6ToothIcon,
text: 'Paramètres', text: 'Paramètres',
to: '/settings/scrappers', to: '/settings',
id: 'settings', id: 'settings',
subItems: [ subItems: [
{ icon: null, text: 'Général', to: '/settings/general' },
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' }, { icon: null, text: 'Scrappers', to: '/settings/scrappers' },
{ icon: null, text: 'UI', to: '/settings/ui' } { icon: null, text: 'UI', to: '/settings/ui' }
] ]
@@ -88,11 +90,13 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{ {
icon: ComputerDesktopIcon, icon: ComputerDesktopIcon,
text: 'Système', text: 'Système',
to: '/system/status', to: '/system',
id: 'system', id: 'system',
subItems: [ subItems: [
{ icon: null, text: 'Status', to: '/system/status' }, { icon: null, text: 'Status', to: '/system/status' },
{ icon: null, text: 'Backup', to: '/system/backup' },
{ icon: null, text: 'Logs', to: '/system/logs' }, { icon: null, text: 'Logs', to: '/system/logs' },
{ icon: null, text: 'Updates', to: '/system/updates' }
] ]
} }
]; ];

View File

@@ -34,6 +34,5 @@ api_platform:
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
patch_formats: patch_formats:
json: ['application/merge-patch+json'] json: ['application/merge-patch+json']

View File

@@ -17,6 +17,7 @@ framework:
command.bus: command.bus:
middleware: middleware:
- validation - validation
- doctrine_transaction
event.bus: event.bus:
default_middleware: allow_no_handlers default_middleware: allow_no_handlers

View File

@@ -200,12 +200,3 @@ services:
# Import Domain API Platform Services # Import Domain API Platform Services
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~ App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~ 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
App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler:
arguments:
$mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%'
$imagesStoragePath: '%kernel.project_dir%/public/images'

View File

@@ -5,8 +5,7 @@ namespace App\Domain\Scraping\Application\Command;
readonly class ScrapeChapter readonly class ScrapeChapter
{ {
public function __construct( public function __construct(
public string $chapterId, public string $chapterId
public string $jobId
) { ) {
} }
} }

View File

@@ -13,11 +13,14 @@ use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\Source; use App\Domain\Scraping\Domain\Model\Source;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest; use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory; use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Doctrine\ORM\EntityManagerInterface;
readonly class ScrapeChapterHandler readonly class ScrapeChapterHandler
{ {
@@ -30,92 +33,151 @@ readonly class ScrapeChapterHandler
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private SourceRepositoryInterface $sourceRepository, private SourceRepositoryInterface $sourceRepository,
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private EntityManagerInterface $entityManager
) { ) {
} }
public function handle(ScrapeChapter $command): void public function handle(ScrapeChapter $command): void
{ {
/** @var Chapter $chapter */ $job = null;
$chapter = $this->chapterRepository->getById($command->chapterId); try {
$manga = $this->mangaRepository->getById($chapter->mangaId); // 1. Récupération du chapitre
/**@var Chapter $chapter */
$chapter = $this->chapterRepository->getById($command->chapterId);
if (!$chapter) {
throw new \InvalidArgumentException("Chapter not found with ID: {$command->chapterId}");
}
$job = $this->jobRepository->get($command->jobId); // 2. Récupération du manga
$job->context['chapterId'] = $command->chapterId; $manga = $this->mangaRepository->getById($chapter->mangaId);
$job->context['mangaTitle'] = $manga->getTitle(); if (!$manga) {
$job->start(); throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
$this->jobRepository->save($job); }
$this->eventBus->dispatch(new ChapterScrapingStarted($job->id, $manga->getTitle(), $chapter->chapterNumber)); // 3. Dispatch de l'événement de démarrage
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
$sources = $this->getSourcesToTry($manga); // 4. Détermination des sources à utiliser
$slugsToTry = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs()); $sources = $this->getSourcesToTry($manga);
$success = false; if (empty($sources)) {
$lastException = null; throw new \InvalidArgumentException("No sources available for scraping");
}
foreach ($sources as $source) { // 5. Essai de scraping sur chaque source jusqu'à succès
foreach ($slugsToTry as $slug) { $success = false;
try { $lastException = null;
$job->context['sourceId'] = $source->getId()->getValue();
foreach ($sources as $source) {
// Préparer la liste des slugs à essayer : slug principal + slugs alternatifs
$slugsToTry = array_merge([$manga->getSlug()], $manga->getAlternativeSlugs());
foreach ($slugsToTry as $slug) {
$job = new ScrapingJob(
Uuid::uuid4()->toString(),
$chapter->mangaId,
$chapter->chapterNumber,
$source->getId()->getValue()
);
// Ajout de l'ID du chapitre et du slug dans le contexte du job
$job->context['chapterId'] = $command->chapterId;
$job->context['slug'] = $slug; $job->context['slug'] = $slug;
$job->context['mangaTitle'] = $manga->getTitle();
$job->start();
$this->jobRepository->save($job); $this->jobRepository->save($job);
$scrapingParameters = $source->getScrappingParameters(); try {
$scrapingParameters['chapterNumber'] = $chapter->chapterNumber; $this->entityManager->beginTransaction();
$scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
$scrapingRequest = new ScrapingRequest( // 5. Scraping des URLs avec le slug courant
$scrapingType, $scrapingParameters = $source->getScrappingParameters();
$source->buildChapterUrl($slug, $chapter->chapterNumber), $scrapingParameters['chapterNumber'] = $chapter->chapterNumber;
$scrapingParameters $scrapingType = $scrapingParameters['scrapingType'] ?? 'html';
);
$scraper = $this->scraperFactory->getScraperWithFallback($scrapingType); $scrapingRequest = new ScrapingRequest(
$scrapingResult = $scraper->scrape($scrapingRequest); $scrapingType,
$source->buildChapterUrl($slug, $chapter->chapterNumber),
$scrapingParameters
);
$tempDir = new TempDirectory(); // Sélection du scraper approprié selon le type
$downloadResults = $this->imageDownloader->downloadBatch( $scraper = $this->scraperFactory->getScraperWithFallback($scrapingType);
$scrapingResult->getImageUrls(), $scrapingResult = $scraper->scrape($scrapingRequest);
$tempDir,
$job->id
);
$localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults); // 6. Téléchargement des images
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths); $tempDir = new TempDirectory();
$pageCount = count($downloadResults); $downloadResults = $this->imageDownloader->downloadBatch(
$scrapingResult->getImageUrls(),
$tempDir,
$job->id
);
$job->complete(); // 7. Stockage des images individuelles
$this->jobRepository->save($job); $localPaths = array_map(fn ($r) => $r->getLocalPath(), $downloadResults);
$pagesDirectory = $this->imageStorage->storeChapterImages($command->chapterId, $localPaths);
$pageCount = count($downloadResults);
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount)); $job->complete();
$tempDir->cleanup(); $this->jobRepository->save($job);
$success = true; $this->entityManager->commit();
$this->eventBus->dispatch(new ChapterScraped($job->id, $command->chapterId, $pagesDirectory, $pageCount));
// 8. Nettoyage
$tempDir->cleanup();
// Scraping réussi, pas besoin d'essayer d'autres slugs ni d'autres sources
$success = true;
break;
} catch (\Exception $e) {
dump('EXCEPTION for source ' . $source->getName() . ' with slug ' . $slug . ': ' . $e->getMessage());
$this->entityManager->rollback();
if (isset($job)) {
$job->fail($e->getMessage());
$this->jobRepository->save($job);
}
$lastException = $e;
// Continuer avec le slug suivant pour cette source
}
}
// Si le scraping a réussi avec un des slugs, sortir de la boucle des sources
if ($success) {
break; break;
} catch (\Exception $e) {
$lastException = $e;
} }
} }
if ($success) { // Si toutes les sources ont échoué
break; if (!$success) {
$errorMessage = $lastException ? $lastException->getMessage() : "Failed to scrape chapter from all available sources";
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId, $chapter->chapterNumber, $errorMessage));
} }
}
if (!$success) { } catch (\Exception $e) {
$errorMessage = $lastException?->getMessage() ?? 'Failed to scrape chapter from all available sources'; if (isset($job)) {
$job->fail($errorMessage); $job->fail($e->getMessage());
$this->jobRepository->save($job); $this->jobRepository->save($job);
$this->eventBus->dispatch(new ChapterScrapingFailed($job->id, $chapter->mangaId, $chapter->chapterNumber, $errorMessage)); }
$this->eventBus->dispatch(new ChapterScrapingFailed($chapter->mangaId ?? 'unknown', $chapter->chapterNumber ?? 'unknown', $e->getMessage()));
} }
} }
/** /**
* Détermine les sources à utiliser pour le scraping en fonction des préférences du manga
*
* @param \App\Domain\Scraping\Domain\Model\Manga $manga * @param \App\Domain\Scraping\Domain\Model\Manga $manga
* @return Source[] * @return Source[]
*/ */
private function getSourcesToTry(\App\Domain\Scraping\Domain\Model\Manga $manga): array private function getSourcesToTry(\App\Domain\Scraping\Domain\Model\Manga $manga): array
{ {
// Si le manga a des sources préférées, les utiliser
if ($manga->hasPreferredSources()) { if ($manga->hasPreferredSources()) {
$preferredSources = []; $preferredSources = [];
foreach ($manga->getPreferredSources() as $sourceId) { foreach ($manga->getPreferredSources() as $sourceId) {
@@ -124,6 +186,7 @@ readonly class ScrapeChapterHandler
$preferredSources[] = $source; $preferredSources[] = $source;
} }
// Limiter à 3 sources préférées maximum
if (count($preferredSources) >= 3) { if (count($preferredSources) >= 3) {
break; break;
} }
@@ -134,6 +197,7 @@ readonly class ScrapeChapterHandler
} }
} }
// Sinon, utiliser toutes les sources disponibles
return $this->sourceRepository->getAll(); return $this->sourceRepository->getAll();
} }
} }

View File

@@ -5,18 +5,12 @@ namespace App\Domain\Scraping\Domain\Event;
readonly class ChapterScrapingFailed readonly class ChapterScrapingFailed
{ {
public function __construct( public function __construct(
private string $jobId,
private string $mangaId, private string $mangaId,
private string $chapterNumber, private string $chapterNumber,
private string $reason private string $reason
) { ) {
} }
public function getJobId(): string
{
return $this->jobId;
}
public function getMangaId(): string public function getMangaId(): string
{ {
return $this->mangaId; return $this->mangaId;

View File

@@ -5,17 +5,11 @@ namespace App\Domain\Scraping\Domain\Event;
class ChapterScrapingStarted class ChapterScrapingStarted
{ {
public function __construct( public function __construct(
private readonly string $jobId,
private readonly string $mangaTitle, private readonly string $mangaTitle,
private readonly float $chapterNumber, private readonly float $chapterNumber,
) { ) {
} }
public function getJobId(): string
{
return $this->jobId;
}
public function getMangaTitle(): string public function getMangaTitle(): string
{ {
return $this->mangaTitle; return $this->mangaTitle;

View File

@@ -8,9 +8,9 @@ class ScrapingJob extends Job
{ {
public function __construct( public function __construct(
string $id, string $id,
?string $mangaId = null, string $mangaId,
?float $chapterNumber = null, float $chapterNumber,
?string $sourceId = null string $sourceId
) { ) {
parent::__construct($id, 'scraping_job'); parent::__construct($id, 'scraping_job');
$this->maxAttempts = 1; $this->maxAttempts = 1;

View File

@@ -5,20 +5,13 @@ namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapeChapterRequest; use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapeChapterRequest;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
final class ScrapeChapterStateProcessor implements ProcessorInterface final class ScrapeChapterStateProcessor implements ProcessorInterface
{ {
public function __construct( public function __construct(
private readonly MessageBusInterface $commandBus, private readonly MessageBusInterface $commandBus
private readonly JobRepositoryInterface $jobRepository,
private readonly HubInterface $hub,
) { ) {
} }
@@ -27,25 +20,10 @@ final class ScrapeChapterStateProcessor implements ProcessorInterface
*/ */
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{ {
$jobId = Uuid::uuid4()->toString(); $this->commandBus->dispatch(
$job = new ScrapingJob($jobId); new ScrapeChapter(
$job->context['chapterId'] = $data->chapterId; $data->chapterId
$this->jobRepository->save($job); )
);
$this->hub->publish(new Update(
'jobs/activity',
json_encode([
'type' => 'job.created',
'id' => $job->id,
'type_job' => $job->type,
'status' => $job->status->value,
'createdAt' => $job->createdAt->format('c'),
'context' => $job->context,
'attempts' => $job->attempts,
'maxAttempts' => $job->maxAttempts,
])
));
$this->commandBus->dispatch(new ScrapeChapter($data->chapterId, $jobId));
} }
} }

View File

@@ -5,7 +5,6 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Event\PageScrapingProgressed;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface; use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Contract\NotificationInterface; use App\Domain\Shared\Domain\Contract\NotificationInterface;
@@ -31,32 +30,14 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
return []; return [];
} }
#[AsMessageHandler]
public function onPageScrapingProgressed(PageScrapingProgressed $event): void
{
$progress = (int) round($event->getProgress()->getPercentage());
$update = new Update(
'jobs/activity',
json_encode([
'type' => 'job.progress_updated',
'jobId' => $event->getJobId(),
'progress' => $progress,
])
);
$this->hub->publish($update);
}
#[AsMessageHandler] #[AsMessageHandler]
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
{ {
$this->hub->publish(new Update( $chapterNumber = $event->getChapterNumber();
'jobs/activity', $mangaTitle = $event->getMangaTitle();
json_encode(['type' => 'job.status_changed', 'jobId' => $event->getJobId(), 'status' => 'in_progress'])
));
$this->notification->sendInfo( $this->notification->sendInfo(
sprintf('Scraping du chapitre %s de "%s" démarré', $event->getChapterNumber(), $event->getMangaTitle()) sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle)
); );
} }
@@ -103,11 +84,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
$update = new Update($topics, json_encode($data)); $update = new Update($topics, json_encode($data));
$this->hub->publish($update); $this->hub->publish($update);
$this->hub->publish(new Update(
'jobs/activity',
json_encode(['type' => 'job.status_changed', 'jobId' => $jobId, 'status' => 'completed'])
));
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu'; $mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
$this->notification->sendSuccess( $this->notification->sendSuccess(
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle) sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
@@ -117,11 +93,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
#[AsMessageHandler] #[AsMessageHandler]
public function onChapterScrapingFailed(ChapterScrapingFailed $event): void public function onChapterScrapingFailed(ChapterScrapingFailed $event): void
{ {
$this->hub->publish(new Update(
'jobs/activity',
json_encode(['type' => 'job.status_changed', 'jobId' => $event->getJobId(), 'status' => 'failed'])
));
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber()); $this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
$data = [ $data = [

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Domain\Setting\Application\Command;
readonly class DeleteContentSourceCommand
{
public function __construct(
public int $id
) {
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Domain\Setting\Application\CommandHandler;
use App\Domain\Setting\Application\Command\DeleteContentSourceCommand;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
readonly class DeleteContentSourceCommandHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {
}
public function handle(DeleteContentSourceCommand $command): void
{
$contentSource = $this->contentSourceRepository->findById($command->id);
if (!$contentSource) {
throw new ContentSourceNotFoundException($command->id);
}
$this->contentSourceRepository->delete($contentSource);
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\DeleteContentSourceStateProcessor;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\DeleteContentSourceStateProvider;
#[ApiResource(
shortName: 'ContentSource',
operations: [
new Delete(
uriTemplate: '/content-sources/{id}',
provider: DeleteContentSourceStateProvider::class,
processor: DeleteContentSourceStateProcessor::class,
name: 'delete_content_source',
openapiContext: [
'summary' => 'Delete a content source',
'description' => 'Permanently deletes a content source',
'parameters' => [
[
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => [
'type' => 'integer'
],
'description' => 'The content source ID'
]
],
'responses' => [
'204' => [
'description' => 'Content source successfully deleted'
],
'404' => [
'description' => 'Content source not found'
]
]
]
)
]
)]
class DeleteContentSourceResource
{
public function __construct(
public int $id
) {
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Setting\Application\Command\DeleteContentSourceCommand;
use App\Domain\Setting\Application\CommandHandler\DeleteContentSourceCommandHandler;
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteContentSourceStateProcessor implements ProcessorInterface
{
public function __construct(
private DeleteContentSourceCommandHandler $handler
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
{
if (!isset($uriVariables['id'])) {
throw new \InvalidArgumentException('Content source ID is required');
}
try {
$command = new DeleteContentSourceCommand((int) $uriVariables['id']);
$this->handler->handle($command);
return Response::HTTP_NO_CONTENT;
} catch (ContentSourceNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\DeleteContentSourceResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteContentSourceStateProvider implements ProviderInterface
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): DeleteContentSourceResource
{
if (!isset($uriVariables['id'])) {
throw new NotFoundHttpException('Content source ID is required');
}
$id = (int) $uriVariables['id'];
try {
$contentSource = $this->contentSourceRepository->findById($id);
if (!$contentSource) {
throw new ContentSourceNotFoundException($id);
}
return new DeleteContentSourceResource($id);
} catch (ContentSourceNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace App\Domain\System\Application\Query;
final class GetSystemStatusQuery
{
}

View File

@@ -1,93 +0,0 @@
<?php
namespace App\Domain\System\Application\QueryHandler;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\System\Application\Query\GetSystemStatusQuery;
use App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface;
use App\Domain\System\Domain\Model\SystemStatus;
readonly class GetSystemStatusQueryHandler
{
public function __construct(
private SystemStatusRepositoryInterface $systemStatusRepository,
private JobRepositoryInterface $jobRepository,
private string $mangaDataPath,
private string $imagesStoragePath,
) {
}
public function handle(GetSystemStatusQuery $query): SystemStatus
{
$now = new \DateTimeImmutable();
$last24h = $now->modify('-24 hours');
$last7d = $now->modify('-7 days');
$totalJobs = $this->jobRepository->countByCriteria([]);
$completedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED]);
$failedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED]);
$pendingJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::PENDING]);
$inProgressJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::IN_PROGRESS]);
$totalJobsLast24h = $this->jobRepository->countByCriteria(['createdAfter' => $last24h]);
$completedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last24h]);
$failedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last24h]);
$totalJobsLast7d = $this->jobRepository->countByCriteria(['createdAfter' => $last7d]);
$completedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last7d]);
$failedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last7d]);
$storagePath = $this->imagesStoragePath;
$storageTotalBytes = (int) (@disk_total_space($storagePath) ?: 0);
$storageFreeBytes = (int) (@disk_free_space($storagePath) ?: 0);
$storageUsedBytes = $this->computeDirectorySize($storagePath);
return new SystemStatus(
totalMangas: $this->systemStatusRepository->countMangas(),
monitoredMangas: $this->systemStatusRepository->countMonitoredMangas(),
mangasByStatus: $this->systemStatusRepository->countMangasByStatus(),
totalChapters: $this->systemStatusRepository->countChapters(),
downloadedChapters: $this->systemStatusRepository->countDownloadedChapters(),
totalJobs: $totalJobs,
completedJobs: $completedJobs,
failedJobs: $failedJobs,
pendingJobs: $pendingJobs,
inProgressJobs: $inProgressJobs,
totalJobsLast24h: $totalJobsLast24h,
completedJobsLast24h: $completedJobsLast24h,
failedJobsLast24h: $failedJobsLast24h,
totalJobsLast7d: $totalJobsLast7d,
completedJobsLast7d: $completedJobsLast7d,
failedJobsLast7d: $failedJobsLast7d,
storagePath: $this->mangaDataPath,
storageTotalBytes: $storageTotalBytes,
storageFreeBytes: $storageFreeBytes,
storageUsedBytes: $storageUsedBytes,
totalSources: $this->systemStatusRepository->countContentSources(),
sourcesByHealth: $this->systemStatusRepository->countContentSourcesByHealth(),
phpVersion: PHP_VERSION,
generatedAt: $now,
);
}
private function computeDirectorySize(string $path): int
{
if (!is_dir($path)) {
return 0;
}
$size = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domain\System\Domain\Contract\Repository;
interface SystemStatusRepositoryInterface
{
public function countMangas(): int;
public function countMonitoredMangas(): int;
/** @return array<string, int> */
public function countMangasByStatus(): array;
public function countChapters(): int;
public function countDownloadedChapters(): int;
public function countContentSources(): int;
/** @return array<string, int> */
public function countContentSourcesByHealth(): array;
}

View File

@@ -1,44 +0,0 @@
<?php
namespace App\Domain\System\Domain\Model;
readonly class SystemStatus
{
public function __construct(
// Mangas
public int $totalMangas,
public int $monitoredMangas,
/** @var array<string, int> */
public array $mangasByStatus,
// Chapitres
public int $totalChapters,
public int $downloadedChapters,
// Jobs global
public int $totalJobs,
public int $completedJobs,
public int $failedJobs,
public int $pendingJobs,
public int $inProgressJobs,
// Jobs 24h
public int $totalJobsLast24h,
public int $completedJobsLast24h,
public int $failedJobsLast24h,
// Jobs 7j
public int $totalJobsLast7d,
public int $completedJobsLast7d,
public int $failedJobsLast7d,
// Stockage
public string $storagePath,
public int $storageTotalBytes,
public int $storageFreeBytes,
public int $storageUsedBytes,
// Sources
public int $totalSources,
/** @var array<string, int> */
public array $sourcesByHealth,
// Système
public string $phpVersion,
public \DateTimeImmutable $generatedAt,
) {
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Domain\System\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\System\Infrastructure\ApiPlatform\State\Provider\GetSystemStatusStateProvider;
#[ApiResource(
shortName: 'System',
operations: [
new Get(
uriTemplate: '/system/status',
provider: GetSystemStatusStateProvider::class,
)
]
)]
class GetSystemStatusResource
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $totalMangas = 0;
public int $monitoredMangas = 0;
/** @var array<string, int> */
public array $mangasByStatus = [];
public int $totalChapters = 0;
public int $downloadedChapters = 0;
public int $pendingChapters = 0;
public int $totalJobs = 0;
public int $completedJobs = 0;
public int $failedJobs = 0;
public int $pendingJobs = 0;
public int $inProgressJobs = 0;
public int $totalJobsLast24h = 0;
public int $completedJobsLast24h = 0;
public int $failedJobsLast24h = 0;
public float $successRateLast24h = 0.0;
public int $totalJobsLast7d = 0;
public int $completedJobsLast7d = 0;
public int $failedJobsLast7d = 0;
public float $successRateLast7d = 0.0;
public string $storagePath = '';
public int $storageTotalBytes = 0;
public int $storageFreeBytes = 0;
public int $storageUsedBytes = 0;
public string $storageTotalHuman = '';
public string $storageFreeHuman = '';
public string $storageUsedHuman = '';
public int $totalSources = 0;
/** @var array<string, int> */
public array $sourcesByHealth = [];
public string $phpVersion = '';
public string $generatedAt = '';
}

View File

@@ -1,88 +0,0 @@
<?php
namespace App\Domain\System\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\System\Application\Query\GetSystemStatusQuery;
use App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler;
use App\Domain\System\Domain\Model\SystemStatus;
use App\Domain\System\Infrastructure\ApiPlatform\Resource\GetSystemStatusResource;
final class GetSystemStatusStateProvider implements ProviderInterface
{
public function __construct(
private readonly GetSystemStatusQueryHandler $handler,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetSystemStatusResource
{
$response = $this->handler->handle(new GetSystemStatusQuery());
return $this->toResource($response);
}
private function toResource(SystemStatus $response): GetSystemStatusResource
{
$resource = new GetSystemStatusResource();
$resource->id = 'current';
$resource->totalMangas = $response->totalMangas;
$resource->monitoredMangas = $response->monitoredMangas;
$resource->mangasByStatus = $response->mangasByStatus;
$resource->totalChapters = $response->totalChapters;
$resource->downloadedChapters = $response->downloadedChapters;
$resource->pendingChapters = $response->totalChapters - $response->downloadedChapters;
$resource->totalJobs = $response->totalJobs;
$resource->completedJobs = $response->completedJobs;
$resource->failedJobs = $response->failedJobs;
$resource->pendingJobs = $response->pendingJobs;
$resource->inProgressJobs = $response->inProgressJobs;
$resource->totalJobsLast24h = $response->totalJobsLast24h;
$resource->completedJobsLast24h = $response->completedJobsLast24h;
$resource->failedJobsLast24h = $response->failedJobsLast24h;
$resource->successRateLast24h = $response->totalJobsLast24h > 0
? round($response->completedJobsLast24h / $response->totalJobsLast24h * 100, 1)
: 0.0;
$resource->totalJobsLast7d = $response->totalJobsLast7d;
$resource->completedJobsLast7d = $response->completedJobsLast7d;
$resource->failedJobsLast7d = $response->failedJobsLast7d;
$resource->successRateLast7d = $response->totalJobsLast7d > 0
? round($response->completedJobsLast7d / $response->totalJobsLast7d * 100, 1)
: 0.0;
$resource->storagePath = $response->storagePath;
$resource->storageTotalBytes = $response->storageTotalBytes;
$resource->storageFreeBytes = $response->storageFreeBytes;
$resource->storageUsedBytes = $response->storageUsedBytes;
$resource->storageTotalHuman = $this->formatBytes($response->storageTotalBytes);
$resource->storageFreeHuman = $this->formatBytes($response->storageFreeBytes);
$resource->storageUsedHuman = $this->formatBytes($response->storageUsedBytes);
$resource->totalSources = $response->totalSources;
$resource->sourcesByHealth = $response->sourcesByHealth;
$resource->phpVersion = $response->phpVersion;
$resource->generatedAt = $response->generatedAt->format(\DateTimeInterface::ATOM);
return $resource;
}
private function formatBytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$exp = (int) floor(log($bytes, 1024));
$exp = min($exp, count($units) - 1);
return round($bytes / (1024 ** $exp), 2) . ' ' . $units[$exp];
}
}

View File

@@ -1,81 +0,0 @@
<?php
namespace App\Domain\System\Infrastructure\Persistence\Repository;
use App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface;
use App\Entity\Chapter;
use App\Entity\ContentSource;
use App\Entity\Manga;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineSystemStatusRepository implements SystemStatusRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function countMangas(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m')
->getSingleScalarResult();
}
public function countMonitoredMangas(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m WHERE m.monitored = true')
->getSingleScalarResult();
}
public function countMangasByStatus(): array
{
$results = $this->entityManager
->createQuery('SELECT m.status, COUNT(m) as cnt FROM ' . Manga::class . ' m GROUP BY m.status')
->getResult();
$counts = [];
foreach ($results as $row) {
$status = $row['status'] ?? 'unknown';
$counts[$status] = (int) $row['cnt'];
}
return $counts;
}
public function countChapters(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c')
->getSingleScalarResult();
}
public function countDownloadedChapters(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c WHERE c.cbzPath IS NOT NULL')
->getSingleScalarResult();
}
public function countContentSources(): int
{
return (int) $this->entityManager
->createQuery('SELECT COUNT(s) FROM ' . ContentSource::class . ' s')
->getSingleScalarResult();
}
public function countContentSourcesByHealth(): array
{
$results = $this->entityManager
->createQuery('SELECT s.healthStatus, COUNT(s) as cnt FROM ' . ContentSource::class . ' s GROUP BY s.healthStatus')
->getResult();
$counts = [];
foreach ($results as $row) {
$counts[$row['healthStatus']] = (int) $row['cnt'];
}
return $counts;
}
}

View File

@@ -7,7 +7,6 @@ use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository; use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus; use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
@@ -17,6 +16,8 @@ use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory; use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository; use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository; use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ScrapeChapterHandlerTest extends TestCase class ScrapeChapterHandlerTest extends TestCase
@@ -29,6 +30,7 @@ class ScrapeChapterHandlerTest extends TestCase
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemorySourceRepository $sourceRepository; private InMemorySourceRepository $sourceRepository;
private InMemoryEventBus $eventBus; private InMemoryEventBus $eventBus;
private EntityManagerInterface|MockObject $entityManager;
private ScrapeChapterHandler $handler; private ScrapeChapterHandler $handler;
protected function setUp(): void protected function setUp(): void
@@ -41,6 +43,11 @@ class ScrapeChapterHandlerTest extends TestCase
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->sourceRepository = new InMemorySourceRepository(); $this->sourceRepository = new InMemorySourceRepository();
$this->eventBus = new InMemoryEventBus(); $this->eventBus = new InMemoryEventBus();
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->entityManager->method('beginTransaction')->willReturn(null);
$this->entityManager->method('commit')->willReturn(null);
$this->entityManager->method('rollback')->willReturn(null);
$this->chapterRepository->save(new Chapter( $this->chapterRepository->save(new Chapter(
id: '1', id: '1',
@@ -58,27 +65,26 @@ class ScrapeChapterHandlerTest extends TestCase
$this->mangaRepository, $this->mangaRepository,
$this->sourceRepository, $this->sourceRepository,
$this->eventBus, $this->eventBus,
$this->entityManager
); );
} }
public function testHandleSuccessfully(): void public function testHandleSuccessfully(): void
{ {
$jobId = 'test-job-id'; $command = new ScrapeChapter(
$job = new ScrapingJob($jobId, 'test-manga', 2); chapterId: '1'
$this->jobRepository->save($job); );
$command = new ScrapeChapter(chapterId: '1', jobId: $jobId);
$this->handler->handle($command); $this->handler->handle($command);
$jobs = $this->jobRepository->findByType('scraping_job'); $job = $this->jobRepository->findByType('scraping_job');
$this->assertCount(1, $jobs); $this->assertCount(1, $job);
$job = array_values($jobs)[0]; $job = array_values($job)[0];
$dispatchedMessages = $this->eventBus->getDispatchedMessages(); $dispatchedMessages = $this->eventBus->getDispatchedMessages();
$this->assertCount(2, $dispatchedMessages); $this->assertCount(2, $dispatchedMessages);
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]); $this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
$this->assertSame($jobId, $dispatchedMessages[0]->getJobId());
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber()); $this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]); $this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);

View File

@@ -35,14 +35,13 @@ class ScrapeChapterTest extends AbstractApiTestCase
// Then // Then
$this->assertResponseStatusCodeSame(202); $this->assertResponseStatusCodeSame(202);
$messages = InMemoryMessageBus::$messages; $messages = $this->messageBus->getDispatchedMessages();
$this->assertCount(1, $messages, 'Un message devrait être dispatché'); $this->assertCount(1, $messages, 'Un message devrait être dispatché');
/** @var ScrapeChapter $message */ /** @var ScrapeChapter $message */
$message = $messages[0]; $message = $messages[0];
$this->assertInstanceOf(ScrapeChapter::class, $message); $this->assertInstanceOf(ScrapeChapter::class, $message);
$this->assertEquals('chapter-123', $message->chapterId); $this->assertEquals('chapter-123', $message->chapterId);
$this->assertNotEmpty($message->jobId);
} }
public function testInitiateChapterScrapingWithInvalidPayload(): void public function testInitiateChapterScrapingWithInvalidPayload(): void
@@ -73,6 +72,6 @@ class ScrapeChapterTest extends AbstractApiTestCase
protected function tearDown(): void protected function tearDown(): void
{ {
parent::tearDown(); parent::tearDown();
InMemoryMessageBus::$messages = []; $this->messageBus->clear();
} }
} }

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Feature\Setting;
use App\Entity\ContentSource;
use App\Tests\Feature\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\ResetDatabase;
final class DeleteContentSourceTest extends AbstractApiTestCase
{
use ResetDatabase;
private int $sourceId;
protected function setUp(): void
{
parent::setUp();
$source = new ContentSource();
$source->setBaseUrl('https://mangadex.org')
->setChapterUrlFormat('https://mangadex.org/chapter/{id}')
->setScrapingType('html');
$this->entityManager->persist($source);
$this->entityManager->flush();
$this->sourceId = $source->getId();
}
public function testItDeletesSourceSuccessfully(): void
{
static::createClient()->request('DELETE', "/api/content-sources/{$this->sourceId}");
$this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT);
$this->entityManager->clear();
$deletedSource = $this->entityManager->find(ContentSource::class, $this->sourceId);
$this->assertNull($deletedSource);
}
public function testItReturnsNotFoundWhenSourceDoesNotExist(): void
{
static::createClient()->request('DELETE', '/api/content-sources/999999');
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
}