Merge branch 'unstable' into main

This commit is contained in:
T++
2025-09-29 23:05:31 +02:00
committed by GitHub
34 changed files with 1019 additions and 149 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

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

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

@@ -0,0 +1,121 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_over_time(
days integer)
RETURNS TABLE("Date" date, "Count" bigint, "Duration" bigint, "Library" text, "LibraryID" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
dates."Date",
COALESCE(counts."Count", 0) AS "Count",
COALESCE(counts."Duration", 0) AS "Duration",
l."Name" as "Library",
l."Id" as "LibraryID"
FROM
(SELECT generate_series(
DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)),
DATE_TRUNC('day', NOW()),
'1 day')::DATE AS "Date"
) dates
CROSS JOIN jf_libraries l
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
(SUM(a."PlaybackDuration") / 60)::bigint AS "Duration",
l."Name" as "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."Date" = dates."Date" AND counts."Library" = l."Name"
where l.archived=false
ORDER BY
"Date", "Library";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_over_time(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer);
CREATE OR REPLACE FUNCTION fs_watch_stats_over_time(
days integer
)
RETURNS TABLE(
"Date" date,
"Count" bigint,
"Library" text
)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
dates."Date",
COALESCE(counts."Count", 0) AS "Count",
l."Name" as "Library"
FROM
(SELECT generate_series(
DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)),
DATE_TRUNC('day', NOW()),
'1 day')::DATE AS "Date"
) dates
CROSS JOIN jf_libraries l
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
l."Name" as "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."Date" = dates."Date" AND counts."Library" = l."Name"
ORDER BY
"Date", "Library";
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_over_time(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,143 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week(
days integer)
RETURNS TABLE("Day" text, "Count" bigint, "Duration" bigint, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH library_days AS (
SELECT
l."Name" AS "Library",
d.day_of_week,
d.day_name
FROM
jf_libraries l,
(SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL
SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL
SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL
SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL
SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL
SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL
SELECT 6 AS "day_of_week", 'Saturday' AS "day_name"
) d
where l.archived=false
)
SELECT
library_days.day_name AS "Day",
COALESCE(SUM(counts."Count"), 0)::bigint AS "Count",
COALESCE(SUM(counts."Duration"), 0)::bigint AS "Duration",
library_days."Library" AS "Library"
FROM
library_days
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
(SUM(a."PlaybackDuration") / 60)::bigint AS "Duration",
EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW",
l."Name" AS "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id" and l.archived=false
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library"
GROUP BY
library_days.day_name, library_days.day_of_week, library_days."Library"
ORDER BY
library_days.day_of_week, library_days."Library";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week(
days integer)
RETURNS TABLE("Day" text, "Count" bigint, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH library_days AS (
SELECT
l."Name" AS "Library",
d.day_of_week,
d.day_name
FROM
jf_libraries l,
(SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL
SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL
SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL
SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL
SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL
SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL
SELECT 6 AS "day_of_week", 'Saturday' AS "day_name"
) d
where l.archived=false
)
SELECT
library_days.day_name AS "Day",
COALESCE(SUM(counts."Count"), 0)::bigint AS "Count",
library_days."Library" AS "Library"
FROM
library_days
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW",
l."Name" AS "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id" and l.archived=false
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library"
GROUP BY
library_days.day_name, library_days.day_of_week, library_days."Library"
ORDER BY
library_days.day_of_week, library_days."Library";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,117 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_hour_of_day(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day(
days integer)
RETURNS TABLE("Hour" integer, "Count" integer, "Duration" integer, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
h."Hour",
COUNT(a."Id")::integer AS "Count",
COALESCE(SUM(a."PlaybackDuration") / 60, 0)::integer AS "Duration",
l."Name" AS "Library"
FROM
(
SELECT
generate_series(0, 23) AS "Hour"
) h
CROSS JOIN jf_libraries l
LEFT JOIN jf_library_items i ON i."ParentId" = l."Id"
LEFT JOIN (
SELECT
"NowPlayingItemId",
DATE_PART('hour', "ActivityDateInserted") AS "Hour",
"Id",
"PlaybackDuration"
FROM
jf_playback_activity
WHERE
"ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW()
) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour"
WHERE
l.archived=false
and l."Id" IN (SELECT "Id" FROM jf_libraries)
GROUP BY
h."Hour",
l."Name"
ORDER BY
l."Name",
h."Hour";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_hour_of_day(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_hour_of_day(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day(
days integer)
RETURNS TABLE("Hour" integer, "Count" integer, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
h."Hour",
COUNT(a."Id")::integer AS "Count",
l."Name" AS "Library"
FROM
(
SELECT
generate_series(0, 23) AS "Hour"
) h
CROSS JOIN jf_libraries l
LEFT JOIN jf_library_items i ON i."ParentId" = l."Id"
LEFT JOIN (
SELECT
"NowPlayingItemId",
DATE_PART('hour', "ActivityDateInserted") AS "Hour",
"Id"
FROM
jf_playback_activity
WHERE
"ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW()
) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour"
WHERE
l.archived=false
and l."Id" IN (SELECT "Id" FROM jf_libraries)
GROUP BY
h."Hour",
l."Name"
ORDER BY
l."Name",
h."Hour";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_hour_of_day(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};

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

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

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

@@ -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,
@@ -423,6 +426,7 @@ router.get("/getViewsOverTime", async (req, res) => {
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const duration = item.Duration;
const date = new Date(item.Date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
@@ -435,7 +439,7 @@ router.get("/getViewsOverTime", async (req, res) => {
};
}
reorganizedData[date] = { ...reorganizedData[date], [library]: count };
reorganizedData[date] = { ...reorganizedData[date], [library]: { count, duration } };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);
@@ -462,6 +466,7 @@ router.get("/getViewsByDays", async (req, res) => {
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const duration = item.Duration;
const day = item.Day;
if (!reorganizedData[day]) {
@@ -470,7 +475,7 @@ router.get("/getViewsByDays", async (req, res) => {
};
}
reorganizedData[day] = { ...reorganizedData[day], [library]: count };
reorganizedData[day] = { ...reorganizedData[day], [library]: { count, duration } };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);
@@ -497,6 +502,7 @@ router.get("/getViewsByHour", async (req, res) => {
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const duration = item.Duration;
const hour = item.Hour;
if (!reorganizedData[hour]) {
@@ -505,7 +511,7 @@ router.get("/getViewsByHour", async (req, res) => {
};
}
reorganizedData[hour] = { ...reorganizedData[hour], [library]: count };
reorganizedData[hour] = { ...reorganizedData[hour], [library]: { count, duration } };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);

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 {
@@ -530,13 +558,13 @@ async function syncPlaybackPluginData() {
let query = `SELECT rowid, * FROM PlaybackActivity`;
if (OldestPlaybackActivity && NewestPlaybackActivity) {
const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
const formattedDateTimeOld = dayjs(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
const formattedDateTimeNew = dayjs(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
query = query + ` WHERE (DateCreated < '${formattedDateTimeOld}' or DateCreated > '${formattedDateTimeNew}')`;
}
if (OldestPlaybackActivity && !NewestPlaybackActivity) {
const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
const formattedDateTimeOld = dayjs(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
query = query + ` WHERE DateCreated < '${formattedDateTimeOld}'`;
if (MaxPlaybackReportingPluginID) {
query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`;
@@ -544,7 +572,7 @@ async function syncPlaybackPluginData() {
}
if (!OldestPlaybackActivity && NewestPlaybackActivity) {
const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
const formattedDateTimeNew = dayjs(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss");
query = query + ` WHERE DateCreated > '${formattedDateTimeNew}'`;
if (MaxPlaybackReportingPluginID) {
query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`;
@@ -871,7 +899,7 @@ async function partialSync(triggertype) {
let updateItemInfoCount = 0;
let updateEpisodeInfoCount = 0;
let lastSyncDate = moment().subtract(24, "hours");
let lastSyncDate = dayjs().subtract(24, "hours");
const last_execution = await db
.query(
@@ -882,7 +910,7 @@ async function partialSync(triggertype) {
)
.then((res) => res.rows);
if (last_execution.length !== 0) {
lastSyncDate = moment(last_execution[0].DateCreated);
lastSyncDate = dayjs(last_execution[0].DateCreated);
}
//for each item in library run get item using that id as the ParentId (This gets the children of the parent id)
@@ -909,7 +937,7 @@ async function partialSync(triggertype) {
},
});
libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate));
libraryItems = libraryItems.filter((item) => dayjs(item.DateCreated).isAfter(lastSyncDate));
while (libraryItems.length != 0) {
if (libraryItems.length === 0 && startIndex === 0) {
@@ -974,7 +1002,7 @@ async function partialSync(triggertype) {
},
});
libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate));
libraryItems = libraryItems.filter((item) => dayjs(item.DateCreated).isAfter(lastSyncDate));
}
}

View File

@@ -1,6 +1,6 @@
const db = require("../db");
const moment = require("moment");
const dayjs = require("dayjs");
const { columnsPlayback } = require("../models/jf_playback_activity");
const { jf_activity_watchdog_columns, jf_activity_watchdog_mapping } = require("../models/jf_activity_watchdog");
const configClass = require("../classes/config");
@@ -12,14 +12,14 @@ const MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK = process.env.MINIMUM_SECONDS_TO_INCLU
: 1;
async function getSessionsInWatchDog(SessionData, WatchdogData) {
let existingData = await WatchdogData.filter((wdData) => {
const existingData = await WatchdogData.filter((wdData) => {
return SessionData.some((sessionData) => {
let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id;
const NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id;
let matchesEpisodeId =
const matchesEpisodeId =
sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true;
let matchingSessionFound =
const matchingSessionFound =
// wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
@@ -31,16 +31,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 +52,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 +75,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 +97,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;
@@ -187,9 +187,9 @@ async function ActivityMonitor(defaultInterval) {
}
// 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);
/////////////////
@@ -222,7 +222,7 @@ async function ActivityMonitor(defaultInterval) {
/////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(
@@ -262,7 +262,7 @@ async function ActivityMonitor(defaultInterval) {
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;

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

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

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.6",
"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",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",

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

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

@@ -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");
@@ -45,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>
@@ -65,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,6 +9,7 @@ function PlayStatsByDay(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(20);
const [viewName, setViewName] = useState("count");
const token = localStorage.getItem("token");
useEffect(() => {
@@ -41,19 +42,24 @@ function PlayStatsByDay(props) {
setDays(props.days);
fetchLibraries();
}
if (props.viewName !== viewName) {
setViewName(props.viewName);
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [stats, libraries, days, props.days, token]);
}, [stats, libraries, days, props.days, props.viewName, token]);
if (!stats) {
return <></>;
}
const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY";
if (stats.length === 0) {
return (
<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>
@@ -62,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,6 +8,7 @@ function PlayStatsByHour(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(20);
const [viewName, setViewName] = useState("count");
const token = localStorage.getItem("token");
useEffect(() => {
@@ -40,19 +41,23 @@ function PlayStatsByHour(props) {
setDays(props.days);
fetchLibraries();
}
if (props.viewName !== viewName) {
setViewName(props.viewName);
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [stats, libraries, days, props.days, token]);
}, [stats, libraries, days, props.days, props.viewName, token]);
if (!stats) {
return <></>;
}
const titleKey = viewName === "count" ? "STAT_PAGE.PLAY_COUNT_BY" : "STAT_PAGE.PLAY_DURATION_BY";
if (stats.length === 0) {
return (
<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>
@@ -62,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

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