feat: ajout de la fonctionnalité de monitoring des mangas, incluant l'activation et la désactivation du suivi, la synchronisation des chapitres, et la mise à jour de l'API pour gérer ces nouvelles actions. Création de nouveaux composants Vue pour le rafraîchissement des chapitres et l'affichage des notifications. Intégration de tests unitaires pour valider le bon fonctionnement de ces fonctionnalités.
This commit is contained in:
parent
d9e78b5229
commit
00d63dffeb
@@ -195,11 +195,37 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
this.chaptersError = null;
|
this.chaptersError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Déclenche la récupération initiale des chapitres depuis la source externe
|
||||||
await mangaRepository.fetchMangaChapters(mangaId);
|
await mangaRepository.fetchMangaChapters(mangaId);
|
||||||
this.mangaChapters[mangaId] = chaptersData;
|
console.log('Récupération initiale des chapitres déclenchée avec succès');
|
||||||
console.log('Chapitres récupérés avec succès');
|
|
||||||
|
// Note: Les nouveaux chapitres seront disponibles après traitement asynchrone
|
||||||
|
// Le MercureListener se chargera de mettre à jour l'interface
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.chaptersError = err.message;
|
this.chaptersError = err.message;
|
||||||
|
console.error('Erreur lors de la récupération des chapitres:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loadingChapters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshMangaChapters(mangaId) {
|
||||||
|
if (this.loadingChapters) return;
|
||||||
|
this.loadingChapters = true;
|
||||||
|
this.chaptersError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Déclenche la synchronisation incrémentale avec scraping automatique
|
||||||
|
await mangaRepository.refreshMangaChapters(mangaId);
|
||||||
|
console.log('Synchronisation incrémentale déclenchée avec succès');
|
||||||
|
|
||||||
|
// Note: Les chapitres mis à jour seront disponibles après traitement asynchrone
|
||||||
|
// Le MercureListener se chargera de mettre à jour l'interface
|
||||||
|
} catch (err) {
|
||||||
|
this.chaptersError = err.message;
|
||||||
|
console.error('Erreur lors de la synchronisation des chapitres:', err);
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingChapters = false;
|
this.loadingChapters = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,26 @@ export class ApiMangaRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshMangaChapters(mangaId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/${mangaId}/chapters/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to refresh manga chapters');
|
||||||
|
}
|
||||||
|
// L'endpoint retourne 202 (Accepted), pas de contenu JSON à parser
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async searchChapter(chapterId) {
|
async searchChapter(chapterId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/scraping/chapters', {
|
const response = await fetch('/api/scraping/chapters', {
|
||||||
@@ -321,4 +341,40 @@ export class ApiMangaRepository {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleMonitoring(mangaId, enabled) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/${mangaId}/monitoring/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Tenter de récupérer le message d'erreur détaillé de l'API
|
||||||
|
let errorMessage = 'Failed to toggle monitoring';
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.violations && errorData.violations.length > 0) {
|
||||||
|
errorMessage = errorData.violations.map(v => v.message).join(', ');
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Could not parse error response:', parseError);
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'endpoint retourne un statut 204 (No Content), donc pas de données à retourner
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
|
||||||
|
export function useMangaMonitoring() {
|
||||||
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
|
const isToggling = ref(false);
|
||||||
|
const toggleError = ref(null);
|
||||||
|
|
||||||
|
const toggleMonitoring = async (mangaId, enabled) => {
|
||||||
|
if (isToggling.value || !mangaId) return;
|
||||||
|
|
||||||
|
isToggling.value = true;
|
||||||
|
toggleError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`${enabled ? 'Activation' : 'Désactivation'} du monitoring pour le manga ${mangaId}`);
|
||||||
|
|
||||||
|
await mangaRepository.toggleMonitoring(mangaId, enabled);
|
||||||
|
|
||||||
|
const message = enabled
|
||||||
|
? 'Monitoring activé avec succès. Vous recevrez les nouveaux chapitres automatiquement.'
|
||||||
|
: 'Monitoring désactivé avec succès. Les nouveaux chapitres ne seront plus téléchargés automatiquement.';
|
||||||
|
|
||||||
|
showSuccess(message);
|
||||||
|
|
||||||
|
console.log(`Monitoring ${enabled ? 'activé' : 'désactivé'} avec succès`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du changement de monitoring:', error);
|
||||||
|
toggleError.value = error.message || 'Erreur lors du changement de monitoring';
|
||||||
|
|
||||||
|
const errorMessage = enabled
|
||||||
|
? `Erreur lors de l'activation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`
|
||||||
|
: `Erreur lors de la désactivation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`;
|
||||||
|
|
||||||
|
showError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isToggling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableMonitoring = async (mangaId) => {
|
||||||
|
return await toggleMonitoring(mangaId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableMonitoring = async (mangaId) => {
|
||||||
|
return await toggleMonitoring(mangaId, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
toggleError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isToggling,
|
||||||
|
toggleError,
|
||||||
|
toggleMonitoring,
|
||||||
|
enableMonitoring,
|
||||||
|
disableMonitoring,
|
||||||
|
clearError
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
export function useMangaRefresh() {
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const refreshError = ref(null);
|
||||||
|
|
||||||
|
const refreshMetadata = async (mangaId) => {
|
||||||
|
if (isRefreshing.value || !mangaId) return;
|
||||||
|
|
||||||
|
isRefreshing.value = true;
|
||||||
|
refreshError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Début du refresh des métadonnées pour le manga ${mangaId}`);
|
||||||
|
|
||||||
|
// Appel à l'endpoint de refresh des chapitres
|
||||||
|
await mangaStore.refreshMangaChapters(mangaId);
|
||||||
|
|
||||||
|
showSuccess('Refresh des métadonnées lancé avec succès. Les nouveaux chapitres apparaîtront sous peu.');
|
||||||
|
|
||||||
|
console.log('Refresh des métadonnées déclenché avec succès');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du refresh des métadonnées:', error);
|
||||||
|
refreshError.value = error.message || 'Erreur lors du refresh des métadonnées';
|
||||||
|
showError(`Erreur lors du refresh: ${error.message || 'Une erreur inattendue est survenue'}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
refreshError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRefreshing,
|
||||||
|
refreshError,
|
||||||
|
refreshMetadata,
|
||||||
|
clearError
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Notifications Toast -->
|
||||||
|
<NotificationToast />
|
||||||
|
|
||||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
||||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -63,6 +66,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
|
BookmarkSlashIcon,
|
||||||
ChevronDoubleDownIcon,
|
ChevronDoubleDownIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
DocumentArrowDownIcon,
|
DocumentArrowDownIcon,
|
||||||
@@ -75,7 +79,9 @@ import { useRoute } from 'vue-router';
|
|||||||
|
|
||||||
import { useMangaDetails } from '../composables/useMangaDetails';
|
import { useMangaDetails } from '../composables/useMangaDetails';
|
||||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
import { useMangaVolumes } from '../composables/useMangaVolumes';
|
import { useMangaVolumes } from '../composables/useMangaVolumes';
|
||||||
|
|
||||||
import MangaEditModal from '../components/MangaEditModal.vue';
|
import MangaEditModal from '../components/MangaEditModal.vue';
|
||||||
@@ -84,6 +90,7 @@ import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal
|
|||||||
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
||||||
import MercureListener from '../components/MercureListener.vue';
|
import MercureListener from '../components/MercureListener.vue';
|
||||||
|
|
||||||
|
import NotificationToast from '../../../../shared/components/ui/NotificationToast.vue';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
@@ -99,7 +106,8 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
|||||||
data: currentManga,
|
data: currentManga,
|
||||||
isLoading: isLoadingDetails,
|
isLoading: isLoadingDetails,
|
||||||
isFetching: isRefreshingDetails,
|
isFetching: isRefreshingDetails,
|
||||||
error: errorDetails
|
error: errorDetails,
|
||||||
|
refetch: refetchMangaDetails
|
||||||
} = useMangaDetails(mangaId);
|
} = useMangaDetails(mangaId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -127,6 +135,19 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
|||||||
error: editError
|
error: editError
|
||||||
} = useMangaEdit();
|
} = useMangaEdit();
|
||||||
|
|
||||||
|
// Composable pour le refresh des métadonnées
|
||||||
|
const {
|
||||||
|
isRefreshing,
|
||||||
|
refreshMetadata
|
||||||
|
} = useMangaRefresh();
|
||||||
|
|
||||||
|
// Composable pour le monitoring
|
||||||
|
const {
|
||||||
|
isToggling: isTogglingMonitoring,
|
||||||
|
toggleMonitoring,
|
||||||
|
toggleError: monitoringError
|
||||||
|
} = useMangaMonitoring();
|
||||||
|
|
||||||
// Charger les chapitres dans le store quand le manga est chargé
|
// Charger les chapitres dans le store quand le manga est chargé
|
||||||
watch(
|
watch(
|
||||||
mangaId,
|
mangaId,
|
||||||
@@ -164,13 +185,42 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fonction pour le refresh des métadonnées
|
||||||
|
const handleRefreshMetadata = async () => {
|
||||||
|
if (!mangaId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshMetadata(mangaId.value);
|
||||||
|
} catch (error) {
|
||||||
|
// L'erreur est déjà gérée dans le composable avec les notifications
|
||||||
|
console.error('Erreur lors du refresh:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour basculer le monitoring
|
||||||
|
const handleToggleMonitoring = async () => {
|
||||||
|
if (!mangaId.value || !currentManga.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newMonitoringState = !currentManga.value.monitored;
|
||||||
|
await toggleMonitoring(mangaId.value, newMonitoringState);
|
||||||
|
|
||||||
|
// Recharger les détails du manga pour mettre à jour l'état du monitoring
|
||||||
|
await refetchMangaDetails();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du changement de monitoring:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toolbarConfig = computed(() => ({
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{
|
||||||
icon: ArrowPathIcon,
|
icon: ArrowPathIcon,
|
||||||
label: 'Refresh metadata',
|
label: 'Refresh metadata',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
onClick: () => console.log('Refresh metadata')
|
onClick: handleRefreshMetadata,
|
||||||
|
loading: isRefreshing.value,
|
||||||
|
disabled: isRefreshing.value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: PencilSquareIcon,
|
icon: PencilSquareIcon,
|
||||||
@@ -193,10 +243,13 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
|||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{
|
{
|
||||||
icon: BookmarkIcon,
|
icon: currentManga.value?.monitored ? BookmarkIcon : BookmarkSlashIcon,
|
||||||
label: 'Monitoring',
|
label: currentManga.value?.monitored ? 'Désactiver monitoring' : 'Activer monitoring',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
onClick: () => console.log('Monitoring')
|
onClick: handleToggleMonitoring,
|
||||||
|
loading: isTogglingMonitoring.value,
|
||||||
|
disabled: isTogglingMonitoring.value,
|
||||||
|
variant: currentManga.value?.monitored ? 'active' : 'default'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: WrenchIcon,
|
icon: WrenchIcon,
|
||||||
@@ -220,7 +273,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const loading = computed(() => isLoadingDetails.value || isLoadingVolumes.value);
|
const loading = computed(() => isLoadingDetails.value || isLoadingVolumes.value);
|
||||||
const isRefreshing = computed(() => isRefreshingDetails.value || isRefreshingVolumes.value);
|
const isRefreshingData = computed(() => isRefreshingDetails.value || isRefreshingVolumes.value || isRefreshing.value);
|
||||||
const error = computed(() => errorDetails.value || errorVolumes.value);
|
const error = computed(() => errorDetails.value || errorVolumes.value);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
103
assets/vue/app/shared/components/ui/NotificationToast.vue
Normal file
103
assets/vue/app/shared/components/ui/NotificationToast.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed top-4 right-4 z-50 space-y-2">
|
||||||
|
<TransitionGroup
|
||||||
|
name="notification"
|
||||||
|
tag="div"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="[
|
||||||
|
'max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||||
|
getNotificationClass(notification.type)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<component :is="getIcon(notification.type)" :class="[
|
||||||
|
'h-6 w-6',
|
||||||
|
getIconClass(notification.type)
|
||||||
|
]" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ notification.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0 flex">
|
||||||
|
<button
|
||||||
|
@click="removeNotification(notification.id)"
|
||||||
|
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<XMarkIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
XMarkIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { useNotifications } from '../../composables/useNotifications';
|
||||||
|
|
||||||
|
const { notifications, removeNotification } = useNotifications();
|
||||||
|
|
||||||
|
const getIcon = (type) => {
|
||||||
|
const icons = {
|
||||||
|
success: CheckCircleIcon,
|
||||||
|
error: ExclamationCircleIcon,
|
||||||
|
warning: ExclamationTriangleIcon,
|
||||||
|
info: InformationCircleIcon
|
||||||
|
};
|
||||||
|
return icons[type] || InformationCircleIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationClass = (type) => {
|
||||||
|
const classes = {
|
||||||
|
success: 'border-l-4 border-green-400',
|
||||||
|
error: 'border-l-4 border-red-400',
|
||||||
|
warning: 'border-l-4 border-yellow-400',
|
||||||
|
info: 'border-l-4 border-blue-400'
|
||||||
|
};
|
||||||
|
return classes[type] || classes.info;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconClass = (type) => {
|
||||||
|
const classes = {
|
||||||
|
success: 'text-green-400',
|
||||||
|
error: 'text-red-400',
|
||||||
|
warning: 'text-yellow-400',
|
||||||
|
info: 'text-blue-400'
|
||||||
|
};
|
||||||
|
return classes[type] || classes.info;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-enter-active,
|
||||||
|
.notification-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
assets/vue/app/shared/composables/useNotifications.js
Normal file
65
assets/vue/app/shared/composables/useNotifications.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const notifications = ref([]);
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const addNotification = (message, type = 'info', duration = 4000) => {
|
||||||
|
const notification = {
|
||||||
|
id: nextId++,
|
||||||
|
message,
|
||||||
|
type, // 'success', 'error', 'warning', 'info'
|
||||||
|
duration,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
notifications.value.push(notification);
|
||||||
|
|
||||||
|
// Auto-remove après la durée spécifiée
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeNotification(notification.id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNotification = (id) => {
|
||||||
|
const index = notifications.value.findIndex(n => n.id === id);
|
||||||
|
if (index > -1) {
|
||||||
|
notifications.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
notifications.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSuccess = (message, duration = 4000) => {
|
||||||
|
return addNotification(message, 'success', duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (message, duration = 6000) => {
|
||||||
|
return addNotification(message, 'error', duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showWarning = (message, duration = 5000) => {
|
||||||
|
return addNotification(message, 'warning', duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showInfo = (message, duration = 4000) => {
|
||||||
|
return addNotification(message, 'info', duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
clearAll,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showWarning,
|
||||||
|
showInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,10 +26,12 @@ framework:
|
|||||||
# Commands
|
# Commands
|
||||||
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
|
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
|
||||||
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
|
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
|
||||||
|
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
|
||||||
# Events
|
# Events
|
||||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
|
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
|
||||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
|
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
|
||||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
||||||
|
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
||||||
|
|
||||||
# Legacy messages (à garder si nécessaire)
|
# Legacy messages (à garder si nécessaire)
|
||||||
'App\Message\DownloadChapter': commands
|
'App\Message\DownloadChapter': commands
|
||||||
|
|||||||
33
migrations/Version20250716105928.php
Normal file
33
migrations/Version20250716105928.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250716105928 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE manga ADD last_monitoring_check TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE manga DROP last_monitoring_check');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1105,9 +1105,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"mangaId": {
|
"mangaId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"description": "L'identifiant unique du manga"
|
||||||
"description": "L'identifiant unique du manga",
|
|
||||||
"example": "123e4567-e89b-12d3-a456-426614174000"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1250,6 +1248,138 @@
|
|||||||
},
|
},
|
||||||
"parameters": []
|
"parameters": []
|
||||||
},
|
},
|
||||||
|
"/api/manga/{mangaId}/chapters/refresh": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "api_manga_mangaIdchaptersrefresh_post",
|
||||||
|
"tags": [
|
||||||
|
"MangaRefresh"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Demande de refresh accept\u00e9e et mise en file d'attente"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Manga non trouv\u00e9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Rafra\u00eechir les chapitres d'un manga",
|
||||||
|
"description": "Lance la synchronisation incr\u00e9mentale avec scraping automatique des nouveaux chapitres",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "mangaId",
|
||||||
|
"in": "path",
|
||||||
|
"description": "L'identifiant unique du manga",
|
||||||
|
"required": true,
|
||||||
|
"deprecated": false,
|
||||||
|
"allowEmptyValue": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"style": "simple",
|
||||||
|
"explode": false,
|
||||||
|
"allowReserved": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "The new MangaRefresh resource",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/ld+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh.jsonld"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/html": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/hal+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh.jsonhal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/x-cbz": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MangaRefresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
"parameters": []
|
||||||
|
},
|
||||||
|
"/api/manga/{mangaId}/monitoring/toggle": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "api_manga_mangaIdmonitoringtoggle_post",
|
||||||
|
"tags": [
|
||||||
|
"MangaMonitoring"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Monitoring modifi\u00e9 avec succ\u00e8s"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Manga non trouv\u00e9"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Donn\u00e9es de validation invalides"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Activer/D\u00e9sactiver le monitoring d'un manga",
|
||||||
|
"description": "Active ou d\u00e9sactive le monitoring automatique pour recevoir les nouveaux chapitres",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "mangaId",
|
||||||
|
"in": "path",
|
||||||
|
"description": "L'identifiant unique du manga",
|
||||||
|
"required": true,
|
||||||
|
"deprecated": false,
|
||||||
|
"allowEmptyValue": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"style": "simple",
|
||||||
|
"explode": false,
|
||||||
|
"allowReserved": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"description": "\u00c9tat du monitoring \u00e0 appliquer",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True pour activer le monitoring, false pour le d\u00e9sactiver",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
"parameters": []
|
||||||
|
},
|
||||||
"/api/mangadex-search": {
|
"/api/mangadex-search": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "api_mangadex-search_get",
|
"operationId": "api_mangadex-search_get",
|
||||||
@@ -4223,6 +4353,9 @@
|
|||||||
"number",
|
"number",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"monitored": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4301,6 +4434,9 @@
|
|||||||
"number",
|
"number",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"monitored": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4400,6 +4536,9 @@
|
|||||||
"number",
|
"number",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"monitored": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4893,6 +5032,156 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MangaMonitoring": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
|
||||||
|
"deprecated": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MangaMonitoring.jsonhal": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
|
||||||
|
"deprecated": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"_links": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"self": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"href": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "iri-reference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MangaMonitoring.jsonld": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
|
||||||
|
"deprecated": false,
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"@context": {
|
||||||
|
"readOnly": true,
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"@vocab": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hydra": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"http://www.w3.org/ns/hydra/core#"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"@vocab",
|
||||||
|
"hydra"
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@id": {
|
||||||
|
"readOnly": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"@type": {
|
||||||
|
"readOnly": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enabled": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MangaRefresh": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
|
||||||
|
"deprecated": false
|
||||||
|
},
|
||||||
|
"MangaRefresh.jsonhal": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
|
||||||
|
"deprecated": false,
|
||||||
|
"properties": {
|
||||||
|
"_links": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"self": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"href": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "iri-reference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MangaRefresh.jsonld": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
|
||||||
|
"deprecated": false,
|
||||||
|
"properties": {
|
||||||
|
"@context": {
|
||||||
|
"readOnly": true,
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"@vocab": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hydra": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"http://www.w3.org/ns/hydra/core#"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"@vocab",
|
||||||
|
"hydra"
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@id": {
|
||||||
|
"readOnly": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"@type": {
|
||||||
|
"readOnly": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"MangaSearchItem": {
|
"MangaSearchItem": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
readonly class CheckMonitoredMangas
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?DateTimeImmutable $since = null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Application\Command;
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
|
||||||
readonly class FetchMangaChapters
|
readonly class FetchMangaChapters
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId
|
public MangaId $mangaId
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
|
||||||
|
readonly class RefreshMangaChapters
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public MangaId $mangaId
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
|
||||||
|
readonly class ToggleMangaMonitoring
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public MangaId $mangaId,
|
||||||
|
public bool $enabled
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||||
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
readonly class CheckMonitoredMangasHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
|
private MessageBusInterface $commandBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(CheckMonitoredMangas $command): void
|
||||||
|
{
|
||||||
|
$criteria = new MonitoringCriteria(
|
||||||
|
enabled: true,
|
||||||
|
lastCheckBefore: $command->since ?? new DateTimeImmutable('-1 hour')
|
||||||
|
);
|
||||||
|
|
||||||
|
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
|
||||||
|
|
||||||
|
foreach ($monitoredMangas as $manga) {
|
||||||
|
$this->commandBus->dispatch(new RefreshMangaChapters($manga->getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,249 +3,25 @@
|
|||||||
namespace App\Domain\Manga\Application\CommandHandler;
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||||
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
|
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Model\Chapter;
|
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
|
||||||
use Ramsey\Uuid\Uuid;
|
|
||||||
|
|
||||||
readonly class FetchMangaChaptersHandler
|
readonly class FetchMangaChaptersHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangadexClientInterface $mangadexClient,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(FetchMangaChapters $command): void
|
public function handle(FetchMangaChapters $command): void
|
||||||
{
|
{
|
||||||
$manga = $this->mangaRepository->findById($command->mangaId);
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
if ($manga === null) {
|
||||||
throw new \RuntimeException('Manga not found');
|
throw new \RuntimeException('Manga not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($manga->getExternalId() === null) {
|
// Synchronisation initiale (pas d'événements)
|
||||||
throw new \RuntimeException('Manga has no external ID');
|
$this->chapterSynchronizationService->synchronizeChapters($manga);
|
||||||
}
|
|
||||||
|
|
||||||
$externalId = $manga->getExternalId()->getValue();
|
|
||||||
|
|
||||||
$offset = 0;
|
|
||||||
$limit = 500;
|
|
||||||
$hasMore = true;
|
|
||||||
$chaptersByNumber = [];
|
|
||||||
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
|
|
||||||
$chapterNumbers = [];
|
|
||||||
|
|
||||||
while ($hasMore) {
|
|
||||||
$feed = $this->mangadexClient->getMangaFeed(
|
|
||||||
$externalId,
|
|
||||||
$offset,
|
|
||||||
$limit
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($feed['data'] as $chapterData) {
|
|
||||||
$chapterNumber = (float) $chapterData['attributes']['chapter'];
|
|
||||||
$language = $chapterData['attributes']['translatedLanguage'];
|
|
||||||
$title = $chapterData['attributes']['title'];
|
|
||||||
|
|
||||||
// Pour les langues autres que français et anglais, on utilise un titre générique
|
|
||||||
if (!in_array($language, ['fr', 'en'])) {
|
|
||||||
$title = "Chapter {$chapterNumber}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Définir les règles de priorité des langues (fr > en > autres)
|
|
||||||
$shouldReplaceChapter = false;
|
|
||||||
|
|
||||||
if (!isset($chaptersByNumber[(string) $chapterNumber])) {
|
|
||||||
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
|
|
||||||
$shouldReplaceChapter = true;
|
|
||||||
$chapterNumbers[] = $chapterNumber;
|
|
||||||
} else if ($language === 'fr') {
|
|
||||||
// Le français est toujours prioritaire
|
|
||||||
$shouldReplaceChapter = true;
|
|
||||||
} else if ($language === 'en' && $chapterLanguages[(string) $chapterNumber] !== 'fr') {
|
|
||||||
// L'anglais est prioritaire sur les autres langues, sauf le français
|
|
||||||
$shouldReplaceChapter = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($shouldReplaceChapter) {
|
|
||||||
$chaptersByNumber[(string) $chapterNumber] = new Chapter(
|
|
||||||
new ChapterId((string) Uuid::uuid4()),
|
|
||||||
$manga->getId()->getValue(),
|
|
||||||
$chapterNumber,
|
|
||||||
$title,
|
|
||||||
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
new \DateTimeImmutable()
|
|
||||||
);
|
|
||||||
$chapterLanguages[(string) $chapterNumber] = $language;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$offset += $limit;
|
|
||||||
$hasMore = count($feed['data']) === $limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
|
||||||
$this->harmonizeVolumes($chaptersByNumber);
|
|
||||||
|
|
||||||
// Récupère les chapitres existants
|
|
||||||
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
|
|
||||||
$manga->getId()->getValue(),
|
|
||||||
$chapterNumbers
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sauvegarde uniquement les nouveaux chapitres
|
|
||||||
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
|
|
||||||
if (!isset($existingChapters[(float) $chapterNumber])) {
|
|
||||||
$this->mangaRepository->saveChapter($chapter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonise les volumes des chapitres:
|
|
||||||
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
|
||||||
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
|
|
||||||
* - Remplit les "trous" de volumes manquants dans une séquence
|
|
||||||
*/
|
|
||||||
private function harmonizeVolumes(array &$chaptersByNumber): void
|
|
||||||
{
|
|
||||||
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
|
|
||||||
uksort($chaptersByNumber, fn($a, $b) => (float)$a <=> (float)$b);
|
|
||||||
|
|
||||||
$chapterNumbers = array_keys($chaptersByNumber);
|
|
||||||
$count = count($chapterNumbers);
|
|
||||||
|
|
||||||
// Première passe : harmonisation locale (chapitres adjacents)
|
|
||||||
for ($i = 1; $i < $count - 1; $i++) {
|
|
||||||
$prevChapterNum = $chapterNumbers[$i - 1];
|
|
||||||
$currentChapterNum = $chapterNumbers[$i];
|
|
||||||
$nextChapterNum = $chapterNumbers[$i + 1];
|
|
||||||
|
|
||||||
$prevChapter = $chaptersByNumber[$prevChapterNum];
|
|
||||||
$currentChapter = $chaptersByNumber[$currentChapterNum];
|
|
||||||
$nextChapter = $chaptersByNumber[$nextChapterNum];
|
|
||||||
|
|
||||||
$shouldUpdateVolume = false;
|
|
||||||
$newVolume = $currentChapter->getVolume();
|
|
||||||
|
|
||||||
// Si les chapitres adjacents ont un volume null, alors le chapitre actuel aussi
|
|
||||||
if ($prevChapter->getVolume() === null && $nextChapter->getVolume() === null && $currentChapter->getVolume() !== null) {
|
|
||||||
$shouldUpdateVolume = true;
|
|
||||||
$newVolume = null;
|
|
||||||
}
|
|
||||||
// Si les chapitres adjacents ont le même volume non-null, alors le chapitre actuel aura ce volume
|
|
||||||
else if ($prevChapter->getVolume() !== null && $prevChapter->getVolume() === $nextChapter->getVolume() && $currentChapter->getVolume() !== $prevChapter->getVolume()) {
|
|
||||||
$shouldUpdateVolume = true;
|
|
||||||
$newVolume = $prevChapter->getVolume();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($shouldUpdateVolume) {
|
|
||||||
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $newVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deuxième passe : remplissage des trous de volumes
|
|
||||||
$this->fillVolumeGaps($chaptersByNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remplit les "trous" de volumes dans une séquence de chapitres.
|
|
||||||
* Par exemple, si on a : Ch.317(Vol.34), Ch.318(Vol.34), Ch.319(null), Ch.320(null), Ch.321(Vol.34)
|
|
||||||
* Alors Ch.319 et Ch.320 seront assignés au Vol.34
|
|
||||||
*/
|
|
||||||
private function fillVolumeGaps(array &$chaptersByNumber): void
|
|
||||||
{
|
|
||||||
$chapterNumbers = array_keys($chaptersByNumber);
|
|
||||||
$count = count($chapterNumbers);
|
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
|
||||||
$currentChapterNum = $chapterNumbers[$i];
|
|
||||||
$currentChapter = $chaptersByNumber[$currentChapterNum];
|
|
||||||
|
|
||||||
// Si le chapitre actuel n'a pas de volume, on cherche à le combler
|
|
||||||
if ($currentChapter->getVolume() === null) {
|
|
||||||
$volumeToAssign = $this->findVolumeForGap($chaptersByNumber, $chapterNumbers, $i);
|
|
||||||
|
|
||||||
if ($volumeToAssign !== null) {
|
|
||||||
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $volumeToAssign);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trouve le volume à assigner pour un chapitre sans volume en analysant son contexte
|
|
||||||
*/
|
|
||||||
private function findVolumeForGap(array $chaptersByNumber, array $chapterNumbers, int $currentIndex): ?int
|
|
||||||
{
|
|
||||||
$count = count($chapterNumbers);
|
|
||||||
|
|
||||||
// Cherche le volume précédent non-null
|
|
||||||
$prevVolume = null;
|
|
||||||
for ($i = $currentIndex - 1; $i >= 0; $i--) {
|
|
||||||
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
|
|
||||||
if ($prevChapter->getVolume() !== null) {
|
|
||||||
$prevVolume = $prevChapter->getVolume();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cherche le volume suivant non-null
|
|
||||||
$nextVolume = null;
|
|
||||||
for ($i = $currentIndex + 1; $i < $count; $i++) {
|
|
||||||
$nextChapter = $chaptersByNumber[$chapterNumbers[$i]];
|
|
||||||
if ($nextChapter->getVolume() !== null) {
|
|
||||||
$nextVolume = $nextChapter->getVolume();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si les volumes précédent et suivant sont identiques et non-null, on utilise ce volume
|
|
||||||
if ($prevVolume !== null && $prevVolume === $nextVolume) {
|
|
||||||
return $prevVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si on a seulement un volume précédent, on vérifie s'il est raisonnable de l'utiliser
|
|
||||||
// (pas plus de 10 chapitres d'écart pour éviter les erreurs)
|
|
||||||
if ($prevVolume !== null && $nextVolume === null) {
|
|
||||||
$currentChapterNumber = (float) $chapterNumbers[$currentIndex];
|
|
||||||
|
|
||||||
// Trouve le numéro du chapitre qui a ce volume précédent
|
|
||||||
for ($i = $currentIndex - 1; $i >= 0; $i--) {
|
|
||||||
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
|
|
||||||
if ($prevChapter->getVolume() === $prevVolume) {
|
|
||||||
$prevChapterNumber = (float) $chapterNumbers[$i];
|
|
||||||
// Si l'écart est raisonnable (moins de 10 chapitres), on assigne le volume
|
|
||||||
if ($currentChapterNumber - $prevChapterNumber <= 10) {
|
|
||||||
return $prevVolume;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crée un nouveau chapitre avec un volume différent (les chapitres sont immuables)
|
|
||||||
*/
|
|
||||||
private function createChapterWithNewVolume(Chapter $chapter, ?int $newVolume): Chapter
|
|
||||||
{
|
|
||||||
return new Chapter(
|
|
||||||
id: new ChapterId($chapter->getId()),
|
|
||||||
mangaId: $chapter->getMangaId(),
|
|
||||||
number: $chapter->getNumber(),
|
|
||||||
title: $chapter->getTitle(),
|
|
||||||
volume: $newVolume,
|
|
||||||
isVisible: $chapter->isVisible(),
|
|
||||||
cbzPath: $chapter->getCbzPath(),
|
|
||||||
createdAt: $chapter->getCreatedAt()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||||
|
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
readonly class RefreshMangaChaptersHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
||||||
|
private MessageBusInterface $eventBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(RefreshMangaChapters $command): void
|
||||||
|
{
|
||||||
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
|
if ($manga === null) {
|
||||||
|
throw new \RuntimeException('Manga not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronisation + récupération des nouveaux IDs
|
||||||
|
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
|
||||||
|
|
||||||
|
// Mise à jour de la date de monitoring
|
||||||
|
$manga->updateLastMonitoringCheck(new DateTimeImmutable());
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
// Événement de scraping pour chaque nouveau chapitre
|
||||||
|
foreach ($newChapterIds as $chapterId) {
|
||||||
|
$this->eventBus->dispatch(
|
||||||
|
new ChapterReadyForScraping(new ChapterId($chapterId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
|
|
||||||
|
readonly class ToggleMangaMonitoringHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(ToggleMangaMonitoring $command): void
|
||||||
|
{
|
||||||
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
|
if (!$manga) {
|
||||||
|
throw new MangaNotFoundException($command->mangaId->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command->enabled) {
|
||||||
|
$manga->enableMonitoring();
|
||||||
|
} else {
|
||||||
|
$manga->disableMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Domain/Manga/Application/Query/MonitoringCriteria.php
Normal file
13
src/Domain/Manga/Application/Query/MonitoringCriteria.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
readonly class MonitoringCriteria
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $enabled,
|
||||||
|
public ?DateTimeImmutable $lastCheckBefore = null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ readonly class GetMangaByIdHandler
|
|||||||
externalId: $manga->getExternalId()?->getValue(),
|
externalId: $manga->getExternalId()?->getValue(),
|
||||||
imageUrl: $manga->getImageUrl(),
|
imageUrl: $manga->getImageUrl(),
|
||||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||||
rating: $manga->getRating()
|
rating: $manga->getRating(),
|
||||||
|
monitored: $manga->isMonitoringEnabled()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ readonly class MangaResponse
|
|||||||
public ?string $externalId,
|
public ?string $externalId,
|
||||||
public ?string $imageUrl,
|
public ?string $imageUrl,
|
||||||
public ?string $thumbnailUrl,
|
public ?string $thumbnailUrl,
|
||||||
public ?float $rating
|
public ?float $rating,
|
||||||
|
public bool $monitored
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Domain\Contract\Repository;
|
namespace App\Domain\Manga\Domain\Contract\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
use App\Domain\Manga\Domain\Model\Manga;
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
use App\Domain\Manga\Domain\Model\Chapter;
|
use App\Domain\Manga\Domain\Model\Chapter;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
|
|
||||||
interface MangaRepositoryInterface
|
interface MangaRepositoryInterface
|
||||||
@@ -17,7 +19,7 @@ interface MangaRepositoryInterface
|
|||||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
||||||
public function countChapters(string $mangaId): int;
|
public function countChapters(string $mangaId): int;
|
||||||
public function findByExternalId(ExternalId $externalId): ?Manga;
|
public function findByExternalId(ExternalId $externalId): ?Manga;
|
||||||
public function saveChapter(Chapter $chapter): void;
|
public function saveChapter(Chapter $chapter): ChapterId;
|
||||||
public function findBySlug(MangaSlug $slug): ?Manga;
|
public function findBySlug(MangaSlug $slug): ?Manga;
|
||||||
public function search(string $query, int $page = 1, int $limit = 20): array;
|
public function search(string $query, int $page = 1, int $limit = 20): array;
|
||||||
public function countSearch(string $query): int;
|
public function countSearch(string $query): int;
|
||||||
@@ -26,4 +28,9 @@ interface MangaRepositoryInterface
|
|||||||
* @return array<float, Chapter>
|
* @return array<float, Chapter>
|
||||||
*/
|
*/
|
||||||
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array;
|
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Manga[]
|
||||||
|
*/
|
||||||
|
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Contract\Service;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
|
||||||
|
interface ChapterSynchronizationServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Synchronise les chapitres d'un manga depuis la source externe
|
||||||
|
* @return string[] IDs des nouveaux chapitres ajoutés
|
||||||
|
*/
|
||||||
|
public function synchronizeChapters(Manga $manga): array;
|
||||||
|
}
|
||||||
12
src/Domain/Manga/Domain/Event/ChapterReadyForScraping.php
Normal file
12
src/Domain/Manga/Domain/Event/ChapterReadyForScraping.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Event;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
|
|
||||||
|
readonly class ChapterReadyForScraping
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ChapterId $chapterId
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final class Manga
|
final class Manga
|
||||||
@@ -26,7 +27,11 @@ final class Manga
|
|||||||
private ?ImageUrls $imageUrls = null,
|
private ?ImageUrls $imageUrls = null,
|
||||||
private array $alternativeSlugs = [],
|
private array $alternativeSlugs = [],
|
||||||
private ?DateTimeImmutable $createdAt = null,
|
private ?DateTimeImmutable $createdAt = null,
|
||||||
) {}
|
private ?MonitoringStatus $monitoringStatus = null,
|
||||||
|
private ?DateTimeImmutable $lastMonitoringCheck = null,
|
||||||
|
) {
|
||||||
|
$this->monitoringStatus = $this->monitoringStatus ?? MonitoringStatus::disabled();
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): MangaId
|
public function getId(): MangaId
|
||||||
{
|
{
|
||||||
@@ -147,4 +152,36 @@ final class Manga
|
|||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMonitoringStatus(): MonitoringStatus
|
||||||
|
{
|
||||||
|
return $this->monitoringStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMonitoringEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->monitoringStatus->isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enableMonitoring(): void
|
||||||
|
{
|
||||||
|
$this->monitoringStatus = MonitoringStatus::enabled();
|
||||||
|
$this->lastMonitoringCheck = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disableMonitoring(): void
|
||||||
|
{
|
||||||
|
$this->monitoringStatus = MonitoringStatus::disabled();
|
||||||
|
$this->lastMonitoringCheck = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastMonitoringCheck(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->lastMonitoringCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLastMonitoringCheck(DateTimeImmutable $lastMonitoringCheck): void
|
||||||
|
{
|
||||||
|
$this->lastMonitoringCheck = $lastMonitoringCheck;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Model\ValueObject;
|
||||||
|
|
||||||
|
readonly class MonitoringStatus
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private bool $enabled
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function enabled(): self
|
||||||
|
{
|
||||||
|
return new self(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function disabled(): self
|
||||||
|
{
|
||||||
|
return new self(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->enabled === $other->enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ readonly class MangaDetail
|
|||||||
public ?string $externalId,
|
public ?string $externalId,
|
||||||
public ?string $imageUrl,
|
public ?string $imageUrl,
|
||||||
public ?string $thumbnailUrl,
|
public ?string $thumbnailUrl,
|
||||||
public ?float $rating
|
public ?float $rating,
|
||||||
|
public bool $monitored
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -28,9 +28,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
'properties' => [
|
'properties' => [
|
||||||
'mangaId' => [
|
'mangaId' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'format' => 'uuid',
|
// 'format' => 'uuid',
|
||||||
'description' => 'L\'identifiant unique du manga',
|
'description' => 'L\'identifiant unique du manga',
|
||||||
'example' => '123e4567-e89b-12d3-a456-426614174000'
|
// 'example' => '123e4567-e89b-12d3-a456-426614174000'
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'required' => ['mangaId']
|
'required' => ['mangaId']
|
||||||
@@ -54,7 +54,7 @@ class FetchMangaChaptersResource
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Assert\NotBlank(message: 'L\'identifiant du manga est obligatoire')]
|
#[Assert\NotBlank(message: 'L\'identifiant du manga est obligatoire')]
|
||||||
#[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
|
// #[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
|
||||||
public string $mangaId
|
public string $mangaId
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\RefreshMangaChaptersProcessor;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'MangaRefresh',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/manga/{mangaId}/chapters/refresh',
|
||||||
|
processor: RefreshMangaChaptersProcessor::class,
|
||||||
|
status: 202,
|
||||||
|
description: 'Déclenche la synchronisation et le scraping des nouveaux chapitres d\'un manga',
|
||||||
|
openapiContext: [
|
||||||
|
'summary' => 'Rafraîchir les chapitres d\'un manga',
|
||||||
|
'description' => 'Lance la synchronisation incrémentale avec scraping automatique des nouveaux chapitres',
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'name' => 'mangaId',
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => ['type' => 'string'],
|
||||||
|
'description' => 'L\'identifiant unique du manga'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'responses' => [
|
||||||
|
'202' => [
|
||||||
|
'description' => 'Demande de refresh acceptée et mise en file d\'attente'
|
||||||
|
],
|
||||||
|
'404' => [
|
||||||
|
'description' => 'Manga non trouvé'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class RefreshMangaChaptersResource
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\ToggleMonitoringProcessor;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'MangaMonitoring',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/manga/{mangaId}/monitoring/toggle',
|
||||||
|
processor: ToggleMonitoringProcessor::class,
|
||||||
|
read: false,
|
||||||
|
status: 204,
|
||||||
|
description: 'Active ou désactive le monitoring automatique d\'un manga',
|
||||||
|
openapiContext: [
|
||||||
|
'summary' => 'Activer/Désactiver le monitoring d\'un manga',
|
||||||
|
'description' => 'Active ou désactive le monitoring automatique pour recevoir les nouveaux chapitres',
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'name' => 'mangaId',
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => ['type' => 'string'],
|
||||||
|
'description' => 'L\'identifiant unique du manga'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'requestBody' => [
|
||||||
|
'description' => 'État du monitoring à appliquer',
|
||||||
|
'required' => true,
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'enabled' => [
|
||||||
|
'type' => 'boolean',
|
||||||
|
'description' => 'True pour activer le monitoring, false pour le désactiver',
|
||||||
|
'example' => true
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'required' => ['enabled']
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'responses' => [
|
||||||
|
'204' => [
|
||||||
|
'description' => 'Monitoring modifié avec succès'
|
||||||
|
],
|
||||||
|
'404' => [
|
||||||
|
'description' => 'Manga non trouvé'
|
||||||
|
],
|
||||||
|
'422' => [
|
||||||
|
'description' => 'Données de validation invalides'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class ToggleMonitoringResource
|
||||||
|
{
|
||||||
|
#[Assert\NotNull(message: 'Le champ enabled est obligatoire')]
|
||||||
|
#[Assert\Type(type: 'boolean', message: 'Cette valeur doit être de type bool.')]
|
||||||
|
public mixed $enabled = null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ readonly class FetchMangaChaptersProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->messageBus->dispatch(
|
$this->messageBus->dispatch(
|
||||||
new FetchMangaChapters($data->mangaId)
|
new FetchMangaChapters(new MangaId($data->mangaId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
readonly class RefreshMangaChaptersProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MessageBusInterface $commandBus,
|
||||||
|
private MangaRepositoryInterface $mangaRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||||
|
{
|
||||||
|
$mangaId = $uriVariables['mangaId'] ?? null;
|
||||||
|
|
||||||
|
if (!$mangaId) {
|
||||||
|
throw new \InvalidArgumentException('Manga ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le manga existe
|
||||||
|
$manga = $this->mangaRepository->findById($mangaId);
|
||||||
|
if (!$manga) {
|
||||||
|
throw new MangaNotFoundException($mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(
|
||||||
|
new RefreshMangaChapters(new MangaId($mangaId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
|
||||||
|
use App\Domain\Manga\Application\CommandHandler\ToggleMangaMonitoringHandler;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ToggleMonitoringResource;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
readonly class ToggleMonitoringProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ToggleMangaMonitoringHandler $handler
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||||
|
{
|
||||||
|
if (!$data instanceof ToggleMonitoringResource) {
|
||||||
|
throw new \InvalidArgumentException('Invalid resource type');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mangaId = $uriVariables['mangaId'] ?? null;
|
||||||
|
|
||||||
|
if (!$mangaId) {
|
||||||
|
throw new \InvalidArgumentException('Manga ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// La validation Symfony s'assure que enabled est un booléen valide
|
||||||
|
if ($data->enabled === null) {
|
||||||
|
throw new \InvalidArgumentException('Enabled field is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new ToggleMangaMonitoring(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
$data->enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->handler->handle($command);
|
||||||
|
} catch (MangaNotFoundException $e) {
|
||||||
|
throw new NotFoundHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ readonly class GetMangaStateProvider implements ProviderInterface
|
|||||||
externalId: $response->externalId,
|
externalId: $response->externalId,
|
||||||
imageUrl: $response->imageUrl,
|
imageUrl: $response->imageUrl,
|
||||||
thumbnailUrl: $response->thumbnailUrl,
|
thumbnailUrl: $response->thumbnailUrl,
|
||||||
rating: $response->rating
|
rating: $response->rating,
|
||||||
|
monitored: $response->monitored
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
|
use App\Domain\Manga\Application\CommandHandler\RefreshMangaChaptersHandler;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
readonly class SymfonyRefreshMangaChaptersHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RefreshMangaChaptersHandler $handler
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(RefreshMangaChapters $command): void
|
||||||
|
{
|
||||||
|
$this->handler->handle($command);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Infrastructure\Persistence;
|
namespace App\Domain\Manga\Infrastructure\Persistence;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
|
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
@@ -9,6 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
|
||||||
use App\Entity\Manga as EntityManga;
|
use App\Entity\Manga as EntityManga;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use App\Domain\Manga\Domain\Model\Chapter;
|
use App\Domain\Manga\Domain\Model\Chapter;
|
||||||
@@ -50,7 +52,12 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
|
|
||||||
public function findById(string $id): ?DomainManga
|
public function findById(string $id): ?DomainManga
|
||||||
{
|
{
|
||||||
$entity = $this->entityManager->find(EntityManga::class, $id);
|
// Convertir le string ID en integer pour la base de données
|
||||||
|
if (!is_numeric($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entity = $this->entityManager->find(EntityManga::class, (int) $id);
|
||||||
|
|
||||||
return $entity ? $this->toDomain($entity) : null;
|
return $entity ? $this->toDomain($entity) : null;
|
||||||
}
|
}
|
||||||
@@ -88,18 +95,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
->setStatus($manga->getStatus())
|
->setStatus($manga->getStatus())
|
||||||
->setImageUrl($fullImageUrl ?? null)
|
->setImageUrl($fullImageUrl ?? null)
|
||||||
->setThumbnailUrl($thumbnailUrl ?? null)
|
->setThumbnailUrl($thumbnailUrl ?? null)
|
||||||
->setAlternativeSlugs($manga->getAlternativeSlugs());
|
->setAlternativeSlugs($manga->getAlternativeSlugs())
|
||||||
|
->setMonitored($manga->isMonitoringEnabled())
|
||||||
|
->setLastMonitoringCheck($manga->getLastMonitoringCheck());
|
||||||
|
|
||||||
// Only set externalId if it exists (to avoid setting null on update)
|
// Only set externalId if it exists (to avoid setting null on update)
|
||||||
if ($manga->getExternalId()) {
|
if ($manga->getExternalId()) {
|
||||||
$entity->setExternalId($manga->getExternalId()->getValue());
|
$entity->setExternalId($manga->getExternalId()->getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set monitored for new entities
|
|
||||||
if (!$entity->getId()) {
|
|
||||||
$entity->setMonitored(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($manga->getRating() !== null) {
|
if ($manga->getRating() !== null) {
|
||||||
$entity->setRating($manga->getRating());
|
$entity->setRating($manga->getRating());
|
||||||
}
|
}
|
||||||
@@ -162,7 +166,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
return $entity ? $this->toDomain($entity) : null;
|
return $entity ? $this->toDomain($entity) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveChapter(Chapter $chapter): void
|
public function saveChapter(Chapter $chapter): ChapterId
|
||||||
{
|
{
|
||||||
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
|
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
|
||||||
|
|
||||||
@@ -179,6 +183,8 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
|
|
||||||
$this->entityManager->persist($entity);
|
$this->entityManager->persist($entity);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new ChapterId((string) $entity->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function search(string $query, int $page = 1, int $limit = 20): array
|
public function search(string $query, int $page = 1, int $limit = 20): array
|
||||||
@@ -241,6 +247,25 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
return $chaptersByNumber;
|
return $chaptersByNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('m')
|
||||||
|
->from(EntityManga::class, 'm')
|
||||||
|
->where('m.monitored = :enabled')
|
||||||
|
->setParameter('enabled', $criteria->enabled);
|
||||||
|
|
||||||
|
if ($criteria->lastCheckBefore) {
|
||||||
|
$queryBuilder->andWhere('(m.lastMonitoringCheck IS NULL OR m.lastMonitoringCheck < :lastCheckBefore)')
|
||||||
|
->setParameter('lastCheckBefore', $criteria->lastCheckBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (EntityManga $entity) => $this->toDomain($entity),
|
||||||
|
$queryBuilder->getQuery()->getResult()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function toDomain(EntityManga $entity): DomainManga
|
private function toDomain(EntityManga $entity): DomainManga
|
||||||
{
|
{
|
||||||
return new DomainManga(
|
return new DomainManga(
|
||||||
@@ -258,6 +283,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
|
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
|
||||||
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
|
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
|
||||||
createdAt: $entity->getCreatedAt(),
|
createdAt: $entity->getCreatedAt(),
|
||||||
|
monitoringStatus: $entity->isMonitored() ? MonitoringStatus::enabled() : MonitoringStatus::disabled()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Scheduler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||||
|
use Symfony\Component\Scheduler\RecurringMessage;
|
||||||
|
use Symfony\Component\Scheduler\Schedule;
|
||||||
|
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
#[AsSchedule]
|
||||||
|
class MonitoringSchedule implements ScheduleProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CacheInterface $cache
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getSchedule(): Schedule
|
||||||
|
{
|
||||||
|
return (new Schedule())->add(
|
||||||
|
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
|
||||||
|
RecurringMessage::every('2 hours', new CheckMonitoredMangas(
|
||||||
|
new DateTimeImmutable('-2 hours')
|
||||||
|
))
|
||||||
|
)->stateful($this->cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||||
|
use App\Domain\Manga\Domain\Model\Chapter;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
readonly class MangadxChapterSynchronizationService implements ChapterSynchronizationServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangadexClientInterface $mangadxClient,
|
||||||
|
private MangaRepositoryInterface $mangaRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function synchronizeChapters(Manga $manga): array
|
||||||
|
{
|
||||||
|
if ($manga->getExternalId() === null) {
|
||||||
|
throw new \RuntimeException('Manga has no external ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalId = $manga->getExternalId()->getValue();
|
||||||
|
|
||||||
|
$offset = 0;
|
||||||
|
$limit = 500;
|
||||||
|
$hasMore = true;
|
||||||
|
$chaptersByNumber = [];
|
||||||
|
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
|
||||||
|
$chapterNumbers = [];
|
||||||
|
|
||||||
|
while ($hasMore) {
|
||||||
|
$feed = $this->mangadxClient->getMangaFeed(
|
||||||
|
$externalId,
|
||||||
|
$offset,
|
||||||
|
$limit
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($feed['data'] as $chapterData) {
|
||||||
|
$chapterNumber = (float) $chapterData['attributes']['chapter'];
|
||||||
|
$language = $chapterData['attributes']['translatedLanguage'];
|
||||||
|
$title = $chapterData['attributes']['title'];
|
||||||
|
|
||||||
|
// Pour les langues autres que français et anglais, on utilise un titre générique
|
||||||
|
if (!in_array($language, ['fr', 'en'])) {
|
||||||
|
$title = "Chapter {$chapterNumber}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Définir les règles de priorité des langues (fr > en > autres)
|
||||||
|
$shouldReplaceChapter = false;
|
||||||
|
|
||||||
|
if (!isset($chaptersByNumber[(string) $chapterNumber])) {
|
||||||
|
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
$chapterNumbers[] = $chapterNumber;
|
||||||
|
} else if ($language === 'fr') {
|
||||||
|
// Le français est toujours prioritaire
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
} else if ($language === 'en' && $chapterLanguages[(string) $chapterNumber] !== 'fr') {
|
||||||
|
// L'anglais est prioritaire sur les autres langues, sauf le français
|
||||||
|
$shouldReplaceChapter = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldReplaceChapter) {
|
||||||
|
$chaptersByNumber[(string) $chapterNumber] = new Chapter(
|
||||||
|
new ChapterId((string) Uuid::uuid4()),
|
||||||
|
$manga->getId()->getValue(),
|
||||||
|
$chapterNumber,
|
||||||
|
$title,
|
||||||
|
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
new \DateTimeImmutable()
|
||||||
|
);
|
||||||
|
$chapterLanguages[(string) $chapterNumber] = $language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += $limit;
|
||||||
|
$hasMore = count($feed['data']) === $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
||||||
|
$this->harmonizeVolumes($chaptersByNumber);
|
||||||
|
|
||||||
|
// Récupère les chapitres existants
|
||||||
|
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
|
||||||
|
$manga->getId()->getValue(),
|
||||||
|
$chapterNumbers
|
||||||
|
);
|
||||||
|
|
||||||
|
$newChapterIds = [];
|
||||||
|
|
||||||
|
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs
|
||||||
|
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
|
||||||
|
if (!isset($existingChapters[(float) $chapterNumber])) {
|
||||||
|
$newChapterId = $this->mangaRepository->saveChapter($chapter);
|
||||||
|
$newChapterIds[] = $newChapterId->getValue(); // ✨ Collecte des IDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $newChapterIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harmonise les volumes des chapitres:
|
||||||
|
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
|
||||||
|
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
|
||||||
|
* - Remplit les "trous" de volumes manquants dans une séquence
|
||||||
|
*/
|
||||||
|
private function harmonizeVolumes(array &$chaptersByNumber): void
|
||||||
|
{
|
||||||
|
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
|
||||||
|
uksort($chaptersByNumber, fn($a, $b) => (float)$a <=> (float)$b);
|
||||||
|
|
||||||
|
$chapterNumbers = array_keys($chaptersByNumber);
|
||||||
|
$count = count($chapterNumbers);
|
||||||
|
|
||||||
|
// Première passe : harmonisation locale (chapitres adjacents)
|
||||||
|
for ($i = 1; $i < $count - 1; $i++) {
|
||||||
|
$prevChapterNum = $chapterNumbers[$i - 1];
|
||||||
|
$currentChapterNum = $chapterNumbers[$i];
|
||||||
|
$nextChapterNum = $chapterNumbers[$i + 1];
|
||||||
|
|
||||||
|
$prevChapter = $chaptersByNumber[$prevChapterNum];
|
||||||
|
$currentChapter = $chaptersByNumber[$currentChapterNum];
|
||||||
|
$nextChapter = $chaptersByNumber[$nextChapterNum];
|
||||||
|
|
||||||
|
$prevVolume = $prevChapter->getVolume();
|
||||||
|
$currentVolume = $currentChapter->getVolume();
|
||||||
|
$nextVolume = $nextChapter->getVolume();
|
||||||
|
|
||||||
|
// Règle 1: Si précédent et suivant sont null, alors actuel aussi
|
||||||
|
if ($prevVolume === null && $nextVolume === null && $currentVolume !== null) {
|
||||||
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
|
new ChapterId($currentChapter->getId()),
|
||||||
|
$currentChapter->getMangaId(),
|
||||||
|
$currentChapter->getNumber(),
|
||||||
|
$currentChapter->getTitle(),
|
||||||
|
null, // volume = null
|
||||||
|
$currentChapter->isVisible(),
|
||||||
|
$currentChapter->getCbzPath(),
|
||||||
|
$currentChapter->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Règle 2: Si précédent et suivant ont le même volume, alors actuel aussi
|
||||||
|
else if ($prevVolume !== null && $prevVolume === $nextVolume && $currentVolume !== $prevVolume) {
|
||||||
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
|
new ChapterId($currentChapter->getId()),
|
||||||
|
$currentChapter->getMangaId(),
|
||||||
|
$currentChapter->getNumber(),
|
||||||
|
$currentChapter->getTitle(),
|
||||||
|
$prevVolume, // prend le volume des adjacents
|
||||||
|
$currentChapter->isVisible(),
|
||||||
|
$currentChapter->getCbzPath(),
|
||||||
|
$currentChapter->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deuxième passe : comblement des trous de volumes
|
||||||
|
$this->fillVolumeGaps($chaptersByNumber, $chapterNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remplit les "trous" de volumes manquants dans une séquence
|
||||||
|
*/
|
||||||
|
private function fillVolumeGaps(array &$chaptersByNumber, array $chapterNumbers): void
|
||||||
|
{
|
||||||
|
$count = count($chapterNumbers);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$currentChapterNum = $chapterNumbers[$i];
|
||||||
|
$currentChapter = $chaptersByNumber[$currentChapterNum];
|
||||||
|
|
||||||
|
if ($currentChapter->getVolume() !== null) {
|
||||||
|
continue; // Ce chapitre a déjà un volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cherche le volume précédent non-null
|
||||||
|
$prevVolume = null;
|
||||||
|
for ($j = $i - 1; $j >= 0; $j--) {
|
||||||
|
$prevChapter = $chaptersByNumber[$chapterNumbers[$j]];
|
||||||
|
if ($prevChapter->getVolume() !== null) {
|
||||||
|
$prevVolume = $prevChapter->getVolume();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cherche le volume suivant non-null
|
||||||
|
$nextVolume = null;
|
||||||
|
for ($k = $i + 1; $k < $count; $k++) {
|
||||||
|
$nextChapter = $chaptersByNumber[$chapterNumbers[$k]];
|
||||||
|
if ($nextChapter->getVolume() !== null) {
|
||||||
|
$nextVolume = $nextChapter->getVolume();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
|
||||||
|
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
|
||||||
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
|
new ChapterId($currentChapter->getId()),
|
||||||
|
$currentChapter->getMangaId(),
|
||||||
|
$currentChapter->getNumber(),
|
||||||
|
$currentChapter->getTitle(),
|
||||||
|
$prevVolume,
|
||||||
|
$currentChapter->isVisible(),
|
||||||
|
$currentChapter->getCbzPath(),
|
||||||
|
$currentChapter->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
|
||||||
|
else if ($nextVolume !== null && $prevVolume === null) {
|
||||||
|
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||||
|
new ChapterId($currentChapter->getId()),
|
||||||
|
$currentChapter->getMangaId(),
|
||||||
|
$currentChapter->getNumber(),
|
||||||
|
$currentChapter->getTitle(),
|
||||||
|
$nextVolume,
|
||||||
|
$currentChapter->isVisible(),
|
||||||
|
$currentChapter->getCbzPath(),
|
||||||
|
$currentChapter->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Scraping\Infrastructure\EventListener;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
||||||
|
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||||
|
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
class AutoScrapingListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ScrapeChapterHandler $scrapeChapterHandler
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
public function onChapterReadyForScraping(ChapterReadyForScraping $event): void
|
||||||
|
{
|
||||||
|
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ class Manga
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?bool $monitored = null;
|
private ?bool $monitored = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $lastMonitoringCheck = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||||
private ?array $AlternativeSlugs = null;
|
private ?array $AlternativeSlugs = null;
|
||||||
|
|
||||||
@@ -318,4 +321,16 @@ class Manga
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLastMonitoringCheck(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->lastMonitoringCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastMonitoringCheck(?\DateTimeImmutable $lastMonitoringCheck): self
|
||||||
|
{
|
||||||
|
$this->lastMonitoringCheck = $lastMonitoringCheck;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Tests\Domain\Manga\Adapter;
|
namespace App\Tests\Domain\Manga\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Model\Chapter;
|
use App\Domain\Manga\Domain\Model\Chapter;
|
||||||
use App\Domain\Manga\Domain\Model\Manga;
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
@@ -190,4 +191,27 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
|||||||
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
|
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
||||||
|
{
|
||||||
|
return array_filter(
|
||||||
|
array_values($this->mangas),
|
||||||
|
function (Manga $manga) use ($criteria) {
|
||||||
|
// Vérifier si le monitoring est activé selon le critère
|
||||||
|
if ($manga->getMonitoringStatus()->isEnabled() !== $criteria->enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la date de dernière vérification si spécifiée
|
||||||
|
if ($criteria->lastCheckBefore !== null) {
|
||||||
|
$lastCheck = $manga->getLastMonitoringCheck();
|
||||||
|
if ($lastCheck === null || $lastCheck >= $criteria->lastCheckBefore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ToggleMangaMonitoringTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCreateCommandWithValidData(): void
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
$mangaId = new MangaId('manga-123');
|
||||||
|
$enabled = true;
|
||||||
|
|
||||||
|
$command = new ToggleMangaMonitoring($mangaId, $enabled);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertEquals($mangaId, $command->mangaId);
|
||||||
|
$this->assertTrue($command->enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateCommandWithDisabled(): void
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
$mangaId = new MangaId('manga-456');
|
||||||
|
$enabled = false;
|
||||||
|
|
||||||
|
$command = new ToggleMangaMonitoring($mangaId, $enabled);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertEquals($mangaId, $command->mangaId);
|
||||||
|
$this->assertFalse($command->enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCommandIsReadonly(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId('manga-123'), true);
|
||||||
|
|
||||||
|
// Act & Assert - Tenter de modifier les propriétés devrait être impossible
|
||||||
|
$reflection = new \ReflectionClass($command);
|
||||||
|
$this->assertTrue($reflection->isReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
|
||||||
|
use App\Domain\Manga\Application\CommandHandler\ToggleMangaMonitoringHandler;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
|
||||||
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ToggleMangaMonitoringHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
|
private ToggleMangaMonitoringHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
|
$this->handler = new ToggleMangaMonitoringHandler($this->mangaRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnableMonitoringForManga(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$mangaId = 'manga-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId('external-123')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId($mangaId), true);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$updatedManga = $this->mangaRepository->findById($mangaId);
|
||||||
|
$this->assertNotNull($updatedManga);
|
||||||
|
$this->assertTrue($updatedManga->getMonitoringStatus()->isEnabled());
|
||||||
|
$this->assertNotNull($updatedManga->getLastMonitoringCheck());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDisableMonitoringForManga(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$mangaId = 'manga-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId('external-123')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activer d'abord le monitoring
|
||||||
|
$manga->enableMonitoring();
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId($mangaId), false);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$updatedManga = $this->mangaRepository->findById($mangaId);
|
||||||
|
$this->assertNotNull($updatedManga);
|
||||||
|
$this->assertFalse($updatedManga->getMonitoringStatus()->isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleMonitoringWithNonExistingManga(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$nonExistingMangaId = 'non-existing-manga';
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
$this->expectException(\App\Domain\Manga\Domain\Exception\MangaNotFoundException::class);
|
||||||
|
$this->expectExceptionMessage($nonExistingMangaId);
|
||||||
|
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId($nonExistingMangaId), true);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnableMonitoringWhenAlreadyEnabled(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$mangaId = 'manga-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId('external-123')
|
||||||
|
);
|
||||||
|
|
||||||
|
$manga->enableMonitoring();
|
||||||
|
$firstActivationTime = $manga->getLastMonitoringCheck();
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
// Wait a bit to ensure time difference
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId($mangaId), true);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$updatedManga = $this->mangaRepository->findById($mangaId);
|
||||||
|
$this->assertNotNull($updatedManga);
|
||||||
|
$this->assertTrue($updatedManga->getMonitoringStatus()->isEnabled());
|
||||||
|
|
||||||
|
// Le timestamp devrait être mis à jour même si déjà activé
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
$firstActivationTime->getTimestamp(),
|
||||||
|
$updatedManga->getLastMonitoringCheck()->getTimestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDisableMonitoringWhenAlreadyDisabled(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$mangaId = 'manga-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId($mangaId),
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId('external-123')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$command = new ToggleMangaMonitoring(new MangaId($mangaId), false);
|
||||||
|
$this->handler->handle($command);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$updatedManga = $this->mangaRepository->findById($mangaId);
|
||||||
|
$this->assertNotNull($updatedManga);
|
||||||
|
$this->assertFalse($updatedManga->getMonitoringStatus()->isEnabled());
|
||||||
|
$this->assertNull($updatedManga->getLastMonitoringCheck());
|
||||||
|
}
|
||||||
|
}
|
||||||
199
tests/Feature/Manga/ToggleMonitoringTest.php
Normal file
199
tests/Feature/Manga/ToggleMonitoringTest.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Feature\Manga;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use App\Tests\Feature\AbstractApiTestCase;
|
||||||
|
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||||
|
|
||||||
|
class ToggleMonitoringTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
use ResetDatabase;
|
||||||
|
|
||||||
|
public function testEnableMonitoringForExistingManga(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$externalId = 'external-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId($externalId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$entity = $this->toEntity($manga);
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
|
||||||
|
|
||||||
|
// Act
|
||||||
|
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
|
||||||
|
'json' => [
|
||||||
|
'enabled' => true
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// Vérifier que le statut de monitoring a été mis à jour dans la base de données
|
||||||
|
$updatedEntity = $this->entityManager->find(\App\Entity\Manga::class, $mangaId);
|
||||||
|
$this->assertNotNull($updatedEntity);
|
||||||
|
$this->assertTrue($updatedEntity->isMonitored(), 'Le manga devrait être monitoré');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDisableMonitoringForExistingManga(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$externalId = 'external-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId($externalId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$entity = $this->toEntity($manga);
|
||||||
|
$entity->setMonitored(true); // Initialiser à true pour tester la désactivation
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
|
||||||
|
|
||||||
|
// Act
|
||||||
|
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
|
||||||
|
'json' => [
|
||||||
|
'enabled' => false
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// Vérifier que le statut de monitoring a été mis à jour dans la base de données
|
||||||
|
$updatedEntity = $this->entityManager->find(\App\Entity\Manga::class, $mangaId);
|
||||||
|
$this->assertNotNull($updatedEntity);
|
||||||
|
$this->assertFalse($updatedEntity->isMonitored(), 'Le manga ne devrait plus être monitoré');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleMonitoringWithNonExistingManga(): void
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
static::createClient()->request('POST', '/api/manga/99999/monitoring/toggle', [
|
||||||
|
'json' => [
|
||||||
|
'enabled' => true
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleMonitoringWithMissingEnabledField(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$externalId = 'external-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId($externalId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$entity = $this->toEntity($manga);
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
|
||||||
|
'json' => []
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
$this->assertJsonContains([
|
||||||
|
'violations' => [
|
||||||
|
[
|
||||||
|
'propertyPath' => 'enabled',
|
||||||
|
'message' => 'Le champ enabled est obligatoire'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleMonitoringWithInvalidEnabledValue(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$externalId = 'external-123';
|
||||||
|
$manga = new Manga(
|
||||||
|
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
|
||||||
|
new MangaTitle('Test Manga'),
|
||||||
|
new MangaSlug('test-manga'),
|
||||||
|
'Description',
|
||||||
|
'Author',
|
||||||
|
2024,
|
||||||
|
[],
|
||||||
|
'ongoing',
|
||||||
|
new ExternalId($externalId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$entity = $this->toEntity($manga);
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
|
||||||
|
'json' => [
|
||||||
|
'enabled' => 'invalid'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
$this->assertJsonContains([
|
||||||
|
'violations' => [
|
||||||
|
[
|
||||||
|
'propertyPath' => 'enabled',
|
||||||
|
'message' => 'Cette valeur doit être de type bool.'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toEntity(Manga $manga): \App\Entity\Manga
|
||||||
|
{
|
||||||
|
$entity = new \App\Entity\Manga();
|
||||||
|
$entity->setTitle($manga->getTitle()->getValue())
|
||||||
|
->setSlug($manga->getSlug()->getValue())
|
||||||
|
->setDescription($manga->getDescription())
|
||||||
|
->setAuthor($manga->getAuthor())
|
||||||
|
->setPublicationYear($manga->getPublicationYear())
|
||||||
|
->setGenres($manga->getGenres())
|
||||||
|
->setStatus($manga->getStatus())
|
||||||
|
->setExternalId($manga->getExternalId()->getValue())
|
||||||
|
->setMonitored(false);
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user