This commit is contained in:
CyferShepard
2025-10-05 17:22:10 +02:00
51 changed files with 1941 additions and 350 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 && \

View File

@@ -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) |

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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];

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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 () => {

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
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,
};

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<string, number>} */
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;

View File

@@ -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 };
}
}

View File

@@ -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;
// 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;

View File

@@ -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": {

View File

@@ -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 = {

View File

@@ -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
}
});

View File

@@ -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
}
});

View File

@@ -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
}
});

View File

@@ -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
}
});

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Jellyfin stats for the masses" />
<link rel="apple-touch-icon" href="icon-b-192.png" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<script src="env.js"></script>
<!--
manifest.json provides metadata used when your web app is installed on a

13
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jfstat",
"version": "1.1.5",
"version": "1.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jfstat",
"version": "1.1.5",
"version": "1.1.7",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
@@ -29,6 +29,7 @@
"config": "^3.3.9",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.13",
"dns-cache": "^2.0.0",
"dotenv": "^16.3.1",
"dottie": "^2.0.6",
@@ -44,7 +45,6 @@
"knex": "^2.4.2",
"material-react-table": "^3.1.0",
"memoizee": "^0.4.17",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"passport": "^0.6.0",
@@ -8799,9 +8799,10 @@
}
},
"node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.5",
"version": "1.1.7",
"private": true,
"main": "src/index.jsx",
"scripts": {
@@ -36,6 +36,7 @@
"config": "^3.3.9",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.13",
"dns-cache": "^2.0.0",
"dotenv": "^16.3.1",
"dottie": "^2.0.6",
@@ -51,7 +52,6 @@
"knex": "^2.4.2",
"material-react-table": "^3.1.0",
"memoizee": "^0.4.17",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"passport": "^0.6.0",

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,319 @@
{
"JELLYSTAT": "Jellystat",
"MENU_TABS": {
"HOME": "Startseite",
"LIBRARIES": "Bibliotheken",
"USERS": "Benutzer",
"ACTIVITY": "Aktivitäten",
"STATISTICS": "Statistiken",
"SETTINGS": "Einstellungen",
"ABOUT": "Über",
"LOGOUT": "Abmelden",
"TIMELINE": "Zeitleiste"
},
"HOME_PAGE": {
"SESSIONS": "Sitzungen",
"RECENTLY_ADDED": "Zuletzt hinzugefügt",
"WATCH_STATISTIC": "Wiedergabestatistiken",
"LIBRARY_OVERVIEW": "Bibliothek-Übersicht"
},
"SESSIONS": {
"NO_SESSIONS": "Keine aktiven Sitzungen gefunden",
"DIRECT_PLAY": "Direkte Wiedergabe",
"TRANSCODE": "Transkodieren"
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "MEISTGESEHENE FILME",
"MOST_POPULAR_MOVIES": "BELIEBTESTE FILME",
"MOST_VIEWED_SERIES": "MEISTGESEHENE SERIEN",
"MOST_POPULAR_SERIES": "BELIEBTESTE SERIEN",
"MOST_LISTENED_MUSIC": "MEISTGEHÖRTE MUSIK",
"MOST_POPULAR_MUSIC": "BELIEBTESTE MUSIK",
"MOST_VIEWED_LIBRARIES": "MEISTGESEHENE BIBLIOTHEKEN",
"MOST_USED_CLIENTS": "MEISTGENUTZTE CLIENTS",
"MOST_ACTIVE_USERS": "AKTIVSTE BENUTZER",
"CONCURRENT_STREAMS": "GLEICHZEITIGE STREAMS"
},
"LIBRARY_OVERVIEW": {
"MOVIE_LIBRARIES": "FILM-BIBLIOTHEKEN",
"SHOW_LIBRARIES": "SERIEN-BIBLIOTHEKEN",
"MUSIC_LIBRARIES": "MUSIK-BIBLIOTHEKEN",
"MIXED_LIBRARIES": "GEMISCHTE BIBLIOTHEKEN"
},
"LIBRARY_CARD": {
"LIBRARY": "Bibliothek",
"TOTAL_TIME": "Gesamtlaufzeit",
"TOTAL_FILES": "Gesamtzahl der Dateien",
"LIBRARY_SIZE": "Größe der Bibliothek",
"TOTAL_PLAYBACK": "Gesamtwiedergabezeit",
"LAST_PLAYED": "Zuletzt gespielt",
"LAST_ACTIVITY": "Letzte Aktivität",
"TRACKED": "Daten-Tracking"
},
"GLOBAL_STATS": {
"LAST_24_HRS": "Letzten 24 Stunden",
"LAST_7_DAYS": "Letzten 7 Tage",
"LAST_30_DAYS": "Letzten 30 Tage",
"LAST_180_DAYS": "Letzten 180 Tage",
"LAST_365_DAYS": "Letzten 365 Tage",
"ALL_TIME": "Gesamtzeit",
"ITEM_STATS": "Statistik"
},
"ITEM_INFO": {
"FILE_PATH": "Dateipfad",
"FILE_SIZE": "Dateigröße",
"RUNTIME": "Laufzeit",
"AVERAGE_RUNTIME": "Durchschnittliche Laufzeit",
"OPEN_IN_JELLYFIN": "In Jellyfin öffnen",
"ARCHIVED_DATA_OPTIONS": "Optionen für archivierte Daten",
"PURGE": "Löschen",
"CONFIRM_ACTION": "Aktion bestätigen",
"CONFIRM_ACTION_MESSAGE": "Sind Sie sicher, dass Sie dieses Element löschen möchten",
"CONFIRM_ACTION_MESSAGE_2": "und zugehörige Wiedergabeaktivitäten"
},
"LIBRARY_INFO": {
"LIBRARY_STATS": "Bibliothek-Statistiken",
"LIBRARY_ACTIVITY": "Bibliothek-Aktivität"
},
"TAB_CONTROLS": {
"OVERVIEW": "Übersicht",
"ACTIVITY": "Aktivität",
"OPTIONS": "Optionen",
"TIMELINE": "Zeitleiste"
},
"ITEM_ACTIVITY": "Elementaktivität",
"ACTIVITY_TABLE": {
"MODAL": {
"HEADER": "Stream-Informationen"
},
"IP_ADDRESS": "IP-Adresse",
"CLIENT": "Client",
"DEVICE": "Gerät",
"PLAYBACK_DURATION": "Wiedergabedauer",
"TOTAL_PLAYBACK": "Gesamtwiedergabezeit",
"EXPAND": "Erweitern",
"COLLAPSE": "Reduzieren",
"SORT_BY": "Sortieren nach",
"ASCENDING": "Aufsteigend",
"DESCENDING": "Absteigend",
"CLEAR_SORT": "Sortierung aufheben",
"CLEAR_FILTER": "Filter löschen",
"FILTER_BY": "Filtern nach",
"COLUMN_ACTIONS": "Spaltenaktionen",
"TOGGLE_SELECT_ROW": "Zeile auswählen/abwählen",
"TOGGLE_SELECT_ALL": "Alle auswählen/abwählen",
"MIN": "Min",
"MAX": "Max"
},
"TABLE_NAV_BUTTONS": {
"FIRST": "Erste",
"LAST": "Letzte",
"NEXT": "Nächste",
"PREVIOUS": "Vorherige"
},
"PURGE_OPTIONS": {
"PURGE_CACHE": "Zwischengespeichertes Element löschen",
"PURGE_CACHE_WITH_ACTIVITY": "Zwischengespeichertes Element und Wiedergabeaktivität löschen",
"PURGE_LIBRARY_CACHE": "Zwischengespeicherte Bibliothek und Elemente löschen",
"PURGE_LIBRARY_CACHE_WITH_ACTIVITY": "Zwischengespeicherte Bibliothek, Elemente und Aktivität löschen",
"PURGE_LIBRARY_ITEMS_CACHE": "Nur zwischengespeicherte Bibliothekelemente löschen",
"PURGE_LIBRARY_ITEMS_CACHE_WITH_ACTIVITY": "Nur zwischengespeicherte Bibliothekelemente und Aktivität löschen",
"PURGE_ACTIVITY": "Möchten Sie die ausgewählte Wiedergabeaktivität wirklich löschen?"
},
"ERROR_MESSAGES": {
"FETCH_THIS_ITEM": "Dieses Element von Jellyfin abrufen",
"NO_ACTIVITY": "Keine Aktivität gefunden",
"NEVER": "Nie",
"N/A": "N/A",
"NO_STATS": "Keine Statistiken zum Anzeigen",
"NO_BACKUPS": "Keine Sicherungen gefunden",
"NO_LOGS": "Keine Protokolle gefunden",
"NO_API_KEYS": "Keine Schlüssel gefunden",
"NETWORK_ERROR": "Verbindung zum Jellyfin-Server nicht möglich",
"INVALID_LOGIN": "Ungültiger Benutzername oder Passwort",
"INVALID_URL": "Fehler {STATUS}: Die angeforderte URL wurde nicht gefunden.",
"UNAUTHORIZED": "Fehler {STATUS}: Nicht autorisiert",
"PASSWORD_LENGTH": "Passwort muss mindestens 6 Zeichen lang sein",
"USERNAME_REQUIRED": "Benutzername ist erforderlich"
},
"SHOW_ARCHIVED_LIBRARIES": "Archivierte Bibliotheken anzeigen",
"HIDE_ARCHIVED_LIBRARIES": "Archivierte Bibliotheken ausblenden",
"UNITS": {
"YEAR": "Jahr",
"YEARS": "Jahre",
"MONTH": "Monat",
"MONTHS": "Monate",
"DAY": "Tag",
"DAYS": "Tage",
"HOUR": "Stunde",
"HOURS": "Stunden",
"MINUTE": "Minute",
"MINUTES": "Minuten",
"SECOND": "Sekunde",
"SECONDS": "Sekunden",
"PLAYS": "Wiedergaben",
"ITEMS": "Elemente",
"STREAMS": "Streams"
},
"USERS_PAGE": {
"ALL_USERS": "Alle Benutzer",
"LAST_CLIENT": "Letzter Client",
"LAST_SEEN": "Zuletzt gesehen",
"AGO": "vor",
"AGO_ALT": "",
"USER_STATS": "Benutzerstatistiken",
"USER_ACTIVITY": "Benutzeraktivität"
},
"STAT_PAGE": {
"STATISTICS": "Statistiken",
"DAILY_PLAY_PER_LIBRARY": "Tägliche Wiedergabezahl pro Bibliothek",
"PLAY_COUNT_BY": "Wiedergabezahl nach"
},
"SETTINGS_PAGE": {
"SETTINGS": "Allgemein",
"LANGUAGE": "Sprache",
"SELECT_AN_ADMIN": "Einen bevorzugten Administrator auswählen",
"LIBRARY_SETTINGS": "Bibliothek",
"BACKUP": "Sicherung",
"BACKUPS": "Sicherungen",
"CHOOSE_FILE": "Datei auswählen",
"LOGS": "Protokolle",
"SIZE": "Größe",
"JELLYFIN_URL": "Jellyfin URL",
"EMBY_URL": "Emby URL",
"EXTERNAL_URL": "Externe URL",
"API_KEY": "API-Schlüssel",
"API_KEYS": "API-Schlüssel",
"KEY_NAME": "Schlüsselname",
"KEY": "Schlüssel",
"NAME": "Name",
"ADD_KEY": "Schlüssel hinzufügen",
"DURATION": "Dauer",
"EXECUTION_TYPE": "Ausführungstyp",
"RESULTS": "Ergebnisse",
"SELECT_ADMIN": "Bevorzugtes Administratorkonto auswählen",
"HOUR_FORMAT": "Stundenformat",
"HOUR_FORMAT_12": "12 Stunden",
"HOUR_FORMAT_24": "24 Stunden",
"SECURITY": "Sicherheit",
"CURRENT_PASSWORD": "Aktuelles Passwort",
"NEW_PASSWORD": "Neues Passwort",
"UPDATE": "Aktualisieren",
"REQUIRE_LOGIN": "Anmeldung erforderlich",
"TASK": "Aufgabe",
"TASKS": "Aufgaben",
"INTERVAL": "Intervall",
"INTERVALS": {
"15_MIN": "15 Minuten",
"30_MIN": "30 Minuten",
"1_HOUR": "1 Stunde",
"12_HOURS": "12 Stunden",
"1_DAY": "1 Tag",
"1_WEEK": "1 Woche"
},
"SELECT_LIBRARIES_TO_IMPORT": "Bibliotheken zum Importieren auswählen",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "Die Aktivität für Elemente innerhalb dieser Bibliotheken wird weiterhin verfolgt - auch wenn sie nicht importiert werden.",
"DATE_ADDED": "Hinzugefügt am"
},
"TASK_TYPE": {
"JOB": "Job",
"IMPORT": "Import"
},
"TASK_DESCRIPTION": {
"PartialJellyfinSync": "Synchronisierung kürzlich hinzugefügter Elemente",
"JellyfinSync": "Vollständige Synchronisierung mit Jellyfin",
"Jellyfin_Playback_Reporting_Plugin_Sync": "Import von Wiedergabeberichts-Plugin-Daten",
"Backup": "Jellystat Sicherung"
},
"ABOUT_PAGE": {
"ABOUT_JELLYSTAT": "Über Jellystat",
"VERSION": "Version",
"UPDATE_AVAILABLE": "Update verfügbar",
"GITHUB": "Github",
"Backup": "Jellystat Sicherung"
},
"TIMELINE_PAGE": {
"TIMELINE": "Zeitleiste",
"EPISODES_one": "Episode",
"EPISODES_other": "Episoden"
},
"SEARCH": "Suchen",
"TOTAL": "Gesamt",
"LAST": "Letzten",
"SERIES": "Serien",
"SEASON": "Staffel",
"SEASONS": "Staffeln",
"EPISODE": "Episode",
"EPISODES": "Episoden",
"MOVIES": "Filme",
"MUSIC": "Musik",
"SONGS": "Lieder",
"FILES": "Dateien",
"LIBRARIES": "Bibliotheken",
"USER": "Benutzer",
"USERS": "Benutzer",
"TYPE": "Typ",
"NEW_VERSION_AVAILABLE": "Neue Version verfügbar",
"ARCHIVED": "Archiviert",
"NOT_ARCHIVED": "Nicht archiviert",
"ALL": "Alle",
"CLOSE": "Schließen",
"TOTAL_PLAYS": "Gesamtwiedergaben",
"TITLE": "Titel",
"VIEWS": "Ansichten",
"WATCH_TIME": "Wiedergabezeit",
"LAST_WATCHED": "Zuletzt angesehen",
"MEDIA": "Medien",
"SAVE": "Speichern",
"YES": "Ja",
"NO": "Nein",
"FILE_NAME": "Dateiname",
"DATE": "Datum",
"START": "Start",
"STOP": "Stop",
"DOWNLOAD": "Herunterladen",
"RESTORE": "Wiederherstellen",
"ACTIONS": "Aktionen",
"DELETE": "Löschen",
"BITRATE": "Bitrate",
"CONTAINER": "Container",
"VIDEO": "Video",
"CODEC": "Codec",
"WIDTH": "Breite",
"HEIGHT": "Höhe",
"FRAMERATE": "Bildrate",
"DYNAMIC_RANGE": "Dynamikbereich",
"ASPECT_RATIO": "Seitenverhältnis",
"AUDIO": "Audio",
"CHANNELS": "Kanäle",
"LANGUAGE": "Sprache",
"STREAM_DETAILS": "Stream Details",
"SOURCE_DETAILS": "Details zur Videoquelle",
"DIRECT": "Direkt",
"TRANSCODE": "Transkodieren",
"DIRECT_STREAM": "Direkt-Stream",
"USERNAME": "Benutzername",
"PASSWORD": "Passwort",
"LOGIN": "Anmelden",
"FT_SETUP_PROGRESS": "Erster Einrichtungsschritt {STEP} von {TOTAL}",
"VALIDATING": "Validierung läuft",
"SAVE_JELLYFIN_DETAILS": "Jellyfin-Details speichern",
"SETTINGS_SAVED": "Einstellungen gespeichert",
"SUCCESS": "Erfolg",
"PASSWORD_UPDATE_SUCCESS": "Passwort erfolgreich aktualisiert",
"CREATE_USER": "Benutzer erstellen",
"GEOLOCATION_INFO_FOR": "Geolokalisierungsinformationen für",
"CITY": "Stadt",
"REGION": "Region",
"COUNTRY": "Land",
"ORGANIZATION": "Organisation",
"ISP": "ISP",
"LATITUDE": "Breitengrad",
"LONGITUDE": "Längengrad",
"TIMEZONE": "Zeitzone",
"POSTCODE": "Postleitzahl",
"X_ROWS_SELECTED": "{ROWS} Zeilen ausgewählt",
"TRANSCODE_REASONS": "Transkodierungsgründe",
"SUBTITLES": "Untertitel",
"GENRES": "Genres"
}

View File

@@ -167,7 +167,11 @@
"STAT_PAGE": {
"STATISTICS": "Statistics",
"DAILY_PLAY_PER_LIBRARY": "Daily Play Count Per Library",
"PLAY_COUNT_BY": "Play Count By"
"DAILY_DURATION_PER_LIBRARY": "Daily Play Duration Per Library",
"PLAY_COUNT_BY": "Play Count By",
"PLAY_DURATION_BY": "Play Duration By",
"COUNT_VIEW": "Count",
"DURATION_VIEW": "Duration"
},
"SETTINGS_PAGE": {
"SETTINGS": "Settings",
@@ -211,6 +215,15 @@
"1_DAY": "1 Day",
"1_WEEK": "1 Week"
},
"ACTIVITY_MONITOR": "Activity Monitor",
"ACTIVE_SESSIONS_INTERVAL": "Active Sessions Interval (ms)",
"ACTIVE_SESSIONS_HELP": "How often to check when users are watching content (recommended: 1000ms)",
"IDLE_INTERVAL": "Idle Interval (ms)",
"IDLE_HELP": "How often to check when no active sessions (recommended: 5000ms)",
"POLLING_INFO_TITLE": "Smart Polling",
"POLLING_INFO": "The system automatically adapts monitoring frequency: fast when users are watching content, slower when the server is idle. This reduces CPU load on your Jellyfin server.",
"INTERVAL_WARNING": "Active sessions interval should not be greater than idle interval",
"REALTIME_UPDATE_INFO": "Changes are applied in real-time without server restart.",
"SELECT_LIBRARIES_TO_IMPORT": "Select Libraries to Import",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "Activity for Items within these libraries are still Tracked - Even when not imported.",
"DATE_ADDED": "Date Added",
@@ -226,7 +239,12 @@
"URL": "URL",
"TYPE": "Type",
"TRIGGER": "Trigger",
"STATUS": "Status"
"STATUS": "Status",
"EVENT_WEBHOOKS": "Event notifications",
"EVENT_WEBHOOKS_TOOLTIP": "Enable or disable event notifications",
"PLAYBACK_STARTED": "Playback Started",
"PLAYBACK_ENDED": "Playback Stopped",
"MEDIA_ADDED": "Media Added"
},
"TASK_TYPE": {
"JOB": "Job",

View File

@@ -211,9 +211,36 @@
"1_DAY": "1 Jour",
"1_WEEK": "1 Semaine"
},
"ACTIVITY_MONITOR": "Surveillance d'activité",
"ACTIVE_SESSIONS_INTERVAL": "Intervalle avec sessions actives (ms)",
"ACTIVE_SESSIONS_HELP": "Fréquence de vérification quand des utilisateurs regardent du contenu (recommandé: 1000ms)",
"IDLE_INTERVAL": "Intervalle en veille (ms)",
"IDLE_HELP": "Fréquence de vérification quand aucune session active (recommandé: 5000ms)",
"POLLING_INFO_TITLE": "Polling intelligent",
"POLLING_INFO": "Le système adapte automatiquement la fréquence de surveillance : rapide quand des utilisateurs regardent du contenu, plus lent quand le serveur est inactif. Cela réduit la charge CPU sur votre serveur Jellyfin.",
"INTERVAL_WARNING": "L'intervalle actif ne devrait pas être supérieur à l'intervalle de veille",
"REALTIME_UPDATE_INFO": "Les modifications sont appliquées en temps réel sans redémarrage du serveur.",
"SELECT_LIBRARIES_TO_IMPORT": "Sélectionner les médiathèques à importer",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "L'activité du contenu de ces médiathèques est toujours suivie, même s'ils ne sont pas importés.",
"DATE_ADDED": "Date d'ajout"
"DATE_ADDED": "Date d'ajout",
"WEBHOOKS": "Webhooks",
"WEBHOOK_TYPE": "Type de webhook",
"TEST_NOW": "Tester maintenant",
"WEBHOOKS_CONFIGURATION": "Configuration des webhooks",
"WEBHOOKS_TOOLTIP": "L'URL des webhooks utiliser pour envoyer des notifications à Discord ou à d'autres services",
"WEBHOOK_SAVED": "Webhook sauvegardé",
"WEBHOOK_NAME": "Nom du webhook",
"DISCORD_WEBHOOK_URL": "URL du webhook Discord",
"ENABLE_WEBHOOK": "Activer le webhook",
"URL": "URL",
"TYPE": "Type",
"TRIGGER": "Déclencheur",
"STATUS": "Status",
"EVENT_WEBHOOKS": "Notifications d'événements",
"EVENT_WEBHOOKS_TOOLTIP": "Activez ou désactivez les notifications pour différents événements du système",
"PLAYBACK_STARTED": "Lecture commencée",
"PLAYBACK_ENDED": "Lecture arrêtée",
"MEDIA_ADDED": "Média ajouté"
},
"TASK_TYPE": {
"JOB": "Job",

View File

@@ -23,4 +23,8 @@ export const languages = [
id: "ca-ES",
description: "Català",
},
{
id: "de-DE",
description: "Deutsch",
},
];

View File

@@ -13,7 +13,7 @@ import baseUrl from "../../../lib/baseurl";
import "../../css/timeline/activity-timeline.css";
import { useMediaQuery, useTheme } from "@mui/material";
import moment from "moment";
import dayjs from "dayjs";
import TvLineIcon from "remixicon-react/TvLineIcon.js";
import FilmLineIcon from "remixicon-react/FilmLineIcon.js";
import { MEDIA_TYPES } from "./helpers";
@@ -29,8 +29,8 @@ const dateFormatOptions = {
};
function formatEntryDates(FirstActivityDate, LastActivityDate, MediaType) {
const startDate = moment(FirstActivityDate);
const endDate = moment(LastActivityDate);
const startDate = dayjs(FirstActivityDate);
const endDate = dayjs(LastActivityDate);
if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) {
return Intl.DateTimeFormat(localization, dateFormatOptions).format(

View File

@@ -23,8 +23,6 @@ function LibraryItems(props) {
localStorage.getItem("PREF_sortAsc") != undefined ? localStorage.getItem("PREF_sortAsc") == "true" : true
);
console.log(sortOrder);
const archive = {
all: "all",
archived: "true",
@@ -212,7 +210,11 @@ function LibraryItems(props) {
}
})
.map((item) => (
<MoreItemCards data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} key={item.Id + item.SeasonNumber + item.EpisodeNumber} />
<MoreItemCards
data={item}
base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl}
key={item.Id + item.SeasonNumber + item.EpisodeNumber}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,175 @@
import { useState, useEffect } from "react";
import axios from "../../../lib/axios_instance";
import { Row, Col, Form, Button, Alert } from "react-bootstrap";
import { Trans } from "react-i18next";
import Loading from "../general/loading";
export default function ActivityMonitorSettings() {
const [settings, setSettings] = useState({
activeSessionsInterval: 1000,
idleInterval: 5000
});
const [isSubmitted, setIsSubmitted] = useState("");
const [submissionMessage, setSubmissionMessage] = useState("");
const [loading, setLoading] = useState(true);
const token = localStorage.getItem("token");
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await axios.get("/api/getActivityMonitorSettings", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setSettings(response.data);
setLoading(false);
} catch (error) {
console.error("Error fetching Activity Monitor settings:", error);
setLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setSettings(prev => ({
...prev,
[name]: parseInt(value, 10)
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitted("");
setSubmissionMessage("");
try {
await axios.post("/api/setActivityMonitorSettings", settings, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setIsSubmitted("Success");
setSubmissionMessage("Paramètres de surveillance mis à jour avec succès - Appliqués en temps réel");
} catch (error) {
console.error("Error updating Activity Monitor settings:", error);
setIsSubmitted("Failed");
setSubmissionMessage(
error.response?.data || "Erreur lors de la mise à jour des paramètres"
);
}
};
if (loading) {
return <Loading />;
}
return (
<div>
<h1>
<Trans i18nKey={"SETTINGS_PAGE.ACTIVITY_MONITOR"} defaults="Surveillance d'activité" />
</h1>
<div className="mb-3 text-muted">
<small>
<Trans
i18nKey={"SETTINGS_PAGE.REALTIME_UPDATE_INFO"}
defaults="Les modifications sont appliquées en temps réel sans redémarrage du serveur."
/>
</small>
</div>
<Form onSubmit={handleSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">
<Trans
i18nKey={"SETTINGS_PAGE.ACTIVE_SESSIONS_INTERVAL"}
defaults="Intervalle avec sessions actives (ms)"
/>
</Form.Label>
<Col sm="10">
<Form.Control
type="number"
name="activeSessionsInterval"
value={settings.activeSessionsInterval}
onChange={handleInputChange}
min="500"
max="10000"
step="100"
required
autoComplete="off"
/>
<Form.Text className="text-muted">
<Trans
i18nKey={"SETTINGS_PAGE.ACTIVE_SESSIONS_HELP"}
defaults="Fréquence de vérification quand des utilisateurs regardent du contenu (recommandé: 1000ms)"
/>
</Form.Text>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">
<Trans
i18nKey={"SETTINGS_PAGE.IDLE_INTERVAL"}
defaults="Intervalle en veille (ms)"
/>
</Form.Label>
<Col sm="10">
<Form.Control
type="number"
name="idleInterval"
value={settings.idleInterval}
onChange={handleInputChange}
min="1000"
max="30000"
step="1000"
required
autoComplete="off"
/>
<Form.Text className="text-muted">
<Trans
i18nKey={"SETTINGS_PAGE.IDLE_HELP"}
defaults="Fréquence de vérification quand aucune session active (recommandé: 5000ms)"
/>
</Form.Text>
</Col>
</Form.Group>
{settings.activeSessionsInterval > settings.idleInterval && (
<Alert bg="dark" data-bs-theme="dark" variant="warning" className="mb-3">
<Trans
i18nKey={"SETTINGS_PAGE.INTERVAL_WARNING"}
defaults="L'intervalle actif ne devrait pas être supérieur à l'intervalle de veille"
/>
</Alert>
)}
{isSubmitted !== "" ? (
isSubmitted === "Failed" ? (
<Alert bg="dark" data-bs-theme="dark" variant="danger">
{submissionMessage}
</Alert>
) : (
<Alert bg="dark" data-bs-theme="dark" variant="success">
{submissionMessage}
</Alert>
)
) : (
<></>
)}
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
<Button variant="outline-success" type="submit">
<Trans i18nKey={"SETTINGS_PAGE.UPDATE"} />
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -44,6 +44,20 @@ export default function SettingsConfig() {
set12hr(Boolean(storage_12hr));
}
const fetchAdmins = async () => {
try {
const adminData = await axios.get(`/proxy/getAdminUsers`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAdmins(adminData.data);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
Config.getConfig()
.then((config) => {
@@ -59,20 +73,6 @@ export default function SettingsConfig() {
setsubmissionMessage("Error Retrieving Configuration. Unable to contact Backend Server");
});
const fetchAdmins = async () => {
try {
const adminData = await axios.get(`/proxy/getAdminUsers`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAdmins(adminData.data);
} catch (error) {
console.log(error);
}
};
fetchAdmins();
}, [token]);
@@ -91,6 +91,8 @@ export default function SettingsConfig() {
console.log("Config updated successfully:", response.data);
setisSubmitted("Success");
setsubmissionMessage("Successfully updated configuration");
Config.setConfig();
fetchAdmins();
})
.catch((error) => {
let errorMessage = error.response.data.errorMessage;
@@ -98,7 +100,6 @@ export default function SettingsConfig() {
setisSubmitted("Failed");
setsubmissionMessage(`Error Updating Configuration: ${errorMessage}`);
});
Config.setConfig();
}
async function handleFormSubmitExternal(event) {
@@ -233,9 +234,13 @@ export default function SettingsConfig() {
</Form.Group>
{isSubmitted !== "" ? (
isSubmitted === "Failed" ? (
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">
{submissionMessage}
</Alert>
) : (
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">
{submissionMessage}
</Alert>
)
) : (
<></>
@@ -265,9 +270,13 @@ export default function SettingsConfig() {
{isSubmittedExternal !== "" ? (
isSubmittedExternal === "Failed" ? (
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">
{submissionMessageExternal}
</Alert>
) : (
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">
{submissionMessageExternal}
</Alert>
)
) : (
<></>

View File

@@ -3,6 +3,7 @@ import axios from "../../../lib/axios_instance";
import { Form, Row, Col, Button, Spinner, Alert } from "react-bootstrap";
import InformationLineIcon from "remixicon-react/InformationLineIcon";
import { Tooltip } from "@mui/material";
import PropTypes from 'prop-types';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@@ -28,16 +29,16 @@ function WebhookRow(props) {
<TableCell>{webhook.webhook_type || 'generic'}</TableCell>
<TableCell>{webhook.trigger_type}</TableCell>
<TableCell>
<span className={`badge ${webhook.enabled ? 'bg-success' : 'bg-secondary'}`}>
{webhook.enabled ? <Trans i18nKey={"ENABLED"} /> : <Trans i18nKey={"DISABLED"} />}
</span>
<span className={`badge ${webhook.enabled ? 'bg-success' : 'bg-secondary'}`}>
{webhook.enabled ? <Trans i18nKey={"ENABLED"} /> : <Trans i18nKey={"DISABLED"} />}
</span>
</TableCell>
<TableCell>
<div className="d-flex justify-content-end gap-2">
<Button size="sm" variant="outline-primary" onClick={() => onEdit(webhook)}>
<Trans i18nKey={"EDIT"} />
</Button>
<Button size="sm" variant="outline-secondary" onClick={() => onTest(webhook.id)}>
<Button size="sm" variant="outline-secondary" onClick={() => onTest(webhook)}>
<Trans i18nKey={"SETTINGS_PAGE.TEST_NOW"} />
</Button>
</div>
@@ -47,6 +48,19 @@ function WebhookRow(props) {
);
}
WebhookRow.propTypes = {
webhook: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
webhook_type: PropTypes.string,
trigger_type: PropTypes.string.isRequired,
enabled: PropTypes.bool.isRequired
}).isRequired,
onEdit: PropTypes.func.isRequired,
onTest: PropTypes.func.isRequired
};
function WebhooksSettings() {
const [webhooks, setWebhooks] = useState([]);
const [loading, setLoading] = useState(true);
@@ -63,6 +77,12 @@ function WebhooksSettings() {
webhook_type: 'discord'
});
const [eventWebhooks, setEventWebhooks] = useState({
playback_started: { exists: false, enabled: false },
playback_ended: { exists: false, enabled: false },
media_recently_added: { exists: false, enabled: false }
});
useEffect(() => {
const fetchWebhooks = async () => {
try {
@@ -73,18 +93,19 @@ function WebhooksSettings() {
},
});
if (response.data != webhooks) {
if (response.data !== webhooks) {
setWebhooks(response.data);
}
if (loading) {
await loadEventWebhooks();
}
if (loading) {
setLoading(false);
}
}
} catch (err) {
console.error("Error loading webhooks:", err);
if (loading) {
setLoading(false);
}
}
}
};
@@ -92,7 +113,7 @@ function WebhooksSettings() {
const intervalId = setInterval(fetchWebhooks, 1000 * 10);
return () => clearInterval(intervalId);
}, []);
}, [webhooks.length]);
const handleInputChange = (e) => {
const { name, value } = e.target;
@@ -111,7 +132,13 @@ function WebhooksSettings() {
setSuccess(false);
if (!currentWebhook.url) {
setError("Discord webhook URL is required");
setError("Webhook URL is required");
setSaving(false);
return;
}
if (currentWebhook.trigger_type === 'event' && !currentWebhook.event_type) {
setError("Event type is required for an event based webhook");
setSaving(false);
return;
}
@@ -134,6 +161,17 @@ function WebhooksSettings() {
});
}
const webhooksResponse = await axios.get('/webhooks', {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setWebhooks(webhooksResponse.data);
await loadEventWebhooks();
setCurrentWebhook({
name: 'New Webhook',
url: '',
@@ -143,10 +181,11 @@ function WebhooksSettings() {
method: 'POST',
webhook_type: 'discord'
});
setSuccess("Webhook saved successfully!");
setSaving(false);
} catch (err) {
setError("Error during webhook saving: " + (err.response?.data?.error || err.message));
setError("Error while saving webhook " + (err.response?.data?.error || err.message));
setSaving(false);
}
};
@@ -155,9 +194,10 @@ function WebhooksSettings() {
setCurrentWebhook(webhook);
};
const handleTest = async (webhookId) => {
if (!webhookId) {
setError("Impossible to test the webhook: no ID provided");
const handleTest = async (webhook) => {
if (!webhook || !webhook.id) {
setError("Impossible to test the webhook: no webhook provided");
setLoading(false);
return;
}
@@ -165,14 +205,20 @@ function WebhooksSettings() {
setLoading(true);
setError(null);
await axios.post(`/webhooks/${webhookId}/trigger-monthly`, {}, {
let endpoint = `/webhooks/${webhook.id}/test`;
if (webhook.trigger_type === 'scheduled' && webhook.schedule && webhook.schedule.includes('1 * *')) {
endpoint = `/webhooks/${webhook.id}/trigger-monthly`;
}
await axios.post(endpoint, {}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
});
setSuccess("Webhook test triggered successfully!");
setSuccess(`Webhook ${webhook.name} test triggered successfully!`);
setLoading(false);
} catch (err) {
setError("Error during the test of the webhook: " + (err.response?.data?.message || err.message));
@@ -180,6 +226,107 @@ function WebhooksSettings() {
}
};
const getEventWebhookStatus = (eventType) => {
return eventWebhooks[eventType]?.enabled || false;
};
const loadEventWebhooks = async () => {
try {
const eventTypes = ['playback_started', 'playback_ended', 'media_recently_added'];
const status = {};
eventTypes.forEach(eventType => {
const matchingWebhooks = webhooks.filter(
webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType
);
status[eventType] = {
exists: matchingWebhooks.length > 0,
enabled: matchingWebhooks.some(webhook => webhook.enabled)
};
});
setEventWebhooks(status);
} catch (error) {
console.error('Error loading event webhook status:', error);
}
};
const toggleEventWebhook = async (eventType) => {
try {
setLoading(true);
setError(null);
const isCurrentlyEnabled = getEventWebhookStatus(eventType);
const matchingWebhooks = webhooks.filter(
webhook => webhook.trigger_type === 'event' && webhook.event_type === eventType
);
if (matchingWebhooks.length === 0 && !isCurrentlyEnabled) {
const newWebhook = {
name: `Notification - ${getEventDisplayName(eventType)}`,
url: '',
enabled: true,
trigger_type: 'event',
event_type: eventType,
method: 'POST',
webhook_type: 'discord'
};
setCurrentWebhook(newWebhook);
setLoading(false);
return;
}
for (const webhook of matchingWebhooks) {
await axios.put(`/webhooks/${webhook.id}`,
{ ...webhook, enabled: !isCurrentlyEnabled },
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
}
);
}
setEventWebhooks(prev => ({
...prev,
[eventType]: {
...prev[eventType],
enabled: !isCurrentlyEnabled
}
}));
const response = await axios.get('/webhooks', {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setWebhooks(response.data);
setLoading(false);
setSuccess(`Webhook for ${getEventDisplayName(eventType)} ${!isCurrentlyEnabled ? 'enabled' : 'disabled'} with success!`);
} catch (error) {
setError("Error while editing webhook: " + (error.response?.data?.error || error.message));
setLoading(false);
}
};
const getEventDisplayName = (eventType) => {
switch(eventType) {
case 'playback_started':
return 'Playback started';
case 'playback_ended':
return 'Playback ended';
case 'media_recently_added':
return 'New media added';
default:
return eventType;
}
};
if (loading && !webhooks.length) {
return <Loading />;
}
@@ -273,7 +420,72 @@ function WebhooksSettings() {
</Col>
</Form.Group>
</Form>
{/* Ajout de la section pour les webhooks événementiels */}
<div className="event-webhooks mt-4 mb-4">
<h3 className="my-3">
<Trans i18nKey={"SETTINGS_PAGE.EVENT_WEBHOOKS"} />
<Tooltip title={<Trans i18nKey={"SETTINGS_PAGE.EVENT_WEBHOOKS_TOOLTIP"} />}>
<span className="ms-2">
<InformationLineIcon />
</span>
</Tooltip>
</h3>
<Row className="g-4">
<Col md={4}>
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5><Trans i18nKey={"SETTINGS_PAGE.PLAYBACK_STARTED"} /></h5>
<Form.Check
type="switch"
id="playback-started-enabled"
checked={getEventWebhookStatus('playback_started')}
onChange={() => toggleEventWebhook('playback_started')}
/>
</div>
<p className="small">
Send a webhook notification when a user starts watching a media
</p>
</div>
</Col>
<Col md={4}>
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5><Trans i18nKey={"SETTINGS_PAGE.PLAYBACK_ENDED"} /></h5>
<Form.Check
type="switch"
id="playback-ended-enabled"
checked={getEventWebhookStatus('playback_ended')}
onChange={() => toggleEventWebhook('playback_ended')}
/>
</div>
<p className="small">
Send a webhook notification when a user finishes watching a media
</p>
</div>
</Col>
<Col md={4}>
<div className="border rounded p-3 h-25">
<div className="d-flex justify-content-between align-items-center mb-2">
<h5><Trans i18nKey={"SETTINGS_PAGE.MEDIA_ADDED"} /></h5>
<Form.Check
type="switch"
id="media-recently-added-enabled"
checked={getEventWebhookStatus('media_recently_added')}
onChange={() => toggleEventWebhook('media_recently_added')}
/>
</div>
<p className="small">
Send a webhook notification when new media is added to the library
</p>
</div>
</Col>
</Row>
</div>
<TableContainer className='rounded-2 mt-4'>
<Table aria-label="webhooks table">
<TableHead>
@@ -305,7 +517,7 @@ function WebhooksSettings() {
</TableBody>
</Table>
</TableContainer>
</ErrorBoundary>
</div>
);

View File

@@ -10,12 +10,26 @@ import i18next from "i18next";
function GenreStatCard(props) {
const [maxRange, setMaxRange] = useState(100);
const [data, setData] = useState(props.data);
useEffect(() => {
const maxDuration = props.data.reduce((max, item) => {
return Math.max(max, parseFloat((props.dataKey == "duration" ? item.duration : item.plays) || 0));
}, 0);
setMaxRange(maxDuration);
let sorted = [...props.data]
.sort((a, b) => {
const valueA = parseFloat(props.dataKey === "duration" ? a.duration : a.plays) || 0;
const valueB = parseFloat(props.dataKey === "duration" ? b.duration : b.plays) || 0;
return valueB - valueA; // Descending order
})
.slice(0, 15); // Take only the top 10
// Sort top 10 genres alphabetically
sorted = sorted.sort((a, b) => a.genre.localeCompare(b.genre));
setData(sorted);
}, [props.data, props.dataKey]);
const CustomTooltip = ({ active, payload }) => {
@@ -67,7 +81,7 @@ function GenreStatCard(props) {
</h1>
<ErrorBoundary>
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={props.data}>
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
<PolarGrid gridType="circle" />
<PolarAngleAxis dataKey="genre" />
<PolarRadiusAxis domain={[0, maxRange]} tick={false} axisLine={false} />

View File

@@ -1,6 +1,6 @@
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, Legend } from "recharts";
function Chart({ stats, libraries }) {
function Chart({ stats, libraries, viewName }) {
const colors = [
"rgb(54, 162, 235)", // blue
"rgb(255, 99, 132)", // pink
@@ -24,13 +24,25 @@ function Chart({ stats, libraries }) {
"rgb(147, 112, 219)", // medium purple
];
const flattenedStats = stats.map(item => {
const flatItem = { Key: item.Key };
for (const [libraryName, data] of Object.entries(item)) {
if (libraryName === "Key") continue;
flatItem[libraryName] = data[viewName] ?? 0;
}
return flatItem;
});
const CustomTooltip = ({ payload, label, active }) => {
if (active) {
return (
<div style={{ backgroundColor: "rgba(0,0,0,0.8)", color: "white" }} className="p-2 rounded-2 border-0">
<p className="text-center fs-5">{label}</p>
{libraries.map((library, index) => (
<p key={library.Id} style={{ color: `${colors[index]}` }}>{`${library.Name} : ${payload[index].value} Views`}</p>
// <p key={library.Id} style={{ color: `${colors[index]}` }}>{`${library.Name} : ${payload[index].value} Views`}</p>
<p key={library.Id} style={{ color: `${colors[index]}` }}>
{`${library.Name} : ${payload?.find(p => p.dataKey === library.Name).value} ${viewName === "count" ? "Views" : "Minutes"}`}
</p>
))}
</div>
);
@@ -41,16 +53,14 @@ function Chart({ stats, libraries }) {
const getMaxValue = () => {
let max = 0;
if (stats) {
stats.forEach((datum) => {
Object.keys(datum).forEach((key) => {
if (key !== "Key") {
max = Math.max(max, parseInt(datum[key]));
}
});
flattenedStats.forEach(datum => {
libraries.forEach(library => {
const value = parseFloat(datum[library.Name]);
if (!isNaN(value)) {
max = Math.max(max, value);
}
});
}
});
return max;
};
@@ -58,7 +68,7 @@ function Chart({ stats, libraries }) {
return (
<ResponsiveContainer width="100%">
<AreaChart data={stats} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<AreaChart data={flattenedStats} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
{libraries.map((library, index) => (
<linearGradient key={library.Id} id={library.Id} x1="0" y1="0" x2="0" y2="1">

View File

@@ -10,6 +10,7 @@ function DailyPlayStats(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(20);
const [viewName, setViewName] = useState("count");
const token = localStorage.getItem("token");
@@ -17,12 +18,11 @@ function DailyPlayStats(props) {
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsOverTime`;
const url = `/stats/getViewsOverTime?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,
@@ -46,19 +46,24 @@ function DailyPlayStats(props) {
setDays(props.days);
fetchLibraries();
}
if (props.viewName !== viewName) {
setViewName(props.viewName);
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [stats,libraries, days, props.days, token]);
}, [stats,libraries, days, props.days, props.viewName, token]);
if (!stats) {
return <></>;
}
const titleKey = viewName === "count" ? "STAT_PAGE.DAILY_PLAY_PER_LIBRARY" : "STAT_PAGE.DAILY_DURATION_PER_LIBRARY";
if (stats.length === 0) {
return (
<div className="main-widget">
<h1><Trans i18nKey={"STAT_PAGE.DAILY_PLAY_PER_LIBRARY"}/> - {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h1><Trans i18nKey={titleKey}/> - {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h5><Trans i18nKey={"ERROR_MESSAGES.NO_STATS"}/></h5>
</div>
@@ -66,10 +71,10 @@ function DailyPlayStats(props) {
}
return (
<div className="main-widget">
<h2 className="text-start my-2"><Trans i18nKey={"STAT_PAGE.DAILY_PLAY_PER_LIBRARY"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<h2 className="text-start my-2"><Trans i18nKey={titleKey}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<div className="graph">
<Chart libraries={libraries} stats={stats} />
<Chart libraries={libraries} stats={stats} viewName={viewName}/>
</div>
</div>
);

View File

@@ -9,16 +9,16 @@ function PlayStatsByDay(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(20);
const [viewName, setViewName] = useState("count");
const token = localStorage.getItem("token");
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsByDays`;
const url = `/stats/getViewsByDays?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,
@@ -42,19 +42,24 @@ function PlayStatsByDay(props) {
setDays(props.days);
fetchLibraries();
}
if (props.viewName !== viewName) {
setViewName(props.viewName);
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [stats, libraries, days, props.days, token]);
}, [stats, libraries, days, props.days, props.viewName, token]);
if (!stats) {
return <></>;
}
const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY";
if (stats.length === 0) {
return (
<div className="statistics-widget small">
<h1><Trans i18nKey={"STAT_PAGE.PLAY_COUNT_BY"}/> <Trans i18nKey={"UNITS.DAY"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h1><Trans i18nKey={titleKey}/> <Trans i18nKey={"UNITS.DAY"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h5><Trans i18nKey={"ERROR_MESSAGES.NO_STATS"}/></h5>
</div>
@@ -63,9 +68,9 @@ function PlayStatsByDay(props) {
return (
<div className="statistics-widget">
<h2 className="text-start my-2"><Trans i18nKey={"STAT_PAGE.PLAY_COUNT_BY"}/> <Trans i18nKey={"UNITS.DAY"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<h2 className="text-start my-2"><Trans i18nKey={titleKey}/> <Trans i18nKey={"UNITS.DAY"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<div className="graph small">
<Chart libraries={libraries} stats={stats} />
<Chart libraries={libraries} stats={stats} viewName={viewName}/>
</div>
</div>
);

View File

@@ -8,16 +8,16 @@ function PlayStatsByHour(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(20);
const [viewName, setViewName] = useState("count");
const token = localStorage.getItem("token");
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsByHour`;
const url = `/stats/getViewsByHour?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,
@@ -41,19 +41,23 @@ function PlayStatsByHour(props) {
setDays(props.days);
fetchLibraries();
}
if (props.viewName !== viewName) {
setViewName(props.viewName);
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [stats, libraries, days, props.days, token]);
}, [stats, libraries, days, props.days, props.viewName, token]);
if (!stats) {
return <></>;
}
const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY";
if (stats.length === 0) {
return (
<div className="statistics-widget small">
<h1><Trans i18nKey={"STAT_PAGE.PLAY_COUNT_BY"}/> <Trans i18nKey={"UNITS.HOUR"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h1><Trans i18nKey={titleKey}/> <Trans i18nKey={"UNITS.HOUR"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h1>
<h5><Trans i18nKey={"ERROR_MESSAGES.NO_STATS"}/></h5>
</div>
@@ -63,9 +67,9 @@ function PlayStatsByHour(props) {
return (
<div className="statistics-widget">
<h2 className="text-start my-2"><Trans i18nKey={"STAT_PAGE.PLAY_COUNT_BY"}/> <Trans i18nKey={"UNITS.HOUR"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<h2 className="text-start my-2"><Trans i18nKey={titleKey}/> <Trans i18nKey={"UNITS.HOUR"}/> - <Trans i18nKey={"LAST"}/> {days} <Trans i18nKey={`UNITS.DAY${days>1 ? 'S':''}`}/></h2>
<div className="graph small">
<Chart libraries={libraries} stats={stats} />
<Chart libraries={libraries} stats={stats} viewName={viewName}/>
</div>
</div>
);

View File

@@ -47,6 +47,17 @@
margin-bottom: 10px !important;
}
.stats-tab-nav {
background-color: var(--secondary-background-color);
border-radius: 8px;
align-self: flex-end;
}
.nav-item {
display: flex;
justify-content: center;
}
.chart-canvas {
width: 100%;
height: 400px;

View File

@@ -7,6 +7,7 @@ import SecuritySettings from "./components/settings/security";
import ApiKeys from "./components/settings/apiKeys";
import WebhooksSettings from "./components/settings/webhooks";
import LibrarySelector from "./library_selector";
import ActivityMonitorSettings from "./components/settings/ActivityMonitorSettings";
import Logs from "./components/settings/logs";
@@ -33,6 +34,7 @@ export default function Settings() {
>
<SettingsConfig />
<SecuritySettings />
<ActivityMonitorSettings />
<Tasks />
</Tab>

View File

@@ -1,3 +1,4 @@
import { Tabs, Tab } from "react-bootstrap";
import { useState } from "react";
import "./css/stats.css";
@@ -20,6 +21,13 @@ function Statistics() {
localStorage.setItem("PREF_STATISTICS_STAT_DAYS_INPUT", event.target.value);
};
const [activeTab, setActiveTab] = useState(localStorage.getItem(`PREF_STATISTICS_LAST_SELECTED_TAB`) ?? "tabCount");
function setTab(tabName) {
setActiveTab(tabName);
localStorage.setItem(`PREF_STATISTICS_LAST_SELECTED_TAB`, tabName);
}
const handleKeyDown = (event) => {
if (event.key === "Enter") {
if (input < 1) {
@@ -43,6 +51,26 @@ function Statistics() {
<h1>
<Trans i18nKey={"STAT_PAGE.STATISTICS"} />
</h1>
<div className="stats-tab-nav">
<Tabs
defaultActiveKey={activeTab}
activeKey={activeTab}
onSelect={setTab}
variant="pills"
>
<Tab
eventKey="tabCount"
className="bg-transparent"
title={<Trans i18nKey="STAT_PAGE.COUNT_VIEW" />}
/>
<Tab
eventKey="tabDuration"
className="bg-transparent"
title={<Trans i18nKey="STAT_PAGE.DURATION_VIEW" />}
/>
</Tabs>
</div>
<div className="date-range">
<div className="header">
<Trans i18nKey={"LAST"} />
@@ -55,14 +83,26 @@ function Statistics() {
</div>
</div>
</div>
<div>
<DailyPlayStats days={days} />
<div className="statistics-graphs">
<PlayStatsByDay days={days} />
<PlayStatsByHour days={days} />
{activeTab === "tabCount" && (
<div>
<DailyPlayStats days={days} viewName="count" />
<div className="statistics-graphs">
<PlayStatsByDay days={days} viewName="count" />
<PlayStatsByHour days={days} viewName="count" />
</div>
</div>
</div>
)}
{activeTab === "tabDuration" && (
<div>
<DailyPlayStats days={days} viewName="duration" />
<div className="statistics-graphs">
<PlayStatsByDay days={days} viewName="duration" />
<PlayStatsByHour days={days} viewName="duration" />
</div>
</div>
)}
</div>
);
}