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 ( + + *': { borderBottom: 'unset' } }}> + {webhook.name} + {webhook.url} + {webhook.webhook_type || 'generic'} + {webhook.trigger_type} + + + {webhook.enabled ? : } + + + +
+ + +
+
+
+
+ ); +} + +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); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [currentWebhook, setCurrentWebhook] = useState({ + name: 'Monthly Report of Most watched movies and series', + url: '', + enabled: false, + trigger_type: 'scheduled', + schedule: '0 9 1 * *', + method: 'POST', + webhook_type: 'discord' + }); + + 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 { + const response = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (response.data !== webhooks) { + setWebhooks(response.data); + await loadEventWebhooks(); + } + + if (loading) { + setLoading(false); + } + } catch (err) { + console.error("Error loading webhooks:", err); + if (loading) { + setLoading(false); + } + } + }; + + fetchWebhooks(); + + const intervalId = setInterval(fetchWebhooks, 1000 * 10); + return () => clearInterval(intervalId); + }, [webhooks.length]); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setCurrentWebhook(prev => ({ ...prev, [name]: value })); + }; + + const handleToggleEnabled = () => { + setCurrentWebhook(prev => ({ ...prev, enabled: !prev.enabled })); + }; + + const handleFormSubmit = async (e) => { + e.preventDefault(); + try { + setSaving(true); + setError(null); + setSuccess(false); + + if (!currentWebhook.url) { + setError("Webhook URL is required"); + setSaving(false); + return; + } + + if (currentWebhook.trigger_type === 'event' && !currentWebhook.event_type) { + setError("Event type is required for an event based webhook"); + setSaving(false); + return; + } + + let response; + + if (currentWebhook.id) { + response = await axios.put(`/webhooks/${currentWebhook.id}`, currentWebhook, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + }); + } else { + response = await axios.post('/webhooks', currentWebhook, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + }); + } + + const webhooksResponse = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(webhooksResponse.data); + + await loadEventWebhooks(); + + setCurrentWebhook({ + name: 'New Webhook', + url: '', + enabled: false, + trigger_type: 'scheduled', + schedule: '0 9 1 * *', + method: 'POST', + webhook_type: 'discord' + }); + + setSuccess("Webhook saved successfully!"); + setSaving(false); + } catch (err) { + setError("Error while saving webhook " + (err.response?.data?.error || err.message)); + setSaving(false); + } + }; + + const handleEdit = (webhook) => { + setCurrentWebhook(webhook); + }; + + const handleTest = async (webhook) => { + if (!webhook || !webhook.id) { + setError("Impossible to test the webhook: no webhook provided"); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + 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 ${webhook.name} test triggered successfully!`); + setLoading(false); + } catch (err) { + setError("Error during the test of the webhook: " + (err.response?.data?.message || err.message)); + setLoading(false); + } + }; + + const getEventWebhookStatus = (eventType) => { + return eventWebhooks[eventType]?.enabled || false; + }; + + const loadEventWebhooks = async () => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const status = {}; + + 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); + } + }; + + 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 + ); + + if (matchingWebhooks.length === 0 && !isCurrentlyEnabled) { + const newWebhook = { + name: `Notification - ${getEventDisplayName(eventType)}`, + url: '', + enabled: true, + trigger_type: 'event', + event_type: eventType, + method: 'POST', + webhook_type: 'discord' + }; + + setCurrentWebhook(newWebhook); + setLoading(false); + return; + } + + for (const webhook of matchingWebhooks) { + await axios.put(`/webhooks/${webhook.id}`, + { ...webhook, enabled: !isCurrentlyEnabled }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + } + ); + } + + setEventWebhooks(prev => ({ + ...prev, + [eventType]: { + ...prev[eventType], + enabled: !isCurrentlyEnabled + } + })); + + const response = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(response.data); + setLoading(false); + setSuccess(`Webhook for ${getEventDisplayName(eventType)} ${!isCurrentlyEnabled ? 'enabled' : 'disabled'} with success!`); + } catch (error) { + setError("Error while editing webhook: " + (error.response?.data?.error || error.message)); + setLoading(false); + } + }; + + const getEventDisplayName = (eventType) => { + switch(eventType) { + case 'playback_started': + return 'Playback started'; + case 'playback_ended': + return 'Playback ended'; + case 'media_recently_added': + return 'New media added'; + default: + return eventType; + } + }; + + if (loading && !webhooks.length) { + return ; + } + + return ( +
+

+ {" "} + }> + + + + +

+ + + {error && setError(null)} dismissible>{error}} + {success && setSuccess(false)} dismissible> + {typeof success === 'string' ? success : } + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + checked={currentWebhook.enabled} + onChange={handleToggleEnabled} + /> + + + + + + + + +
+ + {/* Ajout de la section pour les webhooks événementiels */} +
+

+ + }> + + + + +

+ + + +
+
+
+ toggleEventWebhook('playback_started')} + /> +
+

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

+
+ + + +
+
+
+ toggleEventWebhook('playback_ended')} + /> +
+

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

+
+ + + +
+
+
+ toggleEventWebhook('media_recently_added')} + /> +
+

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

+
+ +
+
+ + + + + + + + + + + + + + + {webhooks.map((webhook) => ( + + ))} + {webhooks.length === 0 && ( + + + + + + )} + +
+
+ +
+
+ ); +} + +export default WebhooksSettings; \ No newline at end of file diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index 03b39d5..57737c3 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -5,6 +5,7 @@ import SettingsConfig from "./components/settings/settingsConfig"; import Tasks from "./components/settings/Tasks"; import SecuritySettings from "./components/settings/security"; import ApiKeys from "./components/settings/apiKeys"; +import WebhooksSettings from "./components/settings/webhooks"; import LibrarySelector from "./library_selector"; import ActivityMonitorSettings from "./components/settings/ActivityMonitorSettings"; @@ -55,6 +56,15 @@ export default function Settings() { + } + style={{ minHeight: "500px" }} + > + + +