From 56039bd3b9d3da90f0275becba0b245d1639faf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= Date: Fri, 25 Apr 2025 10:53:00 +0200 Subject: [PATCH 01/66] Add webhook management and scheduling functionality --- backend/classes/webhook-manager.js | 345 ++++++++++++++++++ backend/classes/webhook-scheduler.js | 53 +++ .../migrations/095_create_webhooks_table.js | 23 ++ backend/routes/webhooks.js | 214 +++++++++++ backend/server.js | 6 + package-lock.json | 40 +- package.json | 1 + 7 files changed, 665 insertions(+), 17 deletions(-) create mode 100644 backend/classes/webhook-manager.js create mode 100644 backend/classes/webhook-scheduler.js create mode 100644 backend/migrations/095_create_webhooks_table.js create mode 100644 backend/routes/webhooks.js diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js new file mode 100644 index 0000000..ea605fa --- /dev/null +++ b/backend/classes/webhook-manager.js @@ -0,0 +1,345 @@ +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('user_login', async (data) => { + await this.triggerEventWebhooks('user_login', 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) { + const webhooks = await this.getWebhooksByEventType(eventType); + + for (const webhook of webhooks) { + await this.executeWebhook(webhook, data); + } + } + + 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; + } + + 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] Erreur SQL (${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] Erreur récupération données:", 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} non trouvé ou désactivé`); + 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} spectateurs`, + inline: false + })); + + const seriesFields = data.topSeries.map((series, index) => ({ + name: `${index + 1}. ${series.title}`, + value: `${Math.round(series.total_minutes)} minutes • ${series.unique_viewers} spectateurs`, + inline: false + })); + + const monthlyPayload = { + content: `📊 **Rapport mensuel - ${data.period.name}**`, + embeds: [ + { + title: "🎬 Most Watched Movies", + color: 15844367, // Orange + fields: moviesFields.length > 0 ? moviesFields : [{ name: "Aucune donnée", value: "Pas de films regardés ce mois-ci" }] + }, + { + title: "📺 Most Watched Series", + color: 5793266, // Bleu + fields: seriesFields.length > 0 ? seriesFields : [{ name: "Aucune donnée", value: "Pas de séries regardées ce mois-ci" }] + }, + { + title: "📈 General Statistics", + color: 5763719, // Vert + fields: [ + { + name: "Utilisateurs actifs", + value: `${data.stats.active_users || 0}`, + inline: true + }, + { + name: "Lectures totales", + value: `${data.stats.total_plays || 0}`, + inline: true + }, + { + name: "Heures visionnées", + value: `${Math.round(data.stats.total_hours || 0)}`, + inline: true + } + ], + footer: { + text: `Période: du ${new Date(data.period.start).toLocaleDateString('fr-FR')} au ${new Date(data.period.end).toLocaleDateString('fr-FR')}` + } + } + ] + }; + + // Send the webhook + await axios({ + method: webhook.method || 'POST', + url: webhook.url, + headers: { 'Content-Type': 'application/json' }, + data: monthlyPayload, + timeout: 10000 + }); + + console.log(`[WEBHOOK] Rapport mensuel envoyé avec succès via ${webhook.name}`); + + // 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] Erreur préparation données:`, dataError.message); + return false; + } + } catch (error) { + console.error(`[WEBHOOK] Erreur lors de l'envoi du rapport mensuel:`, error.message); + 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..2b111fe --- /dev/null +++ b/backend/classes/webhook-scheduler.js @@ -0,0 +1,53 @@ +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(); + + // 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`); + } catch (error) { + console.error('[WEBHOOK] Failed to load scheduled webhooks:', 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(); + } +} + +module.exports = WebhookScheduler; \ No newline at end of file diff --git a/backend/migrations/095_create_webhooks_table.js b/backend/migrations/095_create_webhooks_table.js new file mode 100644 index 0000000..d01f818 --- /dev/null +++ b/backend/migrations/095_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'); +}; \ No newline at end of file diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js new file mode 100644 index 0000000..67a6370 --- /dev/null +++ b/backend/routes/webhooks.js @@ -0,0 +1,214 @@ +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]; + const testData = req.body || {}; + + const success = await webhookManager.executeWebhook(webhook, testData); + + if (success) { + res.json({ message: 'Webhook executed successfully' }); + } else { + res.status(500).json({ error: 'Webhook execution failed' }); + } + } catch (error) { + console.error('Error testing webhook:', error); + res.status(500).json({ error: 'Failed to test webhook' }); + } +}); + +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: "Rapport mensuel envoyé avec succès" }); + } else { + res.status(500).json({ message: "Échec de l'envoi du rapport mensuel" }); + } +}); + +module.exports = router; \ No newline at end of file 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/package-lock.json b/package-lock.json index d39c07f..b222773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "jfstat", - "version": "1.1.4", + "version": "1.1.5", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -46,6 +46,7 @@ "memoizee": "^0.4.17", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pg": "^8.9.0", @@ -15353,6 +15354,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", @@ -19460,14 +19473,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", @@ -19793,14 +19798,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", @@ -21514,6 +21511,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 707978c..1553914 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "memoizee": "^0.4.17", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pg": "^8.9.0", From e3e3a167dae2f2a2a357141b13f17988c0db43cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= Date: Fri, 25 Apr 2025 12:02:11 +0200 Subject: [PATCH 02/66] Add webhooks settings component and integrate into settings page --- public/locales/en-UK/translation.json | 15 +- src/pages/components/settings/webhooks.jsx | 313 +++++++++++++++++++++ src/pages/settings.jsx | 10 + vite.config.js | 1 + 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/pages/components/settings/webhooks.jsx diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 4b9357b..58ce159 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -213,7 +213,20 @@ }, "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" }, "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..445a586 --- /dev/null +++ b/src/pages/components/settings/webhooks.jsx @@ -0,0 +1,313 @@ +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 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 ? : } + + + +
+ + +
+
+
+
+ ); +} + +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: 'Rapport mensuel films et séries', + url: '', + enabled: false, + trigger_type: 'scheduled', + schedule: '0 9 1 * *', // 9h le 1er du mois + method: 'POST', + webhook_type: 'discord' + }); + + useEffect(() => { + const fetchWebhooks = async () => { + try { + setLoading(true); + const response = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(response.data); + setLoading(false); + } catch (err) { + console.log("Erreur lors du chargement des webhooks:", err); + setLoading(false); + } + }; + + fetchWebhooks(); + + const intervalId = setInterval(fetchWebhooks, 1000 * 5); + return () => clearInterval(intervalId); + }, []); + + 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("L'URL du webhook Discord est requise"); + 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", + } + }); + } + + setCurrentWebhook({ + name: 'Nouveau webhook', + url: '', + enabled: false, + trigger_type: 'scheduled', + schedule: '0 9 1 * *', + method: 'POST', + webhook_type: 'discord' + }); + setSuccess("Webhook enregistré avec succès"); + setSaving(false); + } catch (err) { + setError("Erreur lors de l'enregistrement du webhook: " + (err.response?.data?.error || err.message)); + setSaving(false); + } + }; + + const handleEdit = (webhook) => { + setCurrentWebhook(webhook); + }; + + const handleTest = async (webhookId) => { + if (!webhookId) { + setError("Impossible de tester ce webhook"); + return; + } + + try { + setLoading(true); + setError(null); + + await axios.post(`/webhooks/${webhookId}/trigger-monthly`, {}, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + }); + + setSuccess("Webhook testé avec succès !"); + setLoading(false); + } catch (err) { + setError("Erreur lors du test du webhook: " + (err.response?.data?.message || err.message)); + setLoading(false); + } + }; + + if (loading && !webhooks.length) { + return ; + } + + return ( +
+

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

+ + + {error && setError(null)} dismissible>{error}} + {success && setSuccess(false)} dismissible> + {typeof success === 'string' ? success : } + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + checked={currentWebhook.enabled} + onChange={handleToggleEnabled} + /> + + + + + + + + +
+ + {webhooks.length > 0 ? ( + + + + + + + + + + + + + + {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 d96a224..e0ca521 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 Logs from "./components/settings/logs"; @@ -53,6 +54,15 @@ export default function Settings() { + } + style={{ minHeight: "500px" }} + > + + + Date: Fri, 25 Apr 2025 13:57:15 +0200 Subject: [PATCH 03/66] Update webhook component for improved user experience and error handling --- src/pages/components/settings/webhooks.jsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 445a586..68d300b 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -54,11 +54,11 @@ function WebhooksSettings() { const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [currentWebhook, setCurrentWebhook] = useState({ - name: 'Rapport mensuel films et séries', + name: 'Monthly Report of Most watched movies and series', url: '', enabled: false, trigger_type: 'scheduled', - schedule: '0 9 1 * *', // 9h le 1er du mois + schedule: '0 9 1 * *', method: 'POST', webhook_type: 'discord' }); @@ -77,7 +77,7 @@ function WebhooksSettings() { setWebhooks(response.data); setLoading(false); } catch (err) { - console.log("Erreur lors du chargement des webhooks:", err); + console.error("Error loading webhooks:", err); setLoading(false); } }; @@ -105,7 +105,7 @@ function WebhooksSettings() { setSuccess(false); if (!currentWebhook.url) { - setError("L'URL du webhook Discord est requise"); + setError("Discord webhook URL is required"); setSaving(false); return; } @@ -129,7 +129,7 @@ function WebhooksSettings() { } setCurrentWebhook({ - name: 'Nouveau webhook', + name: 'New Webhook', url: '', enabled: false, trigger_type: 'scheduled', @@ -137,10 +137,10 @@ function WebhooksSettings() { method: 'POST', webhook_type: 'discord' }); - setSuccess("Webhook enregistré avec succès"); + setSuccess("Webhook saved successfully!"); setSaving(false); } catch (err) { - setError("Erreur lors de l'enregistrement du webhook: " + (err.response?.data?.error || err.message)); + setError("Error during webhook saving: " + (err.response?.data?.error || err.message)); setSaving(false); } }; @@ -151,7 +151,7 @@ function WebhooksSettings() { const handleTest = async (webhookId) => { if (!webhookId) { - setError("Impossible de tester ce webhook"); + setError("Impossible to test the webhook: no ID provided"); return; } @@ -166,10 +166,10 @@ function WebhooksSettings() { } }); - setSuccess("Webhook testé avec succès !"); + setSuccess("Webhook test triggered successfully!"); setLoading(false); } catch (err) { - setError("Erreur lors du test du webhook: " + (err.response?.data?.message || err.message)); + setError("Error during the test of the webhook: " + (err.response?.data?.message || err.message)); setLoading(false); } }; From 193b47c4afb814e20a2315163c8700fd0b4b9f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Sat, 26 Apr 2025 11:44:44 +0200 Subject: [PATCH 04/66] Improve error messages and logging in webhook manager and scheduler and fix error when webhook is undefined --- backend/classes/webhook-manager.js | 36 ++++++++++++++-------------- backend/classes/webhook-scheduler.js | 5 +++- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js index ea605fa..233f2cc 100644 --- a/backend/classes/webhook-manager.js +++ b/backend/classes/webhook-manager.js @@ -190,7 +190,7 @@ class WebhookManager { const result = await dbInstance.query(query, [formattedStartDate, limit]); return result.rows || []; } catch (error) { - console.error(`[WEBHOOK] Erreur SQL (${contentType}):`, error.message); + console.error(`[WEBHOOK] SQL ERROR (${contentType}):`, error.message); return []; } } @@ -237,7 +237,7 @@ class WebhookManager { stats: generalStats }; } catch (error) { - console.error("[WEBHOOK] Erreur récupération données:", error.message); + console.error("[WEBHOOK] Error while getting data:", error.message); throw error; } } @@ -251,7 +251,7 @@ class WebhookManager { ); if (result.rows.length === 0) { - console.error(`[WEBHOOK] Webhook ID ${webhookId} non trouvé ou désactivé`); + console.error(`[WEBHOOK] Webhook ID ${webhookId} not found or disable`); return false; } @@ -263,51 +263,51 @@ class WebhookManager { const moviesFields = data.topMovies.map((movie, index) => ({ name: `${index + 1}. ${movie.title}`, - value: `${Math.round(movie.total_minutes)} minutes • ${movie.unique_viewers} spectateurs`, + 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} spectateurs`, + value: `${Math.round(series.total_minutes)} minutes • ${series.unique_viewers} viewers`, inline: false })); const monthlyPayload = { - content: `📊 **Rapport mensuel - ${data.period.name}**`, + content: `📊 **Monthly Report - ${data.period.name}**`, embeds: [ { title: "🎬 Most Watched Movies", - color: 15844367, // Orange - fields: moviesFields.length > 0 ? moviesFields : [{ name: "Aucune donnée", value: "Pas de films regardés ce mois-ci" }] + color: 15844367, + fields: moviesFields.length > 0 ? moviesFields : [{ name: "No data", value: "No movies watch this month" }] }, { title: "📺 Most Watched Series", - color: 5793266, // Bleu - fields: seriesFields.length > 0 ? seriesFields : [{ name: "Aucune donnée", value: "Pas de séries regardées ce mois-ci" }] + color: 5793266, + fields: seriesFields.length > 0 ? seriesFields : [{ name: "No data", value: "No Series watch this month" }] }, { title: "📈 General Statistics", - color: 5763719, // Vert + color: 5763719, fields: [ { - name: "Utilisateurs actifs", + name: "Active Users", value: `${data.stats.active_users || 0}`, inline: true }, { - name: "Lectures totales", + name: "Total Plays", value: `${data.stats.total_plays || 0}`, inline: true }, { - name: "Heures visionnées", + name: "Total Hours Watched", value: `${Math.round(data.stats.total_hours || 0)}`, inline: true } ], footer: { - text: `Période: du ${new Date(data.period.start).toLocaleDateString('fr-FR')} au ${new Date(data.period.end).toLocaleDateString('fr-FR')}` + text: `Period: from ${new Date(data.period.start).toLocaleDateString('en-US')} to ${new Date(data.period.end).toLocaleDateString('en-US')}` } } ] @@ -322,7 +322,7 @@ class WebhookManager { timeout: 10000 }); - console.log(`[WEBHOOK] Rapport mensuel envoyé avec succès via ${webhook.name}`); + console.log(`[WEBHOOK] Monthly report webhook ${webhook.name} sent successfully`); // Update the last triggered timestamp await dbInstance.query( @@ -332,11 +332,11 @@ class WebhookManager { return true; } catch (dataError) { - console.error(`[WEBHOOK] Erreur préparation données:`, dataError.message); + console.error(`[WEBHOOK] Error while preparing the data:`, dataError.message); return false; } } catch (error) { - console.error(`[WEBHOOK] Erreur lors de l'envoi du rapport mensuel:`, error.message); + console.error(`[WEBHOOK] Error while sending the monthly report:`, error.message); return false; } } diff --git a/backend/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js index 2b111fe..d1fddc3 100644 --- a/backend/classes/webhook-scheduler.js +++ b/backend/classes/webhook-scheduler.js @@ -12,7 +12,7 @@ class WebhookScheduler { async loadScheduledWebhooks() { try { const webhooks = await this.webhookManager.getScheduledWebhooks(); - + if (webhooks) { // Clean existing tasks Object.values(this.cronJobs).forEach(job => job.stop()); this.cronJobs = {}; @@ -27,6 +27,9 @@ class WebhookScheduler { }); 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); } From 20d100c899de316eb7c92073395c557f7a90469d Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 26 Apr 2025 13:43:13 +0200 Subject: [PATCH 05/66] make loading show on initial page load to prevent flicker when data is refreshed on interval increase refresh interval to 10 seconds removed redundat empty webhooks message and check --- src/pages/components/settings/webhooks.jsx | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 68d300b..4b08657 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -66,7 +66,6 @@ function WebhooksSettings() { useEffect(() => { const fetchWebhooks = async () => { try { - setLoading(true); const response = await axios.get('/webhooks', { headers: { Authorization: `Bearer ${token}`, @@ -74,17 +73,24 @@ function WebhooksSettings() { }, }); - setWebhooks(response.data); - setLoading(false); + if (response.data != webhooks) { + setWebhooks(response.data); + } + + if (loading) { + setLoading(false); + } } catch (err) { console.error("Error loading webhooks:", err); - setLoading(false); + if (loading) { + setLoading(false); + } } }; fetchWebhooks(); - const intervalId = setInterval(fetchWebhooks, 1000 * 5); + const intervalId = setInterval(fetchWebhooks, 1000 * 10); return () => clearInterval(intervalId); }, []); @@ -267,8 +273,7 @@ function WebhooksSettings() { - - {webhooks.length > 0 ? ( + @@ -300,11 +305,7 @@ function WebhooksSettings() {
- ) : ( -
-

-
- )} + ); From 2de6616129a434494e5c2af4252a0664f1465a8a Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 2 May 2025 08:04:56 +0200 Subject: [PATCH 06/66] bump version to 1.1.7 in package.json and package-lock.json --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c04afa4..f9449fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "jfstat", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index eb28e84..79a9494 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jfstat", - "version": "1.1.6", + "version": "1.1.7", "private": true, "main": "src/index.jsx", "scripts": { From f05d9fb948ecbf205c5785ce256e36f08a4e3e62 Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Mon, 5 May 2025 15:39:05 +0530 Subject: [PATCH 07/66] Initial changes for total time view --- ...atch_stats_over_time_include_total_time.js | 121 ++++++++++++++++++ public/locales/en-UK/translation.json | 4 +- src/pages/css/statCard.css | 9 ++ src/pages/statistics.jsx | 45 ++++++- 4 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/095_fs_watch_stats_over_time_include_total_time.js diff --git a/backend/migrations/095_fs_watch_stats_over_time_include_total_time.js b/backend/migrations/095_fs_watch_stats_over_time_include_total_time.js new file mode 100644 index 0000000..d8d29cf --- /dev/null +++ b/backend/migrations/095_fs_watch_stats_over_time_include_total_time.js @@ -0,0 +1,121 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer); + + CREATE OR REPLACE FUNCTION public.fs_watch_stats_over_time( + days integer) + RETURNS TABLE("Date" date, "Count" bigint, "TotalTime" bigint, "Library" text, "LibraryID" text) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + +AS $BODY$ + BEGIN + RETURN QUERY + SELECT + dates."Date", + COALESCE(counts."Count", 0) AS "Count", + COALESCE(counts."TotalTime", 0) AS "TotalTime", + l."Name" as "Library", + l."Id" as "LibraryID" + FROM + (SELECT generate_series( + DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)), + DATE_TRUNC('day', NOW()), + '1 day')::DATE AS "Date" + ) dates + CROSS JOIN jf_libraries l + + LEFT JOIN + (SELECT + DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", + COUNT(*) AS "Count", + (SUM(a."PlaybackDuration") / 60)::bigint AS "TotalTime", + l."Name" as "Library" + FROM + jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + JOIN jf_libraries l ON i."ParentId" = l."Id" + WHERE + a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() + + GROUP BY + l."Name", DATE_TRUNC('day', a."ActivityDateInserted") + ) counts + ON counts."Date" = dates."Date" AND counts."Library" = l."Name" + where l.archived=false + + ORDER BY + "Date", "Library"; + END; + +$BODY$; + +ALTER FUNCTION public.fs_watch_stats_over_time(integer) + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + // try { + // await knex.schema.raw(` + // DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer); + + // CREATE OR REPLACE FUNCTION fs_watch_stats_over_time( + // days integer + // ) + // RETURNS TABLE( + // "Date" date, + // "Count" bigint, + // "Library" text + // ) + // LANGUAGE 'plpgsql' + // COST 100 + // VOLATILE PARALLEL UNSAFE + // ROWS 1000 + + // AS $BODY$ + // BEGIN + // RETURN QUERY + // SELECT + // dates."Date", + // COALESCE(counts."Count", 0) AS "Count", + // l."Name" as "Library" + // FROM + // (SELECT generate_series( + // DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)), + // DATE_TRUNC('day', NOW()), + // '1 day')::DATE AS "Date" + // ) dates + // CROSS JOIN jf_libraries l + // LEFT JOIN + // (SELECT + // DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", + // COUNT(*) AS "Count", + // l."Name" as "Library" + // FROM + // jf_playback_activity a + // JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + // JOIN jf_libraries l ON i."ParentId" = l."Id" + // WHERE + // a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() + // GROUP BY + // l."Name", DATE_TRUNC('day', a."ActivityDateInserted") + // ) counts + // ON counts."Date" = dates."Date" AND counts."Library" = l."Name" + // ORDER BY + // "Date", "Library"; + // END; + // $BODY$; + + // ALTER FUNCTION fs_watch_stats_over_time(integer) + // OWNER TO "${process.env.POSTGRES_ROLE}";`); + // } catch (error) { + // console.error(error); + // } +}; diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 4b9357b..bd048d4 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -167,7 +167,9 @@ "STAT_PAGE": { "STATISTICS": "Statistics", "DAILY_PLAY_PER_LIBRARY": "Daily Play Count Per Library", - "PLAY_COUNT_BY": "Play Count By" + "PLAY_COUNT_BY": "Play Count By", + "COUNT_VIEW": "Total Count", + "TIME_VIEW": "Total Time" }, "SETTINGS_PAGE": { "SETTINGS": "Settings", diff --git a/src/pages/css/statCard.css b/src/pages/css/statCard.css index d4e2200..2bf73ed 100644 --- a/src/pages/css/statCard.css +++ b/src/pages/css/statCard.css @@ -143,3 +143,12 @@ input[type="number"] { .item-name :hover { color: var(--secondary-color) !important; } + +.pill-wrapper { + color: white; + display: flex; + border-radius: 8px; + font-size: 1.2em; + align-self: flex-end; + justify-content: center; +} \ No newline at end of file diff --git a/src/pages/statistics.jsx b/src/pages/statistics.jsx index ae1fb05..1e38913 100644 --- a/src/pages/statistics.jsx +++ b/src/pages/statistics.jsx @@ -1,3 +1,4 @@ +import { Tabs, Tab } from "react-bootstrap"; import { useState } from "react"; import "./css/stats.css"; @@ -20,6 +21,13 @@ function Statistics() { localStorage.setItem("PREF_STATISTICS_STAT_DAYS_INPUT", event.target.value); }; + const [activeTab, setActiveTab] = useState(localStorage.getItem(`PREF_STATISTICS_LAST_SELECTED_TAB`) ?? "tabCount"); + + function setTab(tabName) { + setActiveTab(tabName); + localStorage.setItem(`PREF_STATISTICS_LAST_SELECTED_TAB`, tabName); + } + const handleKeyDown = (event) => { if (event.key === "Enter") { if (input < 1) { @@ -43,6 +51,17 @@ function Statistics() {

+
+ + } /> + } /> + +
@@ -55,14 +74,26 @@ function Statistics() {
-
- -
- - -
-
+ {activeTab === "tabCount" && ( + <> + +
+ + +
+ + )} + + {activeTab === "tabTime" && ( + <> + +
+ + +
+ + )} ); } From c1800334a6d9eb7075cf620d0657551b84d01042 Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Mon, 5 May 2025 22:56:18 +0530 Subject: [PATCH 08/66] Daily watch time changes --- backend/routes/stats.js | 3 +- public/locales/en-UK/translation.json | 2 ++ src/pages/components/statistics/chart.jsx | 34 ++++++++++++------- .../statistics/daily-play-count.jsx | 12 +++++-- src/pages/statistics.jsx | 13 ++++--- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/backend/routes/stats.js b/backend/routes/stats.js index a727991..2cfe320 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -423,6 +423,7 @@ router.get("/getViewsOverTime", async (req, res) => { stats.forEach((item) => { const library = item.Library; const count = item.Count; + const watchTime = item.TotalTime; const date = new Date(item.Date).toLocaleDateString("en-US", { year: "numeric", month: "short", @@ -435,7 +436,7 @@ router.get("/getViewsOverTime", async (req, res) => { }; } - reorganizedData[date] = { ...reorganizedData[date], [library]: count }; + reorganizedData[date] = { ...reorganizedData[date], [library]: { count, watchTime } }; }); const finalData = { libraries: libraries, stats: Object.values(reorganizedData) }; res.send(finalData); diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index bd048d4..2d38e64 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -167,7 +167,9 @@ "STAT_PAGE": { "STATISTICS": "Statistics", "DAILY_PLAY_PER_LIBRARY": "Daily Play Count Per Library", + "DAILY_TIME_PER_LIBRARY": "Daily Watch Time Per Library", "PLAY_COUNT_BY": "Play Count By", + "PLAY_TIME_BY": "Play Time By", "COUNT_VIEW": "Total Count", "TIME_VIEW": "Total Time" }, diff --git a/src/pages/components/statistics/chart.jsx b/src/pages/components/statistics/chart.jsx index 7204f0b..1ba9762 100644 --- a/src/pages/components/statistics/chart.jsx +++ b/src/pages/components/statistics/chart.jsx @@ -1,6 +1,6 @@ import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, Legend } from "recharts"; -function Chart({ stats, libraries }) { +function Chart({ stats, libraries, viewName }) { const colors = [ "rgb(54, 162, 235)", // blue "rgb(255, 99, 132)", // pink @@ -24,13 +24,25 @@ function Chart({ stats, libraries }) { "rgb(147, 112, 219)", // medium purple ]; + const flattenedStats = stats.map(item => { + const flatItem = { Key: item.Key }; + for (const [libraryName, data] of Object.entries(item)) { + if (libraryName === "Key") continue; + flatItem[libraryName] = data[viewName] ?? 0; + } + return flatItem; + }); + const CustomTooltip = ({ payload, label, active }) => { if (active) { return (

{label}

{libraries.map((library, index) => ( -

{`${library.Name} : ${payload[index].value} Views`}

+ //

{`${library.Name} : ${payload[index].value} Views`}

+

+ {`${library.Name} : ${payload?.find(p => p.dataKey === library.Name).value} ${viewName === "count" ? "Views" : "Minutes"}`} +

))}
); @@ -41,16 +53,14 @@ function Chart({ stats, libraries }) { const getMaxValue = () => { let max = 0; - if (stats) { - stats.forEach((datum) => { - Object.keys(datum).forEach((key) => { - if (key !== "Key") { - max = Math.max(max, parseInt(datum[key])); - } - }); + flattenedStats.forEach(datum => { + libraries.forEach(library => { + const value = parseFloat(datum[library.Name]); + if (!isNaN(value)) { + max = Math.max(max, value); + } }); - } - + }); return max; }; @@ -58,7 +68,7 @@ function Chart({ stats, libraries }) { return ( - + {libraries.map((library, index) => ( diff --git a/src/pages/components/statistics/daily-play-count.jsx b/src/pages/components/statistics/daily-play-count.jsx index e7b9316..a79f358 100644 --- a/src/pages/components/statistics/daily-play-count.jsx +++ b/src/pages/components/statistics/daily-play-count.jsx @@ -10,6 +10,7 @@ function DailyPlayStats(props) { const [stats, setStats] = useState(); const [libraries, setLibraries] = useState(); const [days, setDays] = useState(20); + const [viewName, setViewName] = useState("viewCount"); const token = localStorage.getItem("token"); @@ -45,6 +46,9 @@ function DailyPlayStats(props) { setDays(props.days); fetchLibraries(); } + if (props.viewName !== viewName) { + setViewName(props.viewName); + } const intervalId = setInterval(fetchLibraries, 60000 * 5); return () => clearInterval(intervalId); @@ -54,10 +58,12 @@ function DailyPlayStats(props) { return <>; } + const titleKey = viewName === "count" ? "STAT_PAGE.DAILY_PLAY_PER_LIBRARY" : "STAT_PAGE.DAILY_TIME_PER_LIBRARY"; + if (stats.length === 0) { return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

@@ -65,10 +71,10 @@ function DailyPlayStats(props) { } return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

- +
); diff --git a/src/pages/statistics.jsx b/src/pages/statistics.jsx index 1e38913..a81cb50 100644 --- a/src/pages/statistics.jsx +++ b/src/pages/statistics.jsx @@ -56,7 +56,6 @@ function Statistics() { activeKey={activeTab} onSelect={setTab} variant="pills" - className="custom-tabs" > } /> } /> @@ -77,20 +76,20 @@ function Statistics() { {activeTab === "tabCount" && ( <> - +
- - + +
)} {activeTab === "tabTime" && ( <> - +
- - + +
)} From 579a7a3f8b476949ebb2b0ca7f0f50fbde27b64b Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Tue, 6 May 2025 00:24:16 +0530 Subject: [PATCH 09/66] Changes for the daily & weekly playback duration charts --- ...watch_stats_over_time_include_duration.js} | 6 +- ...s_popular_days_of_week_include_duration.js | 141 ++++++++++++++++++ ...ts_popular_hour_of_day_include_duration.js | 115 ++++++++++++++ backend/routes/stats.js | 10 +- public/locales/en-UK/translation.json | 8 +- .../statistics/daily-play-count.jsx | 8 +- .../statistics/play-stats-by-day.jsx | 14 +- .../statistics/play-stats-by-hour.jsx | 13 +- src/pages/css/statCard.css | 9 -- src/pages/css/stats.css | 8 + src/pages/statistics.jsx | 30 ++-- 11 files changed, 319 insertions(+), 43 deletions(-) rename backend/migrations/{095_fs_watch_stats_over_time_include_total_time.js => 095_fs_watch_stats_over_time_include_duration.js} (94%) create mode 100644 backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js create mode 100644 backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js diff --git a/backend/migrations/095_fs_watch_stats_over_time_include_total_time.js b/backend/migrations/095_fs_watch_stats_over_time_include_duration.js similarity index 94% rename from backend/migrations/095_fs_watch_stats_over_time_include_total_time.js rename to backend/migrations/095_fs_watch_stats_over_time_include_duration.js index d8d29cf..e88fa04 100644 --- a/backend/migrations/095_fs_watch_stats_over_time_include_total_time.js +++ b/backend/migrations/095_fs_watch_stats_over_time_include_duration.js @@ -5,7 +5,7 @@ exports.up = async function (knex) { CREATE OR REPLACE FUNCTION public.fs_watch_stats_over_time( days integer) - RETURNS TABLE("Date" date, "Count" bigint, "TotalTime" bigint, "Library" text, "LibraryID" text) + RETURNS TABLE("Date" date, "Count" bigint, "Duration" bigint, "Library" text, "LibraryID" text) LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE @@ -17,7 +17,7 @@ AS $BODY$ SELECT dates."Date", COALESCE(counts."Count", 0) AS "Count", - COALESCE(counts."TotalTime", 0) AS "TotalTime", + COALESCE(counts."Duration", 0) AS "Duration", l."Name" as "Library", l."Id" as "LibraryID" FROM @@ -32,7 +32,7 @@ AS $BODY$ (SELECT DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", COUNT(*) AS "Count", - (SUM(a."PlaybackDuration") / 60)::bigint AS "TotalTime", + (SUM(a."PlaybackDuration") / 60)::bigint AS "Duration", l."Name" as "Library" FROM jf_playback_activity a diff --git a/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js b/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js new file mode 100644 index 0000000..630f72b --- /dev/null +++ b/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js @@ -0,0 +1,141 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer); + +CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week( + days integer) + RETURNS TABLE("Day" text, "Count" bigint, "Duration" bigint, "Library" text) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + +AS $BODY$ + BEGIN + RETURN QUERY + WITH library_days AS ( + SELECT + l."Name" AS "Library", + d.day_of_week, + d.day_name + FROM + jf_libraries l, + (SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL + SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL + SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL + SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL + SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL + SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL + SELECT 6 AS "day_of_week", 'Saturday' AS "day_name" + ) d + where l.archived=false + ) + SELECT + library_days.day_name AS "Day", + COALESCE(SUM(counts."Count"), 0)::bigint AS "Count", + COALESCE(SUM(counts."Duration"), 0)::bigint AS "Duration", + library_days."Library" AS "Library" + FROM + library_days + LEFT JOIN + (SELECT + DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", + COUNT(*) AS "Count", + (SUM(a."PlaybackDuration") / 60)::bigint AS "Duration", + EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW", + l."Name" AS "Library" + FROM + jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + JOIN jf_libraries l ON i."ParentId" = l."Id" and l.archived=false + WHERE + a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() + GROUP BY + l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted") + ) counts + ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library" + GROUP BY + library_days.day_name, library_days.day_of_week, library_days."Library" + ORDER BY + library_days.day_of_week, library_days."Library"; + END; + +$BODY$; + +ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer) + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { +// try { +// await knex.schema.raw(` +// DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer); + +// CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week( +// days integer) +// RETURNS TABLE("Day" text, "Count" bigint, "Library" text) +// LANGUAGE 'plpgsql' +// COST 100 +// VOLATILE PARALLEL UNSAFE +// ROWS 1000 + +// AS $BODY$ +// BEGIN +// RETURN QUERY +// WITH library_days AS ( +// SELECT +// l."Name" AS "Library", +// d.day_of_week, +// d.day_name +// FROM +// jf_libraries l, +// (SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL +// SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL +// SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL +// SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL +// SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL +// SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL +// SELECT 6 AS "day_of_week", 'Saturday' AS "day_name" +// ) d +// ) +// SELECT +// library_days.day_name AS "Day", +// COALESCE(SUM(counts."Count"), 0)::bigint AS "Count", +// library_days."Library" AS "Library" +// FROM +// library_days +// LEFT JOIN +// (SELECT +// DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", +// COUNT(*) AS "Count", +// EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW", +// l."Name" AS "Library" +// FROM +// jf_playback_activity a +// JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" +// JOIN jf_libraries l ON i."ParentId" = l."Id" +// WHERE +// a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() +// GROUP BY +// l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted") +// ) counts +// ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library" +// GROUP BY +// library_days.day_name, library_days.day_of_week, library_days."Library" +// ORDER BY +// library_days.day_of_week, library_days."Library"; +// END; + +// $BODY$; + +// ALTER FUNCTION fs_watch_stats_popular_days_of_week(integer) +// OWNER TO "${process.env.POSTGRES_ROLE}";`); +// } catch (error) { +// console.error(error); +// } +}; diff --git a/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js b/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js new file mode 100644 index 0000000..4909788 --- /dev/null +++ b/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js @@ -0,0 +1,115 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_hour_of_day(integer); + +CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day( + days integer) + RETURNS TABLE("Hour" integer, "Count" integer, "Duration" integer, "Library" text) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + +AS $BODY$ + BEGIN + RETURN QUERY + SELECT + h."Hour", + COUNT(a."Id")::integer AS "Count", + COALESCE(SUM(a."PlaybackDuration") / 60, 0)::integer AS "Duration", + l."Name" AS "Library" + FROM + ( + SELECT + generate_series(0, 23) AS "Hour" + ) h + CROSS JOIN jf_libraries l + LEFT JOIN jf_library_items i ON i."ParentId" = l."Id" + LEFT JOIN ( + SELECT + "NowPlayingItemId", + DATE_PART('hour', "ActivityDateInserted") AS "Hour", + "Id", + "PlaybackDuration" + FROM + jf_playback_activity + WHERE + "ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW() + ) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour" + WHERE + l.archived=false + and l."Id" IN (SELECT "Id" FROM jf_libraries) + GROUP BY + h."Hour", + l."Name" + ORDER BY + l."Name", + h."Hour"; + END; + +$BODY$; + +ALTER FUNCTION public.fs_watch_stats_popular_hour_of_day(integer) + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_hour_of_day(integer); + +CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day( + days integer) + RETURNS TABLE("Hour" integer, "Count" integer, "Library" text) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 + +AS $BODY$ + BEGIN + RETURN QUERY + SELECT + h."Hour", + COUNT(a."Id")::integer AS "Count", + l."Name" AS "Library" + FROM + ( + SELECT + generate_series(0, 23) AS "Hour" + ) h + CROSS JOIN jf_libraries l + LEFT JOIN jf_library_items i ON i."ParentId" = l."Id" + LEFT JOIN ( + SELECT + "NowPlayingItemId", + DATE_PART('hour', "ActivityDateInserted") AS "Hour", + "Id" + FROM + jf_playback_activity + WHERE + "ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW() + ) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour" + WHERE + l."Id" IN (SELECT "Id" FROM jf_libraries) + GROUP BY + h."Hour", + l."Name" + ORDER BY + l."Name", + h."Hour"; + END; + +$BODY$; + + ALTER FUNCTION fs_watch_stats_popular_hour_of_day(integer) + OWNER TO "${process.env.POSTGRES_ROLE}";`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/routes/stats.js b/backend/routes/stats.js index 2cfe320..a3f105a 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -423,7 +423,7 @@ router.get("/getViewsOverTime", async (req, res) => { stats.forEach((item) => { const library = item.Library; const count = item.Count; - const watchTime = item.TotalTime; + const duration = item.Duration; const date = new Date(item.Date).toLocaleDateString("en-US", { year: "numeric", month: "short", @@ -436,7 +436,7 @@ router.get("/getViewsOverTime", async (req, res) => { }; } - reorganizedData[date] = { ...reorganizedData[date], [library]: { count, watchTime } }; + reorganizedData[date] = { ...reorganizedData[date], [library]: { count, duration } }; }); const finalData = { libraries: libraries, stats: Object.values(reorganizedData) }; res.send(finalData); @@ -463,6 +463,7 @@ router.get("/getViewsByDays", async (req, res) => { stats.forEach((item) => { const library = item.Library; const count = item.Count; + const duration = item.Duration; const day = item.Day; if (!reorganizedData[day]) { @@ -471,7 +472,7 @@ router.get("/getViewsByDays", async (req, res) => { }; } - reorganizedData[day] = { ...reorganizedData[day], [library]: count }; + reorganizedData[day] = { ...reorganizedData[day], [library]: { count, duration } }; }); const finalData = { libraries: libraries, stats: Object.values(reorganizedData) }; res.send(finalData); @@ -498,6 +499,7 @@ router.get("/getViewsByHour", async (req, res) => { stats.forEach((item) => { const library = item.Library; const count = item.Count; + const duration = item.Duration; const hour = item.Hour; if (!reorganizedData[hour]) { @@ -506,7 +508,7 @@ router.get("/getViewsByHour", async (req, res) => { }; } - reorganizedData[hour] = { ...reorganizedData[hour], [library]: count }; + reorganizedData[hour] = { ...reorganizedData[hour], [library]: { count, duration } }; }); const finalData = { libraries: libraries, stats: Object.values(reorganizedData) }; res.send(finalData); diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 2d38e64..67d4b18 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -167,11 +167,11 @@ "STAT_PAGE": { "STATISTICS": "Statistics", "DAILY_PLAY_PER_LIBRARY": "Daily Play Count Per Library", - "DAILY_TIME_PER_LIBRARY": "Daily Watch Time Per Library", + "DAILY_DURATION_PER_LIBRARY": "Daily Play Duration Per Library", "PLAY_COUNT_BY": "Play Count By", - "PLAY_TIME_BY": "Play Time By", - "COUNT_VIEW": "Total Count", - "TIME_VIEW": "Total Time" + "PLAY_DURATION_BY": "Play Duration By", + "COUNT_VIEW": "Count", + "DURATION_VIEW": "Duration" }, "SETTINGS_PAGE": { "SETTINGS": "Settings", diff --git a/src/pages/components/statistics/daily-play-count.jsx b/src/pages/components/statistics/daily-play-count.jsx index a79f358..76411d6 100644 --- a/src/pages/components/statistics/daily-play-count.jsx +++ b/src/pages/components/statistics/daily-play-count.jsx @@ -10,7 +10,7 @@ function DailyPlayStats(props) { const [stats, setStats] = useState(); const [libraries, setLibraries] = useState(); const [days, setDays] = useState(20); - const [viewName, setViewName] = useState("viewCount"); + const [viewName, setViewName] = useState("count"); const token = localStorage.getItem("token"); @@ -52,14 +52,14 @@ function DailyPlayStats(props) { const intervalId = setInterval(fetchLibraries, 60000 * 5); return () => clearInterval(intervalId); - }, [stats,libraries, days, props.days, token]); + }, [stats,libraries, days, props.days, props.viewName, token]); if (!stats) { return <>; } - const titleKey = viewName === "count" ? "STAT_PAGE.DAILY_PLAY_PER_LIBRARY" : "STAT_PAGE.DAILY_TIME_PER_LIBRARY"; - + const titleKey = viewName === "count" ? "STAT_PAGE.DAILY_PLAY_PER_LIBRARY" : "STAT_PAGE.DAILY_DURATION_PER_LIBRARY"; + if (stats.length === 0) { return (
diff --git a/src/pages/components/statistics/play-stats-by-day.jsx b/src/pages/components/statistics/play-stats-by-day.jsx index c7f5c09..7bc25cb 100644 --- a/src/pages/components/statistics/play-stats-by-day.jsx +++ b/src/pages/components/statistics/play-stats-by-day.jsx @@ -9,6 +9,7 @@ function PlayStatsByDay(props) { const [stats, setStats] = useState(); const [libraries, setLibraries] = useState(); const [days, setDays] = useState(20); + const [viewName, setViewName] = useState("count"); const token = localStorage.getItem("token"); useEffect(() => { @@ -41,19 +42,24 @@ function PlayStatsByDay(props) { setDays(props.days); fetchLibraries(); } + if (props.viewName !== viewName) { + setViewName(props.viewName); + } const intervalId = setInterval(fetchLibraries, 60000 * 5); return () => clearInterval(intervalId); - }, [stats, libraries, days, props.days, token]); + }, [stats, libraries, days, props.days, props.viewName, token]); if (!stats) { return <>; } + const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY"; + if (stats.length === 0) { return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

@@ -62,9 +68,9 @@ function PlayStatsByDay(props) { return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

- +
); diff --git a/src/pages/components/statistics/play-stats-by-hour.jsx b/src/pages/components/statistics/play-stats-by-hour.jsx index f1895d3..6198768 100644 --- a/src/pages/components/statistics/play-stats-by-hour.jsx +++ b/src/pages/components/statistics/play-stats-by-hour.jsx @@ -8,6 +8,7 @@ function PlayStatsByHour(props) { const [stats, setStats] = useState(); const [libraries, setLibraries] = useState(); const [days, setDays] = useState(20); + const [viewName, setViewName] = useState("count"); const token = localStorage.getItem("token"); useEffect(() => { @@ -40,19 +41,23 @@ function PlayStatsByHour(props) { setDays(props.days); fetchLibraries(); } + if (props.viewName !== viewName) { + setViewName(props.viewName); + } const intervalId = setInterval(fetchLibraries, 60000 * 5); return () => clearInterval(intervalId); - }, [stats, libraries, days, props.days, token]); + }, [stats, libraries, days, props.days, props.viewName, token]); if (!stats) { return <>; } + const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY"; if (stats.length === 0) { return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

@@ -62,9 +67,9 @@ function PlayStatsByHour(props) { return (
-

- {days} 1 ? 'S':''}`}/>

+

- {days} 1 ? 'S':''}`}/>

- +
); diff --git a/src/pages/css/statCard.css b/src/pages/css/statCard.css index 2bf73ed..d4e2200 100644 --- a/src/pages/css/statCard.css +++ b/src/pages/css/statCard.css @@ -143,12 +143,3 @@ input[type="number"] { .item-name :hover { color: var(--secondary-color) !important; } - -.pill-wrapper { - color: white; - display: flex; - border-radius: 8px; - font-size: 1.2em; - align-self: flex-end; - justify-content: center; -} \ No newline at end of file diff --git a/src/pages/css/stats.css b/src/pages/css/stats.css index ca7761b..f300da5 100644 --- a/src/pages/css/stats.css +++ b/src/pages/css/stats.css @@ -47,6 +47,14 @@ margin-bottom: 10px !important; } +.stats-tab-nav { + background-color: var(--secondary-background-color); + display: flex; + border-radius: 8px; + align-self: flex-end; + justify-content: center; +} + .chart-canvas { width: 100%; height: 400px; diff --git a/src/pages/statistics.jsx b/src/pages/statistics.jsx index a81cb50..b04ff7e 100644 --- a/src/pages/statistics.jsx +++ b/src/pages/statistics.jsx @@ -51,14 +51,22 @@ function Statistics() {

-
+
- } /> - } /> + } + /> + } + />
@@ -75,23 +83,23 @@ function Statistics() {
{activeTab === "tabCount" && ( - <> +
- +
)} - {activeTab === "tabTime" && ( - <> - + {activeTab === "tabDuration" && ( +
+
- - + +
- +
)}
); From 07fbfdca7193ea8f1b46f3312ae69d139a3ffdc5 Mon Sep 17 00:00:00 2001 From: Sanidhya Singh Date: Tue, 6 May 2025 14:38:10 +0530 Subject: [PATCH 10/66] Updating exports.down for migrations and css change --- ..._watch_stats_over_time_include_duration.js | 106 +++++++-------- ...s_popular_days_of_week_include_duration.js | 128 +++++++++--------- ...ts_popular_hour_of_day_include_duration.js | 10 +- src/pages/css/stats.css | 9 +- src/pages/statistics.jsx | 2 + 5 files changed, 134 insertions(+), 121 deletions(-) diff --git a/backend/migrations/095_fs_watch_stats_over_time_include_duration.js b/backend/migrations/095_fs_watch_stats_over_time_include_duration.js index e88fa04..78e63b5 100644 --- a/backend/migrations/095_fs_watch_stats_over_time_include_duration.js +++ b/backend/migrations/095_fs_watch_stats_over_time_include_duration.js @@ -62,60 +62,60 @@ ALTER FUNCTION public.fs_watch_stats_over_time(integer) }; exports.down = async function (knex) { - // try { - // await knex.schema.raw(` - // DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer); + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer); - // CREATE OR REPLACE FUNCTION fs_watch_stats_over_time( - // days integer - // ) - // RETURNS TABLE( - // "Date" date, - // "Count" bigint, - // "Library" text - // ) - // LANGUAGE 'plpgsql' - // COST 100 - // VOLATILE PARALLEL UNSAFE - // ROWS 1000 + CREATE OR REPLACE FUNCTION fs_watch_stats_over_time( + days integer + ) + RETURNS TABLE( + "Date" date, + "Count" bigint, + "Library" text + ) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 - // AS $BODY$ - // BEGIN - // RETURN QUERY - // SELECT - // dates."Date", - // COALESCE(counts."Count", 0) AS "Count", - // l."Name" as "Library" - // FROM - // (SELECT generate_series( - // DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)), - // DATE_TRUNC('day', NOW()), - // '1 day')::DATE AS "Date" - // ) dates - // CROSS JOIN jf_libraries l - // LEFT JOIN - // (SELECT - // DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", - // COUNT(*) AS "Count", - // l."Name" as "Library" - // FROM - // jf_playback_activity a - // JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" - // JOIN jf_libraries l ON i."ParentId" = l."Id" - // WHERE - // a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() - // GROUP BY - // l."Name", DATE_TRUNC('day', a."ActivityDateInserted") - // ) counts - // ON counts."Date" = dates."Date" AND counts."Library" = l."Name" - // ORDER BY - // "Date", "Library"; - // END; - // $BODY$; + AS $BODY$ + BEGIN + RETURN QUERY + SELECT + dates."Date", + COALESCE(counts."Count", 0) AS "Count", + l."Name" as "Library" + FROM + (SELECT generate_series( + DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)), + DATE_TRUNC('day', NOW()), + '1 day')::DATE AS "Date" + ) dates + CROSS JOIN jf_libraries l + LEFT JOIN + (SELECT + DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", + COUNT(*) AS "Count", + l."Name" as "Library" + FROM + jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + JOIN jf_libraries l ON i."ParentId" = l."Id" + WHERE + a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() + GROUP BY + l."Name", DATE_TRUNC('day', a."ActivityDateInserted") + ) counts + ON counts."Date" = dates."Date" AND counts."Library" = l."Name" + ORDER BY + "Date", "Library"; + END; + $BODY$; - // ALTER FUNCTION fs_watch_stats_over_time(integer) - // OWNER TO "${process.env.POSTGRES_ROLE}";`); - // } catch (error) { - // console.error(error); - // } + ALTER FUNCTION fs_watch_stats_over_time(integer) + OWNER TO "${process.env.POSTGRES_ROLE}";`); + } catch (error) { + console.error(error); + } }; diff --git a/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js b/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js index 630f72b..c0849e0 100644 --- a/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js +++ b/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js @@ -72,70 +72,72 @@ ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer) }; exports.down = async function (knex) { -// try { -// await knex.schema.raw(` -// DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer); + try { + await knex.schema.raw(` + DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer); -// CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week( -// days integer) -// RETURNS TABLE("Day" text, "Count" bigint, "Library" text) -// LANGUAGE 'plpgsql' -// COST 100 -// VOLATILE PARALLEL UNSAFE -// ROWS 1000 +CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week( + days integer) + RETURNS TABLE("Day" text, "Count" bigint, "Library" text) + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + ROWS 1000 -// AS $BODY$ -// BEGIN -// RETURN QUERY -// WITH library_days AS ( -// SELECT -// l."Name" AS "Library", -// d.day_of_week, -// d.day_name -// FROM -// jf_libraries l, -// (SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL -// SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL -// SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL -// SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL -// SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL -// SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL -// SELECT 6 AS "day_of_week", 'Saturday' AS "day_name" -// ) d -// ) -// SELECT -// library_days.day_name AS "Day", -// COALESCE(SUM(counts."Count"), 0)::bigint AS "Count", -// library_days."Library" AS "Library" -// FROM -// library_days -// LEFT JOIN -// (SELECT -// DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", -// COUNT(*) AS "Count", -// EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW", -// l."Name" AS "Library" -// FROM -// jf_playback_activity a -// JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" -// JOIN jf_libraries l ON i."ParentId" = l."Id" -// WHERE -// a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() -// GROUP BY -// l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted") -// ) counts -// ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library" -// GROUP BY -// library_days.day_name, library_days.day_of_week, library_days."Library" -// ORDER BY -// library_days.day_of_week, library_days."Library"; -// END; +AS $BODY$ + BEGIN + RETURN QUERY + WITH library_days AS ( + SELECT + l."Name" AS "Library", + d.day_of_week, + d.day_name + FROM + jf_libraries l, + (SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL + SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL + SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL + SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL + SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL + SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL + SELECT 6 AS "day_of_week", 'Saturday' AS "day_name" + ) d + where l.archived=false + ) + SELECT + library_days.day_name AS "Day", + COALESCE(SUM(counts."Count"), 0)::bigint AS "Count", + library_days."Library" AS "Library" + FROM + library_days + LEFT JOIN + (SELECT + DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date", + COUNT(*) AS "Count", + EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW", + l."Name" AS "Library" + FROM + jf_playback_activity a + JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" + JOIN jf_libraries l ON i."ParentId" = l."Id" and l.archived=false + WHERE + a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW() + GROUP BY + l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted") + ) counts + ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library" + GROUP BY + library_days.day_name, library_days.day_of_week, library_days."Library" + ORDER BY + library_days.day_of_week, library_days."Library"; + END; -// $BODY$; - -// ALTER FUNCTION fs_watch_stats_popular_days_of_week(integer) -// OWNER TO "${process.env.POSTGRES_ROLE}";`); -// } catch (error) { -// console.error(error); -// } +$BODY$; + +ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer) + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } }; diff --git a/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js b/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js index 4909788..57f943f 100644 --- a/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js +++ b/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js @@ -96,7 +96,8 @@ AS $BODY$ "ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW() ) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour" WHERE - l."Id" IN (SELECT "Id" FROM jf_libraries) + l.archived=false + and l."Id" IN (SELECT "Id" FROM jf_libraries) GROUP BY h."Hour", l."Name" @@ -106,9 +107,10 @@ AS $BODY$ END; $BODY$; - - ALTER FUNCTION fs_watch_stats_popular_hour_of_day(integer) - OWNER TO "${process.env.POSTGRES_ROLE}";`); + +ALTER FUNCTION public.fs_watch_stats_popular_hour_of_day(integer) + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); } catch (error) { console.error(error); } diff --git a/src/pages/css/stats.css b/src/pages/css/stats.css index f300da5..3e0f186 100644 --- a/src/pages/css/stats.css +++ b/src/pages/css/stats.css @@ -49,12 +49,19 @@ .stats-tab-nav { background-color: var(--secondary-background-color); - display: flex; border-radius: 8px; align-self: flex-end; +} + +.nav-item { + display: flex; justify-content: center; } +/* .tab-content { + display: none; +} */ + .chart-canvas { width: 100%; height: 400px; diff --git a/src/pages/statistics.jsx b/src/pages/statistics.jsx index b04ff7e..283db21 100644 --- a/src/pages/statistics.jsx +++ b/src/pages/statistics.jsx @@ -53,6 +53,7 @@ function Statistics() {
} /> + Date: Tue, 6 May 2025 14:39:09 +0530 Subject: [PATCH 11/66] Removing debug changes --- src/pages/css/stats.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pages/css/stats.css b/src/pages/css/stats.css index 3e0f186..de9ff7a 100644 --- a/src/pages/css/stats.css +++ b/src/pages/css/stats.css @@ -58,10 +58,6 @@ justify-content: center; } -/* .tab-content { - display: none; -} */ - .chart-canvas { width: 100%; height: 400px; From e0aa9dee77a33d3a057312914494da55842f597b Mon Sep 17 00:00:00 2001 From: Nath1416 Date: Wed, 21 May 2025 22:31:28 -0400 Subject: [PATCH 12/66] Added github container registry to pipeline --- .github/workflows/docker-image.yml | 11 ++++++++++- .github/workflows/docker-latest.yml | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6312cb2..da225f3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -39,12 +39,21 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build Docker image uses: docker/build-push-action@v5 with: context: . push: true - tags: ${{ steps.meta.outputs.tags }} + tags: | + ${{ steps.meta.outputs.tags }} + ghcr.io/${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-latest.yml b/.github/workflows/docker-latest.yml index 0d63960..23dd6a7 100644 --- a/.github/workflows/docker-latest.yml +++ b/.github/workflows/docker-latest.yml @@ -44,6 +44,13 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image uses: docker/build-push-action@v5 @@ -53,4 +60,6 @@ jobs: tags: | cyfershepard/jellystat:latest cyfershepard/jellystat:${{ env.VERSION }} + ghcr.io/cyfershepard/jellystat:latest + ghcr.io/cyfershepard/jellystat:${{ env.VERSION }} platforms: linux/amd64,linux/arm64,linux/arm/v7 From 280fa89c5938dbbe49bdd96d8c978468661d06eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Fri, 23 May 2025 08:29:08 +0000 Subject: [PATCH 13/66] feat(webhooks): add support for playback and media notification events - Implement event handlers for 'playback_started', 'playback_ended' and 'media_recently_added' - Add API routes to manage event webhook status - Create user interface components for easy webhook configuration - Update webhook manager to properly trigger events - Add translations for new UI elements --- backend/classes/webhook-manager.js | 65 +++++- backend/classes/webhook-scheduler.js | 49 +++++ backend/routes/sync.js | 24 ++- backend/routes/webhooks.js | 100 +++++++++ backend/tasks/ActivityMonitor.js | 77 +++++++ backend/tasks/RecentlyAddedItemsSyncTask.js | 18 +- package-lock.json | 2 +- public/locales/en-UK/translation.json | 4 +- public/locales/fr-FR/translation.json | 17 +- src/pages/components/settings/webhooks.jsx | 228 +++++++++++++++++++- 10 files changed, 557 insertions(+), 27 deletions(-) diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js index 233f2cc..01ee8f0 100644 --- a/backend/classes/webhook-manager.js +++ b/backend/classes/webhook-manager.js @@ -19,8 +19,12 @@ class WebhookManager { await this.triggerEventWebhooks('playback_started', data); }); - this.eventEmitter.on('user_login', async (data) => { - await this.triggerEventWebhooks('user_login', data); + this.eventEmitter.on('playback_ended', async (data) => { + await this.triggerEventWebhooks('playback_ended', data); + }); + + this.eventEmitter.on('media_recently_added', async (data) => { + await this.triggerEventWebhooks('media_recently_added', data); }); // If needed, add more event listeners here @@ -40,11 +44,33 @@ class WebhookManager { ).then(res => res.rows); } - async triggerEventWebhooks(eventType, data) { - const webhooks = await this.getWebhooksByEventType(eventType); - - for (const webhook of webhooks) { - await this.executeWebhook(webhook, data); + async triggerEventWebhooks(eventType, data = {}) { + try { + const webhooks = await this.getWebhooksByEventType(eventType); + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const enrichedData = { + ...data, + event: eventType, + triggeredAt: new Date().toISOString() + }; + + const promises = webhooks.map(webhook => { + return this.executeWebhook(webhook, enrichedData); + }); + + await Promise.all(promises); + + return true; + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + return false; } } @@ -135,6 +161,31 @@ class WebhookManager { return template; } + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks?.[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + ...eventData, + event: eventType, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + emitEvent(eventType, data) { this.eventEmitter.emit(eventType, data); } diff --git a/backend/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js index d1fddc3..4340217 100644 --- a/backend/classes/webhook-scheduler.js +++ b/backend/classes/webhook-scheduler.js @@ -35,6 +35,54 @@ class WebhookScheduler { } } + async loadEventWebhooks() { + try { + const eventWebhooks = await this.webhookManager.getEventWebhooks(); + if (eventWebhooks && eventWebhooks.length > 0) { + this.eventWebhooks = {}; + + eventWebhooks.forEach(webhook => { + if (!this.eventWebhooks[webhook.eventType]) { + this.eventWebhooks[webhook.eventType] = []; + } + this.eventWebhooks[webhook.eventType].push(webhook); + }); + + console.log(`[WEBHOOK] Loaded ${eventWebhooks.length} event-based webhooks`); + } else { + console.log('[WEBHOOK] No event-based webhooks found'); + this.eventWebhooks = {}; + } + } catch (error) { + console.error('[WEBHOOK] Failed to load event-based webhooks:', error); + } + } + + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + event: eventType, + data: eventData, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + scheduleWebhook(webhook) { try { this.cronJobs[webhook.id] = cron.schedule(webhook.schedule, async () => { @@ -50,6 +98,7 @@ class WebhookScheduler { async refreshSchedule() { await this.loadScheduledWebhooks(); + await this.loadEventWebhooks(); } } diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4f811ce..4783ebf 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -824,6 +824,8 @@ async function partialSync(triggertype) { const config = await new configClass().getConfig(); const uuid = randomUUID(); + + const newItems = []; syncTask = { loggedData: [], uuid: uuid, wsKey: "PartialSyncTask", taskName: taskName.partialsync }; try { @@ -833,7 +835,7 @@ async function partialSync(triggertype) { if (config.error) { syncTask.loggedData.push({ Message: config.error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); - return; + return { success: false, error: config.error }; } const libraries = await API.getLibraries(); @@ -842,7 +844,7 @@ async function partialSync(triggertype) { syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " " + taskName.fullsync + " Completed" }); - return; + return { success: false, error: "No libraries found" }; } const excluded_libraries = config.settings.ExcludedLibraries || []; @@ -850,10 +852,10 @@ async function partialSync(triggertype) { const filtered_libraries = libraries.filter((library) => !excluded_libraries.includes(library.Id)); const existing_excluded_libraries = libraries.filter((library) => excluded_libraries.includes(library.Id)); - // //syncUserData + // syncUserData await syncUserData(); - // //syncLibraryFolders + // syncLibraryFolders await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); //item sync counters @@ -956,7 +958,7 @@ async function partialSync(triggertype) { insertEpisodeInfoCount += Number(infoCount.insertEpisodeInfoCount); updateEpisodeInfoCount += Number(infoCount.updateEpisodeInfoCount); - //clear data from memory as its no longer needed + //clear data from memory as it's no longer needed library_items = null; seasons = null; episodes = null; @@ -1023,10 +1025,22 @@ async function partialSync(triggertype) { await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.SUCCESS); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " Sync Completed" }); + + return { + success: true, + newItems: newItems, + stats: { + itemsAdded: insertedItemsCount, + episodesAdded: insertedEpisodeCount, + seasonsAdded: insertedSeasonsCount + } + }; } catch (error) { syncTask.loggedData.push({ color: "red", Message: getErrorLineNumber(error) + ": Error: " + error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Error", message: triggertype + " Sync Halted with Errors" }); + + return { success: false, error: error.message }; } } diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 67a6370..6260d79 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -211,4 +211,104 @@ router.post('/:id/trigger-monthly', async (req, res) => { } }); +// Get status of event webhooks +router.get('/event-status', async (req, res) => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const result = {}; + + for (const eventType of eventTypes) { + const webhooks = await dbInstance.query( + 'SELECT id, name, enabled FROM webhooks WHERE trigger_type = $1 AND event_type = $2', + ['event', eventType] + ); + + result[eventType] = { + exists: webhooks.rows.length > 0, + enabled: webhooks.rows.some(webhook => webhook.enabled), + webhooks: webhooks.rows + }; + } + + res.json(result); + } catch (error) { + console.error('Error fetching webhook status:', error); + res.status(500).json({ error: 'Failed to fetch webhook status' }); + } +}); + +// Toggle all webhooks of a specific event type +router.post('/toggle-event/:eventType', async (req, res) => { + try { + const { eventType } = req.params; + const { enabled } = req.body; + + if (!['playback_started', 'playback_ended', 'media_recently_added'].includes(eventType)) { + return res.status(400).json({ error: 'Invalid event type' }); + } + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'Enabled parameter must be a boolean' }); + } + + // Mettre à jour tous les webhooks de ce type d'événement + const result = await dbInstance.query( + 'UPDATE webhooks SET enabled = $1 WHERE trigger_type = $2 AND event_type = $3 RETURNING id', + [enabled, 'event', eventType] + ); + + // Si aucun webhook n'existe pour ce type, en créer un de base + if (result.rows.length === 0 && enabled) { + const defaultWebhook = { + name: `Webhook pour ${eventType}`, + url: req.body.url || '', + method: 'POST', + trigger_type: 'event', + event_type: eventType, + enabled: true, + headers: '{}', + payload: JSON.stringify({ + event: `{{event}}`, + data: `{{data}}`, + timestamp: `{{triggeredAt}}` + }) + }; + + if (!defaultWebhook.url) { + return res.status(400).json({ + error: 'URL parameter is required when creating a new webhook', + needsUrl: true + }); + } + + await dbInstance.query( + `INSERT INTO webhooks (name, url, method, trigger_type, event_type, enabled, headers, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + defaultWebhook.name, + defaultWebhook.url, + defaultWebhook.method, + defaultWebhook.trigger_type, + defaultWebhook.event_type, + defaultWebhook.enabled, + defaultWebhook.headers, + defaultWebhook.payload + ] + ); + } + + // Rafraîchir le planificateur de webhooks + await webhookScheduler.refreshSchedule(); + + res.json({ + success: true, + message: `Webhooks for ${eventType} ${enabled ? 'enabled' : 'disabled'}`, + affectedCount: result.rows.length + }); + } catch (error) { + console.error('Error toggling webhooks:', error); + res.status(500).json({ error: 'Failed to toggle webhooks' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 43f1969..16d5e11 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -7,10 +7,14 @@ const configClass = require("../classes/config"); const API = require("../classes/api-loader"); const { sendUpdate } = require("../ws"); const { isNumber } = require("@mui/x-data-grid/internals"); +const WebhookManager = require("../classes/webhook-manager"); + const MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK = process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK ? Number(process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK) : 1; +const webhookManager = new WebhookManager(); + async function getSessionsInWatchDog(SessionData, WatchdogData) { let existingData = await WatchdogData.filter((wdData) => { return SessionData.some((sessionData) => { @@ -146,6 +150,42 @@ async function ActivityMonitor(interval) { //filter fix if table is empty if (WatchdogDataToInsert.length > 0) { + for (const session of WatchdogDataToInsert) { + let userData = {}; + try { + const userInfo = await API.getUserById(session.UserId); + if (userInfo) { + userData = { + username: userInfo.Name, + userImageTag: userInfo.PrimaryImageTag + }; + } + } catch (error) { + console.error(`[WEBHOOK] Error fetching user data: ${error.message}`); + } + + await webhookManager.triggerEventWebhooks('playback_started', { + sessionInfo: { + userId: session.UserId, + deviceId: session.DeviceId, + deviceName: session.DeviceName, + clientName: session.ClientName, + isPaused: session.IsPaused, + mediaType: session.MediaType, + mediaName: session.NowPlayingItemName, + startTime: session.ActivityDateInserted + }, + userData, + mediaInfo: { + itemId: session.NowPlayingItemId, + episodeId: session.EpisodeId, + mediaName: session.NowPlayingItemName, + seasonName: session.SeasonName, + seriesName: session.SeriesName + } + }); + } + //insert new rows where not existing items // console.log("Inserted " + WatchdogDataToInsert.length + " wd playback records"); db.insertBulk("jf_activity_watchdog", WatchdogDataToInsert, jf_activity_watchdog_columns); @@ -158,6 +198,43 @@ async function ActivityMonitor(interval) { console.log("Existing Data Updated: ", WatchdogDataToUpdate.length); } + if (dataToRemove.length > 0) { + for (const session of dataToRemove) { + let userData = {}; + try { + const userInfo = await API.getUserById(session.UserId); + if (userInfo) { + userData = { + username: userInfo.Name, + userImageTag: userInfo.PrimaryImageTag + }; + } + } catch (error) { + console.error(`[WEBHOOK] Error fetching user data: ${error.message}`); + } + + await webhookManager.triggerEventWebhooks('playback_ended', { + sessionInfo: { + userId: session.UserId, + deviceId: session.DeviceId, + deviceName: session.DeviceName, + clientName: session.ClientName, + playbackDuration: session.PlaybackDuration, + endTime: session.ActivityDateInserted + }, + userData, + mediaInfo: { + itemId: session.NowPlayingItemId, + episodeId: session.EpisodeId, + mediaName: session.NowPlayingItemName, + seasonName: session.SeasonName, + seriesName: session.SeriesName + } + }); + } + + const toDeleteIds = dataToRemove.map((row) => row.ActivityId); + //delete from db no longer in session data and insert into stats db //Bulk delete from db thats no longer on api diff --git a/backend/tasks/RecentlyAddedItemsSyncTask.js b/backend/tasks/RecentlyAddedItemsSyncTask.js index 85f0676..c688c1e 100644 --- a/backend/tasks/RecentlyAddedItemsSyncTask.js +++ b/backend/tasks/RecentlyAddedItemsSyncTask.js @@ -1,6 +1,7 @@ const { parentPort } = require("worker_threads"); const triggertype = require("../logging/triggertype"); const sync = require("../routes/sync"); +const WebhookManager = require("../classes/webhook-manager"); async function runPartialSyncTask(triggerType = triggertype.Automatic) { try { @@ -17,12 +18,25 @@ async function runPartialSyncTask(triggerType = triggertype.Automatic) { }); parentPort.postMessage({ type: "log", message: formattedArgs.join(" ") }); }; - await sync.partialSync(triggerType); + + const syncResults = await sync.partialSync(triggerType); + + const webhookManager = new WebhookManager(); + + const newMediaCount = syncResults?.newItems?.length || 0; + + if (newMediaCount > 0) { + await webhookManager.triggerEventWebhooks('media_recently_added', { + count: newMediaCount, + items: syncResults.newItems, + syncDate: new Date().toISOString(), + triggerType: triggerType + }); + } parentPort.postMessage({ status: "complete" }); } catch (error) { parentPort.postMessage({ status: "error", message: error.message }); - console.log(error); return []; } diff --git a/package-lock.json b/package-lock.json index e3f19fc..7eba186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "jfstat", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 58ce159..7f6c974 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -226,7 +226,9 @@ "URL": "URL", "TYPE": "Type", "TRIGGER": "Trigger", - "STATUS": "Status" + "STATUS": "Status", + "EVENT_WEBHOOKS": "Event notifications", + "EVENT_WEBHOOKS_TOOLTIP": "Enable or disable event notifications" }, "TASK_TYPE": { "JOB": "Job", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index 78929ef..d4b799e 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -213,7 +213,22 @@ }, "SELECT_LIBRARIES_TO_IMPORT": "Sélectionner les médiathèques à importer", "SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "L'activité du contenu de ces médiathèques est toujours suivie, même s'ils ne sont pas importés.", - "DATE_ADDED": "Date d'ajout" + "DATE_ADDED": "Date d'ajout", + "WEBHOOKS": "Webhooks", + "WEBHOOK_TYPE": "Type de webhook", + "TEST_NOW": "Tester maintenant", + "WEBHOOKS_CONFIGURATION": "Configuration des webhooks", + "WEBHOOKS_TOOLTIP": "L'URL des webhooks utiliser pour envoyer des notifications à Discord ou à d'autres services", + "WEBHOOK_SAVED": "Webhook sauvegardé", + "WEBHOOK_NAME": "Nom du webhook", + "DISCORD_WEBHOOK_URL": "URL du webhook Discord", + "ENABLE_WEBHOOK": "Activer le webhook", + "URL": "URL", + "TYPE": "Type", + "TRIGGER": "Déclencheur", + "STATUS": "Status", + "EVENT_WEBHOOKS": "Notifications d'événements", + "EVENT_WEBHOOKS_TOOLTIP": "Activez ou désactivez les notifications pour différents événements du système" }, "TASK_TYPE": { "JOB": "Job", diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 4b08657..307eabd 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -63,6 +63,13 @@ function WebhooksSettings() { webhook_type: 'discord' }); + // État pour suivre les webhooks événementiels + const [eventWebhooks, setEventWebhooks] = useState({ + playback_started: { exists: false, enabled: false }, + playback_ended: { exists: false, enabled: false }, + media_recently_added: { exists: false, enabled: false } + }); + useEffect(() => { const fetchWebhooks = async () => { try { @@ -73,18 +80,20 @@ function WebhooksSettings() { }, }); - if (response.data != webhooks) { + if (response.data !== webhooks) { setWebhooks(response.data); - } - - if (loading) { + // Charger l'état des webhooks événementiels une fois les webhooks chargés + await loadEventWebhooks(); + } + + if (loading) { setLoading(false); - } + } } catch (err) { console.error("Error loading webhooks:", err); if (loading) { setLoading(false); - } + } } }; @@ -92,7 +101,7 @@ function WebhooksSettings() { const intervalId = setInterval(fetchWebhooks, 1000 * 10); return () => clearInterval(intervalId); - }, []); + }, [webhooks.length]); const handleInputChange = (e) => { const { name, value } = e.target; @@ -111,7 +120,14 @@ function WebhooksSettings() { setSuccess(false); if (!currentWebhook.url) { - setError("Discord webhook URL is required"); + setError("L'URL du webhook est requise"); + setSaving(false); + return; + } + + // Si c'est un webhook de type événement, s'assurer que les propriétés nécessaires sont présentes + if (currentWebhook.trigger_type === 'event' && !currentWebhook.event_type) { + setError("Le type d'événement est requis pour un webhook événementiel"); setSaving(false); return; } @@ -134,6 +150,20 @@ function WebhooksSettings() { }); } + // Rafraîchir la liste des webhooks + const webhooksResponse = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(webhooksResponse.data); + + // Mettre à jour l'état des webhooks événementiels + await loadEventWebhooks(); + + // Réinitialiser le formulaire setCurrentWebhook({ name: 'New Webhook', url: '', @@ -143,10 +173,11 @@ function WebhooksSettings() { method: 'POST', webhook_type: 'discord' }); - setSuccess("Webhook saved successfully!"); + + setSuccess("Webhook enregistré avec succès!"); setSaving(false); } catch (err) { - setError("Error during webhook saving: " + (err.response?.data?.error || err.message)); + setError("Erreur lors de l'enregistrement du webhook: " + (err.response?.data?.error || err.message)); setSaving(false); } }; @@ -180,6 +211,118 @@ function WebhooksSettings() { } }; + // Fonction pour obtenir le statut d'un webhook événementiel + const getEventWebhookStatus = (eventType) => { + return eventWebhooks[eventType]?.enabled || false; + }; + + // Fonction pour charger le statut des webhooks événementiels + const loadEventWebhooks = async () => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const status = {}; + + // Vérifier chaque type d'événement dans les webhooks actuels + eventTypes.forEach(eventType => { + const matchingWebhooks = webhooks.filter( + webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType + ); + + status[eventType] = { + exists: matchingWebhooks.length > 0, + enabled: matchingWebhooks.some(webhook => webhook.enabled) + }; + }); + + setEventWebhooks(status); + } catch (error) { + console.error('Error loading event webhook status:', error); + } + }; + + // Fonction pour basculer un webhook événementiel + const toggleEventWebhook = async (eventType) => { + try { + setLoading(true); + setError(null); + + const isCurrentlyEnabled = getEventWebhookStatus(eventType); + const matchingWebhooks = webhooks.filter( + webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType + ); + + // Si aucun webhook n'existe pour cet événement et qu'on veut l'activer + if (matchingWebhooks.length === 0 && !isCurrentlyEnabled) { + // Créer un nouveau webhook pour cet événement + const newWebhook = { + name: `Notification - ${getEventDisplayName(eventType)}`, + url: '', // Demander à l'utilisateur de saisir l'URL + enabled: true, + trigger_type: 'event', + event_type: eventType, + method: 'POST', + webhook_type: 'discord' + }; + + // Mettre à jour le webhook actuel pour que l'utilisateur puisse le configurer + setCurrentWebhook(newWebhook); + setLoading(false); + return; + } + + // Sinon, activer/désactiver tous les webhooks existants pour cet événement + for (const webhook of matchingWebhooks) { + await axios.put(`/webhooks/${webhook.id}`, + { ...webhook, enabled: !isCurrentlyEnabled }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + } + ); + } + + // Mettre à jour l'état local + setEventWebhooks(prev => ({ + ...prev, + [eventType]: { + ...prev[eventType], + enabled: !isCurrentlyEnabled + } + })); + + // Actualiser la liste des webhooks + const response = await axios.get('/webhooks', { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + setWebhooks(response.data); + setLoading(false); + setSuccess(`Webhook pour ${getEventDisplayName(eventType)} ${!isCurrentlyEnabled ? 'activé' : 'désactivé'} avec succès!`); + } catch (error) { + setError("Erreur lors de la modification du webhook: " + (error.response?.data?.error || error.message)); + setLoading(false); + } + }; + + // Fonction utilitaire pour obtenir le nom d'affichage d'un type d'événement + const getEventDisplayName = (eventType) => { + switch(eventType) { + case 'playback_started': + return 'Lecture démarrée'; + case 'playback_ended': + return 'Lecture terminée'; + case 'media_recently_added': + return 'Nouveaux médias ajoutés'; + default: + return eventType; + } + }; + if (loading && !webhooks.length) { return ; } @@ -273,6 +416,71 @@ function WebhooksSettings() { + + {/* Ajout de la section pour les webhooks événementiels */} +
+

+ + }> + + + + +

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- +
@@ -513,7 +517,7 @@ function WebhooksSettings() {
- +
); From d9aba8a5a3945e583b43950706bc2ecdad46abff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Mon, 26 May 2025 10:11:24 +0200 Subject: [PATCH 16/66] Update src/pages/components/settings/webhooks.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/components/settings/webhooks.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index d048e3d..7427890 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -198,6 +198,7 @@ function WebhooksSettings() { const handleTest = async (webhook) => { if (!webhook || !webhook.id) { setError("Impossible to test the webhook: no webhook provided"); + setLoading(false); return; } From 247df5fed3579b0fa155ddcc2c832607a54a900c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Mon, 26 May 2025 10:11:51 +0200 Subject: [PATCH 17/66] Update backend/routes/webhooks.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/routes/webhooks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 80095f6..b2c1fb0 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -316,7 +316,7 @@ router.post('/:id/trigger-monthly', async (req, res) => { const success = await webhookManager.triggerMonthlySummaryWebhook(req.params.id); if (success) { - res.status(200).json({ message: "Monthly report send with success" }); + res.status(200).json({ message: "Monthly report sent successfully" }); } else { res.status(500).json({ message: "Failed to send monthly report" }); } From 1f1a51fd6cfaef87b30289d8d4601b0da89adba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Mon, 26 May 2025 10:12:02 +0200 Subject: [PATCH 18/66] Update backend/routes/webhooks.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/routes/webhooks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index b2c1fb0..1995969 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -323,7 +323,7 @@ router.post('/:id/trigger-monthly', async (req, res) => { }); // Get status of event webhooks -router.get('/event-status', async (req, res) => { +router.get('/event-status', authMiddleware, async (req, res) => { try { const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; const result = {}; From b2e6a4405c966fb4b45b85fee3f46f7a37dc0831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Mon, 26 May 2025 10:12:15 +0200 Subject: [PATCH 19/66] Update backend/routes/sync.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/routes/sync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4783ebf..ac69d29 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -825,7 +825,7 @@ async function partialSync(triggertype) { const uuid = randomUUID(); - const newItems = []; + const newItems = []; // Array to track newly added items during the sync process syncTask = { loggedData: [], uuid: uuid, wsKey: "PartialSyncTask", taskName: taskName.partialsync }; try { From 2feef5375867e45fad7d74535bd2a07bd481cca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20MARQUET?= <72651575+BreizhHardware@users.noreply.github.com> Date: Mon, 2 Jun 2025 09:04:14 +0200 Subject: [PATCH 20/66] refactor(webhooks): improve Discord webhook handling and update notifications --- backend/routes/webhooks.js | 21 ++++++++++----------- src/pages/components/settings/webhooks.jsx | 1 - 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 1995969..348279d 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -188,21 +188,21 @@ router.post('/:id/test', async (req, res) => { let testData = req.body || {}; let success = false; - // Traitement spécial pour les webhooks Discord + // Discord behaviour if (webhook.url.includes('discord.com/api/webhooks')) { console.log('Discord webhook détecté, préparation du payload spécifique'); - // Format spécifique pour Discord + // Discord specific format testData = { content: "Test de webhook depuis Jellystat", embeds: [{ - title: "Test de notification Discord", - description: "Ceci est un test de notification via webhook Discord", - color: 3447003, // Bleu + title: "Discord test notification", + description: "This is a test notification of jellystat discord webhook", + color: 3447003, fields: [ { - name: "Type de webhook", - value: webhook.trigger_type || "Non spécifié", + name: "Webhook type", + value: webhook.trigger_type || "Not specified", inline: true }, { @@ -215,10 +215,9 @@ router.post('/:id/test', async (req, res) => { }] }; - // Bypass du traitement normal pour Discord + // Bypass classic method for discord success = await webhookManager.executeDiscordWebhook(webhook, testData); } - // Comportement existant pour les autres types de webhook else if (webhook.trigger_type === 'event' && webhook.event_type) { const eventType = webhook.event_type; @@ -301,9 +300,9 @@ router.post('/:id/test', async (req, res) => { } if (success) { - res.json({ message: 'Webhook exécuté avec succès' }); + res.json({ message: 'Webhook executed successfully' }); } else { - res.status(500).json({ error: 'Échec de l\'exécution du webhook' }); + res.status(500).json({ error: 'Error while executing webhook' }); } } catch (error) { console.error('Error testing webhook:', error); diff --git a/src/pages/components/settings/webhooks.jsx b/src/pages/components/settings/webhooks.jsx index 7427890..3c6beac 100644 --- a/src/pages/components/settings/webhooks.jsx +++ b/src/pages/components/settings/webhooks.jsx @@ -18,7 +18,6 @@ import ErrorBoundary from "../general/ErrorBoundary"; const token = localStorage.getItem('token'); -// Modification du composant WebhookRow pour passer l'objet webhook complet function WebhookRow(props) { const { webhook, onEdit, onTest } = props; From db8d0a3ca0c26de96081a85a1cf1a9fc2158a393 Mon Sep 17 00:00:00 2001 From: chrisvgt <30186211+chrisvgt@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:18:14 +0200 Subject: [PATCH 21/66] feat: add language german --- public/locales/de-DE/translation.json | 319 ++++++++++++++++++++++++++ src/lib/languages.jsx | 4 + 2 files changed, 323 insertions(+) create mode 100644 public/locales/de-DE/translation.json diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json new file mode 100644 index 0000000..4094711 --- /dev/null +++ b/public/locales/de-DE/translation.json @@ -0,0 +1,319 @@ +{ + "JELLYSTAT": "Jellystat", + "MENU_TABS": { + "HOME": "Startseite", + "LIBRARIES": "Bibliotheken", + "USERS": "Benutzer", + "ACTIVITY": "Aktivitäten", + "STATISTICS": "Statistiken", + "SETTINGS": "Einstellungen", + "ABOUT": "Über", + "LOGOUT": "Abmelden", + "TIMELINE": "Zeitleiste" + }, + "HOME_PAGE": { + "SESSIONS": "Sitzungen", + "RECENTLY_ADDED": "Zuletzt hinzugefügt", + "WATCH_STATISTIC": "Wiedergabestatistiken", + "LIBRARY_OVERVIEW": "Bibliothek-Übersicht" + }, + "SESSIONS": { + "NO_SESSIONS": "Keine aktiven Sitzungen gefunden", + "DIRECT_PLAY": "Direkte Wiedergabe", + "TRANSCODE": "Transkodieren" + }, + "STAT_CARDS": { + "MOST_VIEWED_MOVIES": "MEISTGESEHENE FILME", + "MOST_POPULAR_MOVIES": "BELIEBTESTE FILME", + "MOST_VIEWED_SERIES": "MEISTGESEHENE SERIEN", + "MOST_POPULAR_SERIES": "BELIEBTESTE SERIEN", + "MOST_LISTENED_MUSIC": "MEISTGEHÖRTE MUSIK", + "MOST_POPULAR_MUSIC": "BELIEBTESTE MUSIK", + "MOST_VIEWED_LIBRARIES": "MEISTGESEHENE BIBLIOTHEKEN", + "MOST_USED_CLIENTS": "MEISTGENUTZTE CLIENTS", + "MOST_ACTIVE_USERS": "AKTIVSTE BENUTZER", + "CONCURRENT_STREAMS": "GLEICHZEITIGE STREAMS" + }, + "LIBRARY_OVERVIEW": { + "MOVIE_LIBRARIES": "FILM-BIBLIOTHEKEN", + "SHOW_LIBRARIES": "SERIEN-BIBLIOTHEKEN", + "MUSIC_LIBRARIES": "MUSIK-BIBLIOTHEKEN", + "MIXED_LIBRARIES": "GEMISCHTE BIBLIOTHEKEN" + }, + "LIBRARY_CARD": { + "LIBRARY": "Bibliothek", + "TOTAL_TIME": "Gesamtlaufzeit", + "TOTAL_FILES": "Gesamtzahl der Dateien", + "LIBRARY_SIZE": "Größe der Bibliothek", + "TOTAL_PLAYBACK": "Gesamtwiedergabezeit", + "LAST_PLAYED": "Zuletzt gespielt", + "LAST_ACTIVITY": "Letzte Aktivität", + "TRACKED": "Daten-Tracking" + }, + "GLOBAL_STATS": { + "LAST_24_HRS": "Letzten 24 Stunden", + "LAST_7_DAYS": "Letzten 7 Tage", + "LAST_30_DAYS": "Letzten 30 Tage", + "LAST_180_DAYS": "Letzten 180 Tage", + "LAST_365_DAYS": "Letzten 365 Tage", + "ALL_TIME": "Gesamtzeit", + "ITEM_STATS": "Statistik" + }, + "ITEM_INFO": { + "FILE_PATH": "Dateipfad", + "FILE_SIZE": "Dateigröße", + "RUNTIME": "Laufzeit", + "AVERAGE_RUNTIME": "Durchschnittliche Laufzeit", + "OPEN_IN_JELLYFIN": "In Jellyfin öffnen", + "ARCHIVED_DATA_OPTIONS": "Optionen für archivierte Daten", + "PURGE": "Löschen", + "CONFIRM_ACTION": "Aktion bestätigen", + "CONFIRM_ACTION_MESSAGE": "Sind Sie sicher, dass Sie dieses Element löschen möchten", + "CONFIRM_ACTION_MESSAGE_2": "und zugehörige Wiedergabeaktivitäten" + }, + "LIBRARY_INFO": { + "LIBRARY_STATS": "Bibliothek-Statistiken", + "LIBRARY_ACTIVITY": "Bibliothek-Aktivität" + }, + "TAB_CONTROLS": { + "OVERVIEW": "Übersicht", + "ACTIVITY": "Aktivität", + "OPTIONS": "Optionen", + "TIMELINE": "Zeitleiste" + }, + "ITEM_ACTIVITY": "Elementaktivität", + "ACTIVITY_TABLE": { + "MODAL": { + "HEADER": "Stream-Informationen" + }, + "IP_ADDRESS": "IP-Adresse", + "CLIENT": "Client", + "DEVICE": "Gerät", + "PLAYBACK_DURATION": "Wiedergabedauer", + "TOTAL_PLAYBACK": "Gesamtwiedergabezeit", + "EXPAND": "Erweitern", + "COLLAPSE": "Reduzieren", + "SORT_BY": "Sortieren nach", + "ASCENDING": "Aufsteigend", + "DESCENDING": "Absteigend", + "CLEAR_SORT": "Sortierung aufheben", + "CLEAR_FILTER": "Filter löschen", + "FILTER_BY": "Filtern nach", + "COLUMN_ACTIONS": "Spaltenaktionen", + "TOGGLE_SELECT_ROW": "Zeile auswählen/abwählen", + "TOGGLE_SELECT_ALL": "Alle auswählen/abwählen", + "MIN": "Min", + "MAX": "Max" + }, + "TABLE_NAV_BUTTONS": { + "FIRST": "Erste", + "LAST": "Letzte", + "NEXT": "Nächste", + "PREVIOUS": "Vorherige" + }, + "PURGE_OPTIONS": { + "PURGE_CACHE": "Zwischengespeichertes Element löschen", + "PURGE_CACHE_WITH_ACTIVITY": "Zwischengespeichertes Element und Wiedergabeaktivität löschen", + "PURGE_LIBRARY_CACHE": "Zwischengespeicherte Bibliothek und Elemente löschen", + "PURGE_LIBRARY_CACHE_WITH_ACTIVITY": "Zwischengespeicherte Bibliothek, Elemente und Aktivität löschen", + "PURGE_LIBRARY_ITEMS_CACHE": "Nur zwischengespeicherte Bibliothekelemente löschen", + "PURGE_LIBRARY_ITEMS_CACHE_WITH_ACTIVITY": "Nur zwischengespeicherte Bibliothekelemente und Aktivität löschen", + "PURGE_ACTIVITY": "Möchten Sie die ausgewählte Wiedergabeaktivität wirklich löschen?" + }, + "ERROR_MESSAGES": { + "FETCH_THIS_ITEM": "Dieses Element von Jellyfin abrufen", + "NO_ACTIVITY": "Keine Aktivität gefunden", + "NEVER": "Nie", + "N/A": "N/A", + "NO_STATS": "Keine Statistiken zum Anzeigen", + "NO_BACKUPS": "Keine Sicherungen gefunden", + "NO_LOGS": "Keine Protokolle gefunden", + "NO_API_KEYS": "Keine Schlüssel gefunden", + "NETWORK_ERROR": "Verbindung zum Jellyfin-Server nicht möglich", + "INVALID_LOGIN": "Ungültiger Benutzername oder Passwort", + "INVALID_URL": "Fehler {STATUS}: Die angeforderte URL wurde nicht gefunden.", + "UNAUTHORIZED": "Fehler {STATUS}: Nicht autorisiert", + "PASSWORD_LENGTH": "Passwort muss mindestens 6 Zeichen lang sein", + "USERNAME_REQUIRED": "Benutzername ist erforderlich" + }, + "SHOW_ARCHIVED_LIBRARIES": "Archivierte Bibliotheken anzeigen", + "HIDE_ARCHIVED_LIBRARIES": "Archivierte Bibliotheken ausblenden", + "UNITS": { + "YEAR": "Jahr", + "YEARS": "Jahre", + "MONTH": "Monat", + "MONTHS": "Monate", + "DAY": "Tag", + "DAYS": "Tage", + "HOUR": "Stunde", + "HOURS": "Stunden", + "MINUTE": "Minute", + "MINUTES": "Minuten", + "SECOND": "Sekunde", + "SECONDS": "Sekunden", + "PLAYS": "Wiedergaben", + "ITEMS": "Elemente", + "STREAMS": "Streams" + }, + "USERS_PAGE": { + "ALL_USERS": "Alle Benutzer", + "LAST_CLIENT": "Letzter Client", + "LAST_SEEN": "Zuletzt gesehen", + "AGO": "vor", + "AGO_ALT": "", + "USER_STATS": "Benutzerstatistiken", + "USER_ACTIVITY": "Benutzeraktivität" + }, + "STAT_PAGE": { + "STATISTICS": "Statistiken", + "DAILY_PLAY_PER_LIBRARY": "Tägliche Wiedergabezahl pro Bibliothek", + "PLAY_COUNT_BY": "Wiedergabezahl nach" + }, + "SETTINGS_PAGE": { + "SETTINGS": "Allgemein", + "LANGUAGE": "Sprache", + "SELECT_AN_ADMIN": "Einen bevorzugten Administrator auswählen", + "LIBRARY_SETTINGS": "Bibliothek", + "BACKUP": "Sicherung", + "BACKUPS": "Sicherungen", + "CHOOSE_FILE": "Datei auswählen", + "LOGS": "Protokolle", + "SIZE": "Größe", + "JELLYFIN_URL": "Jellyfin URL", + "EMBY_URL": "Emby URL", + "EXTERNAL_URL": "Externe URL", + "API_KEY": "API-Schlüssel", + "API_KEYS": "API-Schlüssel", + "KEY_NAME": "Schlüsselname", + "KEY": "Schlüssel", + "NAME": "Name", + "ADD_KEY": "Schlüssel hinzufügen", + "DURATION": "Dauer", + "EXECUTION_TYPE": "Ausführungstyp", + "RESULTS": "Ergebnisse", + "SELECT_ADMIN": "Bevorzugtes Administratorkonto auswählen", + "HOUR_FORMAT": "Stundenformat", + "HOUR_FORMAT_12": "12 Stunden", + "HOUR_FORMAT_24": "24 Stunden", + "SECURITY": "Sicherheit", + "CURRENT_PASSWORD": "Aktuelles Passwort", + "NEW_PASSWORD": "Neues Passwort", + "UPDATE": "Aktualisieren", + "REQUIRE_LOGIN": "Anmeldung erforderlich", + "TASK": "Aufgabe", + "TASKS": "Aufgaben", + "INTERVAL": "Intervall", + "INTERVALS": { + "15_MIN": "15 Minuten", + "30_MIN": "30 Minuten", + "1_HOUR": "1 Stunde", + "12_HOURS": "12 Stunden", + "1_DAY": "1 Tag", + "1_WEEK": "1 Woche" + }, + "SELECT_LIBRARIES_TO_IMPORT": "Bibliotheken zum Importieren auswählen", + "SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "Die Aktivität für Elemente innerhalb dieser Bibliotheken wird weiterhin verfolgt - auch wenn sie nicht importiert werden.", + "DATE_ADDED": "Hinzugefügt am" + }, + "TASK_TYPE": { + "JOB": "Job", + "IMPORT": "Import" + }, + "TASK_DESCRIPTION": { + "PartialJellyfinSync": "Synchronisierung kürzlich hinzugefügter Elemente", + "JellyfinSync": "Vollständige Synchronisierung mit Jellyfin", + "Jellyfin_Playback_Reporting_Plugin_Sync": "Import von Wiedergabeberichts-Plugin-Daten", + "Backup": "Jellystat Sicherung" + }, + "ABOUT_PAGE": { + "ABOUT_JELLYSTAT": "Über Jellystat", + "VERSION": "Version", + "UPDATE_AVAILABLE": "Update verfügbar", + "GITHUB": "Github", + "Backup": "Jellystat Sicherung" + }, + "TIMELINE_PAGE": { + "TIMELINE": "Zeitleiste", + "EPISODES_one": "Episode", + "EPISODES_other": "Episoden" + }, + "SEARCH": "Suchen", + "TOTAL": "Gesamt", + "LAST": "Letzten", + "SERIES": "Serien", + "SEASON": "Staffel", + "SEASONS": "Staffeln", + "EPISODE": "Episode", + "EPISODES": "Episoden", + "MOVIES": "Filme", + "MUSIC": "Musik", + "SONGS": "Lieder", + "FILES": "Dateien", + "LIBRARIES": "Bibliotheken", + "USER": "Benutzer", + "USERS": "Benutzer", + "TYPE": "Typ", + "NEW_VERSION_AVAILABLE": "Neue Version verfügbar", + "ARCHIVED": "Archiviert", + "NOT_ARCHIVED": "Nicht archiviert", + "ALL": "Alle", + "CLOSE": "Schließen", + "TOTAL_PLAYS": "Gesamtwiedergaben", + "TITLE": "Titel", + "VIEWS": "Ansichten", + "WATCH_TIME": "Wiedergabezeit", + "LAST_WATCHED": "Zuletzt angesehen", + "MEDIA": "Medien", + "SAVE": "Speichern", + "YES": "Ja", + "NO": "Nein", + "FILE_NAME": "Dateiname", + "DATE": "Datum", + "START": "Start", + "STOP": "Stop", + "DOWNLOAD": "Herunterladen", + "RESTORE": "Wiederherstellen", + "ACTIONS": "Aktionen", + "DELETE": "Löschen", + "BITRATE": "Bitrate", + "CONTAINER": "Container", + "VIDEO": "Video", + "CODEC": "Codec", + "WIDTH": "Breite", + "HEIGHT": "Höhe", + "FRAMERATE": "Bildrate", + "DYNAMIC_RANGE": "Dynamikbereich", + "ASPECT_RATIO": "Seitenverhältnis", + "AUDIO": "Audio", + "CHANNELS": "Kanäle", + "LANGUAGE": "Sprache", + "STREAM_DETAILS": "Stream Details", + "SOURCE_DETAILS": "Details zur Videoquelle", + "DIRECT": "Direkt", + "TRANSCODE": "Transkodieren", + "DIRECT_STREAM": "Direkt-Stream", + "USERNAME": "Benutzername", + "PASSWORD": "Passwort", + "LOGIN": "Anmelden", + "FT_SETUP_PROGRESS": "Erster Einrichtungsschritt {STEP} von {TOTAL}", + "VALIDATING": "Validierung läuft", + "SAVE_JELLYFIN_DETAILS": "Jellyfin-Details speichern", + "SETTINGS_SAVED": "Einstellungen gespeichert", + "SUCCESS": "Erfolg", + "PASSWORD_UPDATE_SUCCESS": "Passwort erfolgreich aktualisiert", + "CREATE_USER": "Benutzer erstellen", + "GEOLOCATION_INFO_FOR": "Geolokalisierungsinformationen für", + "CITY": "Stadt", + "REGION": "Region", + "COUNTRY": "Land", + "ORGANIZATION": "Organisation", + "ISP": "ISP", + "LATITUDE": "Breitengrad", + "LONGITUDE": "Längengrad", + "TIMEZONE": "Zeitzone", + "POSTCODE": "Postleitzahl", + "X_ROWS_SELECTED": "{ROWS} Zeilen ausgewählt", + "TRANSCODE_REASONS": "Transkodierungsgründe", + "SUBTITLES": "Untertitel", + "GENRES": "Genres" +} \ No newline at end of file diff --git a/src/lib/languages.jsx b/src/lib/languages.jsx index 86683f7..3e00a9f 100644 --- a/src/lib/languages.jsx +++ b/src/lib/languages.jsx @@ -23,4 +23,8 @@ export const languages = [ id: "ca-ES", description: "Català", }, + { + id: "de-DE", + description: "Deutsch", + }, ]; From f11dfc187c5342bf427a5b1768b08ed1ceb55714 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 14 Jun 2025 18:33:56 +0200 Subject: [PATCH 22/66] fix: update base image to node:lts-slim for improved stability --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3abb606..26a325f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the application -FROM node:slim AS builder +FROM node:lts-slim AS builder WORKDIR /app From 24517c4d82382b4b55f575073ffcf4def1ce9984 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 14 Jun 2025 18:45:17 +0200 Subject: [PATCH 23/66] fix: update base image to node:lts-slim for improved stability --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 26a325f..f00d2eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ COPY entry.sh ./ RUN npm run build # Stage 2: Create the production image -FROM node:slim +FROM node:lts-slim RUN apt-get update && \ apt-get install -yqq --no-install-recommends wget && \ From 67bdb8dafba095876a693d2f186861f95549834e Mon Sep 17 00:00:00 2001 From: Zlendy Date: Thu, 12 Jun 2025 01:33:15 +0200 Subject: [PATCH 24/66] refactor: Replace `moment` with `dayjs` https://day.js.org/en/ --- backend/classes/backup.js | 4 ++-- backend/classes/logging.js | 8 ++++---- backend/models/jf_activity_watchdog.js | 4 ++-- backend/routes/api.js | 18 +++++++++--------- backend/routes/stats.js | 18 +++++++++--------- backend/routes/sync.js | 18 +++++++++--------- backend/tasks/ActivityMonitor.js | 14 +++++++------- package-lock.json | 11 ++++++----- package.json | 2 +- .../activity-timeline-item.jsx | 6 +++--- 10 files changed, 52 insertions(+), 51 deletions(-) diff --git a/backend/classes/backup.js b/backend/classes/backup.js index d2c21f0..1c95fbd 100644 --- a/backend/classes/backup.js +++ b/backend/classes/backup.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); const configClass = require("./config"); -const moment = require("moment"); +const dayjs = require("dayjs"); const Logging = require("./logging"); const taskstate = require("../logging/taskstate"); @@ -50,7 +50,7 @@ async function backup(refLog) { // Get data from each table and append it to the backup file try { - let now = moment(); + let now = dayjs(); const backuppath = "./" + backupfolder; if (!fs.existsSync(backuppath)) { diff --git a/backend/classes/logging.js b/backend/classes/logging.js index c75a864..16c78df 100644 --- a/backend/classes/logging.js +++ b/backend/classes/logging.js @@ -1,12 +1,12 @@ const db = require("../db"); -const moment = require("moment"); +const dayjs = require("dayjs"); const taskstate = require("../logging/taskstate"); const { jf_logging_columns, jf_logging_mapping } = require("../models/jf_logging"); async function insertLog(uuid, triggertype, taskType) { try { - let startTime = moment(); + let startTime = dayjs(); const log = { Id: uuid, Name: taskType, @@ -32,8 +32,8 @@ async function updateLog(uuid, data, taskstate) { if (task.length === 0) { console.log("Unable to find task to update"); } else { - let endtime = moment(); - let startTime = moment(task[0].TimeRun); + let endtime = dayjs(); + let startTime = dayjs(task[0].TimeRun); let duration = endtime.diff(startTime, "seconds"); const log = { Id: uuid, diff --git a/backend/models/jf_activity_watchdog.js b/backend/models/jf_activity_watchdog.js index bde3de8..6a1b3b7 100644 --- a/backend/models/jf_activity_watchdog.js +++ b/backend/models/jf_activity_watchdog.js @@ -1,4 +1,4 @@ -const moment = require("moment"); +const dayjs = require("dayjs"); const { randomUUID } = require("crypto"); const jf_activity_watchdog_columns = [ @@ -45,7 +45,7 @@ const jf_activity_watchdog_mapping = (item) => ({ PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration : 0, PlayMethod: item.PlayState.PlayMethod, ActivityDateInserted: - item.ActivityDateInserted !== undefined ? item.ActivityDateInserted : moment().format("YYYY-MM-DD HH:mm:ss.SSSZ"), + item.ActivityDateInserted !== undefined ? item.ActivityDateInserted : dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"), MediaStreams: item.NowPlayingItem.MediaStreams ? item.NowPlayingItem.MediaStreams : null, TranscodingInfo: item.TranscodingInfo ? item.TranscodingInfo : null, PlayState: item.PlayState ? item.PlayState : null, diff --git a/backend/routes/api.js b/backend/routes/api.js index 7cbe0f5..de18996 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -11,7 +11,7 @@ const configClass = require("../classes/config"); const { checkForUpdates } = require("../version-control"); const API = require("../classes/api-loader"); const { sendUpdate } = require("../ws"); -const moment = require("moment"); +const dayjs = require("dayjs"); const { tables } = require("../global/backup_tables"); const TaskScheduler = require("../classes/task-scheduler-singleton"); const TaskManager = require("../classes/task-manager-singleton.js"); @@ -329,11 +329,11 @@ router.get("/getRecentlyAdded", async (req, res) => { let lastSynctedItemDate; if (items.length > 0 && items[0].DateCreated !== undefined && items[0].DateCreated !== null) { - lastSynctedItemDate = moment(items[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); + lastSynctedItemDate = dayjs(items[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); } if (episodes.length > 0 && episodes[0].DateCreated !== undefined && episodes[0].DateCreated !== null) { - const newLastSynctedItemDate = moment(episodes[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); + const newLastSynctedItemDate = dayjs(episodes[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); if (lastSynctedItemDate === undefined || newLastSynctedItemDate.isAfter(lastSynctedItemDate)) { lastSynctedItemDate = newLastSynctedItemDate; @@ -342,7 +342,7 @@ router.get("/getRecentlyAdded", async (req, res) => { if (lastSynctedItemDate !== undefined) { recentlyAddedFromJellystatMapped = recentlyAddedFromJellystatMapped.filter((item) => - moment(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate) + dayjs(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate) ); } @@ -354,7 +354,7 @@ router.get("/getRecentlyAdded", async (req, res) => { const recentlyAdded = [...recentlyAddedFromJellystatMapped, ...filteredDbRows]; // Sort recentlyAdded by DateCreated in descending order recentlyAdded.sort( - (a, b) => moment(b.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") - moment(a.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") + (a, b) => dayjs(b.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") - dayjs(a.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") ); res.send(recentlyAdded); @@ -383,11 +383,11 @@ router.get("/getRecentlyAdded", async (req, res) => { ); let lastSynctedItemDate; if (items.length > 0 && items[0].DateCreated !== undefined && items[0].DateCreated !== null) { - lastSynctedItemDate = moment(items[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); + lastSynctedItemDate = dayjs(items[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); } if (episodes.length > 0 && episodes[0].DateCreated !== undefined && episodes[0].DateCreated !== null) { - const newLastSynctedItemDate = moment(episodes[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); + const newLastSynctedItemDate = dayjs(episodes[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ"); if (lastSynctedItemDate === undefined || newLastSynctedItemDate.isAfter(lastSynctedItemDate)) { lastSynctedItemDate = newLastSynctedItemDate; @@ -396,7 +396,7 @@ router.get("/getRecentlyAdded", async (req, res) => { if (lastSynctedItemDate !== undefined) { recentlyAddedFromJellystatMapped = recentlyAddedFromJellystatMapped.filter((item) => - moment(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate) + dayjs(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate) ); } @@ -414,7 +414,7 @@ router.get("/getRecentlyAdded", async (req, res) => { // Sort recentlyAdded by DateCreated in descending order recentlyAdded.sort( - (a, b) => moment(b.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") - moment(a.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") + (a, b) => dayjs(b.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") - dayjs(a.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ") ); res.send(recentlyAdded); diff --git a/backend/routes/stats.js b/backend/routes/stats.js index a3f105a..a54a007 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -2,7 +2,7 @@ const express = require("express"); const db = require("../db"); const dbHelper = require("../classes/db-helper"); -const moment = require("moment"); +const dayjs = require("dayjs"); const router = express.Router(); @@ -11,8 +11,8 @@ function countOverlapsPerHour(records) { const hourCounts = {}; records.forEach((record) => { - const start = moment(record.StartTime).subtract(1, "hour"); - const end = moment(record.EndTime).add(1, "hour"); + const start = dayjs(record.StartTime).subtract(1, "hour"); + const end = dayjs(record.EndTime).add(1, "hour"); // Iterate through each hour from start to end for (let hour = start.clone().startOf("hour"); hour.isBefore(end); hour.add(1, "hour")) { @@ -289,12 +289,12 @@ router.post("/getLibraryItemsWithStats", async (req, res) => { router.post("/getLibraryItemsPlayMethodStats", async (req, res) => { try { - let { libraryid, startDate, endDate = moment(), hours = 24 } = req.body; + let { libraryid, startDate, endDate = dayjs(), hours = 24 } = req.body; - // Validate startDate and endDate using moment + // Validate startDate and endDate using dayjs if ( startDate !== undefined && - (!moment(startDate, moment.ISO_8601, true).isValid() || !moment(endDate, moment.ISO_8601, true).isValid()) + (!dayjs(startDate, dayjs.ISO_8601, true).isValid() || !dayjs(endDate, dayjs.ISO_8601, true).isValid()) ) { return res.status(400).send({ error: "Invalid date format" }); } @@ -308,7 +308,7 @@ router.post("/getLibraryItemsPlayMethodStats", async (req, res) => { } if (startDate === undefined) { - startDate = moment(endDate).subtract(hours, "hour").format("YYYY-MM-DD HH:mm:ss"); + startDate = dayjs(endDate).subtract(hours, "hour").format("YYYY-MM-DD HH:mm:ss"); } const { rows } = await db.query( @@ -336,8 +336,8 @@ router.post("/getLibraryItemsPlayMethodStats", async (req, res) => { NowPlayingItemName: item.NowPlayingItemName, EpisodeId: item.EpisodeId || null, SeasonId: item.SeasonId || null, - StartTime: moment(item.ActivityDateInserted).subtract(item.PlaybackDuration, "seconds").format("YYYY-MM-DD HH:mm:ss"), - EndTime: moment(item.ActivityDateInserted).format("YYYY-MM-DD HH:mm:ss"), + StartTime: dayjs(item.ActivityDateInserted).subtract(item.PlaybackDuration, "seconds").format("YYYY-MM-DD HH:mm:ss"), + EndTime: dayjs(item.ActivityDateInserted).format("YYYY-MM-DD HH:mm:ss"), PlaybackDuration: item.PlaybackDuration, PlayMethod: item.PlayMethod, TranscodedVideo: item.TranscodingInfo?.IsVideoDirect || false, diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4f811ce..eff6318 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -1,7 +1,7 @@ const express = require("express"); const db = require("../db"); -const moment = require("moment"); +const dayjs = require("dayjs"); const { randomUUID } = require("crypto"); const { sendUpdate } = require("../ws"); @@ -530,13 +530,13 @@ async function syncPlaybackPluginData() { let query = `SELECT rowid, * FROM PlaybackActivity`; if (OldestPlaybackActivity && NewestPlaybackActivity) { - const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); - const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + const formattedDateTimeOld = dayjs(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + const formattedDateTimeNew = dayjs(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); query = query + ` WHERE (DateCreated < '${formattedDateTimeOld}' or DateCreated > '${formattedDateTimeNew}')`; } if (OldestPlaybackActivity && !NewestPlaybackActivity) { - const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + const formattedDateTimeOld = dayjs(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); query = query + ` WHERE DateCreated < '${formattedDateTimeOld}'`; if (MaxPlaybackReportingPluginID) { query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`; @@ -544,7 +544,7 @@ async function syncPlaybackPluginData() { } if (!OldestPlaybackActivity && NewestPlaybackActivity) { - const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + const formattedDateTimeNew = dayjs(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); query = query + ` WHERE DateCreated > '${formattedDateTimeNew}'`; if (MaxPlaybackReportingPluginID) { query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`; @@ -871,7 +871,7 @@ async function partialSync(triggertype) { let updateItemInfoCount = 0; let updateEpisodeInfoCount = 0; - let lastSyncDate = moment().subtract(24, "hours"); + let lastSyncDate = dayjs().subtract(24, "hours"); const last_execution = await db .query( @@ -882,7 +882,7 @@ async function partialSync(triggertype) { ) .then((res) => res.rows); if (last_execution.length !== 0) { - lastSyncDate = moment(last_execution[0].DateCreated); + lastSyncDate = dayjs(last_execution[0].DateCreated); } //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) @@ -909,7 +909,7 @@ async function partialSync(triggertype) { }, }); - libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate)); + libraryItems = libraryItems.filter((item) => dayjs(item.DateCreated).isAfter(lastSyncDate)); while (libraryItems.length != 0) { if (libraryItems.length === 0 && startIndex === 0) { @@ -974,7 +974,7 @@ async function partialSync(triggertype) { }, }); - libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate)); + libraryItems = libraryItems.filter((item) => dayjs(item.DateCreated).isAfter(lastSyncDate)); } } diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 43f1969..f716366 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -1,6 +1,6 @@ const db = require("../db"); -const moment = require("moment"); +const dayjs = require("dayjs"); const { columnsPlayback } = require("../models/jf_playback_activity"); const { jf_activity_watchdog_columns, jf_activity_watchdog_mapping } = require("../models/jf_activity_watchdog"); const configClass = require("../classes/config"); @@ -31,8 +31,8 @@ async function getSessionsInWatchDog(SessionData, WatchdogData) { //if the playstate was paused, calculate the difference in seconds and add to the playback duration if (sessionData.PlayState.IsPaused == true) { - let startTime = moment(wdData.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ"); - let lastPausedDate = moment(sessionData.LastPausedDate); + let startTime = dayjs(wdData.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ"); + let lastPausedDate = dayjs(sessionData.LastPausedDate); let diffInSeconds = lastPausedDate.diff(startTime, "seconds"); @@ -40,7 +40,7 @@ async function getSessionsInWatchDog(SessionData, WatchdogData) { wdData.ActivityDateInserted = `${lastPausedDate.format("YYYY-MM-DD HH:mm:ss.SSSZ")}`; } else { - wdData.ActivityDateInserted = moment().format("YYYY-MM-DD HH:mm:ss.SSSZ"); + wdData.ActivityDateInserted = dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"); } return true; } @@ -97,8 +97,8 @@ function getWatchDogNotInSessions(SessionData, WatchdogData) { removedData.map((obj) => { obj.Id = obj.ActivityId; - let startTime = moment(obj.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ"); - let endTime = moment(); + let startTime = dayjs(obj.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ"); + let endTime = dayjs(); let diffInSeconds = endTime.diff(startTime, "seconds"); @@ -212,7 +212,7 @@ async function ActivityMonitor(interval) { if (existingrow) { playbackData.Id = existingrow.Id; playbackData.PlaybackDuration = Number(existingrow.PlaybackDuration) + Number(playbackData.PlaybackDuration); - playbackData.ActivityDateInserted = moment().format("YYYY-MM-DD HH:mm:ss.SSSZ"); + playbackData.ActivityDateInserted = dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"); return true; } return false; diff --git a/package-lock.json b/package-lock.json index f9449fc..a6b09d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "jfstat", - "version": "1.1.4", + "version": "1.1.7", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -29,6 +29,7 @@ "config": "^3.3.9", "cors": "^2.8.5", "crypto-js": "^4.1.1", + "dayjs": "^1.11.13", "dns-cache": "^2.0.0", "dotenv": "^16.3.1", "dottie": "^2.0.6", @@ -44,7 +45,6 @@ "knex": "^2.4.2", "material-react-table": "^3.1.0", "memoizee": "^0.4.17", - "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", @@ -8798,9 +8798,10 @@ } }, "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" }, "node_modules/debug": { "version": "2.6.9", diff --git a/package.json b/package.json index 79a9494..2b612a8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "config": "^3.3.9", "cors": "^2.8.5", "crypto-js": "^4.1.1", + "dayjs": "^1.11.13", "dns-cache": "^2.0.0", "dotenv": "^16.3.1", "dottie": "^2.0.6", @@ -51,7 +52,6 @@ "knex": "^2.4.2", "material-react-table": "^3.1.0", "memoizee": "^0.4.17", - "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", diff --git a/src/pages/components/activity-timeline/activity-timeline-item.jsx b/src/pages/components/activity-timeline/activity-timeline-item.jsx index 20a78a0..a7e2c6c 100644 --- a/src/pages/components/activity-timeline/activity-timeline-item.jsx +++ b/src/pages/components/activity-timeline/activity-timeline-item.jsx @@ -13,7 +13,7 @@ import baseUrl from "../../../lib/baseurl"; import "../../css/timeline/activity-timeline.css"; import { useMediaQuery, useTheme } from "@mui/material"; -import moment from "moment"; +import dayjs from "dayjs"; import TvLineIcon from "remixicon-react/TvLineIcon.js"; import FilmLineIcon from "remixicon-react/FilmLineIcon.js"; import { MEDIA_TYPES } from "./helpers"; @@ -29,8 +29,8 @@ const dateFormatOptions = { }; function formatEntryDates(FirstActivityDate, LastActivityDate, MediaType) { - const startDate = moment(FirstActivityDate); - const endDate = moment(LastActivityDate); + const startDate = dayjs(FirstActivityDate); + const endDate = dayjs(LastActivityDate); if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) { return Intl.DateTimeFormat(localization, dateFormatOptions).format( From 5b3b0396cbd806a2590d0b90c9c7922615946732 Mon Sep 17 00:00:00 2001 From: jon4hz Date: Sun, 29 Jun 2025 23:08:28 +0200 Subject: [PATCH 25/66] fix: sanitize data before db insert --- backend/routes/sync.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4f811ce..b5ab4ae 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -39,13 +39,41 @@ function getErrorLineNumber(error) { return lineNumber; } +function sanitizeNullBytes(obj) { + if (typeof obj === 'string') { + // Remove various forms of null bytes and control characters that cause Unicode escape sequence errors + return obj + .replace(/\u0000/g, '') // Remove null bytes + .replace(/\\u0000/g, '') // Remove escaped null bytes + .replace(/\x00/g, '') // Remove hex null bytes + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') // Remove all control characters + .trim(); // Remove leading/trailing whitespace + } + + if (Array.isArray(obj)) { + return obj.map(sanitizeNullBytes); + } + + if (obj && typeof obj === 'object') { + const sanitized = {}; + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeNullBytes(value); + } + return sanitized; + } + + return obj; +} + class sync { async getExistingIDsforTable(tablename) { return await db.query(`SELECT "Id" FROM ${tablename}`).then((res) => res.rows.map((row) => row.Id)); } async insertData(tablename, dataToInsert, column_mappings) { - let result = await db.insertBulk(tablename, dataToInsert, column_mappings); + const sanitizedData = sanitizeNullBytes(dataToInsert); + + let result = await db.insertBulk(tablename, sanitizedData, column_mappings); if (result.Result === "SUCCESS") { // syncTask.loggedData.push({ color: "dodgerblue", Message: dataToInsert.length + " Rows Inserted." }); } else { From 03e8ced53c60b3de32d9183af31ac2e4f41c380e Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:55:15 -0400 Subject: [PATCH 26/66] Update iOS PWA Icon Sizing and Background --- index.html | 2 +- public/apple-touch-icon.png | Bin 0 -> 4269 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/apple-touch-icon.png diff --git a/index.html b/index.html index 948e3aa..7e6b101 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - +