From eeada4fbb2c84a3c34f906855cadd9b23b7f8806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= Date: Mon, 26 May 2025 10:01:30 +0200 Subject: [PATCH] feat(webhooks): add Discord webhook support and event notifications for playback and media actions --- backend/classes/webhook-manager.js | 22 ++++ backend/routes/webhooks.js | 127 +++++++++++++++++-- backend/tasks/ActivityMonitor.js | 6 +- public/locales/en-UK/translation.json | 5 +- public/locales/fr-FR/translation.json | 5 +- src/pages/components/settings/webhooks.jsx | 134 +++++++++++---------- 6 files changed, 221 insertions(+), 78 deletions(-) diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js index 01ee8f0..0212c01 100644 --- a/backend/classes/webhook-manager.js +++ b/backend/classes/webhook-manager.js @@ -391,6 +391,28 @@ class WebhookManager { return false; } } + + async executeDiscordWebhook(webhook, data) { + try { + console.log(`Execution of discord webhook: ${webhook.name}`); + + const response = await axios.post(webhook.url, data, { + headers: { + 'Content-Type': 'application/json' + } + }); + + console.log(`[WEBHOOK] Discord response: ${response.status}`); + return response.status >= 200 && response.status < 300; + } catch (error) { + console.error(`[WEBHOOK] Error with Discord webhook ${webhook.name}:`, error.message); + if (error.response) { + console.error('[WEBHOOK] Response status:', error.response.status); + console.error('[WEBHOOK] Response data:', error.response.data); + } + return false; + } + } } module.exports = WebhookManager; \ No newline at end of file diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 6260d79..80095f6 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -185,18 +185,129 @@ router.post('/:id/test', async (req, res) => { } const webhook = result.rows[0]; - const testData = req.body || {}; + let testData = req.body || {}; + let success = false; - const success = await webhookManager.executeWebhook(webhook, testData); + // Traitement spécial pour les webhooks Discord + if (webhook.url.includes('discord.com/api/webhooks')) { + console.log('Discord webhook détecté, préparation du payload spécifique'); + + // Format spécifique pour Discord + testData = { + content: "Test de webhook depuis Jellystat", + embeds: [{ + title: "Test de notification Discord", + description: "Ceci est un test de notification via webhook Discord", + color: 3447003, // Bleu + fields: [ + { + name: "Type de webhook", + value: webhook.trigger_type || "Non spécifié", + inline: true + }, + { + name: "ID", + value: webhook.id, + inline: true + } + ], + timestamp: new Date().toISOString() + }] + }; + + // Bypass du traitement normal pour Discord + success = await webhookManager.executeDiscordWebhook(webhook, testData); + } + // Comportement existant pour les autres types de webhook + else if (webhook.trigger_type === 'event' && webhook.event_type) { + const eventType = webhook.event_type; + + let eventData = {}; + + switch (eventType) { + case 'playback_started': + eventData = { + sessionInfo: { + userId: "test-user-id", + deviceId: "test-device-id", + deviceName: "Test Device", + clientName: "Test Client", + isPaused: false, + mediaType: "Movie", + mediaName: "Test Movie", + startTime: new Date().toISOString() + }, + userData: { + username: "Test User", + userImageTag: "test-image-tag" + }, + mediaInfo: { + itemId: "test-item-id", + episodeId: null, + mediaName: "Test Movie", + seasonName: null, + seriesName: null + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + case 'playback_ended': + eventData = { + sessionInfo: { + userId: "test-user-id", + deviceId: "test-device-id", + deviceName: "Test Device", + clientName: "Test Client", + mediaType: "Movie", + mediaName: "Test Movie", + startTime: new Date(Date.now() - 3600000).toISOString(), + endTime: new Date().toISOString(), + playbackDuration: 3600 + }, + userData: { + username: "Test User", + userImageTag: "test-image-tag" + }, + mediaInfo: { + itemId: "test-item-id", + episodeId: null, + mediaName: "Test Movie", + seasonName: null, + seriesName: null + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + case 'media_recently_added': + eventData = { + mediaItem: { + id: "test-item-id", + name: "Test Media", + type: "Movie", + overview: "This is a test movie for webhook testing", + addedDate: new Date().toISOString() + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + default: + success = await webhookManager.executeWebhook(webhook, testData); + } + } else { + success = await webhookManager.executeWebhook(webhook, testData); + } if (success) { - res.json({ message: 'Webhook executed successfully' }); + res.json({ message: 'Webhook exécuté avec succès' }); } else { - res.status(500).json({ error: 'Webhook execution failed' }); + res.status(500).json({ error: 'Échec de l\'exécution du webhook' }); } } catch (error) { console.error('Error testing webhook:', error); - res.status(500).json({ error: 'Failed to test webhook' }); + res.status(500).json({ error: 'Failed to test webhook: ' + error.message }); } }); @@ -205,9 +316,9 @@ router.post('/:id/trigger-monthly', async (req, res) => { const success = await webhookManager.triggerMonthlySummaryWebhook(req.params.id); if (success) { - res.status(200).json({ message: "Rapport mensuel envoyé avec succès" }); + res.status(200).json({ message: "Monthly report send with success" }); } else { - res.status(500).json({ message: "Échec de l'envoi du rapport mensuel" }); + res.status(500).json({ message: "Failed to send monthly report" }); } }); @@ -311,4 +422,4 @@ router.post('/toggle-event/:eventType', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 16d5e11..6479a2d 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -238,8 +238,6 @@ async function ActivityMonitor(interval) { //delete from db no longer in session data and insert into stats db //Bulk delete from db thats no longer on api - const toDeleteIds = dataToRemove.map((row) => row.ActivityId); - let playbackToInsert = dataToRemove; if (playbackToInsert.length == 0 && toDeleteIds.length == 0) { @@ -325,7 +323,9 @@ async function ActivityMonitor(interval) { } /////////////////////////// - } catch (error) { + } + } + catch (error) { if (error?.code === "ECONNREFUSED") { console.error("Error: Unable to connect to API"); //TO-DO Change this to correct API name } else if (error?.code === "ERR_BAD_RESPONSE") { diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 7f6c974..83cae4b 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -228,7 +228,10 @@ "TRIGGER": "Trigger", "STATUS": "Status", "EVENT_WEBHOOKS": "Event notifications", - "EVENT_WEBHOOKS_TOOLTIP": "Enable or disable event notifications" + "EVENT_WEBHOOKS_TOOLTIP": "Enable or disable event notifications", + "PLAYBACK_STARTED": "Playback Started", + "PLAYBACK_ENDED": "Playback Stopped", + "MEDIA_ADDED": "Media Added" }, "TASK_TYPE": { "JOB": "Job", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index d4b799e..389f6c8 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -228,7 +228,10 @@ "TRIGGER": "Déclencheur", "STATUS": "Status", "EVENT_WEBHOOKS": "Notifications d'événements", - "EVENT_WEBHOOKS_TOOLTIP": "Activez ou désactivez les notifications pour différents événements du système" + "EVENT_WEBHOOKS_TOOLTIP": "Activez ou désactivez les notifications pour différents événements du système", + "PLAYBACK_STARTED": "Lecture commencée", + "PLAYBACK_ENDED": "Lecture arrêtée", + "MEDIA_ADDED": "Média ajouté" }, "TASK_TYPE": { "JOB": "Job", diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 307eabd..d048e3d 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -3,6 +3,7 @@ import axios from "../../../lib/axios_instance"; import { Form, Row, Col, Button, Spinner, Alert } from "react-bootstrap"; import InformationLineIcon from "remixicon-react/InformationLineIcon"; import { Tooltip } from "@mui/material"; +import PropTypes from 'prop-types'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -17,6 +18,7 @@ import ErrorBoundary from "../general/ErrorBoundary"; const token = localStorage.getItem('token'); +// Modification du composant WebhookRow pour passer l'objet webhook complet function WebhookRow(props) { const { webhook, onEdit, onTest } = props; @@ -28,16 +30,16 @@ function WebhookRow(props) { {webhook.webhook_type || 'generic'} {webhook.trigger_type} - - {webhook.enabled ? : } - + + {webhook.enabled ? : } +
-
@@ -47,6 +49,19 @@ function WebhookRow(props) { ); } +WebhookRow.propTypes = { + webhook: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + webhook_type: PropTypes.string, + trigger_type: PropTypes.string.isRequired, + enabled: PropTypes.bool.isRequired + }).isRequired, + onEdit: PropTypes.func.isRequired, + onTest: PropTypes.func.isRequired +}; + function WebhooksSettings() { const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); @@ -63,7 +78,6 @@ function WebhooksSettings() { webhook_type: 'discord' }); - // État pour suivre les webhooks événementiels const [eventWebhooks, setEventWebhooks] = useState({ playback_started: { exists: false, enabled: false }, playback_ended: { exists: false, enabled: false }, @@ -82,7 +96,6 @@ function WebhooksSettings() { if (response.data !== webhooks) { setWebhooks(response.data); - // Charger l'état des webhooks événementiels une fois les webhooks chargés await loadEventWebhooks(); } @@ -120,14 +133,13 @@ function WebhooksSettings() { setSuccess(false); if (!currentWebhook.url) { - setError("L'URL du webhook est requise"); + setError("Webhook URL is required"); setSaving(false); return; } - // Si c'est un webhook de type événement, s'assurer que les propriétés nécessaires sont présentes if (currentWebhook.trigger_type === 'event' && !currentWebhook.event_type) { - setError("Le type d'événement est requis pour un webhook événementiel"); + setError("Event type is required for an event based webhook"); setSaving(false); return; } @@ -150,7 +162,6 @@ function WebhooksSettings() { }); } - // Rafraîchir la liste des webhooks const webhooksResponse = await axios.get('/webhooks', { headers: { Authorization: `Bearer ${token}`, @@ -160,10 +171,8 @@ function WebhooksSettings() { setWebhooks(webhooksResponse.data); - // Mettre à jour l'état des webhooks événementiels await loadEventWebhooks(); - // Réinitialiser le formulaire setCurrentWebhook({ name: 'New Webhook', url: '', @@ -174,10 +183,10 @@ function WebhooksSettings() { webhook_type: 'discord' }); - setSuccess("Webhook enregistré avec succès!"); + setSuccess("Webhook saved successfully!"); setSaving(false); } catch (err) { - setError("Erreur lors de l'enregistrement du webhook: " + (err.response?.data?.error || err.message)); + setError("Error while saving webhook " + (err.response?.data?.error || err.message)); setSaving(false); } }; @@ -186,9 +195,9 @@ function WebhooksSettings() { setCurrentWebhook(webhook); }; - const handleTest = async (webhookId) => { - if (!webhookId) { - setError("Impossible to test the webhook: no ID provided"); + const handleTest = async (webhook) => { + if (!webhook || !webhook.id) { + setError("Impossible to test the webhook: no webhook provided"); return; } @@ -196,14 +205,20 @@ function WebhooksSettings() { setLoading(true); setError(null); - await axios.post(`/webhooks/${webhookId}/trigger-monthly`, {}, { + let endpoint = `/webhooks/${webhook.id}/test`; + + if (webhook.trigger_type === 'scheduled' && webhook.schedule && webhook.schedule.includes('1 * *')) { + endpoint = `/webhooks/${webhook.id}/trigger-monthly`; + } + + await axios.post(endpoint, {}, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", } }); - setSuccess("Webhook test triggered successfully!"); + setSuccess(`Webhook ${webhook.name} test triggered successfully!`); setLoading(false); } catch (err) { setError("Error during the test of the webhook: " + (err.response?.data?.message || err.message)); @@ -211,69 +226,61 @@ function WebhooksSettings() { } }; - // Fonction pour obtenir le statut d'un webhook événementiel const getEventWebhookStatus = (eventType) => { return eventWebhooks[eventType]?.enabled || false; }; - // Fonction pour charger le statut des webhooks événementiels const loadEventWebhooks = async () => { try { const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; const status = {}; - - // Vérifier chaque type d'événement dans les webhooks actuels + eventTypes.forEach(eventType => { const matchingWebhooks = webhooks.filter( webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType ); - + status[eventType] = { exists: matchingWebhooks.length > 0, enabled: matchingWebhooks.some(webhook => webhook.enabled) }; }); - + setEventWebhooks(status); } catch (error) { console.error('Error loading event webhook status:', error); } }; - // Fonction pour basculer un webhook événementiel const toggleEventWebhook = async (eventType) => { try { setLoading(true); setError(null); - + const isCurrentlyEnabled = getEventWebhookStatus(eventType); const matchingWebhooks = webhooks.filter( webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType ); - - // Si aucun webhook n'existe pour cet événement et qu'on veut l'activer + if (matchingWebhooks.length === 0 && !isCurrentlyEnabled) { - // Créer un nouveau webhook pour cet événement const newWebhook = { name: `Notification - ${getEventDisplayName(eventType)}`, - url: '', // Demander à l'utilisateur de saisir l'URL + url: '', enabled: true, trigger_type: 'event', event_type: eventType, method: 'POST', webhook_type: 'discord' }; - - // Mettre à jour le webhook actuel pour que l'utilisateur puisse le configurer + setCurrentWebhook(newWebhook); setLoading(false); return; } - - // Sinon, activer/désactiver tous les webhooks existants pour cet événement + for (const webhook of matchingWebhooks) { - await axios.put(`/webhooks/${webhook.id}`, - { ...webhook, enabled: !isCurrentlyEnabled }, + await axios.put(`/webhooks/${webhook.id}`, + { ...webhook, enabled: !isCurrentlyEnabled }, { headers: { Authorization: `Bearer ${token}`, @@ -282,8 +289,7 @@ function WebhooksSettings() { } ); } - - // Mettre à jour l'état local + setEventWebhooks(prev => ({ ...prev, [eventType]: { @@ -291,33 +297,31 @@ function WebhooksSettings() { enabled: !isCurrentlyEnabled } })); - - // Actualiser la liste des webhooks + const response = await axios.get('/webhooks', { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); - + setWebhooks(response.data); setLoading(false); - setSuccess(`Webhook pour ${getEventDisplayName(eventType)} ${!isCurrentlyEnabled ? 'activé' : 'désactivé'} avec succès!`); + setSuccess(`Webhook for ${getEventDisplayName(eventType)} ${!isCurrentlyEnabled ? 'enabled' : 'disabled'} with success!`); } catch (error) { - setError("Erreur lors de la modification du webhook: " + (error.response?.data?.error || error.message)); + setError("Error while editing webhook: " + (error.response?.data?.error || error.message)); setLoading(false); } }; - // Fonction utilitaire pour obtenir le nom d'affichage d'un type d'événement const getEventDisplayName = (eventType) => { switch(eventType) { case 'playback_started': - return 'Lecture démarrée'; + return 'Playback started'; case 'playback_ended': - return 'Lecture terminée'; + return 'Playback ended'; case 'media_recently_added': - return 'Nouveaux médias ajoutés'; + return 'New media added'; default: return eventType; } @@ -427,12 +431,12 @@ function WebhooksSettings() { - + -
+
-
Lecture démarrée
+
toggleEventWebhook('playback_started')} />
-

- Notification lorsqu'un utilisateur commence à regarder un média +

+ Send a webhook notification when a user starts watching a media

- + -
+
-
Lecture terminée
+
toggleEventWebhook('playback_ended')} />
-

- Notification lorsqu'un utilisateur termine de regarder un média +

+ Send a webhook notification when a user finishes watching a media

- + -
+
-
Nouveaux médias
+
toggleEventWebhook('media_recently_added')} />
-

- Notification lorsque de nouveaux médias sont ajoutés à la bibliothèque +

+ Send a webhook notification when new media is added to the library

- + @@ -513,7 +517,7 @@ function WebhooksSettings() {
- +
);