diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6312cb2..da225f3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -39,12 +39,21 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build Docker image uses: docker/build-push-action@v5 with: context: . push: true - tags: ${{ steps.meta.outputs.tags }} + tags: | + ${{ steps.meta.outputs.tags }} + ghcr.io/${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-latest.yml b/.github/workflows/docker-latest.yml index 0d63960..23dd6a7 100644 --- a/.github/workflows/docker-latest.yml +++ b/.github/workflows/docker-latest.yml @@ -44,6 +44,13 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image uses: docker/build-push-action@v5 @@ -53,4 +60,6 @@ jobs: tags: | cyfershepard/jellystat:latest cyfershepard/jellystat:${{ env.VERSION }} + ghcr.io/cyfershepard/jellystat:latest + ghcr.io/cyfershepard/jellystat:${{ env.VERSION }} platforms: linux/amd64,linux/arm64,linux/arm/v7 diff --git a/Dockerfile b/Dockerfile index 3abb606..f00d2eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the application -FROM node:slim AS builder +FROM node:lts-slim AS builder WORKDIR /app @@ -14,7 +14,7 @@ COPY entry.sh ./ RUN npm run build # Stage 2: Create the production image -FROM node:slim +FROM node:lts-slim RUN apt-get update && \ apt-get install -yqq --no-install-recommends wget && \ diff --git a/README.md b/README.md index cbe2db0..166da3b 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 5282ca0..981b7d6 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/emby-api.js b/backend/classes/emby-api.js index 98836df..a56df89 100644 --- a/backend/classes/emby-api.js +++ b/backend/classes/emby-api.js @@ -104,8 +104,8 @@ class EmbyAPI { //Functions - async getUsers() { - if (!this.configReady) { + async getUsers(refreshConfig = false) { + if (!this.configReady || refreshConfig) { const success = await this.#fetchConfig(); if (!success) { return []; @@ -133,9 +133,9 @@ class EmbyAPI { } } - async getAdmins() { + async getAdmins(refreshConfig = false) { try { - const users = await this.getUsers(); + const users = await this.getUsers(refreshConfig); return users?.filter((user) => user.Policy.IsAdministrator) || []; } catch (error) { this.#errorHandler(error); diff --git a/backend/classes/jellyfin-api.js b/backend/classes/jellyfin-api.js index cf52917..81e244b 100644 --- a/backend/classes/jellyfin-api.js +++ b/backend/classes/jellyfin-api.js @@ -105,8 +105,8 @@ class JellyfinAPI { //Functions - async getUsers() { - if (!this.configReady) { + async getUsers(refreshConfig = false) { + if (!this.configReady || refreshConfig) { const success = await this.#fetchConfig(); if (!success) { return []; @@ -133,9 +133,9 @@ class JellyfinAPI { } } - async getAdmins() { + async getAdmins(refreshConfig = false) { try { - const users = await this.getUsers(); + const users = await this.getUsers(refreshConfig); return users?.filter((user) => user.Policy.IsAdministrator) || []; } catch (error) { this.#errorHandler(error); diff --git a/backend/classes/logging.js b/backend/classes/logging.js index c75a864..16c78df 100644 --- a/backend/classes/logging.js +++ b/backend/classes/logging.js @@ -1,12 +1,12 @@ const db = require("../db"); -const moment = require("moment"); +const dayjs = require("dayjs"); const taskstate = require("../logging/taskstate"); const { jf_logging_columns, jf_logging_mapping } = require("../models/jf_logging"); async function insertLog(uuid, triggertype, taskType) { try { - let startTime = moment(); + let startTime = dayjs(); const log = { Id: uuid, Name: taskType, @@ -32,8 +32,8 @@ async function updateLog(uuid, data, taskstate) { if (task.length === 0) { console.log("Unable to find task to update"); } else { - let endtime = moment(); - let startTime = moment(task[0].TimeRun); + let endtime = dayjs(); + let startTime = dayjs(task[0].TimeRun); let duration = endtime.diff(startTime, "seconds"); const log = { Id: uuid, diff --git a/backend/classes/task-manager.js b/backend/classes/task-manager.js index 7972ea1..b06856a 100644 --- a/backend/classes/task-manager.js +++ b/backend/classes/task-manager.js @@ -45,7 +45,7 @@ class TaskManager { if (code !== 0) { console.error(`Worker ${task.name} stopped with exit code ${code}`); } - if (onExit) { + if (code !== 0 && onExit) { onExit(); } delete this.tasks[task.name]; diff --git a/backend/classes/webhook-manager.js b/backend/classes/webhook-manager.js index 233f2cc..0212c01 100644 --- a/backend/classes/webhook-manager.js +++ b/backend/classes/webhook-manager.js @@ -19,8 +19,12 @@ class WebhookManager { await this.triggerEventWebhooks('playback_started', data); }); - this.eventEmitter.on('user_login', async (data) => { - await this.triggerEventWebhooks('user_login', data); + this.eventEmitter.on('playback_ended', async (data) => { + await this.triggerEventWebhooks('playback_ended', data); + }); + + this.eventEmitter.on('media_recently_added', async (data) => { + await this.triggerEventWebhooks('media_recently_added', data); }); // If needed, add more event listeners here @@ -40,11 +44,33 @@ class WebhookManager { ).then(res => res.rows); } - async triggerEventWebhooks(eventType, data) { - const webhooks = await this.getWebhooksByEventType(eventType); - - for (const webhook of webhooks) { - await this.executeWebhook(webhook, data); + async triggerEventWebhooks(eventType, data = {}) { + try { + const webhooks = await this.getWebhooksByEventType(eventType); + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const enrichedData = { + ...data, + event: eventType, + triggeredAt: new Date().toISOString() + }; + + const promises = webhooks.map(webhook => { + return this.executeWebhook(webhook, enrichedData); + }); + + await Promise.all(promises); + + return true; + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + return false; } } @@ -135,6 +161,31 @@ class WebhookManager { return template; } + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks?.[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + ...eventData, + event: eventType, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + emitEvent(eventType, data) { this.eventEmitter.emit(eventType, data); } @@ -340,6 +391,28 @@ class WebhookManager { return false; } } + + async executeDiscordWebhook(webhook, data) { + try { + console.log(`Execution of discord webhook: ${webhook.name}`); + + const response = await axios.post(webhook.url, data, { + headers: { + 'Content-Type': 'application/json' + } + }); + + console.log(`[WEBHOOK] Discord response: ${response.status}`); + return response.status >= 200 && response.status < 300; + } catch (error) { + console.error(`[WEBHOOK] Error with Discord webhook ${webhook.name}:`, error.message); + if (error.response) { + console.error('[WEBHOOK] Response status:', error.response.status); + console.error('[WEBHOOK] Response data:', error.response.data); + } + return false; + } + } } module.exports = WebhookManager; \ No newline at end of file diff --git a/backend/classes/webhook-scheduler.js b/backend/classes/webhook-scheduler.js index d1fddc3..4340217 100644 --- a/backend/classes/webhook-scheduler.js +++ b/backend/classes/webhook-scheduler.js @@ -35,6 +35,54 @@ class WebhookScheduler { } } + async loadEventWebhooks() { + try { + const eventWebhooks = await this.webhookManager.getEventWebhooks(); + if (eventWebhooks && eventWebhooks.length > 0) { + this.eventWebhooks = {}; + + eventWebhooks.forEach(webhook => { + if (!this.eventWebhooks[webhook.eventType]) { + this.eventWebhooks[webhook.eventType] = []; + } + this.eventWebhooks[webhook.eventType].push(webhook); + }); + + console.log(`[WEBHOOK] Loaded ${eventWebhooks.length} event-based webhooks`); + } else { + console.log('[WEBHOOK] No event-based webhooks found'); + this.eventWebhooks = {}; + } + } catch (error) { + console.error('[WEBHOOK] Failed to load event-based webhooks:', error); + } + } + + async triggerEvent(eventType, eventData = {}) { + try { + const webhooks = this.eventWebhooks[eventType] || []; + + if (webhooks.length === 0) { + console.log(`[WEBHOOK] No webhooks registered for event: ${eventType}`); + return; + } + + console.log(`[WEBHOOK] Triggering ${webhooks.length} webhooks for event: ${eventType}`); + + const promises = webhooks.map(webhook => { + return this.webhookManager.executeWebhook(webhook, { + event: eventType, + data: eventData, + triggeredAt: new Date().toISOString() + }); + }); + + await Promise.all(promises); + } catch (error) { + console.error(`[WEBHOOK] Error triggering webhooks for event ${eventType}:`, error); + } + } + scheduleWebhook(webhook) { try { this.cronJobs[webhook.id] = cron.schedule(webhook.schedule, async () => { @@ -50,6 +98,7 @@ class WebhookScheduler { async refreshSchedule() { await this.loadScheduledWebhooks(); + await this.loadEventWebhooks(); } } diff --git a/backend/create_database.js b/backend/create_database.js index 700d243..a72b317 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 bebde95..a41c2b2 100644 --- a/backend/db.js +++ b/backend/db.js @@ -7,6 +7,7 @@ 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 +23,9 @@ 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) => { diff --git a/backend/migrations.js b/backend/migrations.js index 0240694..a53993a 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/models/jf_activity_watchdog.js b/backend/models/jf_activity_watchdog.js index bde3de8..6a1b3b7 100644 --- a/backend/models/jf_activity_watchdog.js +++ b/backend/models/jf_activity_watchdog.js @@ -1,4 +1,4 @@ -const moment = require("moment"); +const dayjs = require("dayjs"); const { randomUUID } = require("crypto"); const jf_activity_watchdog_columns = [ @@ -45,7 +45,7 @@ const jf_activity_watchdog_mapping = (item) => ({ PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration : 0, PlayMethod: item.PlayState.PlayMethod, ActivityDateInserted: - item.ActivityDateInserted !== undefined ? item.ActivityDateInserted : moment().format("YYYY-MM-DD HH:mm:ss.SSSZ"), + item.ActivityDateInserted !== undefined ? item.ActivityDateInserted : dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"), MediaStreams: item.NowPlayingItem.MediaStreams ? item.NowPlayingItem.MediaStreams : null, TranscodingInfo: item.TranscodingInfo ? item.TranscodingInfo : null, PlayState: item.PlayState ? item.PlayState : null, diff --git a/backend/models/jf_library_items.js b/backend/models/jf_library_items.js index c19a1cf..16db1b1 100644 --- a/backend/models/jf_library_items.js +++ b/backend/models/jf_library_items.js @@ -50,7 +50,7 @@ const jf_library_items_mapping = (item) => ({ ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null, archived: false, - Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(filterInvalidGenres(item.Genres.map(titleCase))) : [], + Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(item.Genres.map(titleCase)) : [], }); // Utility function to title-case a string @@ -62,53 +62,6 @@ function titleCase(str) { .join(" "); } -function filterInvalidGenres(genres) { - const validGenres = [ - "Action", - "Adventure", - "Animated", - "Biography", - "Comedy", - "Crime", - "Dance", - "Disaster", - "Documentary", - "Drama", - "Erotic", - "Family", - "Fantasy", - "Found Footage", - "Historical", - "Horror", - "Independent", - "Legal", - "Live Action", - "Martial Arts", - "Musical", - "Mystery", - "Noir", - "Performance", - "Political", - "Romance", - "Satire", - "Science Fiction", - "Short", - "Silent", - "Slasher", - "Sports", - "Spy", - "Superhero", - "Supernatural", - "Suspense", - "Teen", - "Thriller", - "War", - "Western", - ]; - - return genres.filter((genre) => validGenres.map((g) => g.toLowerCase()).includes(genre.toLowerCase())); -} - module.exports = { jf_library_items_columns, jf_library_items_mapping, diff --git a/backend/models/jf_playback_reporting_plugin_data.js b/backend/models/jf_playback_reporting_plugin_data.js index f00277c..ad36efb 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 4c1862b..9027dbb 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -11,11 +11,14 @@ 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 customParseFormat = require("dayjs/plugin/customParseFormat"); +dayjs.extend(customParseFormat); + const router = express.Router(); //consts @@ -329,11 +332,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 +345,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 +357,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 +386,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 +399,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 +417,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); @@ -463,7 +466,24 @@ router.post("/setconfig", async (req, res) => { settings.ServerID = systemInfo?.Id || null; - let query = 'UPDATE app_config SET settings=$1 where "ID"=1'; + const query = 'UPDATE app_config SET settings=$1 where "ID"=1'; + + await db.query(query, [settings]); + } + } + + const admins = await API.getAdmins(true); + const preferredAdmin = await new configClass().getPreferedAdmin(); + if (admins && admins.length > 0 && preferredAdmin && !admins.map((item) => item.Id).includes(preferredAdmin)) { + const newAdmin = admins[0]; + 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.preferred_admin = { userid: newAdmin.Id, username: newAdmin.Name }; + + const query = 'UPDATE app_config SET settings=$1 where "ID"=1'; await db.query(query, [settings]); } @@ -892,6 +912,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 { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index f6fab73..6afef08 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -20,16 +20,23 @@ router.post("/login", async (req, res) => { try { const { username, password } = req.body; - if (!username || !password || password === CryptoJS.SHA3("").toString()) { + const query = "SELECT * FROM app_config"; + const { rows: login } = await db.query(query); + + if ( + (!username || !password || password === CryptoJS.SHA3("").toString()) && + login.length > 0 && + login[0].REQUIRE_LOGIN == true + ) { res.sendStatus(401); return; } - const query = 'SELECT * FROM app_config WHERE ("APP_USER" = $1 AND "APP_PASSWORD" = $2) OR "REQUIRE_LOGIN" = false'; - const values = [username, password]; - const { rows: login } = await db.query(query, values); + const loginUser = login.filter( + (user) => (user.APP_USER === username && user.APP_PASSWORD === password) || user.REQUIRE_LOGIN == false + ); - if (login.length > 0 || (username === JS_USER && password === CryptoJS.SHA3(JS_PASSWORD).toString())) { + if (loginUser.length > 0 || (username === JS_USER && password === CryptoJS.SHA3(JS_PASSWORD).toString())) { const user = { id: 1, username: username }; jwt.sign({ user }, JWT_SECRET, (err, token) => { diff --git a/backend/routes/backup.js b/backend/routes/backup.js index 6e768ce..f98bf82 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/proxy.js b/backend/routes/proxy.js index 18fdf1f..61a4c28 100644 --- a/backend/routes/proxy.js +++ b/backend/routes/proxy.js @@ -148,7 +148,7 @@ router.get("/getSessions", async (req, res) => { router.get("/getAdminUsers", async (req, res) => { try { - const adminUser = await API.getAdmins(); + const adminUser = await API.getAdmins(true); res.send(adminUser); } catch (error) { res.status(503); diff --git a/backend/routes/stats.js b/backend/routes/stats.js index 2a78f66..2e96b9c 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -2,7 +2,10 @@ const express = require("express"); const db = require("../db"); const dbHelper = require("../classes/db-helper"); -const moment = require("moment"); + +const dayjs = require("dayjs"); +const customParseFormat = require("dayjs/plugin/customParseFormat"); +dayjs.extend(customParseFormat); const router = express.Router(); @@ -11,8 +14,8 @@ function countOverlapsPerHour(records) { const hourCounts = {}; records.forEach((record) => { - const start = moment(record.StartTime).subtract(1, "hour"); - const end = moment(record.EndTime).add(1, "hour"); + const start = dayjs(record.StartTime).subtract(1, "hour"); + const end = dayjs(record.EndTime).add(1, "hour"); // Iterate through each hour from start to end for (let hour = start.clone().startOf("hour"); hour.isBefore(end); hour.add(1, "hour")) { @@ -289,12 +292,12 @@ router.post("/getLibraryItemsWithStats", async (req, res) => { router.post("/getLibraryItemsPlayMethodStats", async (req, res) => { try { - let { libraryid, startDate, endDate = moment(), hours = 24 } = req.body; + let { libraryid, startDate, endDate = dayjs(), hours = 24 } = req.body; - // Validate startDate and endDate using moment + // Validate startDate and endDate using dayjs if ( startDate !== undefined && - (!moment(startDate, moment.ISO_8601, true).isValid() || !moment(endDate, moment.ISO_8601, true).isValid()) + (!dayjs(startDate, "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 +311,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 +339,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, @@ -407,9 +410,9 @@ router.post("/getLibraryLastPlayed", async (req, res) => { } }); -router.post("/getViewsOverTime", async (req, res) => { +router.get("/getViewsOverTime", async (req, res) => { try { - const { days } = req.body; + const { days } = req.query; let _days = days; if (days === undefined) { _days = 30; @@ -423,6 +426,7 @@ router.post("/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 +439,7 @@ router.post("/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); @@ -446,9 +450,9 @@ router.post("/getViewsOverTime", async (req, res) => { } }); -router.post("/getViewsByDays", async (req, res) => { +router.get("/getViewsByDays", async (req, res) => { try { - const { days } = req.body; + const { days } = req.query; let _days = days; if (days === undefined) { _days = 30; @@ -462,6 +466,7 @@ router.post("/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 +475,7 @@ router.post("/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); @@ -481,9 +486,9 @@ router.post("/getViewsByDays", async (req, res) => { } }); -router.post("/getViewsByHour", async (req, res) => { +router.get("/getViewsByHour", async (req, res) => { try { - const { days } = req.body; + const { days } = req.query; let _days = days; if (days === undefined) { _days = 30; @@ -497,6 +502,7 @@ router.post("/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 +511,7 @@ router.post("/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); @@ -516,6 +522,41 @@ router.post("/getViewsByHour", async (req, res) => { } }); +router.get("/getViewsByLibraryType", async (req, res) => { + try { + const { days = 30 } = req.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]); + + const supportedTypes = new Set(["Audio", "Movie", "Series", "Other"]); + /** @type {Map} */ + const reorganizedData = new Map(); + + rows.forEach((item) => { + const { type, count } = item; + + if (!supportedTypes.has(type)) return; + reorganizedData.set(type, count); + }); + + supportedTypes.forEach((type) => { + if (reorganizedData.has(type)) return; + reorganizedData.set(type, 0); + }); + + res.send(Object.fromEntries(reorganizedData)); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + router.get("/getGenreUserStats", async (req, res) => { try { const { size = 50, page = 1, userid } = req.query; diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 5247c9f..c20e11b 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 { @@ -395,12 +423,13 @@ async function removeOrphanedData() { syncTask.loggedData.push({ color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records" }); await db.query("CALL jd_remove_orphaned_data()"); - const archived_items = await db - .query(`select "Id" from jf_library_items where archived=true and "Type"='Series'`) - .then((res) => res.rows.map((row) => row.Id)); - const archived_seasons = await db - .query(`select "Id" from jf_library_seasons where archived=true`) - .then((res) => res.rows.map((row) => row.Id)); + const archived_items_query = `select i."Id" from jf_library_items i join jf_library_seasons s on s."SeriesId"=i."Id" and s.archived=false where i.archived=true and i."Type"='Series' + union + select i."Id" from jf_library_items i join jf_library_episodes e on e."SeriesId"=i."Id" and e.archived=false where i.archived=true and i."Type"='Series' + `; + const archived_items = await db.query(archived_items_query).then((res) => res.rows.map((row) => row.Id)); + const archived_seasons_query = `select s."Id" from jf_library_seasons s join jf_library_episodes e on e."SeasonId"=s."Id" and e.archived=false where s.archived=true`; + const archived_seasons = await db.query(archived_seasons_query).then((res) => res.rows.map((row) => row.Id)); if (!(await _sync.updateSingleFieldOnDB("jf_library_seasons", archived_items, "archived", true, "SeriesId"))) { syncTask.loggedData.push({ color: "red", Message: "Error archiving library seasons" }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); @@ -529,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}`; @@ -543,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}`; @@ -823,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 { @@ -832,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(); @@ -841,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 || []; @@ -849,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 @@ -870,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( @@ -881,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) @@ -908,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) { @@ -955,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; @@ -973,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)); } } @@ -1022,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 index 67a6370..348279d 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -185,18 +185,128 @@ router.post('/:id/test', async (req, res) => { } const webhook = result.rows[0]; - const testData = req.body || {}; + let testData = req.body || {}; + let success = false; - const success = await webhookManager.executeWebhook(webhook, testData); + // 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: 'Webhook execution failed' }); + 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' }); + res.status(500).json({ error: 'Failed to test webhook: ' + error.message }); } }); @@ -205,10 +315,110 @@ router.post('/:id/trigger-monthly', async (req, res) => { const success = await webhookManager.triggerMonthlySummaryWebhook(req.params.id); if (success) { - res.status(200).json({ message: "Rapport mensuel envoyé avec succès" }); + res.status(200).json({ message: "Monthly report sent successfully" }); } else { - res.status(500).json({ message: "Échec de l'envoi du rapport mensuel" }); + res.status(500).json({ message: "Failed to send monthly report" }); } }); -module.exports = router; \ No newline at end of file +// Get status of event webhooks +router.get('/event-status', authMiddleware, async (req, res) => { + try { + const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added']; + const result = {}; + + for (const eventType of eventTypes) { + const webhooks = await dbInstance.query( + 'SELECT id, name, enabled FROM webhooks WHERE trigger_type = $1 AND event_type = $2', + ['event', eventType] + ); + + result[eventType] = { + exists: webhooks.rows.length > 0, + enabled: webhooks.rows.some(webhook => webhook.enabled), + webhooks: webhooks.rows + }; + } + + res.json(result); + } catch (error) { + console.error('Error fetching webhook status:', error); + res.status(500).json({ error: 'Failed to fetch webhook status' }); + } +}); + +// Toggle all webhooks of a specific event type +router.post('/toggle-event/:eventType', async (req, res) => { + try { + const { eventType } = req.params; + const { enabled } = req.body; + + if (!['playback_started', 'playback_ended', 'media_recently_added'].includes(eventType)) { + return res.status(400).json({ error: 'Invalid event type' }); + } + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'Enabled parameter must be a boolean' }); + } + + // Mettre à jour tous les webhooks de ce type d'événement + const result = await dbInstance.query( + 'UPDATE webhooks SET enabled = $1 WHERE trigger_type = $2 AND event_type = $3 RETURNING id', + [enabled, 'event', eventType] + ); + + // Si aucun webhook n'existe pour ce type, en créer un de base + if (result.rows.length === 0 && enabled) { + const defaultWebhook = { + name: `Webhook pour ${eventType}`, + url: req.body.url || '', + method: 'POST', + trigger_type: 'event', + event_type: eventType, + enabled: true, + headers: '{}', + payload: JSON.stringify({ + event: `{{event}}`, + data: `{{data}}`, + timestamp: `{{triggeredAt}}` + }) + }; + + if (!defaultWebhook.url) { + return res.status(400).json({ + error: 'URL parameter is required when creating a new webhook', + needsUrl: true + }); + } + + await dbInstance.query( + `INSERT INTO webhooks (name, url, method, trigger_type, event_type, enabled, headers, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + defaultWebhook.name, + defaultWebhook.url, + defaultWebhook.method, + defaultWebhook.trigger_type, + defaultWebhook.event_type, + defaultWebhook.enabled, + defaultWebhook.headers, + defaultWebhook.payload + ] + ); + } + + // Rafraîchir le planificateur de webhooks + await webhookScheduler.refreshSchedule(); + + res.json({ + success: true, + message: `Webhooks for ${eventType} ${enabled ? 'enabled' : 'disabled'}`, + affectedCount: result.rows.length + }); + } catch (error) { + console.error('Error toggling webhooks:', error); + res.status(500).json({ error: 'Failed to toggle webhooks' }); + } +}); + +module.exports = router; diff --git a/backend/swagger.json b/backend/swagger.json index 84ec7fe..00fc18b 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -3504,7 +3504,7 @@ } }, "/stats/getViewsOverTime": { - "post": { + "get": { "tags": [ "Stats" ], @@ -3526,16 +3526,9 @@ "type": "string" }, { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "days": { - "example": "any" - } - } - } + "name": "days", + "in": "query", + "type": "string" } ], "responses": { @@ -3558,7 +3551,7 @@ } }, "/stats/getViewsByDays": { - "post": { + "get": { "tags": [ "Stats" ], @@ -3580,16 +3573,9 @@ "type": "string" }, { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "days": { - "example": "any" - } - } - } + "name": "days", + "in": "query", + "type": "string" } ], "responses": { @@ -3612,7 +3598,7 @@ } }, "/stats/getViewsByHour": { - "post": { + "get": { "tags": [ "Stats" ], @@ -3634,16 +3620,56 @@ "type": "string" }, { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "days": { - "example": "any" - } - } - } + "name": "days", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/stats/getViewsByLibraryType": { + "get": { + "tags": [ + "Stats" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "days", + "in": "query", + "type": "string" } ], "responses": { diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 43f1969..b3a70da 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 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; -async function getSessionsInWatchDog(SessionData, WatchdogData) { - let existingData = await WatchdogData.filter((wdData) => { - return SessionData.some((sessionData) => { - let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; +const webhookManager = new WebhookManager(); - let matchesEpisodeId = +async function getSessionsInWatchDog(SessionData, WatchdogData) { + const existingData = await WatchdogData.filter((wdData) => { + return SessionData.some((sessionData) => { + const NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id; + + 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,70 @@ 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 +}; - setInterval(async () => { +async function ActivityMonitor(defaultInterval) { + // console.log("Activity Monitor started with default interval: " + defaultInterval); + + 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 +191,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,11 +248,46 @@ async function ActivityMonitor(interval) { console.log("Existing Data Updated: ", WatchdogDataToUpdate.length); } + if (dataToRemove.length > 0) { + for (const session of dataToRemove) { + let userData = {}; + try { + const userInfo = await API.getUserById(session.UserId); + if (userInfo) { + userData = { + username: userInfo.Name, + userImageTag: userInfo.PrimaryImageTag + }; + } + } catch (error) { + console.error(`[WEBHOOK] Error fetching user data: ${error.message}`); + } + + await webhookManager.triggerEventWebhooks('playback_ended', { + sessionInfo: { + userId: session.UserId, + deviceId: session.DeviceId, + deviceName: session.DeviceName, + clientName: session.ClientName, + playbackDuration: session.PlaybackDuration, + endTime: session.ActivityDateInserted + }, + userData, + mediaInfo: { + itemId: session.NowPlayingItemId, + episodeId: session.EpisodeId, + mediaName: session.NowPlayingItemName, + seasonName: session.SeasonName, + seriesName: session.SeriesName + } + }); + } + + const toDeleteIds = dataToRemove.map((row) => row.ActivityId); + //delete from db no longer in session data and insert into stats db //Bulk delete from db thats no longer on api - const toDeleteIds = dataToRemove.map((row) => row.ActivityId); - let playbackToInsert = dataToRemove; if (playbackToInsert.length == 0 && toDeleteIds.length == 0) { @@ -172,7 +297,7 @@ async function ActivityMonitor(interval) { /////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`) + .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( @@ -212,7 +337,7 @@ async function ActivityMonitor(interval) { if (existingrow) { playbackData.Id = existingrow.Id; playbackData.PlaybackDuration = Number(existingrow.PlaybackDuration) + Number(playbackData.PlaybackDuration); - playbackData.ActivityDateInserted = moment().format("YYYY-MM-DD HH:mm:ss.SSSZ"); + playbackData.ActivityDateInserted = dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZ"); return true; } return false; @@ -248,7 +373,9 @@ async function ActivityMonitor(interval) { } /////////////////////////// - } catch (error) { + } + } + catch (error) { if (error?.code === "ECONNREFUSED") { console.error("Error: Unable to connect to API"); //TO-DO Change this to correct API name } else if (error?.code === "ERR_BAD_RESPONSE") { @@ -258,7 +385,50 @@ 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 352a5fa..50d780d 100644 --- a/backend/tasks/BackupTask.js +++ b/backend/tasks/BackupTask.js @@ -27,10 +27,10 @@ async function runBackupTask(triggerType = triggertype.Automatic) { console.log("Running Scheduled Backup"); - Logging.insertLog(uuid, triggerType, taskName.backup); + await Logging.insertLog(uuid, triggerType, taskName.backup); await backup(refLog); - Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS); + await Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS); sendUpdate("BackupTask", { type: "Success", message: `${triggerType} Backup Completed` }); console.log("Scheduled Backup Complete"); parentPort.postMessage({ status: "complete" }); @@ -42,8 +42,9 @@ async function runBackupTask(triggerType = triggertype.Automatic) { } } -parentPort.on("message", (message) => { +parentPort.on("message", async (message) => { if (message.command === "start") { - runBackupTask(message.triggertype); + await runBackupTask(message.triggertype); + process.exit(0); // Exit the worker after the task is done } }); diff --git a/backend/tasks/FullSyncTask.js b/backend/tasks/FullSyncTask.js index 20ca170..196bc05 100644 --- a/backend/tasks/FullSyncTask.js +++ b/backend/tasks/FullSyncTask.js @@ -28,8 +28,9 @@ async function runFullSyncTask(triggerType = triggertype.Automatic) { } } -parentPort.on("message", (message) => { +parentPort.on("message", async (message) => { if (message.command === "start") { - runFullSyncTask(message.triggertype); + await runFullSyncTask(message.triggertype); + process.exit(0); // Exit the worker after the task is done } }); diff --git a/backend/tasks/PlaybackReportingPluginSyncTask.js b/backend/tasks/PlaybackReportingPluginSyncTask.js index 73c5911..63292e9 100644 --- a/backend/tasks/PlaybackReportingPluginSyncTask.js +++ b/backend/tasks/PlaybackReportingPluginSyncTask.js @@ -27,8 +27,9 @@ async function runPlaybackReportingPluginSyncTask() { } } -parentPort.on("message", (message) => { +parentPort.on("message", async (message) => { if (message.command === "start") { - runPlaybackReportingPluginSyncTask(); + await runPlaybackReportingPluginSyncTask(); + process.exit(0); // Exit the worker after the task is done } }); diff --git a/backend/tasks/RecentlyAddedItemsSyncTask.js b/backend/tasks/RecentlyAddedItemsSyncTask.js index a40dc13..c688c1e 100644 --- a/backend/tasks/RecentlyAddedItemsSyncTask.js +++ b/backend/tasks/RecentlyAddedItemsSyncTask.js @@ -1,6 +1,7 @@ const { parentPort } = require("worker_threads"); const triggertype = require("../logging/triggertype"); const sync = require("../routes/sync"); +const WebhookManager = require("../classes/webhook-manager"); async function runPartialSyncTask(triggerType = triggertype.Automatic) { try { @@ -17,19 +18,33 @@ 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 []; } } -parentPort.on("message", (message) => { +parentPort.on("message", async (message) => { if (message.command === "start") { - runPartialSyncTask(message.triggertype); + await runPartialSyncTask(message.triggertype); + process.exit(0); // Exit the worker after the task is done } }); diff --git a/index.html b/index.html index 948e3aa..7e6b101 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - +