diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js
new file mode 100644
index 0000000..0212c01
--- /dev/null
+++ b/backend/classes/webhook-manager.js
@@ -0,0 +1,418 @@
+const axios = require('axios');
+const dbInstance = require('../db');
+const EventEmitter = require('events');
+
+class WebhookManager {
+ constructor() {
+ if (WebhookManager.instance) {
+ return WebhookManager.instance;
+ }
+
+ this.eventEmitter = new EventEmitter();
+ this.setupEventListeners();
+ WebhookManager.instance = this;
+ }
+
+ setupEventListeners() {
+ // Adding event listeners for different events
+ this.eventEmitter.on('playback_started', async (data) => {
+ await this.triggerEventWebhooks('playback_started', 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
+ }
+
+ async getWebhooksByEventType(eventType) {
+ return await dbInstance.query(
+ 'SELECT * FROM webhooks WHERE trigger_type = $1 AND event_type = $2 AND enabled = true',
+ ['event', eventType]
+ ).then(res => res.rows);
+ }
+
+ async getScheduledWebhooks() {
+ return await dbInstance.query(
+ 'SELECT * FROM webhooks WHERE trigger_type = $1 AND enabled = true',
+ ['scheduled']
+ ).then(res => res.rows);
+ }
+
+ 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;
+ }
+ }
+
+ async executeWebhook(webhook, data = {}) {
+ try {
+ let headers = {};
+ let payload = {};
+
+ const isDiscordWebhook = webhook.url.includes('discord.com/api/webhooks');
+
+ try {
+ headers = typeof webhook.headers === 'string'
+ ? JSON.parse(webhook.headers || '{}')
+ : (webhook.headers || {});
+
+ payload = typeof webhook.payload === 'string'
+ ? JSON.parse(webhook.payload || '{}')
+ : (webhook.payload || {});
+ } catch (e) {
+ console.error("[WEBHOOK] Error while parsing:", e);
+ return false;
+ }
+
+ if (isDiscordWebhook) {
+ console.log("[WEBHOOK] Webhook Discord detected");
+
+ await axios({
+ method: webhook.method || 'POST',
+ url: webhook.url,
+ headers: { 'Content-Type': 'application/json' },
+ data: payload,
+ timeout: 10000
+ });
+
+ console.log(`[WEBHOOK] Discord webhook ${webhook.name} send successfully`);
+ } else {
+ const compiledPayload = this.compileTemplate(payload, data);
+
+ await axios({
+ method: webhook.method || 'POST',
+ url: webhook.url,
+ headers,
+ data: compiledPayload,
+ timeout: 10000
+ });
+
+ console.log(`[WEBHOOK] Webhook ${webhook.name} send successfully`);
+ }
+
+ //Update the last triggered timestamp
+ await dbInstance.query(
+ 'UPDATE webhooks SET last_triggered = NOW() WHERE id = $1',
+ [webhook.id]
+ );
+
+ return true;
+ } catch (error) {
+ console.error(`[WEBHOOK] Error triggering 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;
+ }
+ }
+
+ compileTemplate(template, data) {
+ if (typeof template === 'object') {
+ return Object.keys(template).reduce((result, key) => {
+ result[key] = this.compileTemplate(template[key], data);
+ return result;
+ }, {});
+ } else if (typeof template === 'string') {
+ // Replace {{variable}} with the corresponding value from data
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
+ const keys = path.trim().split('.');
+ let value = data;
+
+ for (const key of keys) {
+ if (value === undefined) return match;
+ value = value[key];
+ }
+
+ return value !== undefined ? value : match;
+ });
+ }
+
+ 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);
+ }
+
+ async getTopWatchedContent(contentType, period = 'month', limit = 5) {
+ // Calculate period start date
+ const today = new Date();
+ let startDate;
+
+ if (period === 'month') {
+ startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ } else if (period === 'week') {
+ const day = today.getDay();
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - day - 7);
+ } else {
+ startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ }
+
+ const formattedStartDate = startDate.toISOString().split('T')[0];
+
+ // SQL query to get top watched content
+ let query;
+ if (contentType === 'movie') {
+ query = `
+ SELECT
+ "NowPlayingItemName" as title,
+ COUNT(DISTINCT "UserId") as unique_viewers,
+ SUM("PlaybackDuration") / 60000 as total_minutes
+ FROM jf_playback_activity
+ WHERE "ActivityDateInserted" >= $1
+ AND "NowPlayingItemName" IS NOT NULL
+ AND "SeriesName" IS NULL
+ GROUP BY "NowPlayingItemName", "NowPlayingItemId"
+ ORDER BY total_minutes DESC
+ LIMIT $2
+ `;
+ } else if (contentType === 'series') {
+ query = `
+ SELECT
+ "SeriesName" as title,
+ COUNT(DISTINCT "UserId") as unique_viewers,
+ SUM("PlaybackDuration") / 60000 as total_minutes
+ FROM jf_playback_activity
+ WHERE "ActivityDateInserted" >= $1
+ AND "SeriesName" IS NOT NULL
+ GROUP BY "SeriesName"
+ ORDER BY total_minutes DESC
+ LIMIT $2
+ `;
+ }
+
+ try {
+ const result = await dbInstance.query(query, [formattedStartDate, limit]);
+ return result.rows || [];
+ } catch (error) {
+ console.error(`[WEBHOOK] SQL ERROR (${contentType}):`, error.message);
+ return [];
+ }
+ }
+
+ async getMonthlySummaryData() {
+ try {
+ // Get the top watched movies and series
+ const topMovies = await this.getTopWatchedContent('movie', 'month', 5);
+ const topSeries = await this.getTopWatchedContent('series', 'month', 5);
+
+ const prevMonth = new Date();
+ prevMonth.setMonth(prevMonth.getMonth() - 1);
+ const prevMonthStart = new Date(prevMonth.getFullYear(), prevMonth.getMonth(), 1);
+ const prevMonthEnd = new Date(prevMonth.getFullYear(), prevMonth.getMonth() + 1, 0);
+
+ const formattedStart = prevMonthStart.toISOString().split('T')[0];
+ const formattedEnd = prevMonthEnd.toISOString().split('T')[0];
+
+ // Get general statistics
+ const statsQuery = `
+ SELECT
+ COUNT(DISTINCT "UserId") as active_users,
+ COUNT(*) as total_plays,
+ SUM("PlaybackDuration") / 3600000 as total_hours
+ FROM jf_playback_activity
+ WHERE "ActivityDateInserted" BETWEEN $1 AND $2
+ `;
+
+ const statsResult = await dbInstance.query(statsQuery, [formattedStart, formattedEnd]);
+ const generalStats = statsResult.rows[0] || {
+ active_users: 0,
+ total_plays: 0,
+ total_hours: 0
+ };
+
+ return {
+ period: {
+ start: formattedStart,
+ end: formattedEnd,
+ name: prevMonth.toLocaleString('fr-FR', { month: 'long', year: 'numeric' })
+ },
+ topMovies,
+ topSeries,
+ stats: generalStats
+ };
+ } catch (error) {
+ console.error("[WEBHOOK] Error while getting data:", error.message);
+ throw error;
+ }
+ }
+
+ async triggerMonthlySummaryWebhook(webhookId) {
+ try {
+ // Get the webhook details
+ const result = await dbInstance.query(
+ 'SELECT * FROM webhooks WHERE id = $1 AND enabled = true',
+ [webhookId]
+ );
+
+ if (result.rows.length === 0) {
+ console.error(`[WEBHOOK] Webhook ID ${webhookId} not found or disable`);
+ return false;
+ }
+
+ const webhook = result.rows[0];
+
+ // Generate the monthly summary data
+ try {
+ const data = await this.getMonthlySummaryData();
+
+ const moviesFields = data.topMovies.map((movie, index) => ({
+ name: `${index + 1}. ${movie.title}`,
+ value: `${Math.round(movie.total_minutes)} minutes • ${movie.unique_viewers} viewers`,
+ inline: false
+ }));
+
+ const seriesFields = data.topSeries.map((series, index) => ({
+ name: `${index + 1}. ${series.title}`,
+ value: `${Math.round(series.total_minutes)} minutes • ${series.unique_viewers} viewers`,
+ inline: false
+ }));
+
+ const monthlyPayload = {
+ content: `📊 **Monthly Report - ${data.period.name}**`,
+ embeds: [
+ {
+ title: "🎬 Most Watched Movies",
+ color: 15844367,
+ fields: moviesFields.length > 0 ? moviesFields : [{ name: "No data", value: "No movies watch this month" }]
+ },
+ {
+ title: "📺 Most Watched Series",
+ color: 5793266,
+ fields: seriesFields.length > 0 ? seriesFields : [{ name: "No data", value: "No Series watch this month" }]
+ },
+ {
+ title: "📈 General Statistics",
+ color: 5763719,
+ fields: [
+ {
+ name: "Active Users",
+ value: `${data.stats.active_users || 0}`,
+ inline: true
+ },
+ {
+ name: "Total Plays",
+ value: `${data.stats.total_plays || 0}`,
+ inline: true
+ },
+ {
+ name: "Total Hours Watched",
+ value: `${Math.round(data.stats.total_hours || 0)}`,
+ inline: true
+ }
+ ],
+ footer: {
+ text: `Period: from ${new Date(data.period.start).toLocaleDateString('en-US')} to ${new Date(data.period.end).toLocaleDateString('en-US')}`
+ }
+ }
+ ]
+ };
+
+ // Send the webhook
+ await axios({
+ method: webhook.method || 'POST',
+ url: webhook.url,
+ headers: { 'Content-Type': 'application/json' },
+ data: monthlyPayload,
+ timeout: 10000
+ });
+
+ console.log(`[WEBHOOK] Monthly report webhook ${webhook.name} sent successfully`);
+
+ // Update the last triggered timestamp
+ await dbInstance.query(
+ 'UPDATE webhooks SET last_triggered = NOW() WHERE id = $1',
+ [webhook.id]
+ );
+
+ return true;
+ } catch (dataError) {
+ console.error(`[WEBHOOK] Error while preparing the data:`, dataError.message);
+ return false;
+ }
+ } catch (error) {
+ console.error(`[WEBHOOK] Error while sending the monthly report:`, error.message);
+ 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/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js
new file mode 100644
index 0000000..4340217
--- /dev/null
+++ b/backend/classes/webhook-scheduler.js
@@ -0,0 +1,105 @@
+const cron = require('node-cron');
+const WebhookManager = require('./webhook-manager');
+const dbInstance = require('../db');
+
+class WebhookScheduler {
+ constructor() {
+ this.webhookManager = new WebhookManager();
+ this.cronJobs = {};
+ this.loadScheduledWebhooks();
+ }
+
+ async loadScheduledWebhooks() {
+ try {
+ const webhooks = await this.webhookManager.getScheduledWebhooks();
+ if (webhooks) {
+ // Clean existing tasks
+ Object.values(this.cronJobs).forEach(job => job.stop());
+ this.cronJobs = {};
+
+ // Create new tasks
+ webhooks.forEach(webhook => {
+ if (webhook.schedule && cron.validate(webhook.schedule)) {
+ this.scheduleWebhook(webhook);
+ } else {
+ console.error(`[WEBHOOK] Invalid cron schedule for webhook ${webhook.id}: ${webhook.schedule}`);
+ }
+ });
+
+ console.log(`[WEBHOOK] Scheduled ${Object.keys(this.cronJobs).length} webhooks`);
+ } else {
+ console.log('[WEBHOOK] No scheduled webhooks found');
+ }
+ } catch (error) {
+ console.error('[WEBHOOK] Failed to load scheduled webhooks:', error);
+ }
+ }
+
+ 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 () => {
+ console.log(`[WEBHOOK] Executing scheduled webhook: ${webhook.name}`);
+ await this.webhookManager.executeWebhook(webhook);
+ });
+
+ console.log(`[WEBHOOK] Webhook ${webhook.name} scheduled with cron: ${webhook.schedule}`);
+ } catch (error) {
+ console.error(`[WEBHOOK] Error scheduling webhook ${webhook.id}:`, error);
+ }
+ }
+
+ async refreshSchedule() {
+ await this.loadScheduledWebhooks();
+ await this.loadEventWebhooks();
+ }
+}
+
+module.exports = WebhookScheduler;
\ No newline at end of file
diff --git a/backend/migrations/098_create_webhooks_table.js b/backend/migrations/098_create_webhooks_table.js
new file mode 100644
index 0000000..3aaedcb
--- /dev/null
+++ b/backend/migrations/098_create_webhooks_table.js
@@ -0,0 +1,23 @@
+exports.up = function (knex) {
+ return knex.schema.createTable("webhooks", (table) => {
+ table.increments("id").primary();
+ table.string("name").notNullable();
+ table.string("url").notNullable();
+ table.text("headers").defaultTo("{}");
+ table.text("payload").defaultTo("{}");
+ table.string("method").defaultTo("POST");
+ table.string("trigger_type").notNullable();
+ table.string("webhook_type").defaultTo("generic");
+ table.string("schedule").nullable();
+ table.string("event_type").nullable();
+ table.boolean("enabled").defaultTo(true);
+ table.timestamp("last_triggered").nullable();
+ table.boolean("retry_on_failure").defaultTo(false);
+ table.integer("max_retries").defaultTo(3);
+ table.timestamps(true, true);
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.dropTable("webhooks");
+};
diff --git a/backend/routes/sync.js b/backend/routes/sync.js
index 0bbc098..c20e11b 100644
--- a/backend/routes/sync.js
+++ b/backend/routes/sync.js
@@ -852,6 +852,8 @@ async function partialSync(triggertype) {
const config = await new configClass().getConfig();
const uuid = randomUUID();
+
+ const newItems = []; // Array to track newly added items during the sync process
syncTask = { loggedData: [], uuid: uuid, wsKey: "PartialSyncTask", taskName: taskName.partialsync };
try {
@@ -861,7 +863,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();
@@ -870,7 +872,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 || [];
@@ -878,10 +880,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
@@ -984,7 +986,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;
@@ -1051,10 +1053,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
new file mode 100644
index 0000000..348279d
--- /dev/null
+++ b/backend/routes/webhooks.js
@@ -0,0 +1,424 @@
+const express = require('express');
+const router = express.Router();
+const dbInstance = require('../db');
+const WebhookManager = require('../classes/webhook-manager');
+const WebhookScheduler = require('../classes/webhook-scheduler');
+
+const webhookScheduler = new WebhookScheduler();
+const webhookManager = new WebhookManager();
+
+// Get all webhooks
+router.get('/', async (req, res) => {
+ try {
+ const result = await dbInstance.query('SELECT * FROM webhooks ORDER BY id DESC');
+ res.json(result.rows);
+ } catch (error) {
+ console.error('Error fetching webhooks:', error);
+ res.status(500).json({ error: 'Failed to fetch webhooks' });
+ }
+});
+
+// Get a specific webhook by ID
+router.get('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const result = await dbInstance.query('SELECT * FROM webhooks WHERE id = $1', [id]);
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Webhook not found' });
+ }
+
+ res.json(result.rows[0]);
+ } catch (error) {
+ console.error('Error fetching webhook:', error);
+ res.status(500).json({ error: 'Failed to fetch webhook' });
+ }
+});
+
+// Create a new webhook
+router.post('/', async (req, res) => {
+ try {
+ const {
+ name,
+ url,
+ headers,
+ payload,
+ method,
+ trigger_type,
+ schedule,
+ event_type,
+ enabled,
+ retry_on_failure,
+ max_retries
+ } = req.body;
+
+ if (!name || !url || !trigger_type) {
+ return res.status(400).json({ error: 'Name, URL and trigger type are required' });
+ }
+
+ if (trigger_type === 'scheduled' && !schedule) {
+ return res.status(400).json({ error: 'Schedule is required for scheduled webhooks' });
+ }
+
+ if (trigger_type === 'event' && !event_type) {
+ return res.status(400).json({ error: 'Event type is required for event webhooks' });
+ }
+
+ const result = await dbInstance.query(
+ `INSERT INTO webhooks (name, url, headers, payload, method, trigger_type, schedule, event_type, enabled, retry_on_failure, max_retries)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ RETURNING *`,
+ [
+ name,
+ url,
+ JSON.stringify(headers || {}),
+ JSON.stringify(payload || {}),
+ method || 'POST',
+ trigger_type,
+ schedule,
+ event_type,
+ enabled !== undefined ? enabled : true,
+ retry_on_failure || false,
+ max_retries || 3
+ ]
+ );
+
+ // Refresh the schedule if the webhook is scheduled
+ if (trigger_type === 'scheduled' && enabled) {
+ await webhookScheduler.refreshSchedule();
+ }
+
+ res.status(201).json(result.rows[0]);
+ } catch (error) {
+ console.error('Error creating webhook:', error);
+ res.status(500).json({ error: 'Failed to create webhook' });
+ }
+});
+
+// Update a webhook
+router.put('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const {
+ name,
+ url,
+ headers,
+ payload,
+ method,
+ trigger_type,
+ schedule,
+ event_type,
+ enabled,
+ retry_on_failure,
+ max_retries
+ } = req.body;
+
+ if (!name || !url || !trigger_type) {
+ return res.status(400).json({ error: 'Name, URL and trigger type are required' });
+ }
+
+ const result = await dbInstance.query(
+ `UPDATE webhooks
+ SET name = $1, url = $2, headers = $3, payload = $4, method = $5,
+ trigger_type = $6, schedule = $7, event_type = $8, enabled = $9,
+ retry_on_failure = $10, max_retries = $11
+ WHERE id = $12
+ RETURNING *`,
+ [
+ name,
+ url,
+ JSON.stringify(headers || {}),
+ JSON.stringify(payload || {}),
+ method || 'POST',
+ trigger_type,
+ schedule,
+ event_type,
+ enabled !== undefined ? enabled : true,
+ retry_on_failure || false,
+ max_retries || 3,
+ id
+ ]
+ );
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Webhook not found' });
+ }
+
+ // Refresh the schedule if the webhook is scheduled
+ await webhookScheduler.refreshSchedule();
+
+ res.json(result.rows[0]);
+ } catch (error) {
+ console.error('Error updating webhook:', error);
+ res.status(500).json({ error: 'Failed to update webhook' });
+ }
+});
+
+// Delete a webhook
+router.delete('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const result = await dbInstance.query('DELETE FROM webhooks WHERE id = $1 RETURNING *', [id]);
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Webhook not found' });
+ }
+
+ // Refresh the schedule if the webhook was scheduled
+ await webhookScheduler.refreshSchedule();
+
+ res.json({ message: 'Webhook deleted successfully', webhook: result.rows[0] });
+ } catch (error) {
+ console.error('Error deleting webhook:', error);
+ res.status(500).json({ error: 'Failed to delete webhook' });
+ }
+});
+
+// Test a webhook
+router.post('/:id/test', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const result = await dbInstance.query('SELECT * FROM webhooks WHERE id = $1', [id]);
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Webhook not found' });
+ }
+
+ const webhook = result.rows[0];
+ let testData = req.body || {};
+ let success = false;
+
+ // Discord behaviour
+ if (webhook.url.includes('discord.com/api/webhooks')) {
+ console.log('Discord webhook détecté, préparation du payload spécifique');
+
+ // Discord specific format
+ testData = {
+ content: "Test de webhook depuis Jellystat",
+ embeds: [{
+ title: "Discord test notification",
+ description: "This is a test notification of jellystat discord webhook",
+ color: 3447003,
+ fields: [
+ {
+ name: "Webhook type",
+ value: webhook.trigger_type || "Not specified",
+ inline: true
+ },
+ {
+ name: "ID",
+ value: webhook.id,
+ inline: true
+ }
+ ],
+ timestamp: new Date().toISOString()
+ }]
+ };
+
+ // Bypass classic method for discord
+ success = await webhookManager.executeDiscordWebhook(webhook, testData);
+ }
+ 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' });
+ } else {
+ res.status(500).json({ error: 'Error while executing webhook' });
+ }
+ } catch (error) {
+ console.error('Error testing webhook:', error);
+ res.status(500).json({ error: 'Failed to test webhook: ' + error.message });
+ }
+});
+
+router.post('/:id/trigger-monthly', async (req, res) => {
+ const webhookManager = new WebhookManager();
+ const success = await webhookManager.triggerMonthlySummaryWebhook(req.params.id);
+
+ if (success) {
+ res.status(200).json({ message: "Monthly report sent successfully" });
+ } else {
+ res.status(500).json({ message: "Failed to send monthly report" });
+ }
+});
+
+// Get status of event webhooks
+router.get('/event-status', authMiddleware, 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;
diff --git a/backend/server.js b/backend/server.js
index 8d4b82a..debea8c 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -25,11 +25,13 @@ const statsRouter = require("./routes/stats");
const backupRouter = require("./routes/backup");
const logRouter = require("./routes/logging");
const utilsRouter = require("./routes/utils");
+const webhooksRouter = require('./routes/webhooks');
// tasks
const ActivityMonitor = require("./tasks/ActivityMonitor");
const TaskManager = require("./classes/task-manager-singleton");
const TaskScheduler = require("./classes/task-scheduler-singleton");
+const WebhookScheduler = require('./classes/webhook-scheduler');
// const tasks = require("./tasks/tasks");
// websocket
@@ -165,6 +167,9 @@ app.use("/logs", authenticate, logRouter, () => {
app.use("/utils", authenticate, utilsRouter, () => {
/* #swagger.tags = ['Utils']*/
}); // mount the API router at /utils, with JWT middleware
+app.use("/webhooks", authenticate, webhooksRouter, () => {
+ /* #swagger.tags = ['Webhooks']*/
+}); // mount the API router at /webhooks, with JWT middleware
// Swagger
app.use("/swagger", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
@@ -243,6 +248,7 @@ try {
ActivityMonitor.ActivityMonitor(1000);
new TaskManager();
new TaskScheduler();
+ new WebhookScheduler();
});
});
});
diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js
index 742a514..b3a70da 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) {
const existingData = await WatchdogData.filter((wdData) => {
return SessionData.some((sessionData) => {
@@ -196,6 +200,42 @@ async function ActivityMonitor(defaultInterval) {
//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);
@@ -208,11 +248,46 @@ async function ActivityMonitor(defaultInterval) {
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
- const toDeleteIds = dataToRemove.map((row) => row.ActivityId);
-
let playbackToInsert = dataToRemove;
if (playbackToInsert.length == 0 && toDeleteIds.length == 0) {
@@ -298,7 +373,9 @@ async function ActivityMonitor(defaultInterval) {
}
///////////////////////////
- } 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/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 a6b09d2..55ae570 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"material-react-table": "^3.1.0",
"memoizee": "^0.4.17",
"multer": "^1.4.5-lts.1",
+ "node-cron": "^3.0.3",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.9.0",
@@ -15354,6 +15355,18 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/node-cron": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
+ "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
+ "license": "ISC",
+ "dependencies": {
+ "uuid": "8.3.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -19461,14 +19474,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
- "node_modules/sequelize/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/serialize-javascript": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz",
@@ -19794,14 +19799,6 @@
"websocket-driver": "^0.7.4"
}
},
- "node_modules/sockjs/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@@ -21515,6 +21512,15 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/v8-to-istanbul": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz",
diff --git a/package.json b/package.json
index 2b612a8..32ce937 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"material-react-table": "^3.1.0",
"memoizee": "^0.4.17",
"multer": "^1.4.5-lts.1",
+ "node-cron": "^3.0.3",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.9.0",
diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json
index a56229a..1061a04 100644
--- a/public/locales/en-UK/translation.json
+++ b/public/locales/en-UK/translation.json
@@ -226,7 +226,25 @@
"REALTIME_UPDATE_INFO": "Changes are applied in real-time without server restart.",
"SELECT_LIBRARIES_TO_IMPORT": "Select Libraries to Import",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "Activity for Items within these libraries are still Tracked - Even when not imported.",
- "DATE_ADDED": "Date Added"
+ "DATE_ADDED": "Date Added",
+ "WEBHOOKS": "Webhooks",
+ "WEBHOOK_TYPE": "Webhook Type",
+ "TEST_NOW": "Test Now",
+ "WEBHOOKS_CONFIGURATION": "Webhook Configuration",
+ "WEBHOOKS_TOOLTIP": "Webhook URL to send Playback Activity to",
+ "WEBHOOK_SAVED": "Webhook Saved",
+ "WEBHOOK_NAME": "Webhook Name",
+ "DISCORD_WEBHOOK_URL": "Discord Webhook URL",
+ "ENABLE_WEBHOOK": "Enable Webhook",
+ "URL": "URL",
+ "TYPE": "Type",
+ "TRIGGER": "Trigger",
+ "STATUS": "Status",
+ "EVENT_WEBHOOKS": "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 6047ca7..8da37d8 100644
--- a/public/locales/fr-FR/translation.json
+++ b/public/locales/fr-FR/translation.json
@@ -222,7 +222,25 @@
"REALTIME_UPDATE_INFO": "Les modifications sont appliquées en temps réel sans redémarrage du serveur.",
"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",
+ "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
new file mode 100644
index 0000000..3c6beac
--- /dev/null
+++ b/src/pages/components/settings/webhooks.jsx
@@ -0,0 +1,526 @@
+import React, { useState, useEffect } from "react";
+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';
+import TableCell from '@mui/material/TableCell';
+import TableContainer from '@mui/material/TableContainer';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+
+import { Trans } from "react-i18next";
+import Loading from "../general/loading";
+import ErrorBoundary from "../general/ErrorBoundary";
+
+const token = localStorage.getItem('token');
+
+function WebhookRow(props) {
+ const { webhook, onEdit, onTest } = props;
+
+ return (
+
+ Send a webhook notification when a user starts watching a media +
++ Send a webhook notification when a user finishes watching a media +
++ Send a webhook notification when new media is added to the library +
+