feat: ajout de la gestion des chapitres dans le store Manga avec des actions pour charger et mettre à jour la disponibilité des chapitres, intégration d'un écouteur Mercure pour les mises à jour en temps réel, et amélioration des composants d'interface utilisateur pour gérer les états de chargement et d'erreur.
This commit is contained in:
parent
e51712a800
commit
5928cfd5f0
@@ -28,6 +28,11 @@ export const useMangaStore = defineStore('manga', {
|
||||
// mais les données détaillées ne sont plus stockées ici.
|
||||
currentMangaId: null,
|
||||
|
||||
// --- Manga Chapters State ---
|
||||
mangaChapters: {},
|
||||
loadingChapters: false,
|
||||
chaptersError: null,
|
||||
|
||||
// --- Search State ---
|
||||
searchResults: [],
|
||||
loadingSearch: false,
|
||||
@@ -86,6 +91,60 @@ export const useMangaStore = defineStore('manga', {
|
||||
this.currentMangaId = null;
|
||||
},
|
||||
|
||||
// --- Chapters Actions ---
|
||||
async loadChapters(mangaId) {
|
||||
if (this.loadingChapters) return;
|
||||
this.loadingChapters = true;
|
||||
this.chaptersError = null;
|
||||
|
||||
try {
|
||||
const chaptersData = await mangaRepository.getChapters(mangaId);
|
||||
this.mangaChapters[mangaId] = chaptersData;
|
||||
} catch (err) {
|
||||
this.chaptersError = err.message;
|
||||
} finally {
|
||||
this.loadingChapters = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateChapterAvailability(chapterId, isAvailable = true) {
|
||||
console.log(`Mise à jour du chapitre ${chapterId}, disponible: ${isAvailable}`);
|
||||
|
||||
// Pour chaque manga dans notre store
|
||||
Object.keys(this.mangaChapters).forEach(mangaId => {
|
||||
const chaptersObj = this.mangaChapters[mangaId];
|
||||
if (!chaptersObj || !chaptersObj.items) return;
|
||||
|
||||
const chapters = chaptersObj.items;
|
||||
|
||||
// Chercher le chapitre correspondant
|
||||
const chapterIndex = chapters.findIndex(chapter => chapter.id === chapterId);
|
||||
|
||||
// Si on trouve le chapitre, mettre à jour son état
|
||||
if (chapterIndex !== -1) {
|
||||
console.log(`Chapitre trouvé dans le manga ${mangaId}, index: ${chapterIndex}`);
|
||||
|
||||
// Important: créer une nouvelle référence pour que Vue détecte le changement
|
||||
const updatedChapter = {
|
||||
...chapters[chapterIndex],
|
||||
isAvailable: isAvailable
|
||||
};
|
||||
|
||||
// Créer un nouveau tableau pour garantir la réactivité
|
||||
const updatedChapters = [...chapters];
|
||||
updatedChapters[chapterIndex] = updatedChapter;
|
||||
|
||||
// Mise à jour reactive du store
|
||||
this.mangaChapters[mangaId] = {
|
||||
...chaptersObj,
|
||||
items: updatedChapters
|
||||
};
|
||||
|
||||
console.log('Chapitre mis à jour avec succès');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// --- Search Actions ---
|
||||
async searchMangaDex(query) {
|
||||
if (this.loadingSearch) return;
|
||||
@@ -130,16 +189,13 @@ export const useMangaStore = defineStore('manga', {
|
||||
}
|
||||
},
|
||||
|
||||
// --- Chapter Actions ---
|
||||
// --- Scrape Chapter Action ---
|
||||
async searchChapter(chapterId) {
|
||||
try {
|
||||
await mangaRepository.searchChapter(chapterId);
|
||||
// Rafraîchir la collection après la recherche
|
||||
await this.refreshCollectionInBackground();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||
return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,13 @@
|
||||
params: {
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}"
|
||||
class="text-green-500">
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" class="text-gray-500 hover:text-green-500">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||
<MagnifyingGlassIcon class="h-5 w-5" />
|
||||
</button>
|
||||
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
|
||||
@@ -35,7 +34,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MagnifyingGlassIcon, ArrowDownTrayIcon, XMarkIcon, TrashIcon } from '@heroicons/vue/24/solid';
|
||||
import { ArrowDownTrayIcon, MagnifyingGlassIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -50,11 +50,38 @@
|
||||
});
|
||||
|
||||
const store = useMangaStore();
|
||||
const isLoading = ref(false);
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
return isLoading.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
|
||||
});
|
||||
|
||||
// Surveiller les changements d'état du chapitre
|
||||
watch(
|
||||
() => props.chapter.isAvailable,
|
||||
(newValue, oldValue) => {
|
||||
console.log(
|
||||
`MangaChapter: État du chapitre ${props.chapter.number} (ID: ${props.chapter.id}) modifié - ${oldValue} => ${newValue}`
|
||||
);
|
||||
|
||||
// Si le chapitre devient disponible, on arrête le chargement
|
||||
if (newValue === true) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
try {
|
||||
console.log(`MangaChapter: Recherche du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||
// Montrer l'indicateur de chargement
|
||||
isLoading.value = true;
|
||||
|
||||
// Lancer la recherche du chapitre - L'UI sera mise à jour par l'événement Mercure
|
||||
await store.searchChapter(props.chapter.id);
|
||||
} catch (error) {
|
||||
// En cas d'erreur, on arrête le chargement
|
||||
isLoading.value = false;
|
||||
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,8 +32,14 @@
|
||||
|
||||
<!-- Actions du volume -->
|
||||
<div class="flex space-x-2 text-xl text-bold">
|
||||
<button class="w-8 text-center" @click="handleSearch">
|
||||
<MagnifyingGlassIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
||||
<button
|
||||
class="w-8 text-center"
|
||||
@click="handleSearch"
|
||||
:class="{
|
||||
'text-yellow-500 cursor-wait': isSearching,
|
||||
'text-gray-500 hover:text-green-500': !isSearching
|
||||
}">
|
||||
<MagnifyingGlassIcon class="h-6 w-6" />
|
||||
</button>
|
||||
<button class="w-8 text-center" @click="handleDownload">
|
||||
<ArrowDownTrayIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
|
||||
@@ -54,16 +60,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
BookmarkIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowDownTrayIcon
|
||||
ChevronUpIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import MangaChapterList from './MangaChapterList.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaChapterList from './MangaChapterList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
volume: {
|
||||
@@ -82,14 +88,48 @@
|
||||
|
||||
const store = useMangaStore();
|
||||
const isOpen = ref(props.isOpen);
|
||||
const isSearching = ref(false);
|
||||
|
||||
const toggleVolume = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
// TODO: Implémenter la recherche du volume
|
||||
console.log('Recherche du volume:', props.volume.number);
|
||||
if (isSearching.value) return; // Éviter les clicks multiples
|
||||
|
||||
try {
|
||||
isSearching.value = true;
|
||||
console.log(
|
||||
`Recherche du volume ${props.volume.number} - Lancement du scraping de ${props.volume.chapters.length} chapitres`
|
||||
);
|
||||
|
||||
// Récupérer tous les chapitres non disponibles du volume
|
||||
const chaptersToSearch = props.volume.chapters
|
||||
.filter(chapter => !chapter.isAvailable)
|
||||
.map(chapter => chapter.id);
|
||||
|
||||
if (chaptersToSearch.length === 0) {
|
||||
console.log('Tous les chapitres sont déjà disponibles !');
|
||||
isSearching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Chapitres à scraper: ${chaptersToSearch.length}`);
|
||||
|
||||
// Lancer le scraping de chaque chapitre non disponible en séquentiel
|
||||
for (const chapterId of chaptersToSearch) {
|
||||
console.log(`Scraping du chapitre ${chapterId}...`);
|
||||
await store.searchChapter(chapterId);
|
||||
// Petite pause entre chaque requête pour éviter de surcharger le serveur
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
console.log(`Scraping des chapitres du volume ${props.volume.number} terminé`);
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du scraping du volume ${props.volume.number}:`, error);
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<!-- Composant invisible qui écoute les événements Mercure -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, inject } from 'vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
|
||||
const props = defineProps({
|
||||
mangaId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const store = useMangaStore();
|
||||
const eventSource = ref(null);
|
||||
// On récupère le client de requête pour invalider le cache
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setupMercureEventSource = () => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
}
|
||||
|
||||
// Créer les topics à écouter
|
||||
const topics = [`manga/${props.mangaId}/chapters`, 'scraping/status'];
|
||||
|
||||
// Construire l'URL du hub Mercure avec les topics
|
||||
const mercureHubUrl = new URL('/.well-known/mercure', window.location.origin);
|
||||
topics.forEach(topic => {
|
||||
mercureHubUrl.searchParams.append('topic', topic);
|
||||
});
|
||||
|
||||
console.log(`MercureListener: Abonnement aux topics pour manga ${props.mangaId}`);
|
||||
console.log(`MercureListener: URL Mercure - ${mercureHubUrl.toString()}`);
|
||||
|
||||
// Créer la source d'événements
|
||||
eventSource.value = new EventSource(mercureHubUrl, { withCredentials: true });
|
||||
|
||||
// Définir les gestionnaires d'événements
|
||||
eventSource.value.onmessage = event => {
|
||||
try {
|
||||
console.log('MercureListener: Événement reçu', event.data);
|
||||
const data = JSON.parse(event.data);
|
||||
handleMercureEvent(data);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du traitement de l'événement Mercure:", error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.value.onerror = error => {
|
||||
console.error('Erreur de connexion à Mercure:', error);
|
||||
// Tenter de reconnecter après un délai
|
||||
setTimeout(() => {
|
||||
if (eventSource.value) {
|
||||
setupMercureEventSource();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
const handleMercureEvent = data => {
|
||||
if (!data || !data.type) {
|
||||
console.warn('MercureListener: Événement sans type reçu', data);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'chapter.scraped':
|
||||
console.log(`MercureListener: Chapitre ${data.chapterNumber} scrappé avec succès!`, data);
|
||||
|
||||
// Vérifier que l'ID du chapitre est présent et au bon format
|
||||
if (!data.chapterId) {
|
||||
console.error("MercureListener: ID du chapitre manquant dans l'événement", data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'état du chapitre dans le store
|
||||
try {
|
||||
// Mettre à jour le store Pinia
|
||||
store.updateChapterAvailability(data.chapterId, true);
|
||||
console.log(
|
||||
`MercureListener: Chapitre ${data.chapterNumber} (ID: ${data.chapterId}) marqué comme disponible`
|
||||
);
|
||||
|
||||
// Invalider le cache des requêtes pour forcer un rechargement frais
|
||||
console.log(`MercureListener: Invalidation du cache pour les chapitres du manga ${props.mangaId}`);
|
||||
queryClient.invalidateQueries(['manga', ref(props.mangaId), 'chapters']);
|
||||
|
||||
// Force le rechargement des chapitres via le store
|
||||
setTimeout(() => {
|
||||
console.log('MercureListener: Rechargement forcé des chapitres via le store');
|
||||
store.loadChapters(props.mangaId);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('MercureListener: Erreur lors de la mise à jour du chapitre', error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chapter.scraping.failed':
|
||||
console.error(`MercureListener: Échec du scraping du chapitre ${data.chapterNumber}:`, data.reason);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('MercureListener: Événement Mercure non géré:', data);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.mangaId,
|
||||
(newMangaId, oldMangaId) => {
|
||||
console.log(`MercureListener: MangaId changé de ${oldMangaId} à ${newMangaId}`);
|
||||
if (newMangaId) {
|
||||
setupMercureEventSource();
|
||||
} else if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
setupMercureEventSource();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -11,11 +11,25 @@ export function useMangaChapters(mangaId) {
|
||||
if (!mangaId.value) {
|
||||
return Promise.resolve([]); // Retourne un tableau vide si pas d'ID
|
||||
}
|
||||
|
||||
console.log(`useMangaChapters: Chargement des chapitres pour le manga ${mangaId.value}`);
|
||||
const response = await mangaRepository.getChapters(mangaId.value);
|
||||
|
||||
// Log pour déboguer
|
||||
console.log(`useMangaChapters: ${response.items?.length || 0} chapitres chargés`);
|
||||
|
||||
// Assure de toujours retourner un tableau
|
||||
return Array.isArray(response) ? response : response?.items ?? [];
|
||||
},
|
||||
enabled: computed(() => !!mangaId.value)
|
||||
// Refresh toutes les 30 secondes en arrière-plan
|
||||
refetchInterval: 30000,
|
||||
// S'assurer que si le composant est visible à nouveau, on récupère les données fraîches
|
||||
refetchOnWindowFocus: true,
|
||||
// Query activée uniquement si mangaId est défini
|
||||
enabled: computed(() => !!mangaId.value),
|
||||
// Options pour conserver les données entre les requêtes
|
||||
staleTime: 60000, // Considère les données comme "périmées" après 1 minute
|
||||
cacheTime: 5 * 60 * 1000 // Garde les données en cache pendant 5 minutes
|
||||
});
|
||||
|
||||
// Retourne le résultat de useQuery (contenant data, isLoading, etc.)
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import { useMangaChapters } from './useMangaChapters'; // Importe le composable des chapitres
|
||||
|
||||
export function useMangaVolumes(mangaId) {
|
||||
// Récupération du store pour avoir accès aux chapitres mis à jour en temps réel
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
// Utilise le composable des chapitres pour récupérer les données brutes et les états
|
||||
const { data: rawChaptersData, isLoading, isFetching, error, status } = useMangaChapters(mangaId);
|
||||
const { data: rawChaptersData, isLoading, isFetching, error, status, refetch } = useMangaChapters(mangaId);
|
||||
|
||||
// Fonction pour forcer le rechargement des données
|
||||
const refresh = () => {
|
||||
console.log('useMangaVolumes: Rechargement forcé des chapitres');
|
||||
refetch();
|
||||
};
|
||||
|
||||
// Surveiller les changements dans le store pour les chapitres du manga actuel
|
||||
watch(
|
||||
() => mangaStore.mangaChapters[mangaId.value],
|
||||
() => {
|
||||
console.log('useMangaVolumes: Changement détecté dans les chapitres du store');
|
||||
refresh();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Calcule les volumes à partir des données des chapitres
|
||||
const volumes = computed(() => {
|
||||
console.log('useMangaVolumes: Recalcul des volumes');
|
||||
const chaptersArray = rawChaptersData.value || []; // Utilise la data retournée par useMangaChapters
|
||||
if (chaptersArray.length === 0) return [];
|
||||
|
||||
const volumeMap = new Map();
|
||||
chaptersArray.forEach(chapter => {
|
||||
const volumeNumber = chapter.volume || 'Unknown';
|
||||
const volumeNumber = chapter.volume || '00';
|
||||
if (!volumeMap.has(volumeNumber)) {
|
||||
volumeMap.set(volumeNumber, {
|
||||
number: volumeNumber,
|
||||
@@ -33,8 +54,13 @@ export function useMangaVolumes(mangaId) {
|
||||
});
|
||||
|
||||
return Array.from(volumeMap.values()).sort((a, b) => {
|
||||
const numA = a.number === 'Unknown' ? -Infinity : Number(a.number);
|
||||
const numB = b.number === 'Unknown' ? -Infinity : Number(b.number);
|
||||
// Cas spécial pour le volume 00, qui doit apparaître en premier
|
||||
if (a.number === '00') return -1;
|
||||
if (b.number === '00') return 1;
|
||||
|
||||
// Pour tous les autres volumes, tri décroissant
|
||||
const numA = Number(a.number);
|
||||
const numB = Number(b.number);
|
||||
return numB - numA;
|
||||
});
|
||||
});
|
||||
@@ -45,7 +71,8 @@ export function useMangaVolumes(mangaId) {
|
||||
isLoading, // L'état de chargement initial des chapitres
|
||||
isFetching, // L'état de rafraîchissement des chapitres
|
||||
error, // L'erreur potentielle lors du fetch des chapitres
|
||||
status // L'état global ('pending', 'error', 'success')
|
||||
status, // L'état global ('pending', 'error', 'success')
|
||||
refresh
|
||||
// On pourrait aussi retourner rawChaptersData si nécessaire ailleurs
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentManga" class="relative">
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="mangaId" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500">
|
||||
@@ -31,7 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, watch } from 'vue';
|
||||
import { computed, onUnmounted, watch, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
@@ -49,6 +52,7 @@
|
||||
|
||||
import MangaHeader from '../components/MangaHeader.vue';
|
||||
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
||||
import MercureListener from '../components/MercureListener.vue';
|
||||
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
@@ -72,6 +76,17 @@
|
||||
error: errorVolumes
|
||||
} = useMangaVolumes(mangaId);
|
||||
|
||||
// Charger les chapitres dans le store quand le manga est chargé
|
||||
watch(
|
||||
mangaId,
|
||||
newId => {
|
||||
if (newId) {
|
||||
mangaStore.loadChapters(newId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ readonly class FetchMangaChaptersHandler
|
||||
$limit = 500;
|
||||
$hasMore = true;
|
||||
$chaptersByNumber = [];
|
||||
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
|
||||
$chapterNumbers = [];
|
||||
|
||||
while ($hasMore) {
|
||||
@@ -41,27 +42,40 @@ readonly class FetchMangaChaptersHandler
|
||||
foreach ($feed['data'] as $chapterData) {
|
||||
$chapterNumber = (float) $chapterData['attributes']['chapter'];
|
||||
$language = $chapterData['attributes']['translatedLanguage'];
|
||||
$title = $chapterData['attributes']['title'];
|
||||
|
||||
// On ne traite que les chapitres en français ou en anglais
|
||||
// Pour les langues autres que français et anglais, on utilise un titre générique
|
||||
if (!in_array($language, ['fr', 'en'])) {
|
||||
continue;
|
||||
$title = "Chapter {$chapterNumber}";
|
||||
}
|
||||
|
||||
// Si le chapitre n'existe pas encore ou si c'est une version française
|
||||
if (!isset($chaptersByNumber[$chapterNumber]) || $language === 'fr') {
|
||||
$chapter = new Chapter(
|
||||
// Définir les règles de priorité des langues (fr > en > autres)
|
||||
$shouldReplaceChapter = false;
|
||||
|
||||
if (!isset($chaptersByNumber[$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[$chapterNumber] !== 'fr') {
|
||||
// L'anglais est prioritaire sur les autres langues, sauf le français
|
||||
$shouldReplaceChapter = true;
|
||||
}
|
||||
|
||||
if ($shouldReplaceChapter) {
|
||||
$chaptersByNumber[$chapterNumber] = new Chapter(
|
||||
new ChapterId((string) Uuid::uuid4()),
|
||||
$manga->getId()->getValue(),
|
||||
$chapterNumber,
|
||||
$chapterData['attributes']['title'],
|
||||
$title,
|
||||
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
|
||||
true,
|
||||
false,
|
||||
new \DateTimeImmutable()
|
||||
);
|
||||
|
||||
$chaptersByNumber[$chapterNumber] = $chapter;
|
||||
$chapterNumbers[] = $chapterNumber;
|
||||
$chapterLanguages[$chapterNumber] = $language;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +83,9 @@ readonly class FetchMangaChaptersHandler
|
||||
$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(),
|
||||
@@ -82,4 +99,56 @@ readonly class FetchMangaChaptersHandler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private function harmonizeVolumes(array &$chaptersByNumber): void
|
||||
{
|
||||
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
|
||||
ksort($chaptersByNumber);
|
||||
|
||||
$chapterNumbers = array_keys($chaptersByNumber);
|
||||
$count = count($chapterNumbers);
|
||||
|
||||
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) {
|
||||
// On doit créer un nouveau chapitre car les objets sont immuables
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
id: new ChapterId($currentChapter->getId()),
|
||||
mangaId: $currentChapter->getMangaId(),
|
||||
number: $currentChapter->getNumber(),
|
||||
title: $currentChapter->getTitle(),
|
||||
volume: $newVolume, // On met à jour le volume
|
||||
isVisible: $currentChapter->isVisible(),
|
||||
isAvailable: $currentChapter->isAvailable(),
|
||||
createdAt: $currentChapter->getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class MangadexClient implements MangadexClientInterface
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
|
||||
if (!isset($data['access_token'], $data['refresh_token'])) {
|
||||
throw new MangadexAuthenticationException('Invalid authentication response from Mangadex');
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class MangadexClient implements MangadexClientInterface
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
throw new MangadexAuthenticationException('Invalid refresh token response from Mangadex');
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class MangadexClient implements MangadexClientInterface
|
||||
{
|
||||
return $this->get('/manga/' . $mangaId . '/feed', [
|
||||
'limit' => $limit,
|
||||
'translatedLanguage' => ['en', 'fr'],
|
||||
// 'translatedLanguage' => ['en'],
|
||||
'order' => ['chapter' => $order],
|
||||
'offset' => $offset,
|
||||
]);
|
||||
@@ -160,4 +160,4 @@ class MangadexClient implements MangadexClientInterface
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ readonly class ScrapeChapterHandler
|
||||
$chapter->chapterNumber,
|
||||
$source->getId()->getValue()
|
||||
);
|
||||
|
||||
// Ajout de l'ID du chapitre dans le contexte du job
|
||||
$job->context['chapterId'] = $command->chapterId;
|
||||
|
||||
$job->start();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HubInterface $hub,
|
||||
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||
private readonly JobRepositoryInterface $jobRepository,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// Les événements sont capturés via le système de message handlers
|
||||
];
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScraped(ChapterScraped $event): void
|
||||
{
|
||||
$jobId = $event->getJobId();
|
||||
$this->logger->info('ChapterScraped reçu pour le job: ' . $jobId);
|
||||
|
||||
$job = $this->jobRepository->get($jobId);
|
||||
|
||||
if (!$job) {
|
||||
$this->logger->warning('Job non trouvé pour l\'ID: ' . $jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le chapitre associé au job
|
||||
$chapterId = $job->context['chapterId'] ?? null;
|
||||
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
|
||||
|
||||
$chapter = $this->chapterRepository->getById($chapterId);
|
||||
|
||||
if (!$chapter) {
|
||||
$this->logger->warning('Chapitre non trouvé pour l\'ID: ' . $chapterId);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraped',
|
||||
'chapterId' => $chapter->id,
|
||||
'mangaId' => $chapter->mangaId,
|
||||
'chapterNumber' => $chapter->chapterNumber,
|
||||
'isAvailable' => true,
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
|
||||
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScrapingFailed(ChapterScrapingFailed $event): void
|
||||
{
|
||||
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraping.failed',
|
||||
'mangaId' => $event->getMangaId(),
|
||||
'chapterNumber' => $event->getChapterNumber(),
|
||||
'reason' => $event->getReason(),
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user