feat(webhooks): add Discord webhook support and event notifications for playback and media actions

This commit is contained in:
2025-05-26 10:01:30 +02:00
parent 280fa89c59
commit eeada4fbb2
6 changed files with 221 additions and 78 deletions

View File

@@ -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;

View File

@@ -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;
module.exports = router;

View File

@@ -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") {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {
<TableCell>{webhook.webhook_type || 'generic'}</TableCell>
<TableCell>{webhook.trigger_type}</TableCell>
<TableCell>
<span className={`badge ${webhook.enabled ? 'bg-success' : 'bg-secondary'}`}>
{webhook.enabled ? <Trans i18nKey={"ENABLED"} /> : <Trans i18nKey={"DISABLED"} />}
</span>
<span className={`badge ${webhook.enabled ? 'bg-success' : 'bg-secondary'}`}>
{webhook.enabled ? <Trans i18nKey={"ENABLED"} /> : <Trans i18nKey={"DISABLED"} />}
</span>
</TableCell>
<TableCell>
<div className="d-flex justify-content-end gap-2">
<Button size="sm" variant="outline-primary" onClick={() => onEdit(webhook)}>
<Trans i18nKey={"EDIT"} />
</Button>
<Button size="sm" variant="outline-secondary" onClick={() => onTest(webhook.id)}>
<Button size="sm" variant="outline-secondary" onClick={() => onTest(webhook)}>
<Trans i18nKey={"SETTINGS_PAGE.TEST_NOW"} />
</Button>
</div>
@@ -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() {
</span>
</Tooltip>
</h3>
<Row className="g-4">
<Col md={4}>
<div className="border rounded p-3 h-100">
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5>Lecture démarrée</h5>
<h5><Trans i18nKey={"SETTINGS_PAGE.PLAYBACK_STARTED"} /></h5>
<Form.Check
type="switch"
id="playback-started-enabled"
@@ -440,16 +444,16 @@ function WebhooksSettings() {
onChange={() => toggleEventWebhook('playback_started')}
/>
</div>
<p className="text-muted small">
Notification lorsqu'un utilisateur commence à regarder un média
<p className="small">
Send a webhook notification when a user starts watching a media
</p>
</div>
</Col>
<Col md={4}>
<div className="border rounded p-3 h-100">
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5>Lecture terminée</h5>
<h5><Trans i18nKey={"SETTINGS_PAGE.PLAYBACK_ENDED"} /></h5>
<Form.Check
type="switch"
id="playback-ended-enabled"
@@ -457,16 +461,16 @@ function WebhooksSettings() {
onChange={() => toggleEventWebhook('playback_ended')}
/>
</div>
<p className="text-muted small">
Notification lorsqu'un utilisateur termine de regarder un média
<p className="small">
Send a webhook notification when a user finishes watching a media
</p>
</div>
</Col>
<Col md={4}>
<div className="border rounded p-3 h-100">
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5>Nouveaux médias</h5>
<h5><Trans i18nKey={"SETTINGS_PAGE.MEDIA_ADDED"} /></h5>
<Form.Check
type="switch"
id="media-recently-added-enabled"
@@ -474,14 +478,14 @@ function WebhooksSettings() {
onChange={() => toggleEventWebhook('media_recently_added')}
/>
</div>
<p className="text-muted small">
Notification lorsque de nouveaux médias sont ajoutés à la bibliothèque
<p className="small">
Send a webhook notification when new media is added to the library
</p>
</div>
</Col>
</Row>
</div>
<TableContainer className='rounded-2 mt-4'>
<Table aria-label="webhooks table">
<TableHead>
@@ -513,7 +517,7 @@ function WebhooksSettings() {
</TableBody>
</Table>
</TableContainer>
</ErrorBoundary>
</div>
);