diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js index 233f2cc..01ee8f0 100644 --- a/backend/classes/webhook-manager.js +++ b/backend/classes/webhook-manager.js @@ -19,8 +19,12 @@ class WebhookManager { await this.triggerEventWebhooks('playback_started', data); }); - this.eventEmitter.on('user_login', async (data) => { - await this.triggerEventWebhooks('user_login', data); + this.eventEmitter.on('playback_ended', async (data) => { + await this.triggerEventWebhooks('playback_ended', data); + }); + + this.eventEmitter.on('media_recently_added', async (data) => { + await this.triggerEventWebhooks('media_recently_added', data); }); // If needed, add more event listeners here @@ -40,11 +44,33 @@ class WebhookManager { ).then(res => res.rows); } - async triggerEventWebhooks(eventType, data) { - const webhooks = await this.getWebhooksByEventType(eventType); - - for (const webhook of webhooks) { - await this.executeWebhook(webhook, data); + async triggerEventWebhooks(eventType, data = {}) { + try { + const webhooks = await this.getWebhooksByEventType(eventType); + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const enrichedData = { + ...data, + event: eventType, + triggeredAt: new Date().toISOString() + }; + + const promises = webhooks.map(webhook => { + return this.executeWebhook(webhook, enrichedData); + }); + + await Promise.all(promises); + + return true; + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + return false; } } @@ -135,6 +161,31 @@ class WebhookManager { return template; } + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks?.[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + ...eventData, + event: eventType, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + emitEvent(eventType, data) { this.eventEmitter.emit(eventType, data); } diff --git a/backend/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js index d1fddc3..4340217 100644 --- a/backend/classes/webhook-scheduler.js +++ b/backend/classes/webhook-scheduler.js @@ -35,6 +35,54 @@ class WebhookScheduler { } } + async loadEventWebhooks() { + try { + const eventWebhooks = await this.webhookManager.getEventWebhooks(); + if (eventWebhooks && eventWebhooks.length > 0) { + this.eventWebhooks = {}; + + eventWebhooks.forEach(webhook => { + if (!this.eventWebhooks[webhook.eventType]) { + this.eventWebhooks[webhook.eventType] = []; + } + this.eventWebhooks[webhook.eventType].push(webhook); + }); + + console.log(`[WEBHOOK] Loaded ${eventWebhooks.length} event-based webhooks`); + } else { + console.log('[WEBHOOK] No event-based webhooks found'); + this.eventWebhooks = {}; + } + } catch (error) { + console.error('[WEBHOOK] Failed to load event-based webhooks:', error); + } + } + + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + event: eventType, + data: eventData, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + scheduleWebhook(webhook) { try { this.cronJobs[webhook.id] = cron.schedule(webhook.schedule, async () => { @@ -50,6 +98,7 @@ class WebhookScheduler { async refreshSchedule() { await this.loadScheduledWebhooks(); + await this.loadEventWebhooks(); } } diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4f811ce..4783ebf 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -824,6 +824,8 @@ async function partialSync(triggertype) { const config = await new configClass().getConfig(); const uuid = randomUUID(); + + const newItems = []; syncTask = { loggedData: [], uuid: uuid, wsKey: "PartialSyncTask", taskName: taskName.partialsync }; try { @@ -833,7 +835,7 @@ async function partialSync(triggertype) { if (config.error) { syncTask.loggedData.push({ Message: config.error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); - return; + return { success: false, error: config.error }; } const libraries = await API.getLibraries(); @@ -842,7 +844,7 @@ async function partialSync(triggertype) { syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " " + taskName.fullsync + " Completed" }); - return; + return { success: false, error: "No libraries found" }; } const excluded_libraries = config.settings.ExcludedLibraries || []; @@ -850,10 +852,10 @@ async function partialSync(triggertype) { const filtered_libraries = libraries.filter((library) => !excluded_libraries.includes(library.Id)); const existing_excluded_libraries = libraries.filter((library) => excluded_libraries.includes(library.Id)); - // //syncUserData + // syncUserData await syncUserData(); - // //syncLibraryFolders + // syncLibraryFolders await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); //item sync counters @@ -956,7 +958,7 @@ async function partialSync(triggertype) { insertEpisodeInfoCount += Number(infoCount.insertEpisodeInfoCount); updateEpisodeInfoCount += Number(infoCount.updateEpisodeInfoCount); - //clear data from memory as its no longer needed + //clear data from memory as it's no longer needed library_items = null; seasons = null; episodes = null; @@ -1023,10 +1025,22 @@ async function partialSync(triggertype) { await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.SUCCESS); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " Sync Completed" }); + + return { + success: true, + newItems: newItems, + stats: { + itemsAdded: insertedItemsCount, + episodesAdded: insertedEpisodeCount, + seasonsAdded: insertedSeasonsCount + } + }; } catch (error) { syncTask.loggedData.push({ color: "red", Message: getErrorLineNumber(error) + ": Error: " + error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Error", message: triggertype + " Sync Halted with Errors" }); + + return { success: false, error: error.message }; } } diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 67a6370..6260d79 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -211,4 +211,104 @@ router.post('/:id/trigger-monthly', async (req, res) => { } }); +// Get status of event webhooks +router.get('/event-status', async (req, res) => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const result = {}; + + for (const eventType of eventTypes) { + const webhooks = await dbInstance.query( + 'SELECT id, name, enabled FROM webhooks WHERE trigger_type = $1 AND event_type = $2', + ['event', eventType] + ); + + result[eventType] = { + exists: webhooks.rows.length > 0, + enabled: webhooks.rows.some(webhook => webhook.enabled), + webhooks: webhooks.rows + }; + } + + res.json(result); + } catch (error) { + console.error('Error fetching webhook status:', error); + res.status(500).json({ error: 'Failed to fetch webhook status' }); + } +}); + +// Toggle all webhooks of a specific event type +router.post('/toggle-event/:eventType', async (req, res) => { + try { + const { eventType } = req.params; + const { enabled } = req.body; + + if (!['playback_started', 'playback_ended', 'media_recently_added'].includes(eventType)) { + return res.status(400).json({ error: 'Invalid event type' }); + } + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'Enabled parameter must be a boolean' }); + } + + // Mettre à jour tous les webhooks de ce type d'événement + const result = await dbInstance.query( + 'UPDATE webhooks SET enabled = $1 WHERE trigger_type = $2 AND event_type = $3 RETURNING id', + [enabled, 'event', eventType] + ); + + // Si aucun webhook n'existe pour ce type, en créer un de base + if (result.rows.length === 0 && enabled) { + const defaultWebhook = { + name: `Webhook pour ${eventType}`, + url: req.body.url || '', + method: 'POST', + trigger_type: 'event', + event_type: eventType, + enabled: true, + headers: '{}', + payload: JSON.stringify({ + event: `{{event}}`, + data: `{{data}}`, + timestamp: `{{triggeredAt}}` + }) + }; + + if (!defaultWebhook.url) { + return res.status(400).json({ + error: 'URL parameter is required when creating a new webhook', + needsUrl: true + }); + } + + await dbInstance.query( + `INSERT INTO webhooks (name, url, method, trigger_type, event_type, enabled, headers, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + defaultWebhook.name, + defaultWebhook.url, + defaultWebhook.method, + defaultWebhook.trigger_type, + defaultWebhook.event_type, + defaultWebhook.enabled, + defaultWebhook.headers, + defaultWebhook.payload + ] + ); + } + + // Rafraîchir le planificateur de webhooks + await webhookScheduler.refreshSchedule(); + + res.json({ + success: true, + message: `Webhooks for ${eventType} ${enabled ? 'enabled' : 'disabled'}`, + affectedCount: result.rows.length + }); + } catch (error) { + console.error('Error toggling webhooks:', error); + res.status(500).json({ error: 'Failed to toggle webhooks' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 43f1969..16d5e11 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -7,10 +7,14 @@ const configClass = require("../classes/config"); const API = require("../classes/api-loader"); const { sendUpdate } = require("../ws"); const { isNumber } = require("@mui/x-data-grid/internals"); +const WebhookManager = require("../classes/webhook-manager"); + const MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK = process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK ? Number(process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK) : 1; +const webhookManager = new WebhookManager(); + async function getSessionsInWatchDog(SessionData, WatchdogData) { let existingData = await WatchdogData.filter((wdData) => { return SessionData.some((sessionData) => { @@ -146,6 +150,42 @@ async function ActivityMonitor(interval) { //filter fix if table is empty if (WatchdogDataToInsert.length > 0) { + for (const session of WatchdogDataToInsert) { + let userData = {}; + try { + const userInfo = await API.getUserById(session.UserId); + if (userInfo) { + userData = { + username: userInfo.Name, + userImageTag: userInfo.PrimaryImageTag + }; + } + } catch (error) { + console.error(`[WEBHOOK] Error fetching user data: ${error.message}`); + } + + await webhookManager.triggerEventWebhooks('playback_started', { + sessionInfo: { + userId: session.UserId, + deviceId: session.DeviceId, + deviceName: session.DeviceName, + clientName: session.ClientName, + isPaused: session.IsPaused, + mediaType: session.MediaType, + mediaName: session.NowPlayingItemName, + startTime: session.ActivityDateInserted + }, + userData, + mediaInfo: { + itemId: session.NowPlayingItemId, + episodeId: session.EpisodeId, + mediaName: session.NowPlayingItemName, + seasonName: session.SeasonName, + seriesName: session.SeriesName + } + }); + } + //insert new rows where not existing items // console.log("Inserted " + WatchdogDataToInsert.length + " wd playback records"); db.insertBulk("jf_activity_watchdog", WatchdogDataToInsert, jf_activity_watchdog_columns); @@ -158,6 +198,43 @@ async function ActivityMonitor(interval) { console.log("Existing Data Updated: ", WatchdogDataToUpdate.length); } + if (dataToRemove.length > 0) { + for (const session of dataToRemove) { + let userData = {}; + try { + const userInfo = await API.getUserById(session.UserId); + if (userInfo) { + userData = { + username: userInfo.Name, + userImageTag: userInfo.PrimaryImageTag + }; + } + } catch (error) { + console.error(`[WEBHOOK] Error fetching user data: ${error.message}`); + } + + await webhookManager.triggerEventWebhooks('playback_ended', { + sessionInfo: { + userId: session.UserId, + deviceId: session.DeviceId, + deviceName: session.DeviceName, + clientName: session.ClientName, + playbackDuration: session.PlaybackDuration, + endTime: session.ActivityDateInserted + }, + userData, + mediaInfo: { + itemId: session.NowPlayingItemId, + episodeId: session.EpisodeId, + mediaName: session.NowPlayingItemName, + seasonName: session.SeasonName, + seriesName: session.SeriesName + } + }); + } + + const toDeleteIds = dataToRemove.map((row) => row.ActivityId); + //delete from db no longer in session data and insert into stats db //Bulk delete from db thats no longer on api diff --git a/backend/tasks/RecentlyAddedItemsSyncTask.js b/backend/tasks/RecentlyAddedItemsSyncTask.js index 85f0676..c688c1e 100644 --- a/backend/tasks/RecentlyAddedItemsSyncTask.js +++ b/backend/tasks/RecentlyAddedItemsSyncTask.js @@ -1,6 +1,7 @@ const { parentPort } = require("worker_threads"); const triggertype = require("../logging/triggertype"); const sync = require("../routes/sync"); +const WebhookManager = require("../classes/webhook-manager"); async function runPartialSyncTask(triggerType = triggertype.Automatic) { try { @@ -17,12 +18,25 @@ async function runPartialSyncTask(triggerType = triggertype.Automatic) { }); parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); }; - await sync.partialSync(triggerType); + + const syncResults = await sync.partialSync(triggerType); + + const webhookManager = new WebhookManager(); + + const newMediaCount = syncResults?.newItems?.length || 0; + + if (newMediaCount > 0) { + await webhookManager.triggerEventWebhooks('media_recently_added', { + count: newMediaCount, + items: syncResults.newItems, + syncDate: new Date().toISOString(), + triggerType: triggerType + }); + } parentPort.postMessage({ status: "complete" }); } catch (error) { parentPort.postMessage({ status: "error", message: error.message }); - console.log(error); return []; } diff --git a/package-lock.json b/package-lock.json index e3f19fc..7eba186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "jfstat", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 58ce159..7f6c974 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -226,7 +226,9 @@ "URL": "URL", "TYPE": "Type", "TRIGGER": "Trigger", - "STATUS": "Status" + "STATUS": "Status", + "EVENT_WEBHOOKS": "Event notifications", + "EVENT_WEBHOOKS_TOOLTIP": "Enable or disable event notifications" }, "TASK_TYPE": { "JOB": "Job", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index 78929ef..d4b799e 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -213,7 +213,22 @@ }, "SELECT_LIBRARIES_TO_IMPORT": "Sélectionner les médiathèques à importer", "SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "L'activité du contenu de ces médiathèques est toujours suivie, même s'ils ne sont pas importés.", - "DATE_ADDED": "Date d'ajout" + "DATE_ADDED": "Date d'ajout", + "WEBHOOKS": "Webhooks", + "WEBHOOK_TYPE": "Type de webhook", + "TEST_NOW": "Tester maintenant", + "WEBHOOKS_CONFIGURATION": "Configuration des webhooks", + "WEBHOOKS_TOOLTIP": "L'URL des webhooks utiliser pour envoyer des notifications à Discord ou à d'autres services", + "WEBHOOK_SAVED": "Webhook sauvegardé", + "WEBHOOK_NAME": "Nom du webhook", + "DISCORD_WEBHOOK_URL": "URL du webhook Discord", + "ENABLE_WEBHOOK": "Activer le webhook", + "URL": "URL", + "TYPE": "Type", + "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" }, "TASK_TYPE": { "JOB": "Job", diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 4b08657..307eabd 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -63,6 +63,13 @@ 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 }, + media_recently_added: { exists: false, enabled: false } + }); + useEffect(() => { const fetchWebhooks = async () => { try { @@ -73,18 +80,20 @@ function WebhooksSettings() { }, }); - if (response.data != webhooks) { + if (response.data !== webhooks) { setWebhooks(response.data); - } - - if (loading) { + // Charger l'état des webhooks événementiels une fois les webhooks chargés + await loadEventWebhooks(); + } + + if (loading) { setLoading(false); - } + } } catch (err) { console.error("Error loading webhooks:", err); if (loading) { setLoading(false); - } + } } }; @@ -92,7 +101,7 @@ function WebhooksSettings() { const intervalId = setInterval(fetchWebhooks, 1000 * 10); return () => clearInterval(intervalId); - }, []); + }, [webhooks.length]); const handleInputChange = (e) => { const { name, value } = e.target; @@ -111,7 +120,14 @@ function WebhooksSettings() { setSuccess(false); if (!currentWebhook.url) { - setError("Discord webhook URL is required"); + setError("L'URL du webhook est requise"); + 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"); setSaving(false); return; } @@ -134,6 +150,20 @@ function WebhooksSettings() { }); } + // Rafraîchir la liste des webhooks + const webhooksResponse = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(webhooksResponse.data); + + // Mettre à jour l'état des webhooks événementiels + await loadEventWebhooks(); + + // Réinitialiser le formulaire setCurrentWebhook({ name: 'New Webhook', url: '', @@ -143,10 +173,11 @@ function WebhooksSettings() { method: 'POST', webhook_type: 'discord' }); - setSuccess("Webhook saved successfully!"); + + setSuccess("Webhook enregistré avec succès!"); setSaving(false); } catch (err) { - setError("Error during webhook saving: " + (err.response?.data?.error || err.message)); + setError("Erreur lors de l'enregistrement du webhook: " + (err.response?.data?.error || err.message)); setSaving(false); } }; @@ -180,6 +211,118 @@ 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 + 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 }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + } + ); + } + + // Mettre à jour l'état local + setEventWebhooks(prev => ({ + ...prev, + [eventType]: { + ...prev[eventType], + 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!`); + } catch (error) { + setError("Erreur lors de la modification du 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'; + case 'playback_ended': + return 'Lecture terminée'; + case 'media_recently_added': + return 'Nouveaux médias ajoutés'; + default: + return eventType; + } + }; + if (loading && !webhooks.length) { return ; } @@ -273,6 +416,71 @@ function WebhooksSettings() { + + {/* Ajout de la section pour les webhooks événementiels */} +
+

+ + }> + + + + +

+ + + +
+
+
Lecture démarrée
+ toggleEventWebhook('playback_started')} + /> +
+

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

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

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

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

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

+
+ +
+