diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6312cb23..da225f30 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 0d639608..23dd6a75 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 diff --git a/Dockerfile b/Dockerfile index 3abb6066..3370efd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the application -FROM node:slim AS builder +FROM node:22.21.1-bookworm-slim AS builder WORKDIR /app @@ -14,7 +14,7 @@ COPY entry.sh ./ RUN npm run build # Stage 2: Create the production image -FROM node:slim +FROM node:22.21.1-bookworm-slim RUN apt-get update && \ apt-get install -yqq --no-install-recommends wget && \ diff --git a/README.md b/README.md index cbe2db03..166da3bd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ | POSTGRES_PASSWORD `REQUIRED` | `null` | `postgres` | Password that will be used in postgres database | | POSTGRES_IP `REQUIRED` | `null` | `jellystat-db` or `192.168.0.5` | Hostname/IP of postgres instance | | POSTGRES_PORT `REQUIRED` | `null` | `5432` | Port Postgres is running on | +| POSTGRES_SSL_ENABLED | `null` | `true` | Enable SSL connections to Postgres +| POSTGRES_SSL_REJECT_UNAUTHORIZED | `null` | `false` | Verify Postgres SSL certificates when POSTGRES_SSL_ENABLED=true | JS_LISTEN_IP | `0.0.0.0`| `0.0.0.0` or `::` | Enable listening on specific IP or `::` for IPv6 | | JWT_SECRET `REQUIRED` | `null` | `my-secret-jwt-key` | JWT Key to be used to encrypt JWT tokens for authentication | | TZ `REQUIRED` | `null` | `Etc/UTC` | Server timezone (Can be found at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) | diff --git a/backend/classes/backup.js b/backend/classes/backup.js index 5282ca09..981b7d60 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"); @@ -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; } @@ -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)) { @@ -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,18 +73,18 @@ 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; } - // const backupPath = `../backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`; - const directoryPath = path.join(__dirname, "..", backupfolder, `backup_${now.format("yyyy-MM-DD HH-mm-ss")}.json`); + // const backupPath = `../backup-data/backup_${now.format('YYYY-MM-DD HH-mm-ss')}.json`; + 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/classes/config.js b/backend/classes/config.js index ec335d0f..a83f2d0a 100644 --- a/backend/classes/config.js +++ b/backend/classes/config.js @@ -15,14 +15,17 @@ class Config { return { state: 0, error: "Config Details Not Found" }; } + const _config = config[0]; + return { - JF_HOST: process.env.JF_HOST ?? config[0].JF_HOST, - JF_API_KEY: process.env.JF_API_KEY ?? config[0].JF_API_KEY, - APP_USER: config[0].APP_USER, - APP_PASSWORD: config[0].APP_PASSWORD, - REQUIRE_LOGIN: config[0].REQUIRE_LOGIN, - settings: config[0].settings, - api_keys: config[0].api_keys, + JF_HOST: process.env.JF_HOST ?? _config.JF_HOST, + JF_EXTERNAL_HOST: _config.settings?.EXTERNAL_URL, + JF_API_KEY: process.env.JF_API_KEY ?? _config.JF_API_KEY, + APP_USER: _config.APP_USER, + APP_PASSWORD: _config.APP_PASSWORD, + REQUIRE_LOGIN: _config.REQUIRE_LOGIN, + settings: _config.settings, + api_keys: _config.api_keys, state: state, IS_JELLYFIN: (process.env.IS_EMBY_API || "false").toLowerCase() === "false", }; diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index f5dd5622..b782f037 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -14,7 +14,8 @@ function wrapField(field) { field.includes("AVG") || field.includes("DISTINCT") || field.includes("json_agg") || - field.includes("CASE") + field.includes("CASE") || + field.includes("REGEXP_REPLACE") ) { return field; } @@ -203,6 +204,124 @@ async function query({ client.release(); } } + +function buildFilterList(query, filtersArray, filterFields) { + if (filtersArray.length > 0) { + query.where = query.where || []; + filtersArray.forEach((filter) => { + const findField = filterFields.find((item) => item.field === filter.field); + const column = findField?.column || "a.ActivityDateInserted"; + const isColumn = findField?.isColumn || false; + const applyToCTE = findField?.applyToCTE || false; + if (filter.min) { + query.where.push({ + column: column, + operator: ">=", + value: `$${query.values.length + 1}`, + }); + + query.values.push(filter.min); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + query.cte.where.push({ + column: column, + operator: ">=", + value: `$${query.values.length + 1}`, + }); + + query.values.push(filter.min); + } + } + } + + if (filter.in) { + const values = filter.in.split(","); + const valuesPlaceholders = values.map((_, i) => `$${query.values.length + i + 1}`).join(", "); + query.where.push({ + column: column, + operator: "in", + value: `(${valuesPlaceholders})`, + }); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + const valuesPlaceholdersCTE = values.map((_, i) => `$${query.values.length + values.length + i + 1}`).join(", "); + query.cte.where.push({ + column: column, + operator: "in", + value: `(${valuesPlaceholdersCTE})`, + }); + } + } + query.values.push(...values); + if (applyToCTE && query.cte) { + query.values.push(...values); + } + } + + if (filter.max) { + query.where.push({ + column: column, + operator: "<=", + value: `$${query.values.length + 1}`, + }); + + query.values.push(filter.max); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + query.cte.where.push({ + column: column, + operator: "<=", + value: `$${query.values.length + 1}`, + }); + + query.values.push(filter.max); + } + } + } + + if (filter.value) { + const whereClause = { + operator: "LIKE", + value: `$${query.values.length + 1}`, + }; + + query.values.push(`%${filter.value.toLowerCase()}%`); + + if (isColumn) { + whereClause.column = column; + } else { + whereClause.field = column; + } + query.where.push(whereClause); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + whereClause.value = `$${query.values.length + 1}`; + query.cte.where.push(whereClause); + + query.values.push(`%${filter.value.toLowerCase()}%`); + } + } + } + }); + } +} module.exports = { query, + buildFilterList, }; diff --git a/backend/classes/emby-api.js b/backend/classes/emby-api.js index a56df899..2ca8ad2c 100644 --- a/backend/classes/emby-api.js +++ b/backend/classes/emby-api.js @@ -143,6 +143,23 @@ class EmbyAPI { } } + async getUserById(userid) { + if (!this.configReady) { + const success = await this.#fetchConfig(); + if (!success) { + return null; + } + } + + try { + const users = await this.getUsers(); + return users.find((user) => user.Id === userid) || null; + } catch (error) { + this.#errorHandler(error); + return null; + } + } + async getItemsByID({ ids, params }) { if (!this.configReady) { return []; diff --git a/backend/classes/jellyfin-api.js b/backend/classes/jellyfin-api.js index 81e244b1..a257654d 100644 --- a/backend/classes/jellyfin-api.js +++ b/backend/classes/jellyfin-api.js @@ -1,5 +1,8 @@ +const { compareVersions } = require("compare-versions"); + const configClass = require("./config"); const { axios } = require("./axios"); +const { getSessionData } = require("./sessions-socket-client"); class JellyfinAPI { constructor() { @@ -7,6 +10,7 @@ class JellyfinAPI { this.configReady = false; this.#checkReadyStatus(); this.sessionErrorCounter = 0; + this.version = "1.0.0"; } //Helper classes #checkReadyStatus() { @@ -20,9 +24,12 @@ class JellyfinAPI { async #fetchConfig() { const _config = await new configClass().getConfig(); + if (!_config.error && _config.state === 2) { this.config = _config; this.configReady = true; + const _latest_version = await this.systemInfo(); + this.version = _latest_version?.Version || "1.0.0"; return true; } return false; @@ -117,7 +124,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); if (Array.isArray(response?.data)) { @@ -143,6 +150,23 @@ class JellyfinAPI { } } + async getUserById(userid) { + if (!this.configReady) { + const success = await this.#fetchConfig(); + if (!success) { + return null; + } + } + + try { + const users = await this.getUsers(); + return users.find((user) => user.Id === userid) || null; + } catch (error) { + this.#errorHandler(error); + return null; + } + } + async getItemsByID({ ids, params }) { if (!this.configReady) { return []; @@ -159,7 +183,7 @@ class JellyfinAPI { while (startIndex < total || total === undefined) { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, params: { fields: "MediaSources,DateCreated,Genres", @@ -225,7 +249,7 @@ class JellyfinAPI { while (startIndex < total || total === undefined) { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, params: { fields: "MediaSources,DateCreated,Genres", @@ -293,7 +317,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); @@ -316,7 +340,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); @@ -341,7 +365,7 @@ class JellyfinAPI { try { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); @@ -364,7 +388,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); @@ -408,7 +432,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, params: { fields: "MediaSources,DateCreated,Genres", @@ -429,12 +453,21 @@ class JellyfinAPI { return []; } try { + if (compareVersions(this.version, "10.11.0") >= 0) { + const socketUrl = + (this.config.JF_EXTERNAL_HOST ?? this.config.JF_HOST).replace(/^http/, "ws").replace(/^https/, "wss") + "/socket"; + const sessionData = await getSessionData(socketUrl, this.config.JF_API_KEY); + if (sessionData != null) { + return sessionData; + } + } + let url = `${this.config.JF_HOST}/sessions`; const response = await axios .get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }) .then((response) => { @@ -480,7 +513,7 @@ class JellyfinAPI { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); return response.data; @@ -507,7 +540,7 @@ class JellyfinAPI { }, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, } ); @@ -548,7 +581,7 @@ class JellyfinAPI { const response = await axios.get(validation_url, { headers: { - "X-MediaBrowser-Token": apikey, + "Authorization": 'MediaBrowser Token="' + apikey + '"', }, }); result.isValid = response.status == 200; @@ -569,16 +602,15 @@ class JellyfinAPI { async systemInfo() { if (!this.configReady) { - return []; + return {}; } let url = `${this.config.JF_HOST}/system/info`; try { const response = await axios.get(url, { headers: { - "X-MediaBrowser-Token": this.config.JF_API_KEY, + "Authorization": 'MediaBrowser Token="' + this.config.JF_API_KEY + '"', }, }); - return response?.data || {}; } catch (error) { this.#errorHandler(error, url); diff --git a/backend/classes/logging.js b/backend/classes/logging.js index c75a864e..16c78dfc 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/classes/sessions-socket-client.js b/backend/classes/sessions-socket-client.js new file mode 100644 index 00000000..72e7dc01 --- /dev/null +++ b/backend/classes/sessions-socket-client.js @@ -0,0 +1,117 @@ +const WebSocketClient = require("./websocket-client.js"); + +let wsClient; + +let sessionData = []; +let errorCount = 0; +const maxErrorCount = 3; +const reconnectInterval = 60000; +let reconnectNextAttempt = null; +let keepAliveInterval; + +function initializeClient(websocketUrl, apiKey) { + const options = { + headers: { + "Authorization": 'MediaBrowser Token="' + apiKey + '"' + } + }; + + wsClient = new WebSocketClient(websocketUrl, options); + + wsClient.onOpen = () => { + console.log(`[JELLYFIN-WEBSOCKET]: Connected to the server.`); + errorCount = 0; + reconnectNextAttempt = null; + + // Start sending ForceKeepAlive every 30 seconds + if (keepAliveInterval) { + clearInterval(keepAliveInterval); // Clear any existing interval to avoid duplicates + } + keepAliveInterval = setInterval(() => { + if (wsClient.getConnectionStatus() === "OPEN") { + wsClient.send(JSON.stringify({ MessageType: "KeepAlive" })); + } else { + clearInterval(keepAliveInterval); // Stop sending pings if connection is not open + } + }, 30000); // 30 seconds interval + }; + + wsClient.onMessage = (data) => { + try { + const message = JSON.parse(data); + if (message.MessageType === "Sessions") { + let result = message.Data && Array.isArray(message.Data) ? message.Data : []; + + if (result.length > 0) { + result = result.filter( + (session) => + session.NowPlayingItem !== undefined && + session.NowPlayingItem.Type != "Trailer" && + session.NowPlayingItem.ProviderIds["prerolls.video"] == undefined + ); + } + sessionData = result; + } else if (message.MessageType === "ForceKeepAlive") { + wsClient.send(JSON.stringify({ MessageType: "SessionsStart", Data: "0,1500" })); + } + } catch (error) { + console.error("[JELLYFIN-WEBSOCKET]: Error parsing message:"); + } + }; + + wsClient.onClose = () => { + console.log(`[JELLYFIN-WEBSOCKET]: Disconnected from the server.`); + sessionData = []; + }; + + wsClient.onError = (error) => { + console.error("[JELLYFIN-WEBSOCKET]: Error:", error); + errorCount++; + sessionData = []; + }; +} + +async function connect(websocketUrl, apiKey) { + if (wsClient == null) { + initializeClient(websocketUrl, apiKey); + } + if (errorCount >= maxErrorCount) { + const now = Date.now(); + + if (reconnectNextAttempt == null) { + reconnectNextAttempt = now + reconnectInterval; + sessionData = null; + console.log("[JELLYFIN-WEBSOCKET]: Too many errors. Attempting to reconnect after 60 seconds."); + console.log("[JELLYFIN-API]: getSessions - Falling back to REST API"); + return; + } + + if (now >= reconnectNextAttempt) { + reconnectNextAttempt = now + reconnectInterval; // Reset the next attempt time + await wsClient.connect(); + } else { + return; + } + } + await wsClient.connect(); +} + +async function getSessionData(websocketUrl, apikey) { + if (wsClient == null || wsClient.getConnectionStatus() != "OPEN") { + if (errorCount < maxErrorCount) { + console.log(`[JELLYFIN-WEBSOCKET]: WebSocket not connected. Connecting... (Attempt ${errorCount + 1} of ${maxErrorCount})`); + } + + await connect(websocketUrl, apikey); + if (errorCount < maxErrorCount) { + if (wsClient.getConnectionStatus() != "OPEN") { + console.log("[JELLYFIN-WEBSOCKET]: WebSocket connection failed."); + sessionData = []; + return null; + } + } + } + return sessionData; +} + +module.exports = { getSessionData, connect }; diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js new file mode 100644 index 00000000..0212c017 --- /dev/null +++ b/backend/classes/webhook-manager.js @@ -0,0 +1,418 @@ +const axios = require('axios'); +const dbInstance = require('../db'); +const EventEmitter = require('events'); + +class WebhookManager { + constructor() { + if (WebhookManager.instance) { + return WebhookManager.instance; + } + + this.eventEmitter = new EventEmitter(); + this.setupEventListeners(); + WebhookManager.instance = this; + } + + setupEventListeners() { + // Adding event listeners for different events + this.eventEmitter.on('playback_started', async (data) => { + await this.triggerEventWebhooks('playback_started', data); + }); + + this.eventEmitter.on('playback_ended', async (data) => { + await this.triggerEventWebhooks('playback_ended', data); + }); + + this.eventEmitter.on('media_recently_added', async (data) => { + await this.triggerEventWebhooks('media_recently_added', data); + }); + + // If needed, add more event listeners here + } + + async getWebhooksByEventType(eventType) { + return await dbInstance.query( + 'SELECT * FROM webhooks WHERE trigger_type = $1 AND event_type = $2 AND enabled = true', + ['event', eventType] + ).then(res => res.rows); + } + + async getScheduledWebhooks() { + return await dbInstance.query( + 'SELECT * FROM webhooks WHERE trigger_type = $1 AND enabled = true', + ['scheduled'] + ).then(res => res.rows); + } + + async triggerEventWebhooks(eventType, data = {}) { + try { + const webhooks = await this.getWebhooksByEventType(eventType); + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const enrichedData = { + ...data, + event: eventType, + triggeredAt: new Date().toISOString() + }; + + const promises = webhooks.map(webhook => { + return this.executeWebhook(webhook, enrichedData); + }); + + await Promise.all(promises); + + return true; + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + return false; + } + } + + async executeWebhook(webhook, data = {}) { + try { + let headers = {}; + let payload = {}; + + const isDiscordWebhook = webhook.url.includes('discord.com/api/webhooks'); + + try { + headers = typeof webhook.headers === 'string' + ? JSON.parse(webhook.headers || '{}') + : (webhook.headers || {}); + + payload = typeof webhook.payload === 'string' + ? JSON.parse(webhook.payload || '{}') + : (webhook.payload || {}); + } catch (e) { + console.error("[WEBHOOK] Error while parsing:", e); + return false; + } + + if (isDiscordWebhook) { + console.log("[WEBHOOK] Webhook Discord detected"); + + await axios({ + method: webhook.method || 'POST', + url: webhook.url, + headers: { 'Content-Type': 'application/json' }, + data: payload, + timeout: 10000 + }); + + console.log(`[WEBHOOK] Discord webhook ${webhook.name} send successfully`); + } else { + const compiledPayload = this.compileTemplate(payload, data); + + await axios({ + method: webhook.method || 'POST', + url: webhook.url, + headers, + data: compiledPayload, + timeout: 10000 + }); + + console.log(`[WEBHOOK] Webhook ${webhook.name} send successfully`); + } + + //Update the last triggered timestamp + await dbInstance.query( + 'UPDATE webhooks SET last_triggered = NOW() WHERE id = $1', + [webhook.id] + ); + + return true; + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhook ${webhook.name}:`, error.message); + if (error.response) { + console.error(`[WEBHOOK] Response status: ${error.response.status}`); + console.error(`[WEBHOOK] Response data:`, error.response.data); + } + return false; + } + } + + compileTemplate(template, data) { + if (typeof template === 'object') { + return Object.keys(template).reduce((result, key) => { + result[key] = this.compileTemplate(template[key], data); + return result; + }, {}); + } else if (typeof template === 'string') { + // Replace {{variable}} with the corresponding value from data + return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => { + const keys = path.trim().split('.'); + let value = data; + + for (const key of keys) { + if (value === undefined) return match; + value = value[key]; + } + + return value !== undefined ? value : match; + }); + } + + return template; + } + + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks?.[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + ...eventData, + event: eventType, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + + emitEvent(eventType, data) { + this.eventEmitter.emit(eventType, data); + } + + async getTopWatchedContent(contentType, period = 'month', limit = 5) { + // Calculate period start date + const today = new Date(); + let startDate; + + if (period === 'month') { + startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1); + } else if (period === 'week') { + const day = today.getDay(); + startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - day - 7); + } else { + startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1); + } + + const formattedStartDate = startDate.toISOString().split('T')[0]; + + // SQL query to get top watched content + let query; + if (contentType === 'movie') { + query = ` + SELECT + "NowPlayingItemName" as title, + COUNT(DISTINCT "UserId") as unique_viewers, + SUM("PlaybackDuration") / 60000 as total_minutes + FROM jf_playback_activity + WHERE "ActivityDateInserted" >= $1 + AND "NowPlayingItemName" IS NOT NULL + AND "SeriesName" IS NULL + GROUP BY "NowPlayingItemName", "NowPlayingItemId" + ORDER BY total_minutes DESC + LIMIT $2 + `; + } else if (contentType === 'series') { + query = ` + SELECT + "SeriesName" as title, + COUNT(DISTINCT "UserId") as unique_viewers, + SUM("PlaybackDuration") / 60000 as total_minutes + FROM jf_playback_activity + WHERE "ActivityDateInserted" >= $1 + AND "SeriesName" IS NOT NULL + GROUP BY "SeriesName" + ORDER BY total_minutes DESC + LIMIT $2 + `; + } + + try { + const result = await dbInstance.query(query, [formattedStartDate, limit]); + return result.rows || []; + } catch (error) { + console.error(`[WEBHOOK] SQL ERROR (${contentType}):`, error.message); + return []; + } + } + + async getMonthlySummaryData() { + try { + // Get the top watched movies and series + const topMovies = await this.getTopWatchedContent('movie', 'month', 5); + const topSeries = await this.getTopWatchedContent('series', 'month', 5); + + const prevMonth = new Date(); + prevMonth.setMonth(prevMonth.getMonth() - 1); + const prevMonthStart = new Date(prevMonth.getFullYear(), prevMonth.getMonth(), 1); + const prevMonthEnd = new Date(prevMonth.getFullYear(), prevMonth.getMonth() + 1, 0); + + const formattedStart = prevMonthStart.toISOString().split('T')[0]; + const formattedEnd = prevMonthEnd.toISOString().split('T')[0]; + + // Get general statistics + const statsQuery = ` + SELECT + COUNT(DISTINCT "UserId") as active_users, + COUNT(*) as total_plays, + SUM("PlaybackDuration") / 3600000 as total_hours + FROM jf_playback_activity + WHERE "ActivityDateInserted" BETWEEN $1 AND $2 + `; + + const statsResult = await dbInstance.query(statsQuery, [formattedStart, formattedEnd]); + const generalStats = statsResult.rows[0] || { + active_users: 0, + total_plays: 0, + total_hours: 0 + }; + + return { + period: { + start: formattedStart, + end: formattedEnd, + name: prevMonth.toLocaleString('fr-FR', { month: 'long', year: 'numeric' }) + }, + topMovies, + topSeries, + stats: generalStats + }; + } catch (error) { + console.error("[WEBHOOK] Error while getting data:", error.message); + throw error; + } + } + + async triggerMonthlySummaryWebhook(webhookId) { + try { + // Get the webhook details + const result = await dbInstance.query( + 'SELECT * FROM webhooks WHERE id = $1 AND enabled = true', + [webhookId] + ); + + if (result.rows.length === 0) { + console.error(`[WEBHOOK] Webhook ID ${webhookId} not found or disable`); + return false; + } + + const webhook = result.rows[0]; + + // Generate the monthly summary data + try { + const data = await this.getMonthlySummaryData(); + + const moviesFields = data.topMovies.map((movie, index) => ({ + name: `${index + 1}. ${movie.title}`, + value: `${Math.round(movie.total_minutes)} minutes • ${movie.unique_viewers} viewers`, + inline: false + })); + + const seriesFields = data.topSeries.map((series, index) => ({ + name: `${index + 1}. ${series.title}`, + value: `${Math.round(series.total_minutes)} minutes • ${series.unique_viewers} viewers`, + inline: false + })); + + const monthlyPayload = { + content: `📊 **Monthly Report - ${data.period.name}**`, + embeds: [ + { + title: "🎬 Most Watched Movies", + color: 15844367, + fields: moviesFields.length > 0 ? moviesFields : [{ name: "No data", value: "No movies watch this month" }] + }, + { + title: "📺 Most Watched Series", + color: 5793266, + fields: seriesFields.length > 0 ? seriesFields : [{ name: "No data", value: "No Series watch this month" }] + }, + { + title: "📈 General Statistics", + color: 5763719, + fields: [ + { + name: "Active Users", + value: `${data.stats.active_users || 0}`, + inline: true + }, + { + name: "Total Plays", + value: `${data.stats.total_plays || 0}`, + inline: true + }, + { + name: "Total Hours Watched", + value: `${Math.round(data.stats.total_hours || 0)}`, + inline: true + } + ], + footer: { + text: `Period: from ${new Date(data.period.start).toLocaleDateString('en-US')} to ${new Date(data.period.end).toLocaleDateString('en-US')}` + } + } + ] + }; + + // Send the webhook + await axios({ + method: webhook.method || 'POST', + url: webhook.url, + headers: { 'Content-Type': 'application/json' }, + data: monthlyPayload, + timeout: 10000 + }); + + console.log(`[WEBHOOK] Monthly report webhook ${webhook.name} sent successfully`); + + // Update the last triggered timestamp + await dbInstance.query( + 'UPDATE webhooks SET last_triggered = NOW() WHERE id = $1', + [webhook.id] + ); + + return true; + } catch (dataError) { + console.error(`[WEBHOOK] Error while preparing the data:`, dataError.message); + return false; + } + } catch (error) { + console.error(`[WEBHOOK] Error while sending the monthly report:`, error.message); + return false; + } + } + + async executeDiscordWebhook(webhook, data) { + try { + console.log(`Execution of discord webhook: ${webhook.name}`); + + const response = await axios.post(webhook.url, data, { + headers: { + 'Content-Type': 'application/json' + } + }); + + console.log(`[WEBHOOK] Discord response: ${response.status}`); + return response.status >= 200 && response.status < 300; + } catch (error) { + console.error(`[WEBHOOK] Error with Discord webhook ${webhook.name}:`, error.message); + if (error.response) { + console.error('[WEBHOOK] Response status:', error.response.status); + console.error('[WEBHOOK] Response data:', error.response.data); + } + return false; + } + } +} + +module.exports = WebhookManager; \ No newline at end of file diff --git a/backend/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js new file mode 100644 index 00000000..43402173 --- /dev/null +++ b/backend/classes/webhook-scheduler.js @@ -0,0 +1,105 @@ +const cron = require('node-cron'); +const WebhookManager = require('./webhook-manager'); +const dbInstance = require('../db'); + +class WebhookScheduler { + constructor() { + this.webhookManager = new WebhookManager(); + this.cronJobs = {}; + this.loadScheduledWebhooks(); + } + + async loadScheduledWebhooks() { + try { + const webhooks = await this.webhookManager.getScheduledWebhooks(); + if (webhooks) { + // Clean existing tasks + Object.values(this.cronJobs).forEach(job => job.stop()); + this.cronJobs = {}; + + // Create new tasks + webhooks.forEach(webhook => { + if (webhook.schedule && cron.validate(webhook.schedule)) { + this.scheduleWebhook(webhook); + } else { + console.error(`[WEBHOOK] Invalid cron schedule for webhook ${webhook.id}: ${webhook.schedule}`); + } + }); + + console.log(`[WEBHOOK] Scheduled ${Object.keys(this.cronJobs).length} webhooks`); + } else { + console.log('[WEBHOOK] No scheduled webhooks found'); + } + } catch (error) { + console.error('[WEBHOOK] Failed to load scheduled webhooks:', error); + } + } + + async loadEventWebhooks() { + try { + const eventWebhooks = await this.webhookManager.getEventWebhooks(); + if (eventWebhooks && eventWebhooks.length > 0) { + this.eventWebhooks = {}; + + eventWebhooks.forEach(webhook => { + if (!this.eventWebhooks[webhook.eventType]) { + this.eventWebhooks[webhook.eventType] = []; + } + this.eventWebhooks[webhook.eventType].push(webhook); + }); + + console.log(`[WEBHOOK] Loaded ${eventWebhooks.length} event-based webhooks`); + } else { + console.log('[WEBHOOK] No event-based webhooks found'); + this.eventWebhooks = {}; + } + } catch (error) { + console.error('[WEBHOOK] Failed to load event-based webhooks:', error); + } + } + + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + event: eventType, + data: eventData, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + + scheduleWebhook(webhook) { + try { + this.cronJobs[webhook.id] = cron.schedule(webhook.schedule, async () => { + console.log(`[WEBHOOK] Executing scheduled webhook: ${webhook.name}`); + await this.webhookManager.executeWebhook(webhook); + }); + + console.log(`[WEBHOOK] Webhook ${webhook.name} scheduled with cron: ${webhook.schedule}`); + } catch (error) { + console.error(`[WEBHOOK] Error scheduling webhook ${webhook.id}:`, error); + } + } + + async refreshSchedule() { + await this.loadScheduledWebhooks(); + await this.loadEventWebhooks(); + } +} + +module.exports = WebhookScheduler; \ No newline at end of file diff --git a/backend/classes/websocket-client.js b/backend/classes/websocket-client.js new file mode 100644 index 00000000..fe4b9a34 --- /dev/null +++ b/backend/classes/websocket-client.js @@ -0,0 +1,91 @@ +const WebSocket = require("ws"); + +class WebSocketClient { + constructor(url, options) { + this.url = url; + this.options = options || {}; + this.socket = null; + } + + connect() { + return new Promise((resolve, reject) => { + const wsOptions = { + rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || "true").toLowerCase() === "true", + ...this.options + }; + this.socket = new WebSocket(this.url, wsOptions); + + this.socket.on("open", () => { + // console.log("Connected to WebSocket server"); + this.onOpen(); + resolve(); // Resolve the promise when the connection is established + }); + + this.socket.on("error", (error) => { + // console.error("WebSocket error:", error); + this.onError(error); + reject(error); // Reject the promise if an error occurs + }); + + this.socket.on("message", (data) => { + this.onMessage(data); + }); + + this.socket.on("close", () => { + this.onClose(); + }); + }); + } + + send(message) { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(message); + } else { + console.error("WebSocket is not open. Unable to send message."); + } + } + + close() { + if (this.socket) { + this.socket.close(); + } + } + + // Override these methods in subclasses or instances + onOpen() { + console.log("Default onOpen handler."); + } + + onMessage(data) { + console.log("Default onMessage handler:", data); + } + + onClose() { + console.log("Default onClose handler."); + } + + onError(error) { + console.error("Default onError handler:", error); + } + + getConnectionStatus() { + if (!this.socket) { + return "DISCONNECTED"; + } + + switch (this.socket.readyState) { + case WebSocket.CONNECTING: + return "CONNECTING"; + case WebSocket.OPEN: + return "OPEN"; + case WebSocket.CLOSING: + return "CLOSING"; + case WebSocket.CLOSED: + return "CLOSED"; + default: + return "UNKNOWN"; + } + } +} + +module.exports = WebSocketClient; diff --git a/backend/create_database.js b/backend/create_database.js index 700d2439..a72b3179 100644 --- a/backend/create_database.js +++ b/backend/create_database.js @@ -5,12 +5,16 @@ const _POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD; const _POSTGRES_IP = process.env.POSTGRES_IP; const _POSTGRES_PORT = process.env.POSTGRES_PORT; const _POSTGRES_DATABASE = process.env.POSTGRES_DB || 'jfstat'; +const _POSTGRES_SSL_REJECT_UNAUTHORIZED = process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === undefined ? true : process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === "true"; const client = new Client({ host: _POSTGRES_IP, user: _POSTGRES_USER, password: _POSTGRES_PASSWORD, port: _POSTGRES_PORT, + ...(process.env.POSTGRES_SSL_ENABLED === "true" + ? { ssl: { rejectUnauthorized: _POSTGRES_SSL_REJECT_UNAUTHORIZED } } + : {}) }); const createDatabase = async () => { diff --git a/backend/db.js b/backend/db.js index bebde952..87b97215 100644 --- a/backend/db.js +++ b/backend/db.js @@ -7,6 +7,8 @@ const _POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD; const _POSTGRES_IP = process.env.POSTGRES_IP; const _POSTGRES_PORT = process.env.POSTGRES_PORT; const _POSTGRES_DATABASE = process.env.POSTGRES_DB || "jfstat"; +const _POSTGRES_SSL_REJECT_UNAUTHORIZED = + process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === undefined ? true : process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === "true"; if ([_POSTGRES_USER, _POSTGRES_PASSWORD, _POSTGRES_IP, _POSTGRES_PORT].includes(undefined)) { console.log("Error: Postgres details not defined"); @@ -22,6 +24,7 @@ const pool = new Pool({ max: 20, // Maximum number of connections in the pool idleTimeoutMillis: 30000, // Close idle clients after 30 seconds connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established + ...(process.env.POSTGRES_SSL_ENABLED === "true" ? { ssl: { rejectUnauthorized: _POSTGRES_SSL_REJECT_UNAUTHORIZED } } : {}), }); pool.on("error", (err, client) => { @@ -104,7 +107,11 @@ async function updateSingleFieldBulk(table_name, data, field_name, new_value, wh return { Result: result, message: "" + message }; } -const materializedViews = ["js_latest_playback_activity", "js_library_stats_overview"]; +const materializedViews = [ + "js_latest_playback_activity", + "js_library_stats_overview", + "js_library_items_with_playcount_playtime", +]; async function refreshMaterializedView(view_name) { const client = await pool.connect(); diff --git a/backend/migrations.js b/backend/migrations.js index 0240694b..a53993a2 100644 --- a/backend/migrations.js +++ b/backend/migrations.js @@ -12,6 +12,9 @@ module.exports = { port:process.env.POSTGRES_PORT, database: process.env.POSTGRES_DB || 'jfstat', createDatabase: true, + ...(process.env.POSTGRES_SSL_ENABLED === "true" + ? { ssl: { rejectUnauthorized: process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === undefined ? true : process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === "true" } } + : {}) }, migrations: { directory: __dirname + '/migrations', @@ -39,6 +42,9 @@ module.exports = { port:process.env.POSTGRES_PORT, database: process.env.POSTGRES_DB || 'jfstat', createDatabase: true, + ...(process.env.POSTGRES_SSL_ENABLED === "true" + ? { ssl: { rejectUnauthorized: process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === undefined ? true : process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === "true" } } + : {}) }, migrations: { directory: __dirname + '/migrations', 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 new file mode 100644 index 00000000..78e63b5f --- /dev/null +++ b/backend/migrations/095_fs_watch_stats_over_time_include_duration.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, "Duration" 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."Duration", 0) AS "Duration", + 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 "Duration", + 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/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 00000000..c0849e0e --- /dev/null +++ b/backend/migrations/096_fs_watch_stats_popular_days_of_week_include_duration.js @@ -0,0 +1,143 @@ +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 + 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 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 new file mode 100644 index 00000000..57f943fe --- /dev/null +++ b/backend/migrations/097_fs_watch_stats_popular_hour_of_day_include_duration.js @@ -0,0 +1,117 @@ +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.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); + } +}; diff --git a/backend/migrations/098_create_webhooks_table.js b/backend/migrations/098_create_webhooks_table.js new file mode 100644 index 00000000..3aaedcb2 --- /dev/null +++ b/backend/migrations/098_create_webhooks_table.js @@ -0,0 +1,23 @@ +exports.up = function (knex) { + return knex.schema.createTable("webhooks", (table) => { + table.increments("id").primary(); + table.string("name").notNullable(); + table.string("url").notNullable(); + table.text("headers").defaultTo("{}"); + table.text("payload").defaultTo("{}"); + table.string("method").defaultTo("POST"); + table.string("trigger_type").notNullable(); + table.string("webhook_type").defaultTo("generic"); + table.string("schedule").nullable(); + table.string("event_type").nullable(); + table.boolean("enabled").defaultTo(true); + table.timestamp("last_triggered").nullable(); + table.boolean("retry_on_failure").defaultTo(false); + table.integer("max_retries").defaultTo(3); + table.timestamps(true, true); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTable("webhooks"); +}; diff --git a/backend/migrations/099_js_library_items_with_playcount_playtime.js b/backend/migrations/099_js_library_items_with_playcount_playtime.js new file mode 100644 index 00000000..14388c2e --- /dev/null +++ b/backend/migrations/099_js_library_items_with_playcount_playtime.js @@ -0,0 +1,28 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_items_with_playcount_playtime; + + CREATE MATERIALIZED VIEW public.js_library_items_with_playcount_playtime + AS + SELECT * + FROM "jf_library_items_with_playcount_playtime"; + + + ALTER MATERIALIZED VIEW public.js_library_items_with_playcount_playtime + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_items_with_playcount_playtime; +`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/models/jf_activity_watchdog.js b/backend/models/jf_activity_watchdog.js index bde3de89..6a1b3b7c 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/models/jf_playback_reporting_plugin_data.js b/backend/models/jf_playback_reporting_plugin_data.js index f00277c3..ad36efb3 100644 --- a/backend/models/jf_playback_reporting_plugin_data.js +++ b/backend/models/jf_playback_reporting_plugin_data.js @@ -1,32 +1,39 @@ - ////////////////////////// pn delete move to playback - const columnsPlaybackReporting = [ - "rowid", - "DateCreated", - "UserId", - "ItemId", - "ItemType", - "ItemName", - "PlaybackMethod", - "ClientName", - "DeviceName", - "PlayDuration", - ]; +////////////////////////// pn delete move to playback +const columnsPlaybackReporting = [ + "rowid", + "DateCreated", + "UserId", + "ItemId", + "ItemType", + "ItemName", + "PlaybackMethod", + "ClientName", + "DeviceName", + "PlayDuration", +]; +const mappingPlaybackReporting = (item) => { + let duration = item[9]; - const mappingPlaybackReporting = (item) => ({ - rowid:item[0] , - DateCreated:item[1] , - UserId:item[2] , - ItemId:item[3] , - ItemType:item[4] , - ItemName:item[5] , - PlaybackMethod:item[6] , - ClientName:item[7] , - DeviceName:item[8] , - PlayDuration:item[9] , - }); + if (duration === null || duration === undefined || duration < 0) { + duration = 0; + } - module.exports = { - columnsPlaybackReporting, - mappingPlaybackReporting, - }; \ No newline at end of file + return { + rowid: item[0], + DateCreated: item[1], + UserId: item[2], + ItemId: item[3], + ItemType: item[4], + ItemName: item[5], + PlaybackMethod: item[6], + ClientName: item[7], + DeviceName: item[8], + PlayDuration: duration, + }; +}; + +module.exports = { + columnsPlaybackReporting, + mappingPlaybackReporting, +}; diff --git a/backend/routes/api.js b/backend/routes/api.js index 7cbe0f57..0ac0d292 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -11,11 +11,12 @@ 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 { tables } = require("../global/backup_tables"); const TaskScheduler = require("../classes/task-scheduler-singleton"); const TaskManager = require("../classes/task-manager-singleton.js"); +const dayjs = require("dayjs"); + const router = express.Router(); //consts @@ -60,6 +61,7 @@ const filterFields = [ { field: "PlaybackDuration", column: `a.PlaybackDuration`, isColumn: true, applyToCTE: true }, { field: "TotalPlays", column: `COALESCE("TotalPlays",1)` }, { field: "PlayMethod", column: `LOWER(a."PlayMethod")` }, + { field: "ParentId", column: "a.ParentId", isColumn: true }, ]; //Functions @@ -142,94 +144,8 @@ async function purgeLibraryItems(id, withActivity, purgeAll = false) { }; await db.query(deleteQuery); } -} - -function buildFilterList(query, filtersArray) { - if (filtersArray.length > 0) { - query.where = query.where || []; - filtersArray.forEach((filter) => { - const findField = filterFields.find((item) => item.field === filter.field); - const column = findField?.column || "a.ActivityDateInserted"; - const isColumn = findField?.isColumn || false; - const applyToCTE = findField?.applyToCTE || false; - if (filter.min) { - query.where.push({ - column: column, - operator: ">=", - value: `$${query.values.length + 1}`, - }); - - query.values.push(filter.min); - - if (applyToCTE) { - if (query.cte) { - if (!query.cte.where) { - query.cte.where = []; - } - query.cte.where.push({ - column: column, - operator: ">=", - value: `$${query.values.length + 1}`, - }); - - query.values.push(filter.min); - } - } - } - - if (filter.max) { - query.where.push({ - column: column, - operator: "<=", - value: `$${query.values.length + 1}`, - }); - - query.values.push(filter.max); - - if (applyToCTE) { - if (query.cte) { - if (!query.cte.where) { - query.cte.where = []; - } - query.cte.where.push({ - column: column, - operator: "<=", - value: `$${query.values.length + 1}`, - }); - - query.values.push(filter.max); - } - } - } - - if (filter.value) { - const whereClause = { - operator: "LIKE", - value: `$${query.values.length + 1}`, - }; - - query.values.push(`%${filter.value.toLowerCase()}%`); - - if (isColumn) { - whereClause.column = column; - } else { - whereClause.field = column; - } - query.where.push(whereClause); - - if (applyToCTE) { - if (query.cte) { - if (!query.cte.where) { - query.cte.where = []; - } - whereClause.value = `$${query.values.length + 1}`; - query.cte.where.push(whereClause); - - query.values.push(`%${filter.value.toLowerCase()}%`); - } - } - } - }); + for (const view of db.materializedViews) { + await db.refreshMaterializedView(view); } } @@ -329,11 +245,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 +258,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 +270,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 +299,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 +312,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 +330,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); @@ -909,6 +825,83 @@ router.post("/setTaskSettings", async (req, res) => { } }); +// Get Activity Monitor Polling Settings +router.get("/getActivityMonitorSettings", async (req, res) => { + try { + const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows); + + if (settingsjson.length > 0) { + const settings = settingsjson[0].settings || {}; + console.log(settings); + const pollingSettings = settings.ActivityMonitorPolling || { + activeSessionsInterval: 1000, + idleInterval: 5000, + }; + res.send(pollingSettings); + } else { + res.status(404); + res.send({ error: "Settings Not Found" }); + } + } catch (error) { + res.status(503); + res.send({ error: "Error: " + error }); + } +}); + +// Set Activity Monitor Polling Settings +router.post("/setActivityMonitorSettings", async (req, res) => { + const { activeSessionsInterval, idleInterval } = req.body; + + if (activeSessionsInterval === undefined || idleInterval === undefined) { + res.status(400); + res.send("activeSessionsInterval and idleInterval are required"); + return; + } + + if (!Number.isInteger(activeSessionsInterval) || activeSessionsInterval <= 0) { + res.status(400); + res.send("A valid activeSessionsInterval(int) which is > 0 milliseconds is required"); + return; + } + + if (!Number.isInteger(idleInterval) || idleInterval <= 0) { + res.status(400); + res.send("A valid idleInterval(int) which is > 0 milliseconds is required"); + return; + } + + if (activeSessionsInterval > idleInterval) { + res.status(400); + res.send("activeSessionsInterval should be <= idleInterval for optimal performance"); + return; + } + + try { + const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows); + + if (settingsjson.length > 0) { + const settings = settingsjson[0].settings || {}; + + settings.ActivityMonitorPolling = { + activeSessionsInterval: activeSessionsInterval, + idleInterval: idleInterval, + }; + + let query = 'UPDATE app_config SET settings=$1 where "ID"=1'; + await db.query(query, [settings]); + + res.status(200); + res.send(settings.ActivityMonitorPolling); + } else { + res.status(404); + res.send({ error: "Settings Not Found" }); + } + } catch (error) { + res.status(503); + res.send({ error: "Error: " + error }); + } +}); + //Jellystat functions router.get("/CheckForUpdates", async (req, res) => { try { @@ -1139,6 +1132,10 @@ router.delete("/item/purge", async (req, res) => { } } + for (const view of db.materializedViews) { + await db.refreshMaterializedView(view); + } + sendUpdate("GeneralAlert", { type: "Success", message: `Item ${withActivity ? "with Playback Activity" : ""} has been Purged`, @@ -1394,7 +1391,7 @@ router.get("/getHistory", async (req, res) => { query.values = values; - buildFilterList(query, filtersArray); + dbHelper.buildFilterList(query, filtersArray, filterFields); const result = await dbHelper.query(query); result.results = result.results.map((item) => ({ @@ -1561,7 +1558,7 @@ router.post("/getLibraryHistory", async (req, res) => { query.values = values; - buildFilterList(query, filtersArray); + dbHelper.buildFilterList(query, filtersArray, filterFields); const result = await dbHelper.query(query); @@ -1696,7 +1693,7 @@ router.post("/getItemHistory", async (req, res) => { } query.values = values; - buildFilterList(query, filtersArray); + dbHelper.buildFilterList(query, filtersArray, filterFields); const result = await dbHelper.query(query); const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; @@ -1821,7 +1818,7 @@ router.post("/getUserHistory", async (req, res) => { query.values = values; - buildFilterList(query, filtersArray); + dbHelper.buildFilterList(query, filtersArray, filterFields); const result = await dbHelper.query(query); diff --git a/backend/routes/backup.js b/backend/routes/backup.js index 6e768ce8..f98bf822 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -23,6 +23,8 @@ const postgresPassword = process.env.POSTGRES_PASSWORD; const postgresIp = process.env.POSTGRES_IP; const postgresPort = process.env.POSTGRES_PORT; const postgresDatabase = process.env.POSTGRES_DB || "jfstat"; +const postgresSslRejectUnauthorized = process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === undefined ? true : process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED === "true"; + const backupfolder = "backup-data"; // Restore function @@ -52,6 +54,9 @@ async function restore(file, refLog) { host: postgresIp, port: postgresPort, database: postgresDatabase, + ...(process.env.POSTGRES_SSL_ENABLED === "true" + ? { ssl: { rejectUnauthorized: postgresSslRejectUnauthorized } } + : {}), }); const backupPath = file; diff --git a/backend/routes/stats.js b/backend/routes/stats.js index a7279915..907e15e5 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -2,7 +2,8 @@ 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 +12,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")) { @@ -34,6 +35,40 @@ function countOverlapsPerHour(records) { return sortedHourCounts; } +const sortMap = [ + { field: "UserName", column: "UserName" }, + { field: "RemoteEndPoint", column: "RemoteEndPoint" }, + { field: "NowPlayingItemName", column: "NowPlayingItemName" }, + { field: "Client", column: "Client" }, + { field: "DeviceName", column: "DeviceName" }, + { field: "ActivityDateInserted", column: "ActivityDateInserted" }, + { field: "PlaybackDuration", column: "PlaybackDuration" }, + { field: "PlayMethod", column: "PlayMethod" }, +]; + +const filterFields = [ + { field: "Id", column: "Id", isColumn: true }, + { field: "IsPaused", column: "IsPaused", isColumn: true }, + { field: "UserId", column: "UserId", isColumn: true }, + { field: "UserName", column: "UserName", isColumn: true }, + { field: "Client", column: "Client", isColumn: true }, + { field: "DeviceName", column: "DeviceName", isColumn: true }, + { field: "DeviceId", column: "DeviceId", isColumn: true }, + { field: "ApplicationVersion", column: "ApplicationVersion", isColumn: true }, + { field: "NowPlayingItemId", column: "NowPlayingItemId", isColumn: true }, + { field: "NowPlayingItemName", column: "NowPlayingItemName", isColumn: true }, + { field: "SeasonId", column: "SeasonId", isColumn: true }, + { field: "SeriesName", column: "SeriesName", isColumn: true }, + { field: "EpisodeId", column: "EpisodeId", isColumn: true }, + { field: "PlaybackDuration", column: "PlaybackDuration", isColumn: true }, + { field: "ActivityDateInserted", column: "ActivityDateInserted", isColumn: true }, + { field: "PlayMethod", column: "PlayMethod", isColumn: true }, + { field: "OriginalContainer", column: "OriginalContainer", isColumn: true }, + { field: "RemoteEndPoint", column: "RemoteEndPoint", isColumn: true }, + { field: "ServerId", column: "ServerId", isColumn: true }, + { field: "imported", column: "imported", isColumn: true }, +]; + //endpoints router.get("/getLibraryOverview", async (req, res) => { @@ -148,9 +183,91 @@ router.post("/getMostActiveUsers", async (req, res) => { }); router.get("/getPlaybackActivity", async (req, res) => { + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query; + let filtersArray = []; + if (filters) { + try { + filtersArray = JSON.parse(filters); + } catch (error) { + return res.status(400).json({ + error: "Invalid filters parameter", + example: [ + { field: "UserName", value: "User" }, + { field: "Client", in: "Android TV,Web" }, + { field: "PlaybackDuration", min: 1000, max: 5000 }, + { field: "PlayMethod", value: "DirectPlay" }, + { field: "ActivityDateInserted", min: "2025-01-01", max: "2025-12-31" }, + { field: "IsPaused", value: false }, + ], + allowed_fields: [ + "Id", + "IsPaused", + "UserId", + "UserName", + "Client", + "DeviceName", + "DeviceId", + "ApplicationVersion", + "NowPlayingItemId", + "NowPlayingItemName", + "SeasonId", + "SeriesName", + "EpisodeId", + "PlaybackDuration", + "ActivityDateInserted", + "PlayMethod", + "OriginalContainer", + "RemoteEndPoint", + "ServerId", + "imported", + ], + }); + } + } + + const sortField = sortMap.find((item) => item.field === sort)?.column || "ActivityDateInserted"; + const values = []; try { - const { rows } = await db.query("SELECT * FROM jf_playback_activity"); - res.send(rows); + const query = { + select: ["*"], + table: "jf_playback_activity", + alias: "a", + order_by: sortField, + sort_order: desc ? "desc" : "asc", + pageNumber: page, + pageSize: size, + }; + + if (search && search.length > 0) { + query.where = [ + { + field: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE a."SeriesName" + END + )`, + operator: "LIKE", + value: `$${values.length + 1}`, + }, + ]; + + values.push(`%${search.toLowerCase()}%`); + } + + query.values = values; + dbHelper.buildFilterList(query, filtersArray, filterFields); + + const result = await dbHelper.query(query); + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; + if (search && search.length > 0) { + response.search = search; + } + + if (filtersArray.length > 0) { + response.filters = filtersArray; + } + res.send(response); } catch (error) { res.status(503); res.send(error); @@ -278,10 +395,63 @@ router.get("/getLibraryMetadata", async (req, res) => { }); router.post("/getLibraryItemsWithStats", async (req, res) => { + const { size = 999999999, page = 1, search, sort = "Date", desc = true } = req.query; + const { libraryid } = req.body; + if (libraryid === undefined) { + res.status(400).send({ error: "Invalid Library Id" }); + } + + const sortMap = [ + { field: "Date", column: "DateCreated" }, + { field: "Views", column: "times_played" }, + { field: "Size", column: "Size" }, + { field: "WatchTime", column: "total_play_time" }, + { field: "Title", column: `REGEXP_REPLACE(a."Name", '^(A |An |The )', '', 'i')` }, + ]; + + const sortField = sortMap.find((item) => item.field === sort)?.column || "DateCreated"; + const values = []; try { - const { libraryid } = req.body; - const { rows } = await db.query(`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"=$1`, [libraryid]); - res.send(rows); + const query = { + select: ["*"], + table: "js_library_items_with_playcount_playtime", + alias: "a", + order_by: sortField, + sort_order: desc ? "desc" : "asc", + pageNumber: page, + pageSize: size, + where: [ + { + field: `a."ParentId"`, + operator: "=", + value: `$${values.length + 1}`, + }, + ], + }; + + values.push(libraryid); + + if (search && search.length > 0) { + query.where.push({ + field: `LOWER(a."Name")`, + operator: "LIKE", + value: `$${values.length + 1}`, + }); + + values.push(`%${search.toLowerCase()}%`); + } + + query.values = values; + + // const { rows } = await db.query(`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"=$1`, [libraryid]); + // res.send(rows); + const result = await dbHelper.query(query); + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; + if (search && search.length > 0) { + response.search = search; + } + + res.send(response); } catch (error) { console.log(error); } @@ -289,12 +459,13 @@ 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, "YYYY-MM-DDTHH:mm:ss.SSSZ", true).isValid() || + !dayjs(endDate, "YYYY-MM-DDTHH:mm:ss.SSSZ", true).isValid()) ) { return res.status(400).send({ error: "Invalid date format" }); } @@ -308,7 +479,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 +507,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, @@ -423,6 +594,7 @@ router.get("/getViewsOverTime", async (req, res) => { stats.forEach((item) => { const library = item.Library; const count = item.Count; + const duration = item.Duration; const date = new Date(item.Date).toLocaleDateString("en-US", { year: "numeric", month: "short", @@ -435,7 +607,7 @@ router.get("/getViewsOverTime", async (req, res) => { }; } - reorganizedData[date] = { ...reorganizedData[date], [library]: count }; + reorganizedData[date] = { ...reorganizedData[date], [library]: { count, duration } }; }); const finalData = { libraries: libraries, stats: Object.values(reorganizedData) }; res.send(finalData); @@ -462,6 +634,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]) { @@ -470,7 +643,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); @@ -497,6 +670,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]) { @@ -505,7 +679,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); @@ -520,12 +694,15 @@ router.get("/getViewsByLibraryType", async (req, res) => { try { const { days = 30 } = req.query; - const { rows } = await db.query(` + const { rows } = await db.query( + ` SELECT COALESCE(i."Type", 'Other') AS type, COUNT(a."NowPlayingItemId") AS count FROM jf_playback_activity a LEFT JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId" WHERE a."ActivityDateInserted" BETWEEN NOW() - CAST($1 || ' days' as INTERVAL) AND NOW() GROUP BY i."Type" - `, [days]); + `, + [days] + ); const supportedTypes = new Set(["Audio", "Movie", "Series", "Other"]); /** @type {Map} */ diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 4f811ce4..c20e11ba 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"); @@ -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 { @@ -530,13 +558,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 +572,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}`; @@ -824,6 +852,8 @@ async function partialSync(triggertype) { const config = await new configClass().getConfig(); const uuid = randomUUID(); + + const newItems = []; // Array to track newly added items during the sync process syncTask = { loggedData: [], uuid: uuid, wsKey: "PartialSyncTask", taskName: taskName.partialsync }; try { @@ -833,7 +863,7 @@ async function partialSync(triggertype) { if (config.error) { syncTask.loggedData.push({ Message: config.error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); - return; + return { success: false, error: config.error }; } const libraries = await API.getLibraries(); @@ -842,7 +872,7 @@ async function partialSync(triggertype) { syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " " + taskName.fullsync + " Completed" }); - return; + return { success: false, error: "No libraries found" }; } const excluded_libraries = config.settings.ExcludedLibraries || []; @@ -850,10 +880,10 @@ async function partialSync(triggertype) { const filtered_libraries = libraries.filter((library) => !excluded_libraries.includes(library.Id)); const existing_excluded_libraries = libraries.filter((library) => excluded_libraries.includes(library.Id)); - // //syncUserData + // syncUserData await syncUserData(); - // //syncLibraryFolders + // syncLibraryFolders await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); //item sync counters @@ -871,7 +901,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 +912,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 +939,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) { @@ -956,7 +986,7 @@ async function partialSync(triggertype) { insertEpisodeInfoCount += Number(infoCount.insertEpisodeInfoCount); updateEpisodeInfoCount += Number(infoCount.updateEpisodeInfoCount); - //clear data from memory as its no longer needed + //clear data from memory as it's no longer needed library_items = null; seasons = null; episodes = null; @@ -974,7 +1004,7 @@ async function partialSync(triggertype) { }, }); - libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate)); + libraryItems = libraryItems.filter((item) => dayjs(item.DateCreated).isAfter(lastSyncDate)); } } @@ -1023,10 +1053,22 @@ async function partialSync(triggertype) { await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.SUCCESS); sendUpdate(syncTask.wsKey, { type: "Success", message: triggertype + " Sync Completed" }); + + return { + success: true, + newItems: newItems, + stats: { + itemsAdded: insertedItemsCount, + episodesAdded: insertedEpisodeCount, + seasonsAdded: insertedSeasonsCount + } + }; } catch (error) { syncTask.loggedData.push({ color: "red", Message: getErrorLineNumber(error) + ": Error: " + error }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); sendUpdate(syncTask.wsKey, { type: "Error", message: triggertype + " Sync Halted with Errors" }); + + return { success: false, error: error.message }; } } diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js new file mode 100644 index 00000000..54710e08 --- /dev/null +++ b/backend/routes/webhooks.js @@ -0,0 +1,424 @@ +const express = require('express'); +const router = express.Router(); +const dbInstance = require('../db'); +const WebhookManager = require('../classes/webhook-manager'); +const WebhookScheduler = require('../classes/webhook-scheduler'); + +const webhookScheduler = new WebhookScheduler(); +const webhookManager = new WebhookManager(); + +// Get all webhooks +router.get('/', async (req, res) => { + try { + const result = await dbInstance.query('SELECT * FROM webhooks ORDER BY id DESC'); + res.json(result.rows); + } catch (error) { + console.error('Error fetching webhooks:', error); + res.status(500).json({ error: 'Failed to fetch webhooks' }); + } +}); + +// Get a specific webhook by ID +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const result = await dbInstance.query('SELECT * FROM webhooks WHERE id = $1', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching webhook:', error); + res.status(500).json({ error: 'Failed to fetch webhook' }); + } +}); + +// Create a new webhook +router.post('/', async (req, res) => { + try { + const { + name, + url, + headers, + payload, + method, + trigger_type, + schedule, + event_type, + enabled, + retry_on_failure, + max_retries + } = req.body; + + if (!name || !url || !trigger_type) { + return res.status(400).json({ error: 'Name, URL and trigger type are required' }); + } + + if (trigger_type === 'scheduled' && !schedule) { + return res.status(400).json({ error: 'Schedule is required for scheduled webhooks' }); + } + + if (trigger_type === 'event' && !event_type) { + return res.status(400).json({ error: 'Event type is required for event webhooks' }); + } + + const result = await dbInstance.query( + `INSERT INTO webhooks (name, url, headers, payload, method, trigger_type, schedule, event_type, enabled, retry_on_failure, max_retries) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + name, + url, + JSON.stringify(headers || {}), + JSON.stringify(payload || {}), + method || 'POST', + trigger_type, + schedule, + event_type, + enabled !== undefined ? enabled : true, + retry_on_failure || false, + max_retries || 3 + ] + ); + + // Refresh the schedule if the webhook is scheduled + if (trigger_type === 'scheduled' && enabled) { + await webhookScheduler.refreshSchedule(); + } + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating webhook:', error); + res.status(500).json({ error: 'Failed to create webhook' }); + } +}); + +// Update a webhook +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { + name, + url, + headers, + payload, + method, + trigger_type, + schedule, + event_type, + enabled, + retry_on_failure, + max_retries + } = req.body; + + if (!name || !url || !trigger_type) { + return res.status(400).json({ error: 'Name, URL and trigger type are required' }); + } + + const result = await dbInstance.query( + `UPDATE webhooks + SET name = $1, url = $2, headers = $3, payload = $4, method = $5, + trigger_type = $6, schedule = $7, event_type = $8, enabled = $9, + retry_on_failure = $10, max_retries = $11 + WHERE id = $12 + RETURNING *`, + [ + name, + url, + JSON.stringify(headers || {}), + JSON.stringify(payload || {}), + method || 'POST', + trigger_type, + schedule, + event_type, + enabled !== undefined ? enabled : true, + retry_on_failure || false, + max_retries || 3, + id + ] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + // Refresh the schedule if the webhook is scheduled + await webhookScheduler.refreshSchedule(); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating webhook:', error); + res.status(500).json({ error: 'Failed to update webhook' }); + } +}); + +// Delete a webhook +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const result = await dbInstance.query('DELETE FROM webhooks WHERE id = $1 RETURNING *', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + // Refresh the schedule if the webhook was scheduled + await webhookScheduler.refreshSchedule(); + + res.json({ message: 'Webhook deleted successfully', webhook: result.rows[0] }); + } catch (error) { + console.error('Error deleting webhook:', error); + res.status(500).json({ error: 'Failed to delete webhook' }); + } +}); + +// Test a webhook +router.post('/:id/test', async (req, res) => { + try { + const { id } = req.params; + const result = await dbInstance.query('SELECT * FROM webhooks WHERE id = $1', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + const webhook = result.rows[0]; + let testData = req.body || {}; + let success = false; + + // Discord behaviour + if (webhook.url.includes('discord.com/api/webhooks')) { + console.log('Discord webhook détecté, préparation du payload spécifique'); + + // Discord specific format + testData = { + content: "Test de webhook depuis Jellystat", + embeds: [{ + title: "Discord test notification", + description: "This is a test notification of jellystat discord webhook", + color: 3447003, + fields: [ + { + name: "Webhook type", + value: webhook.trigger_type || "Not specified", + inline: true + }, + { + name: "ID", + value: webhook.id, + inline: true + } + ], + timestamp: new Date().toISOString() + }] + }; + + // Bypass classic method for discord + success = await webhookManager.executeDiscordWebhook(webhook, testData); + } + else if (webhook.trigger_type === 'event' && webhook.event_type) { + const eventType = webhook.event_type; + + let eventData = {}; + + switch (eventType) { + case 'playback_started': + eventData = { + sessionInfo: { + userId: "test-user-id", + deviceId: "test-device-id", + deviceName: "Test Device", + clientName: "Test Client", + isPaused: false, + mediaType: "Movie", + mediaName: "Test Movie", + startTime: new Date().toISOString() + }, + userData: { + username: "Test User", + userImageTag: "test-image-tag" + }, + mediaInfo: { + itemId: "test-item-id", + episodeId: null, + mediaName: "Test Movie", + seasonName: null, + seriesName: null + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + case 'playback_ended': + eventData = { + sessionInfo: { + userId: "test-user-id", + deviceId: "test-device-id", + deviceName: "Test Device", + clientName: "Test Client", + mediaType: "Movie", + mediaName: "Test Movie", + startTime: new Date(Date.now() - 3600000).toISOString(), + endTime: new Date().toISOString(), + playbackDuration: 3600 + }, + userData: { + username: "Test User", + userImageTag: "test-image-tag" + }, + mediaInfo: { + itemId: "test-item-id", + episodeId: null, + mediaName: "Test Movie", + seasonName: null, + seriesName: null + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + case 'media_recently_added': + eventData = { + mediaItem: { + id: "test-item-id", + name: "Test Media", + type: "Movie", + overview: "This is a test movie for webhook testing", + addedDate: new Date().toISOString() + } + }; + success = await webhookManager.triggerEventWebhooks(eventType, eventData, [webhook.id]); + break; + + default: + success = await webhookManager.executeWebhook(webhook, testData); + } + } else { + success = await webhookManager.executeWebhook(webhook, testData); + } + + if (success) { + res.json({ message: 'Webhook executed successfully' }); + } else { + res.status(500).json({ error: 'Error while executing webhook' }); + } + } catch (error) { + console.error('Error testing webhook:', error); + res.status(500).json({ error: 'Failed to test webhook: ' + error.message }); + } +}); + +router.post('/:id/trigger-monthly', async (req, res) => { + const webhookManager = new WebhookManager(); + const success = await webhookManager.triggerMonthlySummaryWebhook(req.params.id); + + if (success) { + res.status(200).json({ message: "Monthly report sent successfully" }); + } else { + res.status(500).json({ message: "Failed to send monthly report" }); + } +}); + +// Get status of event webhooks +router.get('/event-status', async (req, res) => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const result = {}; + + for (const eventType of eventTypes) { + const webhooks = await dbInstance.query( + 'SELECT id, name, enabled FROM webhooks WHERE trigger_type = $1 AND event_type = $2', + ['event', eventType] + ); + + result[eventType] = { + exists: webhooks.rows.length > 0, + enabled: webhooks.rows.some(webhook => webhook.enabled), + webhooks: webhooks.rows + }; + } + + res.json(result); + } catch (error) { + console.error('Error fetching webhook status:', error); + res.status(500).json({ error: 'Failed to fetch webhook status' }); + } +}); + +// Toggle all webhooks of a specific event type +router.post('/toggle-event/:eventType', async (req, res) => { + try { + const { eventType } = req.params; + const { enabled } = req.body; + + if (!['playback_started', 'playback_ended', 'media_recently_added'].includes(eventType)) { + return res.status(400).json({ error: 'Invalid event type' }); + } + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'Enabled parameter must be a boolean' }); + } + + // Mettre à jour tous les webhooks de ce type d'événement + const result = await dbInstance.query( + 'UPDATE webhooks SET enabled = $1 WHERE trigger_type = $2 AND event_type = $3 RETURNING id', + [enabled, 'event', eventType] + ); + + // Si aucun webhook n'existe pour ce type, en créer un de base + if (result.rows.length === 0 && enabled) { + const defaultWebhook = { + name: `Webhook pour ${eventType}`, + url: req.body.url || '', + method: 'POST', + trigger_type: 'event', + event_type: eventType, + enabled: true, + headers: '{}', + payload: JSON.stringify({ + event: `{{event}}`, + data: `{{data}}`, + timestamp: `{{triggeredAt}}` + }) + }; + + if (!defaultWebhook.url) { + return res.status(400).json({ + error: 'URL parameter is required when creating a new webhook', + needsUrl: true + }); + } + + await dbInstance.query( + `INSERT INTO webhooks (name, url, method, trigger_type, event_type, enabled, headers, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + defaultWebhook.name, + defaultWebhook.url, + defaultWebhook.method, + defaultWebhook.trigger_type, + defaultWebhook.event_type, + defaultWebhook.enabled, + defaultWebhook.headers, + defaultWebhook.payload + ] + ); + } + + // Rafraîchir le planificateur de webhooks + await webhookScheduler.refreshSchedule(); + + res.json({ + success: true, + message: `Webhooks for ${eventType} ${enabled ? 'enabled' : 'disabled'}`, + affectedCount: result.rows.length + }); + } catch (error) { + console.error('Error toggling webhooks:', error); + res.status(500).json({ error: 'Failed to toggle webhooks' }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 8d4b82ad..d70975d5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -25,11 +25,13 @@ const statsRouter = require("./routes/stats"); const backupRouter = require("./routes/backup"); const logRouter = require("./routes/logging"); const utilsRouter = require("./routes/utils"); +// const webhooksRouter = require("./routes/webhooks"); // tasks const ActivityMonitor = require("./tasks/ActivityMonitor"); const TaskManager = require("./classes/task-manager-singleton"); const TaskScheduler = require("./classes/task-scheduler-singleton"); +// const WebhookScheduler = require("./classes/webhook-scheduler"); // const tasks = require("./tasks/tasks"); // websocket @@ -165,6 +167,9 @@ app.use("/logs", authenticate, logRouter, () => { app.use("/utils", authenticate, utilsRouter, () => { /* #swagger.tags = ['Utils']*/ }); // mount the API router at /utils, with JWT middleware +// app.use("/webhooks", authenticate, webhooksRouter, () => { +// /* #swagger.tags = ['Webhooks']*/ +// }); // mount the API router at /webhooks, with JWT middleware // Swagger app.use("/swagger", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); @@ -243,6 +248,7 @@ try { ActivityMonitor.ActivityMonitor(1000); new TaskManager(); new TaskScheduler(); + // new WebhookScheduler(); }); }); }); diff --git a/backend/socket-io-client.js b/backend/socket-io-client.js index 7a7e3801..16269d57 100644 --- a/backend/socket-io-client.js +++ b/backend/socket-io-client.js @@ -1,13 +1,14 @@ const io = require("socket.io-client"); class SocketIoClient { - constructor(serverUrl) { + constructor(serverUrl, options = {}) { this.serverUrl = serverUrl; + this.options = options; // Store options for later use this.client = null; } connect() { - this.client = io(this.serverUrl); + this.client = io(this.serverUrl, this.options); // Pass options to io() } waitForConnection() { @@ -20,6 +21,10 @@ class SocketIoClient { }); } + getClient() { + return this.client; + } + sendMessage(message) { if (this.client && this.client.connected) { this.client.emit("message", JSON.stringify(message)); diff --git a/backend/swagger.json b/backend/swagger.json index 00fc18b1..9a6b136e 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -1341,6 +1341,108 @@ } } }, + "/api/getActivityMonitorSettings": { + "get": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/setActivityMonitorSettings": { + "post": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "activeSessionsInterval": { + "example": "any" + }, + "idleInterval": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/CheckForUpdates": { "get": { "tags": [ @@ -2860,12 +2962,45 @@ "name": "req", "in": "query", "type": "string" + }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string" + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" } ], "responses": { "200": { "description": "OK" }, + "400": { + "description": "Bad Request" + }, "401": { "description": "Unauthorized" }, diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 43f19693..548bc2d8 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -1,25 +1,29 @@ 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"); const API = require("../classes/api-loader"); const { sendUpdate } = require("../ws"); -const { isNumber } = require("@mui/x-data-grid/internals"); +const { isNumber } = require("../utils/typeValidation"); +// 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) => { + const existingData = await WatchdogData.filter((wdData) => { return SessionData.some((sessionData) => { - let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; + const NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; - let matchesEpisodeId = + const matchesEpisodeId = sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true; - let matchingSessionFound = + const matchingSessionFound = // wdData.Id === sessionData.Id && wdData.UserId === sessionData.UserId && wdData.DeviceId === sessionData.DeviceId && @@ -31,16 +35,16 @@ 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); + const startTime = dayjs(wdData.ActivityDateInserted); + const lastPausedDate = dayjs(sessionData.LastPausedDate, "YYYY-MM-DD HH:mm:ss.SSSZ"); - let diffInSeconds = lastPausedDate.diff(startTime, "seconds"); + const diffInSeconds = lastPausedDate.diff(startTime, "seconds"); wdData.PlaybackDuration = parseInt(wdData.PlaybackDuration) + diffInSeconds; 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; } @@ -52,15 +56,15 @@ async function getSessionsInWatchDog(SessionData, WatchdogData) { } async function getSessionsNotInWatchDog(SessionData, WatchdogData) { - let newData = await SessionData.filter((sessionData) => { + const newData = await SessionData.filter((sessionData) => { if (WatchdogData.length === 0) return true; return !WatchdogData.some((wdData) => { - let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; + const NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; - let matchesEpisodeId = + const matchesEpisodeId = sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true; - let matchingSessionFound = + const matchingSessionFound = // wdData.Id === sessionData.Id && wdData.UserId === sessionData.UserId && wdData.DeviceId === sessionData.DeviceId && @@ -75,15 +79,15 @@ async function getSessionsNotInWatchDog(SessionData, WatchdogData) { } function getWatchDogNotInSessions(SessionData, WatchdogData) { - let removedData = WatchdogData.filter((wdData) => { + const removedData = WatchdogData.filter((wdData) => { if (SessionData.length === 0) return true; return !SessionData.some((sessionData) => { - let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; + const NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; - let matchesEpisodeId = + const matchesEpisodeId = sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true; - let noMatchingSessionFound = + const noMatchingSessionFound = // wdData.Id === sessionData.Id && wdData.UserId === sessionData.UserId && wdData.DeviceId === sessionData.DeviceId && @@ -97,10 +101,10 @@ 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(); + const startTime = dayjs(obj.ActivityDateInserted); + const endTime = dayjs(); - let diffInSeconds = endTime.diff(startTime, "seconds"); + const diffInSeconds = endTime.diff(startTime, "seconds"); if (obj.IsPaused == false) { obj.PlaybackDuration = parseInt(obj.PlaybackDuration) + diffInSeconds; @@ -114,20 +118,72 @@ function getWatchDogNotInSessions(SessionData, WatchdogData) { return removedData; } -async function ActivityMonitor(interval) { - // console.log("Activity Interval: " + interval); +let currentIntervalId = null; +let lastHadActiveSessions = false; +let cachedPollingSettings = { + activeSessionsInterval: 1000, + idleInterval: 5000, +}; + +async function ActivityMonitor(defaultInterval) { + // console.log("Activity Monitor started with default interval: " + defaultInterval); - setInterval(async () => { + const runMonitoring = async () => { try { const config = await new configClass().getConfig(); if (config.error || config.state !== 2) { return; } + + // Get adaptive polling settings from config + const pollingSettings = config.settings?.ActivityMonitorPolling || { + activeSessionsInterval: 1000, + idleInterval: 5000, + }; + + // Check if polling settings have changed + const settingsChanged = + cachedPollingSettings.activeSessionsInterval !== pollingSettings.activeSessionsInterval || + cachedPollingSettings.idleInterval !== pollingSettings.idleInterval; + + if (settingsChanged) { + console.log("[ActivityMonitor] Polling settings changed, updating intervals"); + console.log("Old settings:", cachedPollingSettings); + console.log("New settings:", pollingSettings); + cachedPollingSettings = { ...pollingSettings }; + } + const ExcludedUsers = config.settings?.ExcludedUsers || []; const apiSessionData = await API.getSessions(); const SessionData = apiSessionData.filter((row) => row.NowPlayingItem !== undefined && !ExcludedUsers.includes(row.UserId)); sendUpdate("sessions", apiSessionData); + + const hasActiveSessions = SessionData.length > 0; + + // Determine current appropriate interval + const currentInterval = hasActiveSessions ? pollingSettings.activeSessionsInterval : pollingSettings.idleInterval; + + // Check if we need to change the interval (either due to session state change OR settings change) + if (hasActiveSessions !== lastHadActiveSessions || settingsChanged) { + if (hasActiveSessions !== lastHadActiveSessions) { + console.log( + `[ActivityMonitor] Switching to ${hasActiveSessions ? "active" : "idle"} polling mode (${currentInterval}ms)` + ); + lastHadActiveSessions = hasActiveSessions; + } + if (settingsChanged) { + console.log(`[ActivityMonitor] Applying new ${hasActiveSessions ? "active" : "idle"} interval: ${currentInterval}ms`); + } + + // Clear current interval and restart with new timing + if (currentIntervalId) { + clearInterval(currentIntervalId); + } + currentIntervalId = setInterval(runMonitoring, currentInterval); + return; // Let the new interval handle the next execution + } + /////get data from jf_activity_monitor const WatchdogData = await db.query("SELECT * FROM jf_activity_watchdog").then((res) => res.rows); @@ -137,15 +193,51 @@ async function ActivityMonitor(interval) { } // New Code - let WatchdogDataToInsert = await getSessionsNotInWatchDog(SessionData, WatchdogData); - let WatchdogDataToUpdate = await getSessionsInWatchDog(SessionData, WatchdogData); - let dataToRemove = await getWatchDogNotInSessions(SessionData, WatchdogData); + const WatchdogDataToInsert = await getSessionsNotInWatchDog(SessionData, WatchdogData); + const WatchdogDataToUpdate = await getSessionsInWatchDog(SessionData, WatchdogData); + const dataToRemove = await getWatchDogNotInSessions(SessionData, WatchdogData); ///////////////// //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,96 +250,132 @@ async function ActivityMonitor(interval) { console.log("Existing Data Updated: ", WatchdogDataToUpdate.length); } - //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) { - return; - } + 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 + + let playbackToInsert = dataToRemove; + + if (playbackToInsert.length == 0 && toDeleteIds.length == 0) { + return; + } - /////get data from jf_playback_activity within the last hour with progress of <=80% for current items in session - - const ExistingRecords = await db - .query(`SELECT * FROM jf_recent_playback_activity(1) limit 0`) - .then((res) => { - if (res.rows && Array.isArray(res.rows) && res.rows.length > 0) { - return res.rows.filter( - (row) => - playbackToInsert.some( - (pbi) => pbi.NowPlayingItemId === row.NowPlayingItemId && pbi.EpisodeId === row.EpisodeId - ) && row.Progress <= 80.0 - ); - } else { - return []; - } - }) - .catch((err) => { - console.error("Error fetching existing records:", err); - }); - let ExistingDataToUpdate = []; - - //for each item in playbackToInsert, check if it exists in the recent playback activity and update accordingly. insert new row if updating existing exceeds the runtime - if (playbackToInsert.length > 0 && ExistingRecords.length > 0) { - ExistingDataToUpdate = playbackToInsert.filter((playbackData) => { - const existingrow = ExistingRecords.find((existing) => { - let newDurationWithingRunTime = true; - - if (existing.RunTimeTicks != undefined && isNumber(existing.RunTimeTicks)) { - newDurationWithingRunTime = - (Number(existing.PlaybackDuration) + Number(playbackData.PlaybackDuration)) * 10000000 <= - Number(existing.RunTimeTicks); + /////get data from jf_playback_activity within the last hour with progress of <=80% for current items in session + + const ExistingRecords = await db + .query(`SELECT * FROM jf_recent_playback_activity(1)`) + .then((res) => { + if (res.rows && Array.isArray(res.rows) && res.rows.length > 0) { + return res.rows.filter( + (row) => + playbackToInsert.some( + (pbi) => pbi.NowPlayingItemId === row.NowPlayingItemId && pbi.EpisodeId === row.EpisodeId + ) && row.Progress <= 80.0 + ); + } else { + return []; + } + }) + .catch((err) => { + console.error("Error fetching existing records:", err); + }); + let ExistingDataToUpdate = []; + + //for each item in playbackToInsert, check if it exists in the recent playback activity and update accordingly. insert new row if updating existing exceeds the runtime + if (playbackToInsert.length > 0 && ExistingRecords.length > 0) { + ExistingDataToUpdate = playbackToInsert.filter((playbackData) => { + const existingrow = ExistingRecords.find((existing) => { + let newDurationWithingRunTime = true; + + if (existing.RunTimeTicks != undefined && isNumber(existing.RunTimeTicks)) { + newDurationWithingRunTime = + (Number(existing.PlaybackDuration) + Number(playbackData.PlaybackDuration)) * 10000000 <= + Number(existing.RunTimeTicks); + } + return ( + existing.NowPlayingItemId === playbackData.NowPlayingItemId && + existing.EpisodeId === playbackData.EpisodeId && + existing.UserId === playbackData.UserId && + newDurationWithingRunTime + ); + }); + + if (existingrow) { + playbackData.Id = existingrow.Id; + playbackData.PlaybackDuration = Number(existingrow.PlaybackDuration) + Number(playbackData.PlaybackDuration); + playbackData.ActivityDateInserted = dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"); + return true; } - return ( - existing.NowPlayingItemId === playbackData.NowPlayingItemId && - existing.EpisodeId === playbackData.EpisodeId && - existing.UserId === playbackData.UserId && - newDurationWithingRunTime - ); + return false; }); + } - 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"); - return true; - } - return false; - }); - } + //remove items from playbackToInsert that already exists in the recent playback activity so it doesnt duplicate or where PlaybackDuration===0 + playbackToInsert = playbackToInsert.filter( + (pb) => + pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK && + !ExistingRecords.some( + (er) => er.NowPlayingItemId === pb.NowPlayingItemId && er.EpisodeId === pb.EpisodeId && er.UserId === pb.UserId + ) + ); - //remove items from playbackToInsert that already exists in the recent playback activity so it doesnt duplicate or where PlaybackDuration===0 - playbackToInsert = playbackToInsert.filter( - (pb) => - pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK && - !ExistingRecords.some( - (er) => er.NowPlayingItemId === pb.NowPlayingItemId && er.EpisodeId === pb.EpisodeId && er.UserId === pb.UserId - ) - ); + //remove items where PlaybackDuration===0 - //remove items where PlaybackDuration===0 + ExistingDataToUpdate = ExistingDataToUpdate.filter((pb) => pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK); - ExistingDataToUpdate = ExistingDataToUpdate.filter((pb) => pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK); + if (toDeleteIds.length > 0) { + await db.deleteBulk("jf_activity_watchdog", toDeleteIds, "ActivityId"); + console.log("Removed Data from WD Count: ", dataToRemove.length); + } + if (playbackToInsert.length > 0) { + await db.insertBulk("jf_playback_activity", playbackToInsert, columnsPlayback); + console.log("Activity inserted/updated Count: ", playbackToInsert.length); + // console.log("Inserted " + playbackToInsert.length + " new playback records"); + } - if (toDeleteIds.length > 0) { - await db.deleteBulk("jf_activity_watchdog", toDeleteIds, "ActivityId"); - console.log("Removed Data from WD Count: ", dataToRemove.length); - } - if (playbackToInsert.length > 0) { - await db.insertBulk("jf_playback_activity", playbackToInsert, columnsPlayback); - console.log("Activity inserted/updated Count: ", playbackToInsert.length); - // console.log("Inserted " + playbackToInsert.length + " new playback records"); - } + if (ExistingDataToUpdate.length > 0) { + await db.insertBulk("jf_playback_activity", ExistingDataToUpdate, columnsPlayback); + // console.log("Updated " + playbackToInsert.length + " playback records"); + } - if (ExistingDataToUpdate.length > 0) { - await db.insertBulk("jf_playback_activity", ExistingDataToUpdate, columnsPlayback); - // console.log("Updated " + playbackToInsert.length + " playback records"); + /////////////////////////// } - - /////////////////////////// } catch (error) { if (error?.code === "ECONNREFUSED") { console.error("Error: Unable to connect to API"); //TO-DO Change this to correct API name @@ -258,7 +386,49 @@ async function ActivityMonitor(interval) { } return []; } - }, interval); + }; + + // Get initial configuration to start with the correct interval + const initConfig = async () => { + try { + const config = await new configClass().getConfig(); + + if (config.error || config.state !== 2) { + console.log("[ActivityMonitor] Config not ready, starting with default interval:", defaultInterval + "ms"); + currentIntervalId = setInterval(runMonitoring, defaultInterval); + return; + } + + // Get adaptive polling settings from config + const pollingSettings = config.settings?.ActivityMonitorPolling || { + activeSessionsInterval: 1000, + idleInterval: 5000, + }; + + // Initialize cached settings + cachedPollingSettings = { ...pollingSettings }; + + // Start with idle interval since there are likely no active sessions at startup + const initialInterval = pollingSettings.idleInterval; + console.log("[ActivityMonitor] Starting adaptive polling with idle interval:", initialInterval + "ms"); + console.log("[ActivityMonitor] Loaded settings:", pollingSettings); + currentIntervalId = setInterval(runMonitoring, initialInterval); + } catch (error) { + console.log("[ActivityMonitor] Error loading config, using default interval:", defaultInterval + "ms"); + currentIntervalId = setInterval(runMonitoring, defaultInterval); + } + }; + + // Initialize with proper configuration + await initConfig(); + + // Return a cleanup function + return () => { + if (currentIntervalId) { + clearInterval(currentIntervalId); + currentIntervalId = null; + } + }; } module.exports = { diff --git a/backend/tasks/BackupTask.js b/backend/tasks/BackupTask.js index 7f55e97c..50d780d3 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" }); diff --git a/backend/tasks/RecentlyAddedItemsSyncTask.js b/backend/tasks/RecentlyAddedItemsSyncTask.js index 85f06762..1852df00 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/backend/utils/typeValidation.js b/backend/utils/typeValidation.js new file mode 100644 index 00000000..9607c0c6 --- /dev/null +++ b/backend/utils/typeValidation.js @@ -0,0 +1,3 @@ +export function isNumber(value) { + return typeof value === "number" && !Number.isNaN(value); +} diff --git a/backend/ws.js b/backend/ws.js index b9433106..cd9687e9 100644 --- a/backend/ws.js +++ b/backend/ws.js @@ -2,15 +2,32 @@ const socketIO = require("socket.io"); const webSocketServerSingleton = require("./ws-server-singleton.js"); const SocketIoClient = require("./socket-io-client.js"); +const jwt = require("jsonwebtoken"); -const socketClient = new SocketIoClient("http://127.0.0.1:3000"); +const token = jwt.sign({ user: "internal" }, process.env.JWT_SECRET); +const socketClient = new SocketIoClient("http://127.0.0.1:3000", { auth: { token } }); let io; // Store the socket.io server instance +const JWT_SECRET = process.env.JWT_SECRET; const setupWebSocketServer = (server, namespacePath) => { io = socketIO(server, { path: namespacePath + "/socket.io" }); socketClient.connect(); + io.use((socket, next) => { + const token = socket.handshake.auth?.token || socket.handshake.query?.token; + if (!token) { + return next(new Error("Authentication error: No token provided")); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + socket.user = decoded.user; + return next(); + } catch (err) { + return next(new Error("Authentication error: Invalid token")); + } + }); + io.on("connection", (socket) => { // console.log("Client connected to namespace:", namespacePath); @@ -37,6 +54,10 @@ const sendToAllClients = (message) => { }; const sendUpdate = async (tag, message) => { + // const ignoredTags = ["task-list", "sessions"]; + // if (!ignoredTags.includes(tag)) { + // console.log(`Sending update - Tag: ${tag}, Message: ${JSON.stringify(message)}`); + // } const ioInstance = webSocketServerSingleton.getInstance(); if (ioInstance) { ioInstance.emit(tag, message); diff --git a/docker-compose.yml b/docker-compose.yml index b31069a7..42982c40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: jellystat-db: - image: postgres:15.2 + image: postgres:15.14 shm_size: '1gb' container_name: jellystat-db restart: unless-stopped diff --git a/index.html b/index.html index 948e3aa6..7e6b1011 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - +