From 5d9540c92fd45cee88864bf6d7d4fcd46938cbfd Mon Sep 17 00:00:00 2001 From: brikim Date: Wed, 25 Dec 2024 13:15:53 -0600 Subject: [PATCH 01/40] Added limit attribute for getHistory, getLibraryHistory and getUserHistory --- backend/routes/api.js | 32 ++++++++++++++++++++------------ backend/swagger.json | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index 3526802..2cc0a4d 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -16,9 +16,10 @@ const { tables } = require("../global/backup_tables"); const router = express.Router(); //Functions -function groupActivity(rows) { +function groupActivity(rows, limit) { const groupedResults = {}; - rows.forEach((row) => { + let objectsAdded = 0 + rows.every((row) => { const key = row.NowPlayingItemId + row.EpisodeId + row.UserId; if (groupedResults[key]) { if (row.ActivityDateInserted > groupedResults[key].ActivityDateInserted) { @@ -34,7 +35,9 @@ function groupActivity(rows) { results: [], }; groupedResults[key].results.push(row); + objectsAdded++; } + return (objectsAdded < limit); }); // Update GroupedResults with playbackDurationSum @@ -1085,6 +1088,8 @@ router.post("/setExcludedBackupTable", async (req, res) => { //DB Queries - History router.get("/getHistory", async (req, res) => { + const { limit = 50 } = req.query; + try { const { rows } = await db.query(` SELECT a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" , i."ParentId" @@ -1094,9 +1099,9 @@ router.get("/getHistory", async (req, res) => { and a."SeasonId"=e."SeasonId" left join jf_library_items i on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id" - order by a."ActivityDateInserted" desc`); + order by a."ActivityDateInserted" desc`); - const groupedResults = groupActivity(rows); + const groupedResults = groupActivity(rows, limit); res.send(Object.values(groupedResults)); } catch (error) { @@ -1106,6 +1111,7 @@ router.get("/getHistory", async (req, res) => { router.post("/getLibraryHistory", async (req, res) => { try { + const { limit = 50 } = req.query; const { libraryid } = req.body; if (libraryid === undefined) { @@ -1126,7 +1132,7 @@ router.post("/getLibraryHistory", async (req, res) => { order by a."ActivityDateInserted" desc`, [libraryid] ); - const groupedResults = groupActivity(rows); + const groupedResults = groupActivity(rows, limit); res.send(Object.values(groupedResults)); } catch (error) { console.log(error); @@ -1149,10 +1155,11 @@ router.post("/getItemHistory", async (req, res) => { `select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" from jf_playback_activity a left join jf_library_episodes e - on a."EpisodeId"=e."EpisodeId" - and a."SeasonId"=e."SeasonId" + on a."EpisodeId"=e."EpisodeId" + and a."SeasonId"=e."SeasonId" where - (a."EpisodeId"=$1 OR a."SeasonId"=$1 OR a."NowPlayingItemId"=$1);`, + (a."EpisodeId"=$1 OR a."SeasonId"=$1 OR a."NowPlayingItemId"=$1) + order by a."ActivityDateInserted" desc;`, [itemid] ); @@ -1171,6 +1178,7 @@ router.post("/getItemHistory", async (req, res) => { router.post("/getUserHistory", async (req, res) => { try { + const { limit = 50 } = req.query; const { userid } = req.body; if (userid === undefined) { @@ -1186,12 +1194,13 @@ router.post("/getUserHistory", async (req, res) => { on a."EpisodeId"=e."EpisodeId" and a."SeasonId"=e."SeasonId" left join jf_library_items i - on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id" - where a."UserId"=$1;`, + on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id" + where a."UserId"=$1 + order by a."ActivityDateInserted" desc`, [userid] ); - const groupedResults = groupActivity(rows); + const groupedResults = groupActivity(rows, limit); res.send(Object.values(groupedResults)); } catch (error) { @@ -1212,7 +1221,6 @@ router.post("/deletePlaybackActivity", async (req, res) => { } await db.query(`DELETE from jf_playback_activity where "Id" = ANY($1)`, [ids]); - // const groupedResults = groupActivity(rows); res.send(`${ids.length} Records Deleted`); } catch (error) { console.log(error); diff --git a/backend/swagger.json b/backend/swagger.json index f8ec8c5..dbd38fc 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -1735,6 +1735,11 @@ "name": "req", "in": "query", "type": "string" + }, + { + "name": "limit", + "in": "query", + "type": "string" } ], "responses": { @@ -1773,6 +1778,11 @@ "in": "query", "type": "string" }, + { + "name": "limit", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -1883,6 +1893,11 @@ "in": "query", "type": "string" }, + { + "name": "limit", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", From 7eae08c7970cea9cf9e629b9e12bb556e788be03 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 00:23:22 +0200 Subject: [PATCH 02/40] added pagination to backend history endpoints changed frontend activity elements to use server-side paging Bug in MaterialReactTable causing page index to reset was found aswell as a bug that prevents expanding subrows when using manual pagination. Solution is WIP Added a busy loader to activity tables --- backend/classes/db-helper.js | 106 +++ backend/db.js | 5 +- backend/routes/api.js | 203 +++-- package-lock.json | 717 +++++++++++------- package.json | 16 +- src/pages/activity.jsx | 33 +- .../components/activity/activity-table.jsx | 33 +- src/pages/components/general/busyLoader.jsx | 12 + .../components/item-info/item-activity.jsx | 26 +- .../components/library/library-activity.jsx | 32 +- .../components/user-info/user-activity.jsx | 26 +- src/pages/css/activity.css | 1 + src/pages/css/loading.css | 72 +- 13 files changed, 889 insertions(+), 393 deletions(-) create mode 100644 backend/classes/db-helper.js create mode 100644 src/pages/components/general/busyLoader.jsx diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js new file mode 100644 index 0000000..e0fea09 --- /dev/null +++ b/backend/classes/db-helper.js @@ -0,0 +1,106 @@ +const { pool } = require("../db.js"); + +function wrapField(field) { + if (field === "*") { + return field; + } + if (field.includes(" as ")) { + const [column, alias] = field.split(" as "); + return `${column + .split(".") + .map((part) => (part == "*" ? part : `"${part}"`)) + .join(".")} as "${alias}"`; + } + return field + .split(".") + .map((part) => (part == "*" ? part : `"${part}"`)) + .join("."); +} + +function buildWhereClause(conditions) { + if (!Array.isArray(conditions)) { + return ""; + } + + return conditions + .map((condition, index) => { + if (Array.isArray(condition)) { + return `(${buildWhereClause(condition)})`; + } else if (typeof condition === "object") { + const { column, operator, value, type } = condition; + const conjunction = index === 0 ? "" : type ? type.toUpperCase() : "AND"; + return `${conjunction} ${wrapField(column)} ${operator} '${value}'`; + } + return ""; + }) + .join(" ") + .trim(); +} + +async function query({ + select = ["*"], + table, + alias, + joins = [], + where = [], + order_by = "Id", + sort_order = "desc", + pageNumber = 1, + pageSize = 50, +}) { + const client = await pool.connect(); + try { + // Build the base query + let countQuery = `SELECT COUNT(*) FROM ${wrapField(table)} AS ${wrapField(alias)}`; + let query = `SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`; + + // Add joins + joins.forEach((join) => { + const joinConditions = join.conditions + .map((condition, index) => { + const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND"; + return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${wrapField(condition.second)}`; + }) + .join(" "); + const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`; + query += joinQuery; + countQuery += joinQuery; + }); + + // Add where conditions + const whereClause = buildWhereClause(where); + if (whereClause) { + query += ` WHERE ${whereClause}`; + countQuery += ` WHERE ${whereClause}`; + } + + // Add order by and pagination + query += ` ORDER BY ${wrapField(order_by)} ${sort_order}`; + query += ` LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}`; + + // Execute the query + const result = await client.query(query); + + // Count total rows + const countResult = await client.query(countQuery); + const totalRows = parseInt(countResult.rows[0].count, 10); + + // Return the structured response + return { + pages: Math.ceil(totalRows / pageSize), + results: result.rows, + }; + } catch (error) { + // console.timeEnd("queryWithPagingAndJoins"); + console.error("Error occurred while executing query:", error.message); + return { + pages: 0, + results: [], + }; + } finally { + client.release(); + } +} +module.exports = { + query, +}; diff --git a/backend/db.js b/backend/db.js index 9eaa16c..4ecefe9 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,7 +1,6 @@ const { Pool } = require("pg"); const pgp = require("pg-promise")(); const { update_query: update_query_map } = require("./models/bulk_insert_update_handler"); -const moment = require("moment"); const _POSTGRES_USER = process.env.POSTGRES_USER; const _POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD; @@ -20,6 +19,9 @@ const pool = new Pool({ database: _POSTGRES_DATABASE, password: _POSTGRES_PASSWORD, port: _POSTGRES_PORT, + 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 }); pool.on("error", (err, client) => { @@ -163,6 +165,7 @@ async function querySingle(sql, params) { } module.exports = { + pool: pool, query: query, deleteBulk: deleteBulk, insertBulk: insertBulk, diff --git a/backend/routes/api.js b/backend/routes/api.js index 2cc0a4d..5de01d4 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -2,6 +2,8 @@ const express = require("express"); const db = require("../db"); +const dbHelper = require("../classes/db-helper"); + const pgp = require("pg-promise")(); const { randomUUID } = require("crypto"); @@ -16,9 +18,8 @@ const { tables } = require("../global/backup_tables"); const router = express.Router(); //Functions -function groupActivity(rows, limit) { +function groupActivity(rows) { const groupedResults = {}; - let objectsAdded = 0 rows.every((row) => { const key = row.NowPlayingItemId + row.EpisodeId + row.UserId; if (groupedResults[key]) { @@ -35,9 +36,8 @@ function groupActivity(rows, limit) { results: [], }; groupedResults[key].results.push(row); - objectsAdded++; } - return (objectsAdded < limit); + return true; }); // Update GroupedResults with playbackDurationSum @@ -1088,22 +1088,41 @@ router.post("/setExcludedBackupTable", async (req, res) => { //DB Queries - History router.get("/getHistory", async (req, res) => { - const { limit = 50 } = req.query; + const { size = 50, page = 1 } = req.query; try { - const { rows } = await db.query(` - SELECT a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" , i."ParentId" - FROM jf_playback_activity a - left join jf_library_episodes e - on a."EpisodeId"=e."EpisodeId" - and a."SeasonId"=e."SeasonId" - left join jf_library_items i - on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id" - order by a."ActivityDateInserted" desc`); + const result = await dbHelper.query({ + select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], + table: "jf_playback_activity", + alias: "a", + joins: [ + { + type: "left", + table: "jf_library_episodes", + alias: "e", + conditions: [ + { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId", type: "and" }, + { first: "a.SeasonId", operator: "=", second: "e.SeasonId", type: "and" }, + ], + }, + { + type: "left", + table: "jf_library_items", + alias: "i", + conditions: [ + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, + ], + }, + ], + order_by: "a.ActivityDateInserted", + sort_order: "desc", + pageNumber: page, + pageSize: size, + }); - const groupedResults = groupActivity(rows, limit); - - res.send(Object.values(groupedResults)); + const groupedResults = groupActivity(result.results); + res.send({ current_page: page, pages: result.pages, results: Object.values(groupedResults) }); } catch (error) { console.log(error); } @@ -1111,7 +1130,7 @@ router.get("/getHistory", async (req, res) => { router.post("/getLibraryHistory", async (req, res) => { try { - const { limit = 50 } = req.query; + const { size = 50, page = 1 } = req.query; const { libraryid } = req.body; if (libraryid === undefined) { @@ -1120,20 +1139,36 @@ router.post("/getLibraryHistory", async (req, res) => { return; } - const { rows } = await db.query( - `select a.* , e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" - from jf_playback_activity a - join jf_library_items i - on i."Id"=a."NowPlayingItemId" - left join jf_library_episodes e - on a."EpisodeId"=e."EpisodeId" - and a."SeasonId"=e."SeasonId" - where i."ParentId"=$1 - order by a."ActivityDateInserted" desc`, - [libraryid] - ); - const groupedResults = groupActivity(rows, limit); - res.send(Object.values(groupedResults)); + const result = await dbHelper.query({ + select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], + table: "jf_playback_activity", + alias: "a", + joins: [ + { + type: "inner", + table: "jf_library_items", + alias: "i", + conditions: [{ first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }], + }, + { + type: "left", + table: "jf_library_episodes", + alias: "e", + conditions: [ + { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, + { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, + ], + }, + ], + where: [{ column: "i.ParentId", operator: "=", value: libraryid }], + order_by: "ActivityDateInserted", + sort_order: "desc", + pageNumber: page, + pageSize: size, + }); + + const groupedResults = groupActivity(result.results); + res.send({ current_page: page, pages: result.pages, results: Object.values(groupedResults) }); } catch (error) { console.log(error); res.status(503); @@ -1143,6 +1178,7 @@ router.post("/getLibraryHistory", async (req, res) => { router.post("/getItemHistory", async (req, res) => { try { + const { size = 50, page = 1 } = req.query; const { itemid } = req.body; if (itemid === undefined) { @@ -1151,24 +1187,47 @@ router.post("/getItemHistory", async (req, res) => { return; } - const { rows } = await db.query( - `select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" - from jf_playback_activity a - left join jf_library_episodes e - on a."EpisodeId"=e."EpisodeId" - and a."SeasonId"=e."SeasonId" - where - (a."EpisodeId"=$1 OR a."SeasonId"=$1 OR a."NowPlayingItemId"=$1) - order by a."ActivityDateInserted" desc;`, - [itemid] - ); + const result = await dbHelper.query({ + select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber"], + table: "jf_playback_activity", + alias: "a", + joins: [ + { + type: "left", + table: "jf_library_episodes", + alias: "e", + conditions: [ + { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, + { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, + ], + }, + { + type: "left", + table: "jf_library_items", + alias: "i", + conditions: [ + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, + ], + }, + ], + where: [ + { column: "a.EpisodeId", operator: "=", value: itemid, type: "or" }, + { column: "a.SeasonId", operator: "=", value: itemid, type: "or" }, + { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, + ], + order_by: "ActivityDateInserted", + sort_order: "desc", + pageNumber: page, + pageSize: size, + }); - const groupedResults = rows.map((item) => ({ - ...item, - results: [], - })); + // const groupedResults = rows.map((item) => ({ + // ...item, + // results: [], + // })); - res.send(groupedResults); + res.send({ current_page: page, pages: result.pages, results: result.results }); } catch (error) { console.log(error); res.status(503); @@ -1178,7 +1237,7 @@ router.post("/getItemHistory", async (req, res) => { router.post("/getUserHistory", async (req, res) => { try { - const { limit = 50 } = req.query; + const { size = 50, page = 1 } = req.query; const { userid } = req.body; if (userid === undefined) { @@ -1187,22 +1246,38 @@ router.post("/getUserHistory", async (req, res) => { return; } - const { rows } = await db.query( - `select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" , i."ParentId" - from jf_playback_activity a - left join jf_library_episodes e - on a."EpisodeId"=e."EpisodeId" - and a."SeasonId"=e."SeasonId" - left join jf_library_items i - on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id" - where a."UserId"=$1 - order by a."ActivityDateInserted" desc`, - [userid] - ); + const result = await dbHelper.query({ + select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], + table: "jf_playback_activity", + alias: "a", + joins: [ + { + type: "left", + table: "jf_library_episodes", + alias: "e", + conditions: [ + { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, + { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, + ], + }, + { + type: "left", + table: "jf_library_items", + alias: "i", + conditions: [ + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, + ], + }, + ], + where: [{ column: "a.UserId", operator: "=", value: userid }], + order_by: "ActivityDateInserted", + sort_order: "desc", + pageNumber: page, + pageSize: size, + }); - const groupedResults = groupActivity(rows, limit); - - res.send(Object.values(groupedResults)); + res.send({ current_page: page, pages: result.pages, results: result.results }); } catch (error) { console.log(error); res.status(503); diff --git a/package-lock.json b/package-lock.json index 2d78d6c..7d84311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "jfstat", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jfstat", - "version": "1.1.2", + "version": "1.1.3", "dependencies": { - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.14", - "@mui/material": "^5.15.14", - "@mui/x-data-grid": "^6.2.1", - "@mui/x-date-pickers": "^7.0.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.3.0", + "@mui/material": "^6.3.0", + "@mui/x-data-grid": "^7.23.3", + "@mui/x-date-pickers": "^7.23.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -41,7 +41,7 @@ "i18next-fs-backend": "^2.3.1", "i18next-http-backend": "^2.4.3", "knex": "^2.4.2", - "material-react-table": "^2.12.1", + "material-react-table": "^3.1.0", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "passport": "^0.6.0", @@ -2330,9 +2330,9 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", - "integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2699,15 +2699,15 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", @@ -2717,47 +2717,47 @@ } }, "node_modules/@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "dependencies": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" }, "node_modules/@emotion/react": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", - "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { @@ -2770,33 +2770,33 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", - "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "dependencies": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" }, "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -2809,27 +2809,27 @@ } }, "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", @@ -3256,40 +3256,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", - "dependencies": { - "@floating-ui/utils": "^0.2.1" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", - "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", - "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", - "dependencies": { - "@floating-ui/dom": "^1.6.1" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -4184,64 +4150,33 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, - "node_modules/@mui/base": { - "version": "5.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", - "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.14.tgz", - "integrity": "sha512-on75VMd0XqZfaQW+9pGjSNiqW+ghc5E2ZSLRBXwcXl/C4YzjfyjrLPhrEpKnR9Uym9KXBvxrhoHfPcczYHweyA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.0.tgz", + "integrity": "sha512-/d8NwSuC3rMwCjswmGB3oXC4sdDuhIUJ8inVQAxGrADJhf0eq/kmy+foFKvpYhHl2siOZR+MLdFttw6/Bzqtqg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.14.tgz", - "integrity": "sha512-vj/51k7MdFmt+XVw94sl30SCvGx6+wJLsNYjZRgxhS6y3UtnWnypMOsm3Kmg8TN+P0dqwsjy4/fX7B1HufJIhw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.3.0.tgz", + "integrity": "sha512-3uWws6DveDn5KxCS34p+sUNMxehuclQY6OmoJeJJ+Sfg9L7LGBpksY/nX5ywKAqickTZnn+sQyVcp963ep9jvw==", "dependencies": { - "@babel/runtime": "^7.23.9" + "@babel/runtime": "^7.26.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^5.0.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@mui/material": "^6.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4250,25 +4185,25 @@ } }, "node_modules/@mui/material": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.14.tgz", - "integrity": "sha512-kEbRw6fASdQ1SQ7LVdWR5OlWV3y7Y54ZxkLzd6LV5tmz+NpO3MJKZXSfgR0LHMP7meKsPiMm4AuzV0pXDpk/BQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.0.tgz", + "integrity": "sha512-qhlTFyRMxfoVPxUtA5e8IvqxP0dWo2Ij7cvot7Orag+etUlZH+3UwD8gZGt+3irOoy7Ms3UNBflYjwEikUXtAQ==", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/base": "5.0.0-beta.40", - "@mui/core-downloads-tracker": "^5.15.14", - "@mui/system": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.3.0", + "@mui/system": "^6.3.0", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^18.2.0", + "react-is": "^19.0.0", "react-transition-group": "^4.4.5" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -4277,9 +4212,111 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@mui/material-pigment-css": "^6.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/private-theming": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.0.tgz", + "integrity": "sha512-tdS8jvqMokltNTXg6ioRCCbVdDmZUJZa/T9VtTnX2Lwww3FTgCakst9tWLZSxm1fEE9Xp0m7onZJmgeUmWQYVw==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.0.tgz", + "integrity": "sha512-iWA6eyiPkO07AlHxRUvI7dwVRSc+84zV54kLmjUms67GJeOWVuXlu8ZO+UhCnwJxHacghxnabsMEqet5PYQmHg==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/system": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.0.tgz", + "integrity": "sha512-L+8hUHMNlfReKSqcnVslFrVhoNfz/jw7Fe9NfDE85R3KarvZ4O3MU9daF/lZeqEAvnYxEilkkTfDwQ7qCgJdFg==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.3.0", + "@mui/styled-engine": "^6.3.0", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4293,10 +4330,45 @@ } } }, + "node_modules/@mui/material/node_modules/@mui/utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.20", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, "node_modules/@mui/private-theming": { "version": "5.15.14", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/utils": "^5.15.14", @@ -4323,6 +4395,7 @@ "version": "5.15.14", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", @@ -4354,6 +4427,7 @@ "version": "5.15.14", "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.14.tgz", "integrity": "sha512-auXLXzUaCSSOLqJXmsAaq7P96VPRXg2Rrz6OHNV7lr+kB8lobUF+/N84Vd9C4G/wvCXYPs5TYuuGBRhcGbiBGg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/private-theming": "^5.15.14", @@ -4390,11 +4464,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.20", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", + "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4406,6 +4480,7 @@ "version": "5.15.14", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", @@ -4430,41 +4505,85 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.18.0.tgz", - "integrity": "sha512-js7Qhv+8XLgXilghKZmfBgUbkP7dYt7V1HLkM4C9285jFRUDFgAM9L6PVlRyUMn+YhVVPbvy+SWT+VWC8rYeQQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.23.3.tgz", + "integrity": "sha512-EiM5kut6N/0o0iEYx8A7M3fJqknAa1kcPvGhlX3hH50ERLDeuJaqoKzvRYLBbYKWydHIc+0hHIFcK5oQTXLenw==", "dependencies": { - "@babel/runtime": "^7.23.2", - "@mui/utils": "^5.14.16", - "clsx": "^2.0.0", + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "reselect": "^4.1.8" + "reselect": "^5.1.1" }, "engines": { "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } } }, - "node_modules/@mui/x-date-pickers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.0.0.tgz", - "integrity": "sha512-/9mp4O2WMixHOso63DBoZVfJVYGrzOHF5voheV2tYQ4XqDdTKp2AdWS3oh8PGwrsvCzqkvb3quzTqhKoEsJUwA==", + "node_modules/@mui/x-data-grid/node_modules/@mui/utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", "dependencies": { - "@babel/runtime": "^7.24.0", - "@mui/base": "^5.0.0-beta.40", - "@mui/system": "^5.15.14", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.20", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, + "node_modules/@mui/x-date-pickers": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz", + "integrity": "sha512-bjTYX/QzD5ZhVZNNnastMUS3j2Hy4p4IXmJgPJ0vKvQBvUdfEO+ZF42r3PJNNde0FVT1MmTzkmdTlz0JZ6ukdw==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, @@ -4478,16 +4597,17 @@ "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14", - "date-fns": "^2.25.0 || ^3.2.0", - "date-fns-jalali": "^2.13.0-0", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", "dayjs": "^1.10.7", "luxon": "^3.0.2", "moment": "^2.29.4", - "moment-hijri": "^2.1.2", + "moment-hijri": "^2.1.2 || ^3.0.0", "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4519,6 +4639,93 @@ } } }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.20", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, + "node_modules/@mui/x-internals": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz", + "integrity": "sha512-bPclKpqUiJYIHqmTxSzMVZi6MH51cQsn5U+8jskaTlo3J4QiMeCYJn/gn7YbeR9GOZFp8hetyHjoQoVHKRXCig==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.20", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -5286,11 +5493,11 @@ "dev": true }, "node_modules/@tanstack/match-sorter-utils": { - "version": "8.11.8", - "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz", - "integrity": "sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==", + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", "dependencies": { - "remove-accents": "0.4.2" + "remove-accents": "0.5.0" }, "engines": { "node": ">=12" @@ -5301,11 +5508,11 @@ } }, "node_modules/@tanstack/react-table": { - "version": "8.13.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.13.2.tgz", - "integrity": "sha512-b6mR3mYkjRtJ443QZh9sc7CvGTce81J35F/XMr0OoWbx0KIM7TTTdyNP2XKObvkLpYnLpCrYDwI3CZnLezWvpg==", + "version": "8.20.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz", + "integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==", "dependencies": { - "@tanstack/table-core": "8.13.2" + "@tanstack/table-core": "8.20.5" }, "engines": { "node": ">=12" @@ -5315,30 +5522,30 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/@tanstack/react-virtual": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.1.3.tgz", - "integrity": "sha512-YCzcbF/Ws/uZ0q3Z6fagH+JVhx4JLvbSflgldMgLsuvB8aXjZLLb3HvrEVxY480F9wFlBiXlvQxOyXb5ENPrNA==", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", + "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==", "dependencies": { - "@tanstack/virtual-core": "3.1.3" + "@tanstack/virtual-core": "3.11.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@tanstack/table-core": { - "version": "8.13.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.13.2.tgz", - "integrity": "sha512-/2saD1lWBUV6/uNAwrsg2tw58uvMJ07bO2F1IWMxjFRkJiXKQRuc3Oq2aufeobD3873+4oIM/DRySIw7+QsPPw==", + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", "engines": { "node": ">=12" }, @@ -5348,9 +5555,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.1.3.tgz", - "integrity": "sha512-Y5B4EYyv1j9V8LzeAoOVeTg0LI7Fo5InYKgAjkY1Pu9GjtUwX/EKxNcU7ng3sKr99WEf+bPTcktAeybyMOYo+g==", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", + "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -5797,9 +6004,9 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" }, "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/q": { "version": "1.5.8", @@ -5835,10 +6042,10 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", - "dependencies": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { "@types/react": "*" } }, @@ -7736,9 +7943,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -11128,12 +11335,12 @@ } }, "node_modules/highlight-words": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.2.tgz", - "integrity": "sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz", + "integrity": "sha512-If5n+IhSBRXTScE7wl16VPmd+44Vy7kof24EdqhjsZsDuHikpv1OCagVcJFpB4fS4UPUniedlWqrjIO8vWOsIQ==", "engines": { - "node": ">= 16", - "npm": ">= 8" + "node": ">= 20", + "npm": ">= 9" } }, "node_modules/hoist-non-react-statics": { @@ -14754,14 +14961,14 @@ } }, "node_modules/material-react-table": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-2.12.1.tgz", - "integrity": "sha512-nqILE26I/6/UfiILEc7mnVhIoVdG78qvkav85dcbn5BbNXkAJeA57Slut50eYytd7aQHCbaq9MoLvsVeO1yaIw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-3.1.0.tgz", + "integrity": "sha512-/zPn38QhxQE7mkwLex4CojX3UP2+/+/u7NVq7CHS5d6P8LdTteJPVYXVzym/uhaXzAzFB1ojsbP7zI/y6iUdtQ==", "dependencies": { - "@tanstack/match-sorter-utils": "8.11.8", - "@tanstack/react-table": "8.13.2", - "@tanstack/react-virtual": "3.1.3", - "highlight-words": "1.2.2" + "@tanstack/match-sorter-utils": "8.19.4", + "@tanstack/react-table": "8.20.6", + "@tanstack/react-virtual": "3.11.2", + "highlight-words": "2.0.0" }, "engines": { "node": ">=16" @@ -14771,13 +14978,13 @@ "url": "https://github.com/sponsors/kevinvandy" }, "peerDependencies": { - "@emotion/react": ">=11.11", - "@emotion/styled": ">=11.11", - "@mui/icons-material": ">=5.11", - "@mui/material": ">=5.13", - "@mui/x-date-pickers": ">=6.15.0", - "react": ">=17.0", - "react-dom": ">=17.0" + "@emotion/react": ">=11.13", + "@emotion/styled": ">=11.13", + "@mui/icons-material": ">=6", + "@mui/material": ">=6", + "@mui/x-date-pickers": ">=7.15", + "react": ">=18.0", + "react-dom": ">=18.0" } }, "node_modules/mdn-data": { @@ -18656,9 +18863,9 @@ } }, "node_modules/remove-accents": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", - "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" }, "node_modules/renderkid": { "version": "3.0.0", @@ -18694,9 +18901,9 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", diff --git a/package.json b/package.json index 068b57a..a211501 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jfstat", - "version": "1.1.2", + "version": "1.1.3", "private": true, "main": "src/index.jsx", "scripts": { @@ -15,12 +15,12 @@ "start": "cd backend && node server.js" }, "dependencies": { - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.14", - "@mui/material": "^5.15.14", - "@mui/x-data-grid": "^6.2.1", - "@mui/x-date-pickers": "^7.0.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.3.0", + "@mui/material": "^6.3.0", + "@mui/x-data-grid": "^7.23.3", + "@mui/x-date-pickers": "^7.23.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -48,7 +48,7 @@ "i18next-fs-backend": "^2.3.1", "i18next-http-backend": "^2.4.3", "knex": "^2.4.2", - "material-react-table": "^2.12.1", + "material-react-table": "^3.1.0", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", "passport": "^0.6.0", diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 12eb53f..38269b7 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -25,6 +25,12 @@ function Activity() { ); const [libraries, setLibraries] = useState([]); const [showLibraryFilters, setShowLibraryFilters] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [isBusy, setIsBusy] = useState(false); + + const handlePageChange = (newPage) => { + setCurrentPage(newPage); + }; function setItemLimit(limit) { setItemCount(limit); @@ -64,7 +70,8 @@ function Activity() { }; const fetchHistory = () => { - const url = `/api/getHistory`; + setIsBusy(true); + const url = `/api/getHistory?size=${itemCount}&page=${currentPage}`; axios .get(url, { headers: { @@ -74,9 +81,11 @@ function Activity() { }) .then((data) => { setData(data.data); + setIsBusy(false); }) .catch((error) => { console.log(error); + setIsBusy(false); }); }; @@ -111,9 +120,11 @@ function Activity() { }); }; - if (!data && config) { - fetchHistory(); - fetchLibraries(); + if (config) { + if (!data || data.current_page !== currentPage) { + fetchHistory(); + fetchLibraries(); + } } if (!config) { @@ -122,7 +133,7 @@ function Activity() { const intervalId = setInterval(fetchHistory, 60000 * 60); return () => clearInterval(intervalId); - }, [data, config]); + }, [data, config, itemCount, currentPage]); if (!data) { return ; @@ -145,10 +156,10 @@ function Activity() { ); } - let filteredData = data; + let filteredData = data.results; if (searchQuery) { - filteredData = data.filter((item) => + filteredData = data.results.filter((item) => (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) .toLowerCase() .includes(searchQuery.toLowerCase()) @@ -240,7 +251,13 @@ function Activity() {
- +
); diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index d92cb1d..f1caf1f 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -14,7 +14,7 @@ import StreamInfo from "./stream_info"; import "../../css/activity/activity-table.css"; import i18next from "i18next"; import IpInfoModal from "../ip-info"; -// import Loading from "../general/loading"; +import BusyLoader from "../general/busyLoader.jsx"; import { MRT_TablePagination, MaterialReactTable, useMaterialReactTable } from "material-react-table"; import { Box, ThemeProvider, Typography, createTheme } from "@mui/material"; @@ -34,7 +34,9 @@ function formatTotalWatchTime(seconds) { } if (minutes > 0) { - timeString += `${minutes} ${minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase()} `; + timeString += `${minutes} ${ + minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase() + } `; } if (remainingSeconds > 0) { @@ -60,16 +62,29 @@ export default function ActivityTable(props) { const [data, setData] = React.useState(props.data ?? []); const uniqueUserNames = [...new Set(data.map((item) => item.UserName))]; const uniqueClients = [...new Set(data.map((item) => item.Client))]; + const pages = props.pageCount || 1; + const isBusy = props.isBusy; const [rowSelection, setRowSelection] = React.useState({}); const [pagination, setPagination] = React.useState({ + pageSize: 10, pageIndex: 0, - pageSize: 10, //customize the default page size }); const [modalState, setModalState] = React.useState(false); const [modalData, setModalData] = React.useState(); + const handlePageChange = (updater) => { + setPagination((old) => { + const newPaginationState = typeof updater === "function" ? updater(old) : updater; + console.log(newPaginationState); + const newPage = newPaginationState.pageIndex; // MaterialReactTable uses 0-based index + if (props.onPageChange) { + props.onPageChange(newPage + 1); + } + return newPaginationState; + }); + }; //IP MODAL const ipv4Regex = new RegExp( @@ -266,10 +281,15 @@ export default function ActivityTable(props) { enableExpanding: true, enableDensityToggle: false, enableTopToolbar: Object.keys(rowSelection).length > 0, + manualPagination: true, + autoResetPageIndex: false, initialState: { expanded: false, showGlobalFilter: true, - pagination: { pageSize: 10, pageIndex: 0 }, + pagination: { + pageSize: 10, + pageIndex: 0, + }, sorting: [ { id: "Date", @@ -277,6 +297,7 @@ export default function ActivityTable(props) { }, ], }, + pageCount: pages, showAlertBanner: false, enableHiding: false, enableFullScreenToggle: false, @@ -343,7 +364,7 @@ export default function ActivityTable(props) { return row.results; }, paginateExpandedRows: false, - onPaginationChange: setPagination, + onPaginationChange: handlePageChange, getRowId: (row) => row.Id, muiExpandButtonProps: ({ row }) => ({ children: row.getIsExpanded() ? : , @@ -417,6 +438,8 @@ export default function ActivityTable(props) { return ( + {isBusy && } + setIPModalVisible(false)} ipAddress={ipAddressLookup} /> +
+ + ); +} + +export default BusyLoader; diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 8e01370..72539eb 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -13,6 +13,12 @@ function ItemActivity(props) { const [searchQuery, setSearchQuery] = useState(""); const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [config, setConfig] = useState(); + const [currentPage, setCurrentPage] = useState(1); + const [isBusy, setIsBusy] = useState(false); + + const handlePageChange = (newPage) => { + setCurrentPage(newPage); + }; useEffect(() => { const fetchConfig = async () => { @@ -30,8 +36,9 @@ function ItemActivity(props) { const fetchData = async () => { try { + setIsBusy(true); const itemData = await axios.post( - `/api/getItemHistory`, + `/api/getItemHistory?size=${itemCount}&page=${currentPage}`, { itemid: props.itemid, }, @@ -43,6 +50,7 @@ function ItemActivity(props) { } ); setData(itemData.data); + setIsBusy(false); } catch (error) { console.log(error); } @@ -54,16 +62,16 @@ function ItemActivity(props) { const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.itemid, token]); + }, [data, props.itemid, token, itemCount, currentPage]); - if (!data) { + if (!data || !data.results) { return <>; } - let filteredData = data; + let filteredData = data.results; if (searchQuery) { - filteredData = data.filter( + filteredData = data.results.filter( (item) => (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) .toLowerCase() @@ -136,7 +144,13 @@ function ItemActivity(props) {
- +
); diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index d3eec2d..e623a7b 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -16,6 +16,12 @@ function LibraryActivity(props) { localStorage.getItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter") ?? "All" ); const [config, setConfig] = useState(); + const [currentPage, setCurrentPage] = useState(1); + const [isBusy, setIsBusy] = useState(false); + + const handlePageChange = (newPage) => { + setCurrentPage(newPage); + }; function setItemLimit(limit) { setItemCount(limit); @@ -42,8 +48,9 @@ function LibraryActivity(props) { } const fetchData = async () => { try { - const libraryrData = await axios.post( - `/api/getLibraryHistory`, + setIsBusy(true); + const libraryData = await axios.post( + `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}`, { libraryid: props.LibraryId, }, @@ -54,28 +61,29 @@ function LibraryActivity(props) { }, } ); - setData(libraryrData.data); + setData(libraryData.data); + setIsBusy(false); } catch (error) { console.log(error); } }; - if (!data) { + if (!data || data.current_page !== currentPage) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.LibraryId, token]); + }, [data, props.LibraryId, token, itemCount, currentPage]); - if (!data) { + if (!data || !data.results) { return <>; } - let filteredData = data; + let filteredData = data.results; if (searchQuery) { - filteredData = data.filter((item) => + filteredData = data.results.filter((item) => (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) .toLowerCase() .includes(searchQuery.toLowerCase()) @@ -147,7 +155,13 @@ function LibraryActivity(props) {
- +
); diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index b252d1f..e8f5366 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -20,6 +20,8 @@ function UserActivity(props) { const [libraries, setLibraries] = useState([]); const [showLibraryFilters, setShowLibraryFilters] = useState(false); const [config, setConfig] = useState(); + const [currentPage, setCurrentPage] = useState(1); + const [isBusy, setIsBusy] = useState(false); useEffect(() => { const fetchConfig = async () => { @@ -51,11 +53,16 @@ function UserActivity(props) { } }; + const handlePageChange = (newPage) => { + setCurrentPage(newPage); + }; + useEffect(() => { const fetchHistory = async () => { try { + setIsBusy(true); const itemData = await axios.post( - `/api/getUserHistory`, + `/api/getUserHistory?size=${itemCount}&page=${currentPage}`, { userid: props.UserId, }, @@ -67,6 +74,7 @@ function UserActivity(props) { } ); setData(itemData.data); + setIsBusy(false); } catch (error) { console.log(error); } @@ -102,16 +110,16 @@ function UserActivity(props) { const intervalId = setInterval(fetchHistory, 60000 * 5); return () => clearInterval(intervalId); - }, [props.UserId, token]); + }, [props.UserId, token, itemCount, currentPage]); - if (!data) { + if (!data || !data.results) { return <>; } - let filteredData = data; + let filteredData = data.results; if (searchQuery) { - filteredData = data.filter((item) => + filteredData = data.results.filter((item) => (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) .toLowerCase() .includes(searchQuery.toLowerCase()) @@ -204,7 +212,13 @@ function UserActivity(props) {
- +
); diff --git a/src/pages/css/activity.css b/src/pages/css/activity.css index 97e441e..f463011 100644 --- a/src/pages/css/activity.css +++ b/src/pages/css/activity.css @@ -1,3 +1,4 @@ .Activity { /* margin-top: 10px; */ + position: relative; } diff --git a/src/pages/css/loading.css b/src/pages/css/loading.css index 0b6c992..3d2bca4 100644 --- a/src/pages/css/loading.css +++ b/src/pages/css/loading.css @@ -1,38 +1,48 @@ -@import './variables.module.css'; +@import "./variables.module.css"; .loading { + margin: 0px; + height: calc(100vh - 100px); - margin: 0px; - height: calc(100vh - 100px); - - display: flex; - justify-content: center; - align-items: center; - background-color: var(--background-color); - transition: opacity 800ms ease-in; - opacity: 1; - } - -.loading::before -{ + display: flex; + justify-content: center; + align-items: center; + background-color: var(--background-color); + transition: opacity 800ms ease-in; + opacity: 1; +} + +.busy { + height: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 55px; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */ + z-index: 9999; /* High z-index to be above other elements */ +} + +.loading::before { opacity: 0; } - - .loading__spinner { - width: 50px; - height: 50px; - border: 5px solid #ccc; - border-top-color: #333; - border-radius: 50%; - animation: spin 1s ease-in-out infinite; +.loading__spinner { + width: 50px; + height: 50px; + border: 5px solid #ccc; + border-top-color: #333; + border-radius: 50%; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); } - - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + to { + transform: rotate(360deg); } - \ No newline at end of file +} From 42402b7cfdcb9fa5614b16f78198d6e8994e7a32 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 00:25:01 +0200 Subject: [PATCH 03/40] updated swagger docs --- backend/swagger.json | 386 ++++++++++++++++++++++++------------------- 1 file changed, 214 insertions(+), 172 deletions(-) diff --git a/backend/swagger.json b/backend/swagger.json index dbd38fc..428a6d1 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -74,6 +74,9 @@ "401": { "description": "Unauthorized" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -88,6 +91,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -122,6 +128,9 @@ "403": { "description": "Forbidden" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -156,6 +165,9 @@ "400": { "description": "Bad Request" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -177,6 +189,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -213,6 +228,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -244,6 +262,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -275,6 +296,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -289,6 +313,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -303,6 +330,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -324,6 +354,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -357,6 +390,9 @@ }, "400": { "description": "Bad Request" + }, + "404": { + "description": "Not Found" } } } @@ -548,6 +584,61 @@ } } }, + "/api/setExternalUrl": { + "post": { + "tags": ["API"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "ExternalUrl": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/setPreferredAdmin": { "post": { "tags": ["API"], @@ -1716,6 +1807,98 @@ } } }, + "/api/getBackupTables": { + "get": { + "tags": ["API"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/setExcludedBackupTable": { + "post": { + "tags": ["API"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "table": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/api/getHistory": { "get": { "tags": ["API"], @@ -1737,7 +1920,12 @@ "type": "string" }, { - "name": "limit", + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", "in": "query", "type": "string" } @@ -1779,7 +1967,12 @@ "type": "string" }, { - "name": "limit", + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", "in": "query", "type": "string" }, @@ -1838,6 +2031,16 @@ "in": "query", "type": "string" }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -1894,7 +2097,12 @@ "type": "string" }, { - "name": "limit", + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", "in": "query", "type": "string" }, @@ -1988,175 +2196,6 @@ } } }, - "/sync/beginSync": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/sync/beginPartialSync": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/sync/fetchItem": { - "post": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - }, - { - "name": "body", - "in": "body", - "schema": { - "type": "object", - "properties": { - "itemId": { - "example": "any" - } - } - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/sync/syncPlaybackPluginData": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, "/stats/getLibraryOverview": { "get": { "tags": ["Stats"], @@ -3411,6 +3450,9 @@ } ], "responses": { + "200": { + "description": "OK" + }, "401": { "description": "Unauthorized" }, From abc9e2234cefe18af1a1279c0fa944986c334061 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 01:29:18 +0200 Subject: [PATCH 04/40] fix for non expanding rows --- src/pages/components/activity/activity-table.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index f1caf1f..682f0e4 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -363,7 +363,6 @@ export default function ActivityTable(props) { return row.results; }, - paginateExpandedRows: false, onPaginationChange: handlePageChange, getRowId: (row) => row.Id, muiExpandButtonProps: ({ row }) => ({ From a02fa138e36a89e2eaacae19f0bee662a14f6087 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 01:36:22 +0200 Subject: [PATCH 05/40] page index resetting fix - this doesnt seem like the indended use, just a quick fix to be addressed in by the creators of material react table --- src/pages/components/activity/activity-table.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index 682f0e4..d0719a0 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -298,6 +298,7 @@ export default function ActivityTable(props) { ], }, pageCount: pages, + rowCount: pagination.pageSize, // fix for bug causing pagination index to reset when row count changes showAlertBanner: false, enableHiding: false, enableFullScreenToggle: false, From ebeccd6f2b874102cb5d71839b1cbfc6de4e9291 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 02:03:27 +0200 Subject: [PATCH 06/40] fix data not fetched on page change for item activity --- src/pages/components/item-info/item-activity.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 72539eb..83321dd 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -56,7 +56,7 @@ function ItemActivity(props) { } }; - if (!data) { + if (!data || data.current_page !== currentPage) { fetchData(); } From e621115b2d79308b3bb509d255225748afba0adf Mon Sep 17 00:00:00 2001 From: brikim Date: Thu, 26 Dec 2024 21:01:39 -0600 Subject: [PATCH 07/40] Split out Video and Audio into different columns. Use the IsVideoDirect and IsAudioDirect flags to determine if the cooresponding field is Direct Stream or not. --- .../components/sessions/session-card.jsx | 36 ++--------- src/pages/components/sessions/sessions.jsx | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/pages/components/sessions/session-card.jsx b/src/pages/components/sessions/session-card.jsx index 3158f76..1fb0d52 100644 --- a/src/pages/components/sessions/session-card.jsx +++ b/src/pages/components/sessions/session-card.jsx @@ -43,20 +43,6 @@ function getETAFromTicks(ticks) { return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } -function convertBitrate(bitrate) { - if (!bitrate) { - return "N/A"; - } - const kbps = (bitrate / 1000).toFixed(1); - const mbps = (bitrate / 1000000).toFixed(1); - - if (kbps >= 1000) { - return mbps + " Mbps"; - } else { - return kbps + " Kbps"; - } -} - function SessionCard(props) { const cardStyle = { backgroundImage: `url(proxy/Items/Images/Backdrop?id=${ @@ -137,24 +123,10 @@ function SessionCard(props) { - {props.data.session.PlayState.PlayMethod + - (props.data.session.NowPlayingItem.MediaStreams - ? " ( " + - props.data.session.NowPlayingItem.MediaStreams.find( - (stream) => stream.Type === "Video" - )?.Codec.toUpperCase() + - (props.data.session.TranscodingInfo - ? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase() - : "") + - " - " + - convertBitrate( - props.data.session.TranscodingInfo - ? props.data.session.TranscodingInfo.Bitrate - : props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video") - ?.BitRate - ) + - " )" - : "")} + {props.data.session.NowPlayingItem.VideoStream} + + + {props.data.session.NowPlayingItem.AudioStream} diff --git a/src/pages/components/sessions/sessions.jsx b/src/pages/components/sessions/sessions.jsx index ebe92aa..f2825eb 100644 --- a/src/pages/components/sessions/sessions.jsx +++ b/src/pages/components/sessions/sessions.jsx @@ -11,6 +11,20 @@ import Loading from "../general/loading"; import { Trans } from "react-i18next"; import socket from "../../../socket"; +function convertBitrate(bitrate) { + if (!bitrate) { + return "N/A"; + } + const kbps = (bitrate / 1000).toFixed(1); + const mbps = (bitrate / 1000000).toFixed(1); + + if (kbps >= 1000) { + return mbps + " Mbps"; + } else { + return kbps + " Kbps"; + } +} + function Sessions() { const [data, setData] = useState(); const [config, setConfig] = useState(); @@ -21,6 +35,8 @@ function Sessions() { let toSet = data.filter((row) => row.NowPlayingItem !== undefined); toSet.forEach((s) => { handleLiveTV(s); + s.NowPlayingItem.VideoStream = getVideoStream(s); + s.NowPlayingItem.AudioStream = getAudioStream(s); s.NowPlayingItem.SubtitleStream = getSubtitleStream(s); }); setData(toSet); @@ -39,6 +55,53 @@ function Sessions() { } }; + const getVideoStream = (row) => { + let videoStream = row.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video"); + + if (videoStream === undefined) { + return ""; + } + + let transcodeType = "Direct Stream"; + let transcodeVideoCodec = ""; + if (row.TranscodingInfo && !row.TranscodingInfo.IsVideoDirect){ + transcodeType = "Transcode"; + transcodeVideoCodec = ` -> ${row.TranscodingInfo.VideoCodec.toUpperCase()}`; + } + let bitRate = convertBitrate( + row.TranscodingInfo + ? row.TranscodingInfo.Bitrate + : videoStream.BitRate); + + const originalVideoCodec = videoStream.Codec.toUpperCase(); + + return `Video: ${transcodeType} (${originalVideoCodec}${transcodeVideoCodec} - ${bitRate})`; + } + + const getAudioStream = (row) => { + let result = ""; + + let streamIndex = row.PlayState.AudioStreamIndex; + if (streamIndex === undefined || streamIndex === -1) { + return result; + } + + let transcodeType = "Direct Stream"; + let transcodeCodec = ""; + if (row.TranscodingInfo && !row.TranscodingInfo.IsAudioDirect){ + transcodeType = "Transcode"; + transcodeCodec = ` -> ${row.TranscodingInfo.AudioCodec.toUpperCase()}`; + } + + let originalCodec = ""; + if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length && streamIndex < row.NowPlayingItem.MediaStreams.length) { + originalCodec = row.NowPlayingItem.MediaStreams[streamIndex].Codec.toUpperCase(); + } + + return originalCodec != "" ? `Audio: ${transcodeType} (${originalCodec}${transcodeCodec})` + : `Audio: ${transcodeType}`; + } + const getSubtitleStream = (row) => { let result = ""; From 58804e08d3420abf90a22d073856dccf74e57edb Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 14:53:14 +0200 Subject: [PATCH 08/40] optiomisations on history queries --- backend/classes/db-helper.js | 4 +++- backend/routes/api.js | 24 +++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index e0fea09..8af10f9 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -59,7 +59,9 @@ async function query({ const joinConditions = join.conditions .map((condition, index) => { const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND"; - return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${wrapField(condition.second)}`; + return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${ + condition.second ? wrapField(condition.second) : `'${condition.value}'` + }`; }) .join(" "); const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`; diff --git a/backend/routes/api.js b/backend/routes/api.js index 5de01d4..f958c6a 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1101,8 +1101,8 @@ router.get("/getHistory", async (req, res) => { table: "jf_library_episodes", alias: "e", conditions: [ - { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId", type: "and" }, - { first: "a.SeasonId", operator: "=", second: "e.SeasonId", type: "and" }, + { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, + { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, ], }, { @@ -1110,7 +1110,7 @@ router.get("/getHistory", async (req, res) => { table: "jf_library_items", alias: "i", conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, ], }, @@ -1148,7 +1148,10 @@ router.post("/getLibraryHistory", async (req, res) => { type: "inner", table: "jf_library_items", alias: "i", - conditions: [{ first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }], + conditions: [ + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, + { first: "i.ParentId", operator: "=", value: libraryid }, + ], }, { type: "left", @@ -1160,7 +1163,7 @@ router.post("/getLibraryHistory", async (req, res) => { ], }, ], - where: [{ column: "i.ParentId", operator: "=", value: libraryid }], + order_by: "ActivityDateInserted", sort_order: "desc", pageNumber: page, @@ -1206,13 +1209,13 @@ router.post("/getItemHistory", async (req, res) => { table: "jf_library_items", alias: "i", conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, ], }, ], where: [ - { column: "a.EpisodeId", operator: "=", value: itemid, type: "or" }, + { column: "a.EpisodeId", operator: "=", value: itemid }, { column: "a.SeasonId", operator: "=", value: itemid, type: "or" }, { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, ], @@ -1222,11 +1225,6 @@ router.post("/getItemHistory", async (req, res) => { pageSize: size, }); - // const groupedResults = rows.map((item) => ({ - // ...item, - // results: [], - // })); - res.send({ current_page: page, pages: result.pages, results: result.results }); } catch (error) { console.log(error); @@ -1265,7 +1263,7 @@ router.post("/getUserHistory", async (req, res) => { table: "jf_library_items", alias: "i", conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId", type: "or" }, + { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, ], }, From be3c3fc1758803cd3da83d553e0a98d9b8abc0fa Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 18:29:02 +0200 Subject: [PATCH 09/40] Added refresh on item count per row change --- backend/routes/api.js | 8 ++++---- src/pages/activity.jsx | 4 ++-- src/pages/components/item-info.jsx | 7 +++++-- src/pages/components/item-info/item-activity.jsx | 10 +++++++--- src/pages/components/library/library-activity.jsx | 4 ++-- src/pages/components/user-info/user-activity.jsx | 14 +++++++++++--- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index f958c6a..75e1699 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1122,7 +1122,7 @@ router.get("/getHistory", async (req, res) => { }); const groupedResults = groupActivity(result.results); - res.send({ current_page: page, pages: result.pages, results: Object.values(groupedResults) }); + res.send({ current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }); } catch (error) { console.log(error); } @@ -1171,7 +1171,7 @@ router.post("/getLibraryHistory", async (req, res) => { }); const groupedResults = groupActivity(result.results); - res.send({ current_page: page, pages: result.pages, results: Object.values(groupedResults) }); + res.send({ current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }); } catch (error) { console.log(error); res.status(503); @@ -1225,7 +1225,7 @@ router.post("/getItemHistory", async (req, res) => { pageSize: size, }); - res.send({ current_page: page, pages: result.pages, results: result.results }); + res.send({ current_page: page, pages: result.pages, size: size, results: result.results }); } catch (error) { console.log(error); res.status(503); @@ -1275,7 +1275,7 @@ router.post("/getUserHistory", async (req, res) => { pageSize: size, }); - res.send({ current_page: page, pages: result.pages, results: result.results }); + res.send({ current_page: page, pages: result.pages, size: size, results: result.results }); } catch (error) { console.log(error); res.status(503); diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 38269b7..2452021 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -33,7 +33,7 @@ function Activity() { }; function setItemLimit(limit) { - setItemCount(limit); + setItemCount(parseInt(limit)); localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); } @@ -121,7 +121,7 @@ function Activity() { }; if (config) { - if (!data || data.current_page !== currentPage) { + if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { fetchHistory(); fetchLibraries(); } diff --git a/src/pages/components/item-info.jsx b/src/pages/components/item-info.jsx index cd0bfd5..a2bdd5f 100644 --- a/src/pages/components/item-info.jsx +++ b/src/pages/components/item-info.jsx @@ -27,6 +27,7 @@ import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon"; import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon"; import baseUrl from "../../lib/baseurl"; import GlobalStats from "./general/globalStats"; +import ErrorBoundary from "./general/ErrorBoundary.jsx"; function ItemInfo() { const { Id } = useParams(); @@ -257,7 +258,7 @@ function ItemInfo() { : <>} - + + + diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 83321dd..74a6cd2 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -9,7 +9,7 @@ import Config from "../../../lib/config.jsx"; function ItemActivity(props) { const [data, setData] = useState(); const token = localStorage.getItem("token"); - const [itemCount, setItemCount] = useState(10); + const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [config, setConfig] = useState(); @@ -19,6 +19,10 @@ function ItemActivity(props) { const handlePageChange = (newPage) => { setCurrentPage(newPage); }; + function setItemLimit(limit) { + setItemCount(parseInt(limit)); + localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); + } useEffect(() => { const fetchConfig = async () => { @@ -56,7 +60,7 @@ function ItemActivity(props) { } }; - if (!data || data.current_page !== currentPage) { + if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { fetchData(); } @@ -122,7 +126,7 @@ function ItemActivity(props) { { - setItemCount(event.target.value); + setItemLimit(event.target.value); }} value={itemCount} className="my-md-3 w-md-75 rounded-0 rounded-end" diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index e623a7b..9e94362 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -24,7 +24,7 @@ function LibraryActivity(props) { }; function setItemLimit(limit) { - setItemCount(limit); + setItemCount(parseInt(limit)); localStorage.setItem("PREF_LIBRARY_ACTIVITY_ItemCount", limit); } @@ -68,7 +68,7 @@ function LibraryActivity(props) { } }; - if (!data || data.current_page !== currentPage) { + if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { fetchData(); } diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index e8f5366..ce7309b 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -13,7 +13,7 @@ import Config from "../../../lib/config.jsx"; function UserActivity(props) { const [data, setData] = useState(); const token = localStorage.getItem("token"); - const [itemCount, setItemCount] = useState(10); + const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [libraryFilters, setLibraryFilters] = useState([]); @@ -23,6 +23,11 @@ function UserActivity(props) { const [currentPage, setCurrentPage] = useState(1); const [isBusy, setIsBusy] = useState(false); + function setItemLimit(limit) { + setItemCount(parseInt(limit)); + localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); + } + useEffect(() => { const fetchConfig = async () => { try { @@ -105,7 +110,10 @@ function UserActivity(props) { }); }; - fetchHistory(); + if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { + fetchHistory(); + } + fetchLibraries(); const intervalId = setInterval(fetchHistory, 60000 * 5); @@ -190,7 +198,7 @@ function UserActivity(props) { { - setItemCount(event.target.value); + setItemLimit(event.target.value); }} value={itemCount} className="my-md-3 w-md-75 rounded-0 rounded-end" From da6b61968fd11433e19f997fa939771b46031b11 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 27 Dec 2024 18:47:51 +0200 Subject: [PATCH 10/40] added memoizee to update checker to prevent hitting githubs api limit --- backend/version-control.js | 55 +++++++++----- package-lock.json | 145 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 184 insertions(+), 17 deletions(-) diff --git a/backend/version-control.js b/backend/version-control.js index 99a774e..1cf964a 100644 --- a/backend/version-control.js +++ b/backend/version-control.js @@ -1,43 +1,64 @@ -const GitHub = require('github-api'); -const packageJson = require('../package.json'); -const {compareVersions} =require('compare-versions'); +const GitHub = require("github-api"); +const packageJson = require("../package.json"); +const { compareVersions } = require("compare-versions"); +const memoizee = require("memoizee"); async function checkForUpdates() { const currentVersion = packageJson.version; - const repoOwner = 'cyfershepard'; - const repoName = 'Jellystat'; + const repoOwner = "cyfershepard"; + const repoName = "Jellystat"; const gh = new GitHub(); - let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false}; + let result = { current_version: packageJson.version, latest_version: "", message: "", update_available: false }; let latestVersion; try { - const path = 'package.json'; + const path = "package.json"; - const response = await gh.getRepo(repoOwner, repoName).getContents('main', path); + const response = await gh.getRepo(repoOwner, repoName).getContents("main", path); const content = response.data.content; - const decodedContent = Buffer.from(content, 'base64').toString(); + const decodedContent = Buffer.from(content, "base64").toString(); latestVersion = JSON.parse(decodedContent).version; - if (compareVersions(latestVersion,currentVersion) > 0) { + if (compareVersions(latestVersion, currentVersion) > 0) { // console.log(`A new version V.${latestVersion} of ${repoName} is available.`); - result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} has an update ${latestVersion}`, update_available:true }; - } else if (compareVersions(latestVersion,currentVersion) < 0) { + result = { + current_version: packageJson.version, + latest_version: latestVersion, + message: `${repoName} has an update ${latestVersion}`, + update_available: true, + }; + } else if (compareVersions(latestVersion, currentVersion) < 0) { // console.log(`${repoName} is using a beta version.`); - result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is using a beta version`, update_available:false }; + result = { + current_version: packageJson.version, + latest_version: latestVersion, + message: `${repoName} is using a beta version`, + update_available: false, + }; } else { // console.log(`${repoName} is up to date.`); - result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is up to date`, update_available:false }; + result = { + current_version: packageJson.version, + latest_version: latestVersion, + message: `${repoName} is up to date`, + update_available: false, + }; } } catch (error) { console.error(`Failed to fetch releases for ${repoName}: ${error.message}`); - result = { current_version: packageJson.version, latest_version: 'N/A', message: `Failed to fetch releases for ${repoName}: ${error.message}`, update_available:false }; + result = { + current_version: packageJson.version, + latest_version: "N/A", + message: `Failed to fetch releases for ${repoName}: ${error.message}`, + update_available: false, + }; } return result; } +const memoizedCheckForUpdates = memoizee(checkForUpdates, { maxAge: 300000, promise: true }); - -module.exports = { checkForUpdates }; +module.exports = { checkForUpdates: memoizedCheckForUpdates }; diff --git a/package-lock.json b/package-lock.json index 7d84311..f0f494f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "i18next-http-backend": "^2.4.3", "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", @@ -8792,6 +8793,18 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -9649,6 +9662,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -10264,6 +10325,20 @@ "node": ">=6" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -10343,6 +10418,15 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -10530,6 +10614,14 @@ } ] }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12112,6 +12204,11 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -14904,6 +15001,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, "node_modules/luxon": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", @@ -15011,6 +15116,24 @@ "node": ">= 4.0.0" } }, + "node_modules/memoizee": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", + "dependencies": { + "d": "^1.0.2", + "es5-ext": "^0.10.64", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -15300,6 +15423,11 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -20908,6 +21036,18 @@ "node": ">=8" } }, + "node_modules/timers-ext": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", + "dependencies": { + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -21073,6 +21213,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index a211501..af1b69c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "i18next-http-backend": "^2.4.3", "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", From d4e0e9856e551bd1c77ca4bee1fbf839ca320c48 Mon Sep 17 00:00:00 2001 From: brikim Date: Sat, 28 Dec 2024 21:15:34 -0600 Subject: [PATCH 11/40] Cleaned up Audio section for audio type media. Added Artist name to session card. --- .../components/sessions/session-card.jsx | 29 ++++++++++++++----- src/pages/components/sessions/sessions.jsx | 27 ++++++++++++----- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/pages/components/sessions/session-card.jsx b/src/pages/components/sessions/session-card.jsx index 1fb0d52..5f1c7af 100644 --- a/src/pages/components/sessions/session-card.jsx +++ b/src/pages/components/sessions/session-card.jsx @@ -122,16 +122,22 @@ function SessionCard(props) { + {props.data.session.NowPlayingItem.VideoStream !== "" && + + {props.data.session.NowPlayingItem.VideoStream} + + } - {props.data.session.NowPlayingItem.VideoStream} + {props.data.session.NowPlayingItem.AudioStream !== "" && + {props.data.session.NowPlayingItem.AudioStream} + } - {props.data.session.NowPlayingItem.AudioStream} - - - - {props.data.session.NowPlayingItem.SubtitleStream} - + {props.data.session.NowPlayingItem.SubtitleStream !== "" && + + {props.data.session.NowPlayingItem.SubtitleStream} + + } @@ -185,7 +191,14 @@ function SessionCard(props) { - ) : ( + ) : (props.data.session.NowPlayingItem.Type === "Audio" && props.data.session.NowPlayingItem.Artists.length > 0) ? ( + + + {props.data.session.NowPlayingItem.Artists[0]} + + + ) : + ( <> )} diff --git a/src/pages/components/sessions/sessions.jsx b/src/pages/components/sessions/sessions.jsx index f2825eb..b540048 100644 --- a/src/pages/components/sessions/sessions.jsx +++ b/src/pages/components/sessions/sessions.jsx @@ -79,11 +79,10 @@ function Sessions() { } const getAudioStream = (row) => { - let result = ""; - + let mediaTypeAudio = row.NowPlayingItem.Type === 'Audio'; let streamIndex = row.PlayState.AudioStreamIndex; - if (streamIndex === undefined || streamIndex === -1) { - return result; + if ((streamIndex === undefined || streamIndex === -1) && !mediaTypeAudio) { + return ""; } let transcodeType = "Direct Stream"; @@ -93,12 +92,24 @@ function Sessions() { transcodeCodec = ` -> ${row.TranscodingInfo.AudioCodec.toUpperCase()}`; } - let originalCodec = ""; - if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length && streamIndex < row.NowPlayingItem.MediaStreams.length) { - originalCodec = row.NowPlayingItem.MediaStreams[streamIndex].Codec.toUpperCase(); + let bitRate = ""; + if (mediaTypeAudio) { + bitRate = " - " + convertBitrate( + row.TranscodingInfo + ? row.TranscodingInfo.Bitrate + : row.NowPlayingItem.Bitrate); } - return originalCodec != "" ? `Audio: ${transcodeType} (${originalCodec}${transcodeCodec})` + let originalCodec = ""; + if (mediaTypeAudio){ + + originalCodec = `${row.NowPlayingItem.Container.toUpperCase()}`; + } + else if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length && streamIndex < row.NowPlayingItem.MediaStreams.length) { + originalCodec = row.NowPlayingItem.MediaStreams[streamIndex].Codec.toUpperCase(); + } + + return originalCodec != "" ? `Audio: ${transcodeType} (${originalCodec}${transcodeCodec}${bitRate})` : `Audio: ${transcodeType}`; } From b6324f82080388266f49d7cf27c477b1ce561cc6 Mon Sep 17 00:00:00 2001 From: brikim Date: Sun, 29 Dec 2024 08:04:27 -0600 Subject: [PATCH 12/40] Added translations for hard coded items added in sessions. Improved music session card. --- public/locales/en-UK/translation.json | 7 +++++-- public/locales/fr-FR/translation.json | 7 +++++-- public/locales/zh-CN/translation.json | 7 +++++-- src/pages/components/sessions/sessions.jsx | 18 ++++++++++-------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index e7b5bf6..eddda06 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -17,7 +17,9 @@ "LIBRARY_OVERVIEW": "Library Overview" }, "SESSIONS": { - "NO_SESSIONS": "No Active Sessions Found" + "NO_SESSIONS": "No Active Sessions Found", + "DIRECT_PLAY": "Direct Play", + "TRANSCODE": "Transcode" }, "STAT_CARDS": { "MOST_VIEWED_MOVIES": "MOST VIEWED MOVIES", @@ -301,5 +303,6 @@ "LONGITUDE": "Longitude", "TIMEZONE": "Timezone", "POSTCODE": "Postcode", - "X_ROWS_SELECTED": "{ROWS} Rows Selected" + "X_ROWS_SELECTED": "{ROWS} Rows Selected", + "SUBTITLES": "Subtitles" } diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index b93c71d..c2039bc 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -17,7 +17,9 @@ "LIBRARY_OVERVIEW": "Vue d'ensemble des médiathèques" }, "SESSIONS": { - "NO_SESSIONS": "Aucune lecture en cours n'a été trouvée" + "NO_SESSIONS": "Aucune lecture en cours n'a été trouvée", + "DIRECT_PLAY": "", + "TRANSCODE": "" }, "STAT_CARDS": { "MOST_VIEWED_MOVIES": "FILMS LES PLUS VUS", @@ -301,5 +303,6 @@ "LONGITUDE": "Longitude", "TIMEZONE": "Fuseau horaire", "POSTCODE": "Code postal", - "X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)" + "X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)", + "SUBTITLES": "" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index cb1f548..a16763b 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -17,7 +17,9 @@ "LIBRARY_OVERVIEW": "媒体库总览" }, "SESSIONS": { - "NO_SESSIONS": "未找到活动会话" + "NO_SESSIONS": "未找到活动会话", + "DIRECT_PLAY": "", + "TRANSCODE": "" }, "STAT_CARDS": { "MOST_VIEWED_MOVIES": "最多观看电影", @@ -301,5 +303,6 @@ "LONGITUDE": "经度", "TIMEZONE": "时区", "POSTCODE": "邮编", - "X_ROWS_SELECTED": "已选中 {ROWS} 行" + "X_ROWS_SELECTED": "已选中 {ROWS} 行", + "SUBTITLES": "" } diff --git a/src/pages/components/sessions/sessions.jsx b/src/pages/components/sessions/sessions.jsx index b540048..d7597f0 100644 --- a/src/pages/components/sessions/sessions.jsx +++ b/src/pages/components/sessions/sessions.jsx @@ -9,6 +9,7 @@ import SessionCard from "./session-card"; import Loading from "../general/loading"; import { Trans } from "react-i18next"; +import i18next from "i18next"; import socket from "../../../socket"; function convertBitrate(bitrate) { @@ -62,10 +63,11 @@ function Sessions() { return ""; } - let transcodeType = "Direct Stream"; + + let transcodeType = i18next.t("SESSIONS.DIRECT_PLAY"); let transcodeVideoCodec = ""; if (row.TranscodingInfo && !row.TranscodingInfo.IsVideoDirect){ - transcodeType = "Transcode"; + transcodeType = i18next.t("SESSIONS.TRANSCODE"); transcodeVideoCodec = ` -> ${row.TranscodingInfo.VideoCodec.toUpperCase()}`; } let bitRate = convertBitrate( @@ -75,7 +77,7 @@ function Sessions() { const originalVideoCodec = videoStream.Codec.toUpperCase(); - return `Video: ${transcodeType} (${originalVideoCodec}${transcodeVideoCodec} - ${bitRate})`; + return `${i18next.t("VIDEO")}: ${transcodeType} (${originalVideoCodec}${transcodeVideoCodec} - ${bitRate})`; } const getAudioStream = (row) => { @@ -85,10 +87,10 @@ function Sessions() { return ""; } - let transcodeType = "Direct Stream"; + let transcodeType = i18next.t("SESSIONS.DIRECT_PLAY"); let transcodeCodec = ""; if (row.TranscodingInfo && !row.TranscodingInfo.IsAudioDirect){ - transcodeType = "Transcode"; + transcodeType = i18next.t("SESSIONS.TRANSCODE"); transcodeCodec = ` -> ${row.TranscodingInfo.AudioCodec.toUpperCase()}`; } @@ -109,8 +111,8 @@ function Sessions() { originalCodec = row.NowPlayingItem.MediaStreams[streamIndex].Codec.toUpperCase(); } - return originalCodec != "" ? `Audio: ${transcodeType} (${originalCodec}${transcodeCodec}${bitRate})` - : `Audio: ${transcodeType}`; + return originalCodec != "" ? `${i18next.t("AUDIO")}: ${transcodeType} (${originalCodec}${transcodeCodec}${bitRate})` + : `${i18next.t("AUDIO")}: ${transcodeType}`; } const getSubtitleStream = (row) => { @@ -127,7 +129,7 @@ function Sessions() { } if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length) { - result = `Subtitles: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`; + result = `${i18next.t("SUBTITLES")}: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`; } return result; From 9cf93f2862fd6c22bfa05c69fb00b26024b84aad Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Thu, 2 Jan 2025 01:44:21 +0200 Subject: [PATCH 13/40] expanded api querying of history to add a search field --- backend/classes/db-helper.js | 9 +- backend/routes/api.js | 106 ++++-- backend/swagger.json | 335 +++++++++++++----- src/pages/activity.jsx | 36 +- .../components/item-info/item-activity.jsx | 38 +- .../components/library/library-activity.jsx | 36 +- .../components/user-info/user-activity.jsx | 36 +- 7 files changed, 453 insertions(+), 143 deletions(-) diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index 8af10f9..773626a 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -25,11 +25,14 @@ function buildWhereClause(conditions) { return conditions .map((condition, index) => { if (Array.isArray(condition)) { - return `(${buildWhereClause(condition)})`; + return `${index > 0 ? "AND" : ""} (${buildWhereClause(condition)})`; } else if (typeof condition === "object") { - const { column, operator, value, type } = condition; + const { column, field, operator, value, type } = condition; const conjunction = index === 0 ? "" : type ? type.toUpperCase() : "AND"; - return `${conjunction} ${wrapField(column)} ${operator} '${value}'`; + if (operator == "LIKE") { + return `${conjunction} ${column ? wrapField(column) : field} ${operator} '%${value}%'`; + } + return `${conjunction} ${column ? wrapField(column) : field} ${operator} '${value}'`; } return ""; }) diff --git a/backend/routes/api.js b/backend/routes/api.js index 75e1699..3fd6f04 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1088,10 +1088,10 @@ router.post("/setExcludedBackupTable", async (req, res) => { //DB Queries - History router.get("/getHistory", async (req, res) => { - const { size = 50, page = 1 } = req.query; + const { size = 50, page = 1, search } = req.query; try { - const result = await dbHelper.query({ + const query = { select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], table: "jf_playback_activity", alias: "a", @@ -1115,14 +1115,29 @@ router.get("/getHistory", async (req, res) => { ], }, ], + order_by: "a.ActivityDateInserted", sort_order: "desc", pageNumber: page, pageSize: size, - }); + }; + if (search && search.length > 0) { + query.where = [ + { + field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + operator: "LIKE", + value: `%${search.toLowerCase()}%`, + }, + ]; + } + const result = await dbHelper.query(query); const groupedResults = groupActivity(result.results); - res.send({ current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }); + const response = { current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }; + if (search && search.length > 0) { + response.search = search; + } + res.send(response); } catch (error) { console.log(error); } @@ -1130,7 +1145,7 @@ router.get("/getHistory", async (req, res) => { router.post("/getLibraryHistory", async (req, res) => { try { - const { size = 50, page = 1 } = req.query; + const { size = 50, page = 1, search } = req.query; const { libraryid } = req.body; if (libraryid === undefined) { @@ -1139,7 +1154,7 @@ router.post("/getLibraryHistory", async (req, res) => { return; } - const result = await dbHelper.query({ + const query = { select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], table: "jf_playback_activity", alias: "a", @@ -1168,10 +1183,26 @@ router.post("/getLibraryHistory", async (req, res) => { sort_order: "desc", pageNumber: page, pageSize: size, - }); + }; + + if (search && search.length > 0) { + query.where = [ + { + field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + operator: "LIKE", + value: `%${search.toLowerCase()}%`, + }, + ]; + } + + const result = await dbHelper.query(query); const groupedResults = groupActivity(result.results); - res.send({ current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }); + const response = { current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }; + if (search && search.length > 0) { + response.search = search; + } + res.send(response); } catch (error) { console.log(error); res.status(503); @@ -1181,7 +1212,7 @@ router.post("/getLibraryHistory", async (req, res) => { router.post("/getItemHistory", async (req, res) => { try { - const { size = 50, page = 1 } = req.query; + const { size = 50, page = 1, search } = req.query; const { itemid } = req.body; if (itemid === undefined) { @@ -1190,7 +1221,7 @@ router.post("/getItemHistory", async (req, res) => { return; } - const result = await dbHelper.query({ + const query = { select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber"], table: "jf_playback_activity", alias: "a", @@ -1215,17 +1246,35 @@ router.post("/getItemHistory", async (req, res) => { }, ], where: [ - { column: "a.EpisodeId", operator: "=", value: itemid }, - { column: "a.SeasonId", operator: "=", value: itemid, type: "or" }, - { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, + [ + { column: "a.EpisodeId", operator: "=", value: itemid }, + { column: "a.SeasonId", operator: "=", value: itemid, type: "or" }, + { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, + ], ], order_by: "ActivityDateInserted", sort_order: "desc", pageNumber: page, pageSize: size, - }); + }; - res.send({ current_page: page, pages: result.pages, size: size, results: result.results }); + if (search && search.length > 0) { + query.where.push([ + { + field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + operator: "LIKE", + value: `%${search.toLowerCase()}%`, + }, + ]); + } + + const result = await dbHelper.query(query); + + const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + if (search && search.length > 0) { + response.search = search; + } + res.send(response); } catch (error) { console.log(error); res.status(503); @@ -1235,7 +1284,7 @@ router.post("/getItemHistory", async (req, res) => { router.post("/getUserHistory", async (req, res) => { try { - const { size = 50, page = 1 } = req.query; + const { size = 50, page = 1, search } = req.query; const { userid } = req.body; if (userid === undefined) { @@ -1244,7 +1293,7 @@ router.post("/getUserHistory", async (req, res) => { return; } - const result = await dbHelper.query({ + const query = { select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], table: "jf_playback_activity", alias: "a", @@ -1268,14 +1317,31 @@ router.post("/getUserHistory", async (req, res) => { ], }, ], - where: [{ column: "a.UserId", operator: "=", value: userid }], + where: [[{ column: "a.UserId", operator: "=", value: userid }]], order_by: "ActivityDateInserted", sort_order: "desc", pageNumber: page, pageSize: size, - }); + }; - res.send({ current_page: page, pages: result.pages, size: size, results: result.results }); + if (search && search.length > 0) { + query.where.push([ + { + field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + operator: "LIKE", + value: `%${search.toLowerCase()}%`, + }, + ]); + } + const result = await dbHelper.query(query); + + const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + + if (search && search.length > 0) { + response.search = search; + } + + res.send(response); } catch (error) { console.log(error); res.status(503); diff --git a/backend/swagger.json b/backend/swagger.json index 428a6d1..c036d86 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -37,7 +37,10 @@ "description": "Jellystat Log Endpoints" } ], - "schemes": ["http", "https"], + "schemes": [ + "http", + "https" + ], "securityDefinitions": { "apiKey": { "type": "apiKey", @@ -48,7 +51,9 @@ "paths": { "/auth/login": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -85,7 +90,9 @@ }, "/auth/isConfigured": { "get": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "responses": { "200": { @@ -102,7 +109,9 @@ }, "/auth/createuser": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -139,7 +148,9 @@ }, "/auth/configSetup": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -176,7 +187,9 @@ }, "/proxy/web/assets/img/devices/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -200,7 +213,9 @@ }, "/proxy/Items/Images/Backdrop/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -239,7 +254,9 @@ }, "/proxy/Items/Images/Primary/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -273,7 +290,9 @@ }, "/proxy/Users/Images/Primary/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -307,7 +326,9 @@ }, "/proxy/getSessions": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "responses": { "200": { @@ -324,7 +345,9 @@ }, "/proxy/getAdminUsers": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "responses": { "200": { @@ -341,7 +364,9 @@ }, "/proxy/getRecentlyAdded": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -365,7 +390,9 @@ }, "/proxy/validateSettings": { "post": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -399,7 +426,9 @@ }, "/api/getconfig": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -439,7 +468,9 @@ }, "/api/getLibraries": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -476,7 +507,9 @@ }, "/api/getRecentlyAdded": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -531,7 +564,9 @@ }, "/api/setconfig": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -586,7 +621,9 @@ }, "/api/setExternalUrl": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -641,7 +678,9 @@ }, "/api/setPreferredAdmin": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -696,7 +735,9 @@ }, "/api/setRequireLogin": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -748,7 +789,9 @@ }, "/api/updateCredentials": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -809,7 +852,9 @@ }, "/api/updatePassword": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -861,7 +906,9 @@ }, "/api/TrackedLibraries": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -901,7 +948,9 @@ }, "/api/setExcludedLibraries": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -953,7 +1002,9 @@ }, "/api/UntrackedUsers": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -993,7 +1044,9 @@ }, "/api/setUntrackedUsers": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1045,7 +1098,9 @@ }, "/api/keys": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1080,7 +1135,9 @@ } }, "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1130,7 +1187,9 @@ } }, "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1182,7 +1241,9 @@ }, "/api/getTaskSettings": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1222,7 +1283,9 @@ }, "/api/setTaskSettings": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1280,7 +1343,9 @@ }, "/api/CheckForUpdates": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1317,7 +1382,9 @@ }, "/api/getUserDetails": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1372,7 +1439,9 @@ }, "/api/getLibrary": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1427,7 +1496,9 @@ }, "/api/getLibraryItems": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1479,7 +1550,9 @@ }, "/api/getSeasons": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1531,7 +1604,9 @@ }, "/api/getEpisodes": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1583,7 +1658,9 @@ }, "/api/getItemDetails": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1635,7 +1712,9 @@ }, "/api/item/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1693,7 +1772,9 @@ }, "/api/library/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1751,7 +1832,9 @@ }, "/api/libraryItems/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1809,7 +1892,9 @@ }, "/api/getBackupTables": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1849,7 +1934,9 @@ }, "/api/setExcludedBackupTable": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1901,7 +1988,9 @@ }, "/api/getHistory": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1928,6 +2017,11 @@ "name": "page", "in": "query", "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" } ], "responses": { @@ -1948,7 +2042,9 @@ }, "/api/getLibraryHistory": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1976,6 +2072,11 @@ "in": "query", "type": "string" }, + { + "name": "search", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2013,7 +2114,9 @@ }, "/api/getItemHistory": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -2041,6 +2144,11 @@ "in": "query", "type": "string" }, + { + "name": "search", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2078,7 +2186,9 @@ }, "/api/getUserHistory": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -2106,6 +2216,11 @@ "in": "query", "type": "string" }, + { + "name": "search", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2143,7 +2258,9 @@ }, "/api/deletePlaybackActivity": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -2198,7 +2315,9 @@ }, "/stats/getLibraryOverview": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2238,7 +2357,9 @@ }, "/stats/getMostViewedByType": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2293,7 +2414,9 @@ }, "/stats/getMostPopularByType": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2348,7 +2471,9 @@ }, "/stats/getMostViewedLibraries": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2400,7 +2525,9 @@ }, "/stats/getMostUsedClient": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2452,7 +2579,9 @@ }, "/stats/getMostActiveUsers": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2504,7 +2633,9 @@ }, "/stats/getPlaybackActivity": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2544,7 +2675,9 @@ }, "/stats/getAllUserActivity": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2581,7 +2714,9 @@ }, "/stats/getUserLastPlayed": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2633,7 +2768,9 @@ }, "/stats/getGlobalUserStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2688,7 +2825,9 @@ }, "/stats/getGlobalItemStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2743,7 +2882,9 @@ }, "/stats/getGlobalLibraryStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2798,7 +2939,9 @@ }, "/stats/getLibraryCardStats": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2836,7 +2979,9 @@ } }, "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2888,7 +3033,9 @@ }, "/stats/getLibraryMetadata": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2928,7 +3075,9 @@ }, "/stats/getLibraryItemsWithStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2977,7 +3126,9 @@ }, "/stats/getLibraryItemsPlayMethodStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3038,7 +3189,9 @@ }, "/stats/getPlaybackMethodStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3090,7 +3243,9 @@ }, "/stats/getLibraryLastPlayed": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3142,7 +3297,9 @@ }, "/stats/getViewsOverTime": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3194,7 +3351,9 @@ }, "/stats/getViewsByDays": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3246,7 +3405,9 @@ }, "/stats/getViewsByHour": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3298,7 +3459,9 @@ }, "/backup/beginBackup": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3338,7 +3501,9 @@ }, "/backup/restore/{filename}": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3384,7 +3549,9 @@ }, "/backup/files": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3424,7 +3591,9 @@ }, "/backup/files/{filename}": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3465,7 +3634,9 @@ } }, "delete": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3511,7 +3682,9 @@ }, "/backup/upload": { "post": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3548,7 +3721,9 @@ }, "/logs/getLogs": { "get": { - "tags": ["Logs"], + "tags": [ + "Logs" + ], "description": "", "parameters": [ { @@ -3585,7 +3760,9 @@ }, "/utils/geolocateIp": { "post": { - "tags": ["Utils"], + "tags": [ + "Utils" + ], "description": "", "parameters": [ { @@ -3647,4 +3824,4 @@ "apiKey": [] } ] -} +} \ No newline at end of file diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 2452021..78c0c7c 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -17,6 +17,7 @@ function Activity() { const [config, setConfig] = useState(null); const [streamTypeFilter, setStreamTypeFilter] = useState(localStorage.getItem("PREF_ACTIVITY_StreamTypeFilter") ?? "All"); const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10")); const [libraryFilters, setLibraryFilters] = useState( localStorage.getItem("PREF_ACTIVITY_libraryFilters") != undefined @@ -57,6 +58,16 @@ function Activity() { } }; + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // Adjust the delay as needed + + return () => { + clearTimeout(handler); + }; + }, [searchQuery]); + useEffect(() => { const fetchConfig = async () => { try { @@ -71,7 +82,7 @@ function Activity() { const fetchHistory = () => { setIsBusy(true); - const url = `/api/getHistory?size=${itemCount}&page=${currentPage}`; + const url = `/api/getHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`; axios .get(url, { headers: { @@ -121,7 +132,12 @@ function Activity() { }; if (config) { - if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { + if ( + !data || + (data.current_page && data.current_page !== currentPage) || + (data.size && data.size !== itemCount) || + (data.search ? data.search : "") !== debouncedSearchQuery + ) { fetchHistory(); fetchLibraries(); } @@ -133,7 +149,7 @@ function Activity() { const intervalId = setInterval(fetchHistory, 60000 * 60); return () => clearInterval(intervalId); - }, [data, config, itemCount, currentPage]); + }, [data, config, itemCount, currentPage, debouncedSearchQuery]); if (!data) { return ; @@ -158,13 +174,13 @@ function Activity() { let filteredData = data.results; - if (searchQuery) { - filteredData = data.results.filter((item) => - (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - } + // if (searchQuery) { + // filteredData = data.results.filter((item) => + // (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) + // .toLowerCase() + // .includes(searchQuery.toLowerCase()) + // ); + // } filteredData = filteredData.filter( (item) => (libraryFilters.includes(item.ParentId) || item.ParentId == null) && diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 74a6cd2..b6d0f53 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -11,6 +11,7 @@ function ItemActivity(props) { const token = localStorage.getItem("token"); const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); @@ -24,6 +25,16 @@ function ItemActivity(props) { localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); } + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // Adjust the delay as needed + + return () => { + clearTimeout(handler); + }; + }, [searchQuery]); + useEffect(() => { const fetchConfig = async () => { try { @@ -42,7 +53,7 @@ function ItemActivity(props) { try { setIsBusy(true); const itemData = await axios.post( - `/api/getItemHistory?size=${itemCount}&page=${currentPage}`, + `/api/getItemHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, { itemid: props.itemid, }, @@ -60,13 +71,18 @@ function ItemActivity(props) { } }; - if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { + if ( + !data || + (data.current_page && data.current_page !== currentPage) || + (data.size && data.size !== itemCount) || + (data.search ? data.search : "") !== debouncedSearchQuery + ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.itemid, token, itemCount, currentPage]); + }, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery]); if (!data || !data.results) { return <>; @@ -74,14 +90,14 @@ function ItemActivity(props) { let filteredData = data.results; - if (searchQuery) { - filteredData = data.results.filter( - (item) => - (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) - .toLowerCase() - .includes(searchQuery.toLowerCase()) || item.UserName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - } + // if (searchQuery) { + // filteredData = data.results.filter( + // (item) => + // (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) + // .toLowerCase() + // .includes(searchQuery.toLowerCase()) || item.UserName.toLowerCase().includes(searchQuery.toLowerCase()) + // ); + // } filteredData = filteredData.filter((item) => streamTypeFilter == "All" diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index 9e94362..f2290bb 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -12,6 +12,7 @@ function LibraryActivity(props) { const token = localStorage.getItem("token"); const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_LIBRARY_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); const [streamTypeFilter, setStreamTypeFilter] = useState( localStorage.getItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter") ?? "All" ); @@ -33,6 +34,16 @@ function LibraryActivity(props) { localStorage.setItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter", filter); } + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // Adjust the delay as needed + + return () => { + clearTimeout(handler); + }; + }, [searchQuery]); + useEffect(() => { const fetchConfig = async () => { try { @@ -50,7 +61,7 @@ function LibraryActivity(props) { try { setIsBusy(true); const libraryData = await axios.post( - `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}`, + `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, { libraryid: props.LibraryId, }, @@ -68,13 +79,18 @@ function LibraryActivity(props) { } }; - if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { + if ( + !data || + (data.current_page && data.current_page !== currentPage) || + (data.size && data.size !== itemCount) || + (data.search ? data.search : "") !== debouncedSearchQuery + ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.LibraryId, token, itemCount, currentPage]); + }, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery]); if (!data || !data.results) { return <>; @@ -82,13 +98,13 @@ function LibraryActivity(props) { let filteredData = data.results; - if (searchQuery) { - filteredData = data.results.filter((item) => - (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - } + // if (searchQuery) { + // filteredData = data.results.filter((item) => + // (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) + // .toLowerCase() + // .includes(searchQuery.toLowerCase()) + // ); + // } filteredData = filteredData.filter((item) => streamTypeFilter == "All" diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index ce7309b..c0cc924 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -15,6 +15,7 @@ function UserActivity(props) { const token = localStorage.getItem("token"); const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [libraryFilters, setLibraryFilters] = useState([]); const [libraries, setLibraries] = useState([]); @@ -28,6 +29,16 @@ function UserActivity(props) { localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); } + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // Adjust the delay as needed + + return () => { + clearTimeout(handler); + }; + }, [searchQuery]); + useEffect(() => { const fetchConfig = async () => { try { @@ -67,7 +78,7 @@ function UserActivity(props) { try { setIsBusy(true); const itemData = await axios.post( - `/api/getUserHistory?size=${itemCount}&page=${currentPage}`, + `/api/getUserHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, { userid: props.UserId, }, @@ -110,7 +121,12 @@ function UserActivity(props) { }); }; - if (!data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount)) { + if ( + !data || + (data.current_page && data.current_page !== currentPage) || + (data.size && data.size !== itemCount) || + (data.search ? data.search : "") !== debouncedSearchQuery + ) { fetchHistory(); } @@ -118,7 +134,7 @@ function UserActivity(props) { const intervalId = setInterval(fetchHistory, 60000 * 5); return () => clearInterval(intervalId); - }, [props.UserId, token, itemCount, currentPage]); + }, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery]); if (!data || !data.results) { return <>; @@ -126,13 +142,13 @@ function UserActivity(props) { let filteredData = data.results; - if (searchQuery) { - filteredData = data.results.filter((item) => - (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - } + // if (searchQuery) { + // filteredData = data.results.filter((item) => + // (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName) + // .toLowerCase() + // .includes(searchQuery.toLowerCase()) + // ); + // } filteredData = filteredData.filter( (item) => From 6b1d5932b659a98552e30323210cd5f9776a2f11 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Thu, 2 Jan 2025 20:32:39 +0200 Subject: [PATCH 14/40] changed grouping to be perfomed on sql to present more accurate data in tables. added latest activity view to assist in grouping added triggers as the view is a material view and will need to be updated when changes are made to activity table created new view to include episode and library metadata to reduce join operations needed in query --- backend/classes/db-helper.js | 59 ++++++- ...view_jf_playback_activity_with_metadata.js | 32 ++++ .../080_js_latest_playback_activity.js | 43 +++++ ...sh_function_js_latest_playback_activity.js | 28 +++ ...ger_refresh_js_latest_playback_activity.js | 29 +++ backend/routes/api.js | 165 +++++++----------- 6 files changed, 251 insertions(+), 105 deletions(-) create mode 100644 backend/migrations/079_create_view_jf_playback_activity_with_metadata.js create mode 100644 backend/migrations/080_js_latest_playback_activity.js create mode 100644 backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js create mode 100644 backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index 773626a..c816754 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -4,6 +4,18 @@ function wrapField(field) { if (field === "*") { return field; } + if ( + field.includes("COALESCE") || + field.includes("SUM") || + field.includes("COUNT") || + field.includes("MAX") || + field.includes("MIN") || + field.includes("AVG") || + field.includes("DISTINCT") || + field.includes("json_agg") + ) { + return field; + } if (field.includes(" as ")) { const [column, alias] = field.split(" as "); return `${column @@ -40,7 +52,50 @@ function buildWhereClause(conditions) { .trim(); } +function buildCTE(cte) { + if (!cte) { + return ""; + } + + const { select, table, cteAlias, alias, joins = [], where = [], group_by = [], order_by, sort_order = "desc" } = cte; + let query = `WITH ${cteAlias} AS (SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`; + + // Add joins + joins.forEach((join) => { + const joinConditions = join.conditions + .map((condition, index) => { + const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND"; + return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${ + condition.second ? wrapField(condition.second) : `'${condition.value}'` + }`; + }) + .join(" "); + const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`; + query += joinQuery; + }); + + // Add where conditions + const whereClause = buildWhereClause(where); + if (whereClause) { + query += ` WHERE ${whereClause}`; + } + + // Add group by + if (group_by.length > 0) { + query += ` GROUP BY ${group_by.map(wrapField).join(", ")}`; + } + + // Add order by + if (order_by) { + query += ` ORDER BY ${wrapField(order_by)} ${sort_order}`; + } + + query += ")"; + return query; +} + async function query({ + cte, select = ["*"], table, alias, @@ -54,8 +109,8 @@ async function query({ const client = await pool.connect(); try { // Build the base query - let countQuery = `SELECT COUNT(*) FROM ${wrapField(table)} AS ${wrapField(alias)}`; - let query = `SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`; + let countQuery = `${buildCTE(cte)} SELECT COUNT(*) FROM ${wrapField(table)} AS ${wrapField(alias)}`; + let query = `${buildCTE(cte)} SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`; // Add joins joins.forEach((join) => { diff --git a/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js b/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js new file mode 100644 index 0000000..31b539f --- /dev/null +++ b/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js @@ -0,0 +1,32 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata; + + CREATE VIEW jf_playback_activity_with_metadata AS + select a.*,e."IndexNumber" as "EpisodeNumber",e."ParentIndexNumber" as "SeasonNumber",i."ParentId" + FROM "jf_playback_activity" AS "a" + LEFT JOIN jf_library_episodes AS e + ON "a"."EpisodeId" = "e"."EpisodeId" + AND "a"."SeasonId" = "e"."SeasonId" + LEFT JOIN jf_library_items AS i + ON "i"."Id" = "a"."NowPlayingItemId" OR "e"."SeriesId" = "i"."Id" + order by a."ActivityDateInserted" desc; + + + ALTER VIEW public.jf_playback_activity_with_metadata + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/080_js_latest_playback_activity.js b/backend/migrations/080_js_latest_playback_activity.js new file mode 100644 index 0000000..cc7e031 --- /dev/null +++ b/backend/migrations/080_js_latest_playback_activity.js @@ -0,0 +1,43 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity; + + CREATE MATERIALIZED VIEW js_latest_playback_activity AS + WITH latest_activity AS ( + SELECT + "NowPlayingItemId", + "EpisodeId", + "UserId", + MAX("ActivityDateInserted") AS max_date + FROM public.jf_playback_activity + GROUP BY "NowPlayingItemId", "EpisodeId", "UserId" + order by max_date desc + ) + SELECT + a.* + FROM public.jf_playback_activity_with_metadata a + JOIN latest_activity u + ON a."NowPlayingItemId" = u."NowPlayingItemId" + AND COALESCE(a."EpisodeId", '1') = COALESCE(u."EpisodeId", '1') + AND a."UserId" = u."UserId" + AND a."ActivityDateInserted" = u.max_date + order by a."ActivityDateInserted" desc; + + + ALTER MATERIALIZED VIEW public.js_latest_playback_activity + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js b/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js new file mode 100644 index 0000000..d0b4e10 --- /dev/null +++ b/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js @@ -0,0 +1,28 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + CREATE OR REPLACE FUNCTION refresh_js_latest_playback_activity() + RETURNS TRIGGER AS $$ + BEGIN + REFRESH MATERIALIZED VIEW js_latest_playback_activity; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + + ALTER MATERIALIZED VIEW public.js_latest_playback_activity + 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.refresh_js_latest_playback_activity;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js b/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js new file mode 100644 index 0000000..767d4be --- /dev/null +++ b/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js @@ -0,0 +1,29 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_latest_playback_activity') THEN + CREATE TRIGGER refresh_js_latest_playback_activity_trigger + AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity + FOR EACH STATEMENT + EXECUTE FUNCTION refresh_js_latest_playback_activity(); + END IF; + END + $$ LANGUAGE plpgsql; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/routes/api.js b/backend/routes/api.js index 3fd6f04..7e6afc7 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -18,38 +18,6 @@ const { tables } = require("../global/backup_tables"); const router = express.Router(); //Functions -function groupActivity(rows) { - const groupedResults = {}; - rows.every((row) => { - const key = row.NowPlayingItemId + row.EpisodeId + row.UserId; - if (groupedResults[key]) { - if (row.ActivityDateInserted > groupedResults[key].ActivityDateInserted) { - groupedResults[key] = { - ...row, - results: groupedResults[key].results, - }; - } - groupedResults[key].results.push(row); - } else { - groupedResults[key] = { - ...row, - results: [], - }; - groupedResults[key].results.push(row); - } - return true; - }); - - // Update GroupedResults with playbackDurationSum - Object.values(groupedResults).forEach((row) => { - if (row.results && row.results.length > 0) { - row.PlaybackDuration = row.results.reduce((acc, item) => acc + parseInt(item.PlaybackDuration), 0); - row.TotalPlays = row.results.length; - } - }); - return groupedResults; -} - function groupRecentlyAdded(rows) { const groupedResults = {}; rows.forEach((row) => { @@ -1091,27 +1059,33 @@ router.get("/getHistory", async (req, res) => { const { size = 50, page = 1, search } = req.query; try { + const cte = { + cteAlias: "activity_results", + select: [ + "a.NowPlayingItemId", + `COALESCE(a."EpisodeId", '1') as "EpisodeId"`, + "a.UserId", + `json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`, + ], + table: "jf_playback_activity_with_metadata", + alias: "a", + group_by: ["a.NowPlayingItemId", `COALESCE(a."EpisodeId", '1')`, "a.UserId"], + }; + const query = { - select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], - table: "jf_playback_activity", + cte: cte, + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results"], + table: "js_latest_playback_activity", alias: "a", joins: [ { type: "left", - table: "jf_library_episodes", - alias: "e", + table: "activity_results", + alias: "ar", conditions: [ - { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, - { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, - ], - }, - { - type: "left", - table: "jf_library_items", - alias: "i", - conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, - { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, + { first: "a.NowPlayingItemId", operator: "=", second: "ar.NowPlayingItemId" }, + { first: "a.EpisodeId", operator: "=", second: "ar.EpisodeId", type: "and" }, + { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, ], @@ -1121,19 +1095,24 @@ router.get("/getHistory", async (req, res) => { pageNumber: page, pageSize: size, }; + if (search && search.length > 0) { query.where = [ { field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, operator: "LIKE", - value: `%${search.toLowerCase()}%`, + value: `${search.toLowerCase()}`, }, ]; } const result = await dbHelper.query(query); - const groupedResults = groupActivity(result.results); - const response = { current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }; + result.results = result.results.map((item) => ({ + ...item, + TotalPlays: item.results?.length ?? 1, + PlaybackDuration: item.results ? item.results.reduce((acc, cur) => acc + cur.PlaybackDuration, 0) : item.PlaybackDuration, + })); + const response = { current_page: page, pages: result.pages, size: size, results: result.results }; if (search && search.length > 0) { response.search = search; } @@ -1154,9 +1133,23 @@ router.post("/getLibraryHistory", async (req, res) => { return; } + const cte = { + cteAlias: "activity_results", + select: [ + "a.NowPlayingItemId", + `COALESCE(a."EpisodeId", '1') as "EpisodeId"`, + "a.UserId", + `json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`, + ], + table: "jf_playback_activity_with_metadata", + alias: "a", + group_by: ["a.NowPlayingItemId", `COALESCE(a."EpisodeId", '1')`, "a.UserId"], + }; + const query = { - select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], - table: "jf_playback_activity", + cte: cte, + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results"], + table: "js_latest_playback_activity", alias: "a", joins: [ { @@ -1170,16 +1163,17 @@ router.post("/getLibraryHistory", async (req, res) => { }, { type: "left", - table: "jf_library_episodes", - alias: "e", + table: "activity_results", + alias: "ar", conditions: [ - { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, - { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, + { first: "a.NowPlayingItemId", operator: "=", second: "ar.NowPlayingItemId" }, + { first: "a.EpisodeId", operator: "=", second: "ar.EpisodeId", type: "and" }, + { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, ], - order_by: "ActivityDateInserted", + order_by: "a.ActivityDateInserted", sort_order: "desc", pageNumber: page, pageSize: size, @@ -1197,8 +1191,13 @@ router.post("/getLibraryHistory", async (req, res) => { const result = await dbHelper.query(query); - const groupedResults = groupActivity(result.results); - const response = { current_page: page, pages: result.pages, size: size, results: Object.values(groupedResults) }; + result.results = result.results.map((item) => ({ + ...item, + TotalPlays: item.results?.length ?? 1, + PlaybackDuration: item.results ? item.results.reduce((acc, cur) => acc + cur.PlaybackDuration, 0) : item.PlaybackDuration, + })); + + const response = { current_page: page, pages: result.pages, size: size, results: result.results }; if (search && search.length > 0) { response.search = search; } @@ -1222,29 +1221,9 @@ router.post("/getItemHistory", async (req, res) => { } const query = { - select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber"], - table: "jf_playback_activity", + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId"], + table: "jf_playback_activity_with_metadata", alias: "a", - joins: [ - { - type: "left", - table: "jf_library_episodes", - alias: "e", - conditions: [ - { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, - { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, - ], - }, - { - type: "left", - table: "jf_library_items", - alias: "i", - conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, - { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, - ], - }, - ], where: [ [ { column: "a.EpisodeId", operator: "=", value: itemid }, @@ -1294,29 +1273,9 @@ router.post("/getUserHistory", async (req, res) => { } const query = { - select: ["a.*", "e.IndexNumber as EpisodeNumber", "e.ParentIndexNumber as SeasonNumber", "i.ParentId"], - table: "jf_playback_activity", + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId"], + table: "jf_playback_activity_with_metadata", alias: "a", - joins: [ - { - type: "left", - table: "jf_library_episodes", - alias: "e", - conditions: [ - { first: "a.EpisodeId", operator: "=", second: "e.EpisodeId" }, - { first: "a.SeasonId", operator: "=", second: "e.SeasonId" }, - ], - }, - { - type: "left", - table: "jf_library_items", - alias: "i", - conditions: [ - { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, - { first: "e.SeriesId", operator: "=", second: "i.Id", type: "or" }, - ], - }, - ], where: [[{ column: "a.UserId", operator: "=", value: userid }]], order_by: "ActivityDateInserted", sort_order: "desc", From b1e0b5b45ff6d19c0c138889fecb0d4cff49b3be Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Thu, 2 Jan 2025 20:49:47 +0200 Subject: [PATCH 15/40] fixed infinte load issue on activity data table due to white spaces removed debug logging --- src/pages/activity.jsx | 2 +- src/pages/components/activity/activity-table.jsx | 1 - src/pages/components/item-info/item-activity.jsx | 2 +- src/pages/components/library/library-activity.jsx | 2 +- src/pages/components/user-info/user-activity.jsx | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 78c0c7c..a7635fd 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -136,7 +136,7 @@ function Activity() { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery + (data.search ? data.search : "") !== debouncedSearchQuery.trim() ) { fetchHistory(); fetchLibraries(); diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index d0719a0..ed8e536 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -77,7 +77,6 @@ export default function ActivityTable(props) { const handlePageChange = (updater) => { setPagination((old) => { const newPaginationState = typeof updater === "function" ? updater(old) : updater; - console.log(newPaginationState); const newPage = newPaginationState.pageIndex; // MaterialReactTable uses 0-based index if (props.onPageChange) { props.onPageChange(newPage + 1); diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index b6d0f53..4696fd8 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -75,7 +75,7 @@ function ItemActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery + (data.search ? data.search : "") !== debouncedSearchQuery.trim() ) { fetchData(); } diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index f2290bb..df77082 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -83,7 +83,7 @@ function LibraryActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery + (data.search ? data.search : "") !== debouncedSearchQuery.trim() ) { fetchData(); } diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index c0cc924..293498a 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -125,7 +125,7 @@ function UserActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery + (data.search ? data.search : "") !== debouncedSearchQuery.trim() ) { fetchHistory(); } From 9f0c3fcb2b53fe4356faf05d4d7ba3ebfba302e5 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Thu, 2 Jan 2025 23:02:17 +0200 Subject: [PATCH 16/40] convert js_library_stats_overview to material view to reduce load times added triggers on jf_playback_activity to refresh js_library_stats_overview view when data changes --- ...rialized_view_js_library_stats_overview.js | 137 ++++++++++++++++++ ...resh_function_js_library_stats_overview.js | 28 ++++ ...igger_refresh_js_library_stats_overview.js | 29 ++++ 3 files changed, 194 insertions(+) create mode 100644 backend/migrations/083_create_materialized_view_js_library_stats_overview.js create mode 100644 backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js create mode 100644 backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js diff --git a/backend/migrations/083_create_materialized_view_js_library_stats_overview.js b/backend/migrations/083_create_materialized_view_js_library_stats_overview.js new file mode 100644 index 0000000..3b65269 --- /dev/null +++ b/backend/migrations/083_create_materialized_view_js_library_stats_overview.js @@ -0,0 +1,137 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP VIEW IF EXISTS public.js_library_stats_overview; + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + + CREATE MATERIALIZED VIEW public.js_library_stats_overview + AS + SELECT l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + LEFT JOIN ( + + SELECT + jso."Id", + jso."NowPlayingItemId", + jso."SeasonId", + jso."EpisodeId", + jso."ParentId", + jso."ActivityDateInserted" + FROM + jf_playback_activity_with_metadata jso + INNER JOIN ( + SELECT + "ParentId", + MAX("ActivityDateInserted") AS max_date + FROM + jf_playback_activity_with_metadata + GROUP BY + "ParentId" + ) latest ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date + + ) latest_activity ON l."Id" = latest_activity."ParentId" + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + DROP VIEW IF EXISTS public.js_library_stats_overview; + + CREATE OR REPLACE VIEW public.js_library_stats_overview + AS + SELECT DISTINCT ON (l."Id") l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity a + JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id" + WHERE i_1."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity a + JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id" + WHERE i_1."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + + FROM jf_libraries l + LEFT JOIN ( SELECT DISTINCT ON (i_1."ParentId") jf_playback_activity."Id", + jf_playback_activity."IsPaused", + jf_playback_activity."UserId", + jf_playback_activity."UserName", + jf_playback_activity."Client", + jf_playback_activity."DeviceName", + jf_playback_activity."DeviceId", + jf_playback_activity."ApplicationVersion", + jf_playback_activity."NowPlayingItemId", + jf_playback_activity."NowPlayingItemName", + jf_playback_activity."SeasonId", + jf_playback_activity."SeriesName", + jf_playback_activity."EpisodeId", + jf_playback_activity."PlaybackDuration", + jf_playback_activity."ActivityDateInserted", + jf_playback_activity."PlayMethod", + i_1."ParentId" + FROM jf_playback_activity + JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId" + ORDER BY i_1."ParentId", jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId" + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js b/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js new file mode 100644 index 0000000..04035bf --- /dev/null +++ b/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js @@ -0,0 +1,28 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + CREATE OR REPLACE FUNCTION refresh_js_library_stats_overview() + RETURNS TRIGGER AS $$ + BEGIN + REFRESH MATERIALIZED VIEW js_library_stats_overview; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + 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.refresh_js_library_stats_overview;`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js b/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js new file mode 100644 index 0000000..c8a5f92 --- /dev/null +++ b/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js @@ -0,0 +1,29 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_library_stats_overview') THEN + CREATE TRIGGER refresh_js_library_stats_overview_trigger + AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity + FOR EACH STATEMENT + EXECUTE FUNCTION refresh_js_library_stats_overview(); + END IF; + END + $$ LANGUAGE plpgsql; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;`); + } catch (error) { + console.error(error); + } +}; From 12b0d95ae6d337ca121d6bac7460fe5a5abd535e Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Thu, 2 Jan 2025 23:23:12 +0200 Subject: [PATCH 17/40] css fixes on placeholder colours set alerts to dark theme --- src/App.css | 4 +++ src/pages/components/settings/apiKeys.jsx | 2 +- src/pages/components/settings/backupfiles.jsx | 28 +++++++++++-------- src/pages/components/settings/security.jsx | 4 +-- .../components/settings/settingsConfig.jsx | 8 +++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/App.css b/src/App.css index 43e53f5..6b7a3ad 100644 --- a/src/App.css +++ b/src/App.css @@ -151,3 +151,7 @@ h2 { .hide-tab-titles { display: none !important; } + +::placeholder { + color: #a7a7a7 !important; /* Replace with your desired color */ +} diff --git a/src/pages/components/settings/apiKeys.jsx b/src/pages/components/settings/apiKeys.jsx index a71c5a8..799e3d7 100644 --- a/src/pages/components/settings/apiKeys.jsx +++ b/src/pages/components/settings/apiKeys.jsx @@ -150,7 +150,7 @@ async function handleFormSubmit(event) {

{showAlert && showAlert.visible && ( - + {showAlert.title}

{showAlert.message} diff --git a/src/pages/components/settings/backupfiles.jsx b/src/pages/components/settings/backupfiles.jsx index a109afe..c109e3b 100644 --- a/src/pages/components/settings/backupfiles.jsx +++ b/src/pages/components/settings/backupfiles.jsx @@ -133,14 +133,14 @@ function Row(file) {

downloadBackup(data.name)}> - + restoreBackup(data.name)}> - + deleteBackup(data.name)}> - +
@@ -222,7 +222,7 @@ export default function BackupFiles() { {showAlert && showAlert.visible && ( - + {showAlert.title}

{showAlert.message}

@@ -233,10 +233,14 @@ export default function BackupFiles() { - + + + + + + + - - @@ -249,7 +253,7 @@ export default function BackupFiles() { {files.length === 0 ? ( - + ) : ( @@ -275,11 +279,11 @@ export default function BackupFiles() {
{`${page * rowsPerPage + 1}-${Math.min( @@ -288,7 +292,7 @@ export default function BackupFiles() { )} of ${files.length}`}
diff --git a/src/pages/components/settings/security.jsx b/src/pages/components/settings/security.jsx index 4ff3e46..51f34ac 100644 --- a/src/pages/components/settings/security.jsx +++ b/src/pages/components/settings/security.jsx @@ -213,9 +213,9 @@ export default function SettingsConfig() { {isSubmitted !== "" ? ( isSubmitted === "Failed" ? ( - {submissionMessage} + {submissionMessage} ) : ( - {submissionMessage} + {submissionMessage} ) ) : ( <> diff --git a/src/pages/components/settings/settingsConfig.jsx b/src/pages/components/settings/settingsConfig.jsx index 559a15d..d1558c1 100644 --- a/src/pages/components/settings/settingsConfig.jsx +++ b/src/pages/components/settings/settingsConfig.jsx @@ -231,9 +231,9 @@ export default function SettingsConfig() { {isSubmitted !== "" ? ( isSubmitted === "Failed" ? ( - {submissionMessage} + {submissionMessage} ) : ( - {submissionMessage} + {submissionMessage} ) ) : ( <> @@ -263,9 +263,9 @@ export default function SettingsConfig() { {isSubmittedExternal !== "" ? ( isSubmittedExternal === "Failed" ? ( - {submissionMessageExternal} + {submissionMessageExternal} ) : ( - {submissionMessageExternal} + {submissionMessageExternal} ) ) : ( <> From 6b3cf946ee0e0a6d7414a2a1b08cbab25087a5d2 Mon Sep 17 00:00:00 2001 From: brikim Date: Thu, 2 Jan 2025 18:40:55 -0600 Subject: [PATCH 18/40] Fix NaN showing up for audio only activity --- src/pages/components/activity/stream_info.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/components/activity/stream_info.jsx b/src/pages/components/activity/stream_info.jsx index 7ad9408..d5ced2a 100644 --- a/src/pages/components/activity/stream_info.jsx +++ b/src/pages/components/activity/stream_info.jsx @@ -90,8 +90,8 @@ function Row(logs) { - {data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'} - {data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'} From 3242e7c615b76734b111bceb84bf00b6d50385f1 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 3 Jan 2025 20:38:33 +0200 Subject: [PATCH 19/40] optimized materialized view for possible duplication bug with activity having the same activity date moved refresh views trigger to code as it caused a delay on inserts/updates/deletes --- backend/db.js | 46 ++++++- ..._all_refresh_triggers_on_activity_table.js | 21 +++ ...87_optimize_js_latest_playback_activity.js | 114 +++++++++++++++ ...rialized_view_js_library_stats_overview.js | 130 ++++++++++++++++++ backend/routes/api.js | 4 +- 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js create mode 100644 backend/migrations/087_optimize_js_latest_playback_activity.js create mode 100644 backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js diff --git a/backend/db.js b/backend/db.js index 4ecefe9..748eb27 100644 --- a/backend/db.js +++ b/backend/db.js @@ -47,6 +47,12 @@ async function deleteBulk(table_name, data, pkName) { await client.query("COMMIT"); message = data.length + " Rows removed."; + + if (table_name === "jf_playback_activity") { + for (const view of materializedViews) { + refreshMaterializedView(view); + } + } } catch (error) { await client.query("ROLLBACK"); message = "Bulk delete error: " + error; @@ -87,6 +93,32 @@ async function updateSingleFieldBulk(table_name, data, field_name, new_value, wh return { Result: result, message: "" + message }; } +const materializedViews = ["js_latest_playback_activity", "js_library_stats_overview"]; + +async function refreshMaterializedView(view_name) { + const client = await pool.connect(); + let result = "SUCCESS"; + let message = ""; + try { + await client.query("BEGIN"); + + const refreshQuery = { + text: `REFRESH MATERIALIZED VIEW ${view_name}`, + }; + await client.query(refreshQuery); + + await client.query("COMMIT"); + message = view_name + " refreshed."; + } catch (error) { + await client.query("ROLLBACK"); + message = "Refresh materialized view error: " + error; + result = "ERROR"; + } finally { + client.release(); + } + return { Result: result, message: "" + message }; +} + async function insertBulk(table_name, data, columns) { //dedupe data @@ -115,6 +147,12 @@ async function insertBulk(table_name, data, columns) { const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly await client.query(query); await client.query("COMMIT"); + + if (table_name === "jf_playback_activity") { + for (const view of materializedViews) { + refreshMaterializedView(view); + } + } } catch (error) { await client.query("ROLLBACK"); message = "" + error; @@ -128,9 +166,15 @@ async function insertBulk(table_name, data, columns) { }; } -async function query(text, params) { +async function query(text, params, refreshViews = false) { try { const result = await pool.query(text, params); + + if (refreshViews) { + for (const view of materializedViews) { + refreshMaterializedView(view); + } + } return result; } catch (error) { if (error?.routine === "auth_failed") { diff --git a/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js b/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js new file mode 100644 index 0000000..a6df304 --- /dev/null +++ b/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js @@ -0,0 +1,21 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; + DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity; + DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity; + `); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/087_optimize_js_latest_playback_activity.js b/backend/migrations/087_optimize_js_latest_playback_activity.js new file mode 100644 index 0000000..82b3f4b --- /dev/null +++ b/backend/migrations/087_optimize_js_latest_playback_activity.js @@ -0,0 +1,114 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity; + + CREATE MATERIALIZED VIEW js_latest_playback_activity AS + WITH ranked_activity AS ( + SELECT + "Id", + "IsPaused", + "UserId", + "UserName", + "Client", + "DeviceName", + "DeviceId", + "ApplicationVersion", + "NowPlayingItemId", + "NowPlayingItemName", + "SeasonId", + "SeriesName", + "EpisodeId", + "PlaybackDuration", + "ActivityDateInserted", + "PlayMethod", + "MediaStreams", + "TranscodingInfo", + "PlayState", + "OriginalContainer", + "RemoteEndPoint", + "ServerId", + imported, + "EpisodeNumber", + "SeasonNumber", + "ParentId", + ROW_NUMBER() OVER ( + PARTITION BY "NowPlayingItemId", COALESCE("EpisodeId", '1'), "UserId" + ORDER BY "ActivityDateInserted" DESC + ) AS rn + FROM jf_playback_activity_with_metadata + ) + SELECT + "Id", + "IsPaused", + "UserId", + "UserName", + "Client", + "DeviceName", + "DeviceId", + "ApplicationVersion", + "NowPlayingItemId", + "NowPlayingItemName", + "SeasonId", + "SeriesName", + "EpisodeId", + "PlaybackDuration", + "ActivityDateInserted", + "PlayMethod", + "MediaStreams", + "TranscodingInfo", + "PlayState", + "OriginalContainer", + "RemoteEndPoint", + "ServerId", + imported, + "EpisodeNumber", + "SeasonNumber", + "ParentId" + FROM ranked_activity + WHERE rn = 1 + ORDER BY "ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_latest_playback_activity + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity; + + CREATE MATERIALIZED VIEW js_latest_playback_activity AS + WITH latest_activity AS ( + SELECT + "NowPlayingItemId", + "EpisodeId", + "UserId", + MAX("ActivityDateInserted") AS max_date + FROM public.jf_playback_activity + GROUP BY "NowPlayingItemId", "EpisodeId", "UserId" + order by max_date desc + ) + SELECT + a.* + FROM public.jf_playback_activity_with_metadata a + JOIN latest_activity u + ON a."NowPlayingItemId" = u."NowPlayingItemId" + AND COALESCE(a."EpisodeId", '1') = COALESCE(u."EpisodeId", '1') + AND a."UserId" = u."UserId" + AND a."ActivityDateInserted" = u.max_date + order by a."ActivityDateInserted" desc; + + + ALTER MATERIALIZED VIEW public.js_latest_playback_activity + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js b/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js new file mode 100644 index 0000000..7cdbbf2 --- /dev/null +++ b/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js @@ -0,0 +1,130 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + + CREATE MATERIALIZED VIEW public.js_library_stats_overview + AS + SELECT l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + LEFT JOIN ( SELECT jso."Id", + jso."NowPlayingItemId", + jso."SeasonId", + jso."EpisodeId", + jso."ParentId", + jso."ActivityDateInserted" + FROM js_latest_playback_activity jso + JOIN ( SELECT js_latest_playback_activity."ParentId", + max(js_latest_playback_activity."ActivityDateInserted") AS max_date + FROM js_latest_playback_activity + GROUP BY js_latest_playback_activity."ParentId") latest + ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date ) + latest_activity ON l."Id" = latest_activity."ParentId" + + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + + CREATE MATERIALIZED VIEW public.js_library_stats_overview + AS + SELECT l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + LEFT JOIN ( + + SELECT + jso."Id", + jso."NowPlayingItemId", + jso."SeasonId", + jso."EpisodeId", + jso."ParentId", + jso."ActivityDateInserted" + FROM + jf_playback_activity_with_metadata jso + INNER JOIN ( + SELECT + "ParentId", + MAX("ActivityDateInserted") AS max_date + FROM + jf_playback_activity_with_metadata + GROUP BY + "ParentId" + ) latest ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date + + ) latest_activity ON l."Id" = latest_activity."ParentId" + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + OWNER TO "${process.env.POSTGRES_ROLE}";`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/routes/api.js b/backend/routes/api.js index 7e6afc7..3465079 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -93,6 +93,7 @@ async function purgeLibraryItems(id, withActivity, purgeAll = false) { text: `DELETE FROM jf_playback_activity WHERE${ episodeIds.length > 0 ? ` "EpisodeId" IN (${pgp.as.csv(episodeIds)}) OR` : "" }${seasonIds.length > 0 ? ` "SeasonId" IN (${pgp.as.csv(seasonIds)}) OR` : ""} "NowPlayingItemId"='${id}'`, + refreshViews: true, }; await db.query(deleteQuery); } @@ -923,6 +924,7 @@ router.delete("/item/purge", async (req, res) => { }${ seasons.length > 0 ? ` "SeasonId" IN (${pgp.as.csv(seasons.map((item) => item.SeasonId))}) OR` : "" } "NowPlayingItemId"='${id}'`, + refreshViews: true, }; await db.query(deleteQuery); } @@ -1318,7 +1320,7 @@ router.post("/deletePlaybackActivity", async (req, res) => { return; } - await db.query(`DELETE from jf_playback_activity where "Id" = ANY($1)`, [ids]); + await db.query(`DELETE from jf_playback_activity where "Id" = ANY($1)`, [ids], true); res.send(`${ids.length} Records Deleted`); } catch (error) { console.log(error); From 73b35c50dca47610abfd4f098de50778bd605333 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 3 Jan 2025 20:50:59 +0200 Subject: [PATCH 20/40] fix for Last Activity not updating in library view due to materialized view being static Last Activity is now calculated in the select statement for that view using ActivityDateInserted --- ...js_library_stats_overview_last_activity.js | 122 ++++++++++++++++++ backend/routes/stats.js | 9 +- 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js diff --git a/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js b/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js new file mode 100644 index 0000000..721b1d2 --- /dev/null +++ b/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js @@ -0,0 +1,122 @@ +exports.up = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + + CREATE MATERIALIZED VIEW public.js_library_stats_overview + AS + SELECT l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + latest_activity."ActivityDateInserted" + FROM jf_libraries l + LEFT JOIN ( SELECT jso."Id", + jso."NowPlayingItemId", + jso."SeasonId", + jso."EpisodeId", + jso."ParentId", + jso."ActivityDateInserted" + FROM js_latest_playback_activity jso + JOIN ( SELECT js_latest_playback_activity."ParentId", + max(js_latest_playback_activity."ActivityDateInserted") AS max_date + FROM js_latest_playback_activity + GROUP BY js_latest_playback_activity."ParentId") latest + ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date ) + latest_activity ON l."Id" = latest_activity."ParentId" + + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + OWNER TO "${process.env.POSTGRES_ROLE}"; + `); + } catch (error) { + console.error(error); + } +}; + +exports.down = async function (knex) { + try { + await knex.schema.raw(` + DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview; + + CREATE MATERIALIZED VIEW public.js_library_stats_overview + AS + SELECT l."Id", + l."Name", + l."ServerId", + l."IsFolder", + l."Type", + l."CollectionType", + l."ImageTagsPrimary", + i."Id" AS "ItemId", + i."Name" AS "ItemName", + i."Type" AS "ItemType", + i."PrimaryImageHash", + s."IndexNumber" AS "SeasonNumber", + e."IndexNumber" AS "EpisodeNumber", + e."Name" AS "EpisodeName", + ( SELECT count(*) AS count + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS "Plays", + ( SELECT sum(a."PlaybackDuration") AS sum + FROM jf_playback_activity_with_metadata a + WHERE a."ParentId" = l."Id") AS total_playback_duration, + l.total_play_time::numeric AS total_play_time, + l.item_count AS "Library_Count", + l.season_count AS "Season_Count", + l.episode_count AS "Episode_Count", + l.archived, + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + LEFT JOIN ( SELECT jso."Id", + jso."NowPlayingItemId", + jso."SeasonId", + jso."EpisodeId", + jso."ParentId", + jso."ActivityDateInserted" + FROM js_latest_playback_activity jso + JOIN ( SELECT js_latest_playback_activity."ParentId", + max(js_latest_playback_activity."ActivityDateInserted") AS max_date + FROM js_latest_playback_activity + GROUP BY js_latest_playback_activity."ParentId") latest + ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date ) + latest_activity ON l."Id" = latest_activity."ParentId" + + LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId" + LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId" + LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId" + ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC; + + + ALTER MATERIALIZED VIEW public.js_library_stats_overview + OWNER TO "${process.env.POSTGRES_ROLE}";`); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/routes/stats.js b/backend/routes/stats.js index f213bfc..0f8bd98 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -236,7 +236,9 @@ router.post("/getGlobalLibraryStats", async (req, res) => { router.get("/getLibraryCardStats", async (req, res) => { try { - const { rows } = await db.query("select * from js_library_stats_overview"); + const { rows } = await db.query( + `select *, now() - js_library_stats_overview."ActivityDateInserted" AS "LastActivity" from js_library_stats_overview` + ); res.send(rows); } catch (error) { res.status(503); @@ -252,7 +254,10 @@ router.post("/getLibraryCardStats", async (req, res) => { return res.send("Invalid Library Id"); } - const { rows } = await db.query(`select * from js_library_stats_overview where "Id"=$1`, [libraryid]); + const { rows } = await db.query( + `select *, now() - js_library_stats_overview."ActivityDateInserted" AS "LastActivity" from js_library_stats_overview where "Id"=$1`, + [libraryid] + ); res.send(rows[0]); } catch (error) { console.log(error); From cd0f2ae6a620e1564ada79c96478ad0215d5ef79 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Fri, 3 Jan 2025 21:50:20 +0200 Subject: [PATCH 21/40] Moved TotalPlays and TotalPlaybackDuration to sql query to allow for future sorting callback in material react table --- backend/routes/api.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index 3465079..41b3a0d 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1068,6 +1068,8 @@ router.get("/getHistory", async (req, res) => { `COALESCE(a."EpisodeId", '1') as "EpisodeId"`, "a.UserId", `json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`, + `COUNT(a.*) as "TotalPlays"`, + `SUM(a."PlaybackDuration") as "TotalDuration"`, ], table: "jf_playback_activity_with_metadata", alias: "a", @@ -1076,7 +1078,7 @@ router.get("/getHistory", async (req, res) => { const query = { cte: cte, - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results"], + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results", "ar.TotalPlays", "ar.TotalDuration"], table: "js_latest_playback_activity", alias: "a", joins: [ @@ -1111,8 +1113,7 @@ router.get("/getHistory", async (req, res) => { result.results = result.results.map((item) => ({ ...item, - TotalPlays: item.results?.length ?? 1, - PlaybackDuration: item.results ? item.results.reduce((acc, cur) => acc + cur.PlaybackDuration, 0) : item.PlaybackDuration, + PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration, })); const response = { current_page: page, pages: result.pages, size: size, results: result.results }; if (search && search.length > 0) { @@ -1142,6 +1143,8 @@ router.post("/getLibraryHistory", async (req, res) => { `COALESCE(a."EpisodeId", '1') as "EpisodeId"`, "a.UserId", `json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`, + `COUNT(a.*) as "TotalPlays"`, + `SUM(a."PlaybackDuration") as "TotalDuration"`, ], table: "jf_playback_activity_with_metadata", alias: "a", @@ -1150,7 +1153,7 @@ router.post("/getLibraryHistory", async (req, res) => { const query = { cte: cte, - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results"], + select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results", "ar.TotalPlays", "ar.TotalDuration"], table: "js_latest_playback_activity", alias: "a", joins: [ @@ -1195,8 +1198,7 @@ router.post("/getLibraryHistory", async (req, res) => { result.results = result.results.map((item) => ({ ...item, - TotalPlays: item.results?.length ?? 1, - PlaybackDuration: item.results ? item.results.reduce((acc, cur) => acc + cur.PlaybackDuration, 0) : item.PlaybackDuration, + PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration, })); const response = { current_page: page, pages: result.pages, size: size, results: result.results }; From 76363ea0bab39bc23f2cee10d2571dadd1a14cdc Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 4 Jan 2025 03:50:39 +0200 Subject: [PATCH 22/40] added sorting to history endpoints and changed Activity tables to apply sorting to api calls --- backend/classes/db-helper.js | 3 +- backend/routes/api.js | 163 ++++++++++++++---- src/pages/activity.jsx | 14 +- .../components/activity/activity-table.jsx | 25 ++- .../components/item-info/item-activity.jsx | 15 +- .../components/library/library-activity.jsx | 14 +- .../components/user-info/user-activity.jsx | 14 +- 7 files changed, 203 insertions(+), 45 deletions(-) diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index c816754..b636d96 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -12,7 +12,8 @@ function wrapField(field) { field.includes("MIN") || field.includes("AVG") || field.includes("DISTINCT") || - field.includes("json_agg") + field.includes("json_agg") || + field.includes("CASE") ) { return field; } diff --git a/backend/routes/api.js b/backend/routes/api.js index 41b3a0d..a9f7cc0 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -17,6 +17,28 @@ const { tables } = require("../global/backup_tables"); const router = express.Router(); +//consts +const groupedSortMap = [ + { field: "UserName", column: "a.UserName" }, + { field: "RemoteEndPoint", column: "a.RemoteEndPoint" }, + { field: "NowPlayingItemName", column: "FullName" }, + { field: "Client", column: "a.Client" }, + { field: "DeviceName", column: "a.DeviceName" }, + { field: "ActivityDateInserted", column: "a.ActivityDateInserted" }, + { field: "PlaybackDuration", column: "ar.TotalDuration" }, + { field: "TotalPlays", column: "TotalPlays" }, +]; + +const unGroupedSortMap = [ + { field: "UserName", column: "a.UserName" }, + { field: "RemoteEndPoint", column: "a.RemoteEndPoint" }, + { field: "NowPlayingItemName", column: "FullName" }, + { field: "Client", column: "a.Client" }, + { field: "DeviceName", column: "a.DeviceName" }, + { field: "ActivityDateInserted", column: "a.ActivityDateInserted" }, + { field: "PlaybackDuration", column: "a.PlaybackDuration" }, +]; + //Functions function groupRecentlyAdded(rows) { const groupedResults = {}; @@ -1058,7 +1080,9 @@ router.post("/setExcludedBackupTable", async (req, res) => { //DB Queries - History router.get("/getHistory", async (req, res) => { - const { size = 50, page = 1, search } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; + + const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; try { const cte = { @@ -1078,7 +1102,21 @@ router.get("/getHistory", async (req, res) => { const query = { cte: cte, - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results", "ar.TotalPlays", "ar.TotalDuration"], + select: [ + "a.*", + "a.EpisodeNumber", + "a.SeasonNumber", + "a.ParentId", + "ar.results", + "ar.TotalPlays", + "ar.TotalDuration", + ` + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END AS "FullName" + `, + ], table: "js_latest_playback_activity", alias: "a", joins: [ @@ -1094,8 +1132,8 @@ router.get("/getHistory", async (req, res) => { }, ], - order_by: "a.ActivityDateInserted", - sort_order: "desc", + order_by: sortField, + sort_order: desc ? "desc" : "asc", pageNumber: page, pageSize: size, }; @@ -1103,7 +1141,12 @@ router.get("/getHistory", async (req, res) => { if (search && search.length > 0) { query.where = [ { - field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + field: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END + )`, operator: "LIKE", value: `${search.toLowerCase()}`, }, @@ -1115,10 +1158,11 @@ router.get("/getHistory", async (req, res) => { ...item, PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration, })); - const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; if (search && search.length > 0) { response.search = search; } + res.send(response); } catch (error) { console.log(error); @@ -1127,7 +1171,7 @@ router.get("/getHistory", async (req, res) => { router.post("/getLibraryHistory", async (req, res) => { try { - const { size = 50, page = 1, search } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; const { libraryid } = req.body; if (libraryid === undefined) { @@ -1136,6 +1180,8 @@ router.post("/getLibraryHistory", async (req, res) => { return; } + const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const cte = { cteAlias: "activity_results", select: [ @@ -1153,7 +1199,21 @@ router.post("/getLibraryHistory", async (req, res) => { const query = { cte: cte, - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId", "ar.results", "ar.TotalPlays", "ar.TotalDuration"], + select: [ + "a.*", + "a.EpisodeNumber", + "a.SeasonNumber", + "a.ParentId", + "ar.results", + "ar.TotalPlays", + "ar.TotalDuration", + ` + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END AS "FullName" + `, + ], table: "js_latest_playback_activity", alias: "a", joins: [ @@ -1178,8 +1238,8 @@ router.post("/getLibraryHistory", async (req, res) => { }, ], - order_by: "a.ActivityDateInserted", - sort_order: "desc", + order_by: sortField, + sort_order: desc ? "desc" : "asc", pageNumber: page, pageSize: size, }; @@ -1187,9 +1247,14 @@ router.post("/getLibraryHistory", async (req, res) => { if (search && search.length > 0) { query.where = [ { - field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + field: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END + )`, operator: "LIKE", - value: `%${search.toLowerCase()}%`, + value: `${search.toLowerCase()}`, }, ]; } @@ -1201,7 +1266,7 @@ router.post("/getLibraryHistory", async (req, res) => { PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration, })); - const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; if (search && search.length > 0) { response.search = search; } @@ -1215,7 +1280,7 @@ router.post("/getLibraryHistory", async (req, res) => { router.post("/getItemHistory", async (req, res) => { try { - const { size = 50, page = 1, search } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; const { itemid } = req.body; if (itemid === undefined) { @@ -1224,8 +1289,21 @@ router.post("/getItemHistory", async (req, res) => { return; } + const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const query = { - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId"], + select: [ + "a.*", + "a.EpisodeNumber", + "a.SeasonNumber", + "a.ParentId", + ` + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END AS "FullName" + `, + ], table: "jf_playback_activity_with_metadata", alias: "a", where: [ @@ -1235,25 +1313,30 @@ router.post("/getItemHistory", async (req, res) => { { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, ], ], - order_by: "ActivityDateInserted", - sort_order: "desc", + order_by: sortField, + sort_order: desc ? "desc" : "asc", pageNumber: page, pageSize: size, }; if (search && search.length > 0) { - query.where.push([ + query.where = [ { - field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + field: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END + )`, operator: "LIKE", - value: `%${search.toLowerCase()}%`, + value: `${search.toLowerCase()}`, }, - ]); + ]; } const result = await dbHelper.query(query); - const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; if (search && search.length > 0) { response.search = search; } @@ -1267,7 +1350,7 @@ router.post("/getItemHistory", async (req, res) => { router.post("/getUserHistory", async (req, res) => { try { - const { size = 50, page = 1, search } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; const { userid } = req.body; if (userid === undefined) { @@ -1276,29 +1359,47 @@ router.post("/getUserHistory", async (req, res) => { return; } + const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const query = { - select: ["a.*", "a.EpisodeNumber", "a.SeasonNumber", "a.ParentId"], + select: [ + "a.*", + "a.EpisodeNumber", + "a.SeasonNumber", + "a.ParentId", + ` + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END AS "FullName" + `, + ], table: "jf_playback_activity_with_metadata", alias: "a", where: [[{ column: "a.UserId", operator: "=", value: userid }]], - order_by: "ActivityDateInserted", - sort_order: "desc", + order_by: sortField, + sort_order: desc ? "desc" : "asc", pageNumber: page, pageSize: size, }; if (search && search.length > 0) { - query.where.push([ + query.where = [ { - field: `LOWER(COALESCE(a."SeriesName" || ' - ' || a."NowPlayingItemName", a."NowPlayingItemName"))`, + field: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END + )`, operator: "LIKE", - value: `%${search.toLowerCase()}%`, + value: `${search.toLowerCase()}`, }, - ]); + ]; } const result = await dbHelper.query(query); - const response = { current_page: page, pages: result.pages, size: size, results: result.results }; + const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; if (search && search.length > 0) { response.search = search; diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index a7635fd..2b40089 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -27,12 +27,17 @@ function Activity() { const [libraries, setLibraries] = useState([]); const [showLibraryFilters, setShowLibraryFilters] = useState(false); const [currentPage, setCurrentPage] = useState(1); + const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { setCurrentPage(newPage); }; + const onSortChange = (sort) => { + setSorting({ column: sort.column, desc: sort.desc }); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); @@ -82,7 +87,7 @@ function Activity() { const fetchHistory = () => { setIsBusy(true); - const url = `/api/getHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`; + const url = `/api/getHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`; axios .get(url, { headers: { @@ -136,7 +141,9 @@ function Activity() { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery.trim() + (data?.search ?? "") !== debouncedSearchQuery.trim() || + (data?.sort ?? "") !== sorting.column || + (data?.desc ?? true) !== sorting.desc ) { fetchHistory(); fetchLibraries(); @@ -149,7 +156,7 @@ function Activity() { const intervalId = setInterval(fetchHistory, 60000 * 60); return () => clearInterval(intervalId); - }, [data, config, itemCount, currentPage, debouncedSearchQuery]); + }, [data, config, itemCount, currentPage, debouncedSearchQuery, sorting]); if (!data) { return ; @@ -271,6 +278,7 @@ function Activity() { data={filteredData} itemCount={itemCount} onPageChange={handlePageChange} + onSortChange={onSortChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index ed8e536..ff0473a 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -70,6 +70,7 @@ export default function ActivityTable(props) { pageSize: 10, pageIndex: 0, }); + const [sorting, setSorting] = React.useState([{ id: "Date", desc: true }]); const [modalState, setModalState] = React.useState(false); const [modalData, setModalData] = React.useState(); @@ -84,6 +85,7 @@ export default function ActivityTable(props) { return newPaginationState; }); }; + //IP MODAL const ipv4Regex = new RegExp( @@ -188,6 +190,7 @@ export default function ActivityTable(props) { ? row.NowPlayingItemName : row.SeriesName + " : S" + row.SeasonNumber + "E" + row.EpisodeNumber + " - " + row.NowPlayingItemName }`, + field: "NowPlayingItemName", header: i18next.t("TITLE"), minSize: 300, Cell: ({ row }) => { @@ -221,6 +224,7 @@ export default function ActivityTable(props) { }, { accessorFn: (row) => new Date(row.ActivityDateInserted), + field: "ActivityDateInserted", header: i18next.t("DATE"), size: 110, filterVariant: "date-range", @@ -248,6 +252,7 @@ export default function ActivityTable(props) { }, { accessorFn: (row) => Number(row.TotalPlays ?? 1), + field: "TotalPlays", header: i18next.t("TOTAL_PLAYS"), filterFn: "betweenInclusive", @@ -255,6 +260,22 @@ export default function ActivityTable(props) { }, ]; + const fieldMap = columns.map((column) => { + return { accessorKey: column.accessorKey ?? column.field, header: column.header }; + }); + + const handleSortingChange = (updater) => { + setSorting((old) => { + const newSortingState = typeof updater === "function" ? updater(old) : updater; + const column = newSortingState.length > 0 ? newSortingState[0].id : "Date"; + const desc = newSortingState.length > 0 ? newSortingState[0].desc : true; + if (props.onSortChange) { + props.onSortChange({ column: fieldMap.find((field) => field.header == column)?.accessorKey ?? column, desc: desc }); + } + return newSortingState; + }); + }; + useEffect(() => { setData(props.data); }, [props.data]); @@ -279,8 +300,10 @@ export default function ActivityTable(props) { enableExpandAll: false, enableExpanding: true, enableDensityToggle: false, + onSortingChange: handleSortingChange, enableTopToolbar: Object.keys(rowSelection).length > 0, manualPagination: true, + manualSorting: true, autoResetPageIndex: false, initialState: { expanded: false, @@ -354,7 +377,7 @@ export default function ActivityTable(props) { }, }, }, - state: { rowSelection, pagination }, + state: { rowSelection, pagination, sorting }, filterFromLeafRows: true, getSubRows: (row) => { if (Array.isArray(row.results) && row.results.length == 1) { diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 4696fd8..4cbf38a 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -15,11 +15,17 @@ function ItemActivity(props) { const [streamTypeFilter, setStreamTypeFilter] = useState("All"); const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); + const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { setCurrentPage(newPage); }; + + const onSortChange = (sort) => { + setSorting({ column: sort.column, desc: sort.desc }); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); @@ -53,7 +59,7 @@ function ItemActivity(props) { try { setIsBusy(true); const itemData = await axios.post( - `/api/getItemHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, + `/api/getItemHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, { itemid: props.itemid, }, @@ -75,14 +81,16 @@ function ItemActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery.trim() + (data?.search ?? "") !== debouncedSearchQuery.trim() || + (data?.sort ?? "") !== sorting.column || + (data?.desc ?? true) !== sorting.desc ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery]); + }, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery, sorting]); if (!data || !data.results) { return <>; @@ -168,6 +176,7 @@ function ItemActivity(props) { data={filteredData} itemCount={itemCount} onPageChange={handlePageChange} + onSortChange={onSortChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index df77082..3e5b7d0 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -18,12 +18,17 @@ function LibraryActivity(props) { ); const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); + const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { setCurrentPage(newPage); }; + const onSortChange = (sort) => { + setSorting({ column: sort.column, desc: sort.desc }); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_LIBRARY_ACTIVITY_ItemCount", limit); @@ -61,7 +66,7 @@ function LibraryActivity(props) { try { setIsBusy(true); const libraryData = await axios.post( - `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, + `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, { libraryid: props.LibraryId, }, @@ -83,14 +88,16 @@ function LibraryActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery.trim() + (data?.search ?? "") !== debouncedSearchQuery.trim() || + (data?.sort ?? "") !== sorting.column || + (data?.desc ?? true) !== sorting.desc ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery]); + }, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery, sorting]); if (!data || !data.results) { return <>; @@ -175,6 +182,7 @@ function LibraryActivity(props) { data={filteredData} itemCount={itemCount} onPageChange={handlePageChange} + onSortChange={onSortChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index 293498a..d46dca8 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -22,6 +22,7 @@ function UserActivity(props) { const [showLibraryFilters, setShowLibraryFilters] = useState(false); const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); + const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); const [isBusy, setIsBusy] = useState(false); function setItemLimit(limit) { @@ -73,12 +74,16 @@ function UserActivity(props) { setCurrentPage(newPage); }; + const onSortChange = (sort) => { + setSorting({ column: sort.column, desc: sort.desc }); + }; + useEffect(() => { const fetchHistory = async () => { try { setIsBusy(true); const itemData = await axios.post( - `/api/getUserHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}`, + `/api/getUserHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, { userid: props.UserId, }, @@ -125,7 +130,9 @@ function UserActivity(props) { !data || (data.current_page && data.current_page !== currentPage) || (data.size && data.size !== itemCount) || - (data.search ? data.search : "") !== debouncedSearchQuery.trim() + (data?.search ?? "") !== debouncedSearchQuery.trim() || + (data?.sort ?? "") !== sorting.column || + (data?.desc ?? true) !== sorting.desc ) { fetchHistory(); } @@ -134,7 +141,7 @@ function UserActivity(props) { const intervalId = setInterval(fetchHistory, 60000 * 5); return () => clearInterval(intervalId); - }, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery]); + }, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery, sorting]); if (!data || !data.results) { return <>; @@ -240,6 +247,7 @@ function UserActivity(props) { data={filteredData} itemCount={itemCount} onPageChange={handlePageChange} + onSortChange={onSortChange} pageCount={data.pages} isBusy={isBusy} /> From ebdc6f943332bfe6541bbb009865f4dc5facb06a Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 4 Jan 2025 04:13:33 +0200 Subject: [PATCH 23/40] updated swagger doc added hacky code to add param enums to sort field, to fix later --- backend/swagautogen.js | 41 +++++++++++++++++++++- backend/swagger.json | 78 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/backend/swagautogen.js b/backend/swagautogen.js index 3520a8e..76b6ec4 100644 --- a/backend/swagautogen.js +++ b/backend/swagautogen.js @@ -1,4 +1,5 @@ const swaggerAutogen = require("swagger-autogen")(); +const fs = require("fs"); const outputFile = "./swagger.json"; const endpointsFiles = ["./server.js"]; @@ -55,4 +56,42 @@ const config = { module.exports = config; -swaggerAutogen(outputFile, endpointsFiles, config); +const modifySwaggerFile = (filePath) => { + const swaggerData = JSON.parse(fs.readFileSync(filePath, "utf8")); + + const endpointsToModify = ["/api/getHistory", "/api/getLibraryHistory", "/api/getUserHistory", "/api/getItemHistory"]; // Add more endpoints as needed + + endpointsToModify.forEach((endpoint) => { + if (swaggerData.paths[endpoint]) { + const methods = Object.keys(swaggerData.paths[endpoint]); + methods.forEach((method) => { + const parameters = swaggerData.paths[endpoint][method].parameters; + if (parameters) { + parameters.forEach((param) => { + if (param.name === "sort") { + param.enum = [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration", + ]; + + if (endpoint.includes("getHistory") || endpoint.includes("getLibraryHistory")) { + param.enum.push("TotalPlays"); + } + } + }); + } + }); + } + }); + + fs.writeFileSync(filePath, JSON.stringify(swaggerData, null, 2)); +}; + +swaggerAutogen(outputFile, endpointsFiles, config).then(() => { + modifySwaggerFile(outputFile); +}); diff --git a/backend/swagger.json b/backend/swagger.json index c036d86..d994fa4 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -2022,6 +2022,26 @@ "name": "search", "in": "query", "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration", + "TotalPlays" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" } ], "responses": { @@ -2077,6 +2097,26 @@ "in": "query", "type": "string" }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration", + "TotalPlays" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2149,6 +2189,25 @@ "in": "query", "type": "string" }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2221,6 +2280,25 @@ "in": "query", "type": "string" }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", From 2a4f036dd7b1486f89aaa50561502ccdbadad9ad Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 4 Jan 2025 19:32:51 +0200 Subject: [PATCH 24/40] fix sort on Total Plays and Total Duration for Group Activity --- backend/routes/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index a9f7cc0..e0da1e5 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -25,8 +25,8 @@ const groupedSortMap = [ { field: "Client", column: "a.Client" }, { field: "DeviceName", column: "a.DeviceName" }, { field: "ActivityDateInserted", column: "a.ActivityDateInserted" }, - { field: "PlaybackDuration", column: "ar.TotalDuration" }, - { field: "TotalPlays", column: "TotalPlays" }, + { field: "PlaybackDuration", column: `COALESCE(ar."TotalDuration", a."PlaybackDuration")` }, + { field: "TotalPlays", column: `COALESCE("TotalPlays",1)` }, ]; const unGroupedSortMap = [ From 38b14c99f1bb02a5827d04aecd0cbcf47e8eb2ef Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 4 Jan 2025 22:58:44 +0200 Subject: [PATCH 25/40] css fixes for overflown text on item stat cards --- .../components/activity/activity-table.jsx | 1 + .../statCards/ItemStatComponent.jsx | 17 ++-- src/pages/css/items/item-stat-component.css | 5 + src/pages/css/statCard.css | 96 +++++++++---------- 4 files changed, 63 insertions(+), 56 deletions(-) create mode 100644 src/pages/css/items/item-stat-component.css diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index ff0473a..8afbb04 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -300,6 +300,7 @@ export default function ActivityTable(props) { enableExpandAll: false, enableExpanding: true, enableDensityToggle: false, + enableFilters: false, onSortingChange: handleSortingChange, enableTopToolbar: Object.keys(rowSelection).length > 0, manualPagination: true, diff --git a/src/pages/components/statCards/ItemStatComponent.jsx b/src/pages/components/statCards/ItemStatComponent.jsx index 012ac2c..506ed66 100644 --- a/src/pages/components/statCards/ItemStatComponent.jsx +++ b/src/pages/components/statCards/ItemStatComponent.jsx @@ -7,6 +7,7 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import Tooltip from "@mui/material/Tooltip"; import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon"; +import "../../css/items/item-stat-component.css"; function ItemStatComponent(props) { const [loaded, setLoaded] = useState(false); @@ -36,8 +37,8 @@ function ItemStatComponent(props) { return (
- - + + {props.icon ? (
{props.icon}
) : ( @@ -80,7 +81,7 @@ function ItemStatComponent(props) { )} - +
@@ -98,30 +99,30 @@ function ItemStatComponent(props) { {item.UserId ? ( - {item.Name} + {item.Name} ) : !item.Client && !props.icon ? ( - {item.Name} + {item.Name} ) : !item.Client && props.icon ? ( item.Id ? ( - {item.Name} + {item.Name} ) : ( - {item.Name} + {item.Name} ) ) : ( - {item.Client} + {item.Client} )}
diff --git a/src/pages/css/items/item-stat-component.css b/src/pages/css/items/item-stat-component.css new file mode 100644 index 0000000..24c0a8a --- /dev/null +++ b/src/pages/css/items/item-stat-component.css @@ -0,0 +1,5 @@ +.overflow-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/pages/css/statCard.css b/src/pages/css/statCard.css index 29dc651..1f75850 100644 --- a/src/pages/css/statCard.css +++ b/src/pages/css/statCard.css @@ -1,52 +1,66 @@ -@import './variables.module.css'; -.grid-stat-cards -{ +@import "./variables.module.css"; +.grid-stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(auto, 520px)); - grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/ + grid-auto-rows: 200px; /* max-width+offset so 215 + 20*/ margin-top: 8px; background-color: var(--secondary-background-color); border-radius: 8px; padding: 20px; } -.stat-card{ +.stat-card { border: 0 !important; - background-color: var(--background-color)!important; + background-color: var(--background-color) !important; color: white; max-width: 500px; max-height: 180px; } -.stat-card-banner -{ +.no-gutters { + --bs-gutter-x: 0 !important; /* Remove horizontal gutter */ + --bs-gutter-y: 0 !important; /* Remove vertical gutter if needed */ +} + +.row-max-witdh { + max-width: 500px; +} + +.stat-card-banner { max-width: 120px !important; } +.stat-card-details { + max-width: 380px !important; +} + .stat-card-image-audio { width: 120px !important; top: 15%; position: relative; } +.item-text { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .stat-card-image { width: 120px !important; height: 180px; } -.stat-card-icon -{ +.stat-card-icon { width: 120px !important; - position: relative; top: 50%; left: 65%; transform: translate(-50%, -50%); } - -.stat-items -{ +.stat-items { color: white; } @@ -56,85 +70,71 @@ } .stat-item-count { text-align: right; - color: var(--secondary-color); + color: var(--secondary-color); font-weight: 500; font-size: 1.1em; - } - -.Heading -{ +.Heading { display: flex; flex-direction: row; } -.Heading h1 -{ +.Heading h1 { padding-right: 10px; } -.date-range -{ +.date-range { width: 220px; height: 35px; color: white; display: flex; - background-color: var(--secondary-background-color); + background-color: var(--secondary-background-color); border-radius: 8px; font-size: 1.2em; align-self: flex-end; justify-content: space-evenly; - } - -.date-range .days input -{ +.date-range .days input { height: 35px; outline: none; border: none; - background-color:transparent; - color:white; + background-color: transparent; + color: white; font-size: 1em; width: 40px; } -.date-range .days -{ +.date-range .days { background-color: rgb(255, 255, 255, 0.1); - padding-inline: 10px; + padding-inline: 10px; } - -input[type=number]::-webkit-outer-spin-button, -input[type=number]::-webkit-inner-spin-button { +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } - -input[type=number] { + +input[type="number"] { -moz-appearance: textfield; } - .date-range .header, -.date-range .trailer -{ +.date-range .trailer { padding-inline: 10px; align-self: center; } -.stat-items div a{ +.stat-items div a { text-decoration: none !important; color: white !important; } -.stat-items div a:hover{ - color: var(--secondary-color) !important; +.stat-items div a:hover { + color: var(--secondary-color) !important; } - -.item-name :hover{ - color: var(--secondary-color) !important; +.item-name :hover { + color: var(--secondary-color) !important; } - From 3b0f732cc7c81d039513014bccef393f8abf8a30 Mon Sep 17 00:00:00 2001 From: ItsKiddow <56514234+ItsKiddow@users.noreply.github.com> Date: Mon, 6 Jan 2025 20:19:44 +0100 Subject: [PATCH 26/40] Update security.jsx Removed a check that evaluated for any input in the Current Password field and would cause the entry to enter in any non-null input to fail due to password length. This is related to: https://github.com/CyferShepard/Jellystat/issues/254 --- src/pages/components/settings/security.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/components/settings/security.jsx b/src/pages/components/settings/security.jsx index 4ff3e46..4fa9bf5 100644 --- a/src/pages/components/settings/security.jsx +++ b/src/pages/components/settings/security.jsx @@ -95,11 +95,6 @@ export default function SettingsConfig() { async function handleFormSubmit(event) { event.preventDefault(); setisSubmitted(""); - if (formValues.JS_C_PASSWORD) { - setisSubmitted("Failed"); - setsubmissionMessage(i18next.t("ERROR_MESSAGES.PASSWORD_LENGTH")); - return; - } if ( (formValues.JS_C_PASSWORD && !formValues.JS_PASSWORD) || From e38c918df0eb7d173ea532834d2bce75be3de1fc Mon Sep 17 00:00:00 2001 From: EVO <45015615+EVOTk@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:16:18 +0100 Subject: [PATCH 27/40] Update translation.json fr > external url --- public/locales/fr-FR/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index c2039bc..2e7381b 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -179,7 +179,7 @@ "SIZE": "Taille", "JELLYFIN_URL": "URL du serveur Jellyfin", "EMBY_URL": "URL du serveur Emby", - "EXTERNAL_URL": "External URL", + "EXTERNAL_URL": "URL externe", "API_KEY": "Clé API", "API_KEYS": "Clés API", "KEY_NAME": "Nom de la clé", From 44f10d0a09c6bd4d1840fa87cb6cc489cc5dd481 Mon Sep 17 00:00:00 2001 From: Gianmarco Novelli Date: Thu, 9 Jan 2025 22:21:04 +0100 Subject: [PATCH 28/40] Update languages.jsx --- src/lib/languages.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/languages.jsx b/src/lib/languages.jsx index 033af18..6e7552b 100644 --- a/src/lib/languages.jsx +++ b/src/lib/languages.jsx @@ -11,4 +11,8 @@ export const languages = [ id: "zh-CN", description: "简体中文", }, + { + id: "it-IT", + description: "Italiano", + }, ]; From 2d5be28ed93ce3a6bd1a32542440aa16252befaa Mon Sep 17 00:00:00 2001 From: Gianmarco Novelli Date: Thu, 9 Jan 2025 22:23:13 +0100 Subject: [PATCH 29/40] Create translation.json --- public/locales/it-IT/translation.json | 297 ++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 public/locales/it-IT/translation.json diff --git a/public/locales/it-IT/translation.json b/public/locales/it-IT/translation.json new file mode 100644 index 0000000..b80d80a --- /dev/null +++ b/public/locales/it-IT/translation.json @@ -0,0 +1,297 @@ +{ + "JELLYSTAT": "Jellystat", + "MENU_TABS": { + "HOME": "Home", + "LIBRARIES": "Librerie", + "USERS": "Utenti", + "ACTIVITY": "Attività", + "STATISTICS": "Statistiche", + "SETTINGS": "Impostazioni", + "ABOUT": "Informazioni", + "LOGOUT": "Disconnetti" + }, + "HOME_PAGE": { + "SESSIONS": "Sessioni", + "RECENTLY_ADDED": "Aggiunti di recente", + "WATCH_STATISTIC": "Statistiche di visualizzazione", + "LIBRARY_OVERVIEW": "Panoramica della libreria" + }, + "SESSIONS": { + "NO_SESSIONS": "Nessuna sessione attiva trovata" + }, + "STAT_CARDS": { + "MOST_VIEWED_MOVIES": "FILM PIÙ VISTI", + "MOST_POPULAR_MOVIES": "FILM PIÙ POPOLARI", + "MOST_VIEWED_SERIES": "SERIE PIÙ VISTE", + "MOST_POPULAR_SERIES": "SERIE PIÙ POPOLARI", + "MOST_LISTENED_MUSIC": "MUSICA PIÙ ASCOLTATA", + "MOST_POPULAR_MUSIC": "MUSICA PIÙ POPOLARE", + "MOST_VIEWED_LIBRARIES": "LIBRERIE PIÙ VISTE", + "MOST_USED_CLIENTS": "CLIENTS PIÙ UTILIZZATI", + "MOST_ACTIVE_USERS": "UTENTI PIÙ ATTIVI" + }, + "LIBRARY_OVERVIEW": { + "MOVIE_LIBRARIES": "LIBRERIE FILM", + "SHOW_LIBRARIES": "LIBRERIE SERIE", + "MUSIC_LIBRARIES": "LIBRERIE MUSICALI", + "MIXED_LIBRARIES": "LIBRERIE MISTE" + }, + "LIBRARY_CARD": { + "LIBRARY": "Libreria", + "TOTAL_TIME": "Tempo totale", + "TOTAL_FILES": "File totali", + "LIBRARY_SIZE": "Dimensione libreria", + "TOTAL_PLAYBACK": "Riproduzione totale", + "LAST_PLAYED": "Ultima riproduzione", + "LAST_ACTIVITY": "Ultima attività", + "TRACKED": "Tracciato" + }, + "GLOBAL_STATS": { + "LAST_24_HRS": "Ultime 24 ore", + "LAST_7_DAYS": "Ultimi 7 giorni", + "LAST_30_DAYS": "Ultimi 30 giorni", + "ALL_TIME": "Totale", + "ITEM_STATS": "Statistiche elemento" + }, + "ITEM_INFO": { + "FILE_PATH": "Percorso file", + "FILE_SIZE": "Dimensione file", + "RUNTIME": "Durata", + "AVERAGE_RUNTIME": "Durata media", + "OPEN_IN_JELLYFIN": "Apri in Jellyfin", + "ARCHIVED_DATA_OPTIONS": "Opzioni dati archiviati", + "PURGE": "Elimina", + "CONFIRM_ACTION": "Conferma azione", + "CONFIRM_ACTION_MESSAGE": "Sei sicuro di voler eliminare questo elemento", + "CONFIRM_ACTION_MESSAGE_2": "e l'attività di riproduzione associata" + }, + "LIBRARY_INFO": { + "LIBRARY_STATS": "Statistiche libreria", + "LIBRARY_ACTIVITY": "Attività della libreria" + }, + "TAB_CONTROLS": { + "OVERVIEW": "Panoramica", + "ACTIVITY": "Attività", + "OPTIONS": "Opzioni" + }, + "ITEM_ACTIVITY": "Attività elemento", + "ACTIVITY_TABLE": { + "MODAL": { + "HEADER": "Informazioni flusso" + }, + "IP_ADDRESS": "Indirizzo IP", + "CLIENT": "Client", + "DEVICE": "Dispositivo", + "PLAYBACK_DURATION": "Durata riproduzione", + "TOTAL_PLAYBACK": "Riproduzione totale", + "EXPAND": "Espandi", + "COLLAPSE": "Comprimi", + "SORT_BY": "Ordina per", + "ASCENDING": "Ascendente", + "DESCENDING": "Discendente", + "CLEAR_SORT": "Cancella ordinamento", + "CLEAR_FILTER": "Cancella filtro", + "FILTER_BY": "Filtra per", + "COLUMN_ACTIONS": "Azioni colonna", + "TOGGLE_SELECT_ROW": "Seleziona/Deseleziona riga", + "TOGGLE_SELECT_ALL": "Seleziona/Deseleziona tutto", + "MIN": "Min", + "MAX": "Max" + }, + "TABLE_NAV_BUTTONS": { + "FIRST": "Primo", + "LAST": "Ultimo", + "NEXT": "Successivo", + "PREVIOUS": "Precedente" + }, + "PURGE_OPTIONS": { + "PURGE_CACHE": "Elimina elemento nella cache", + "PURGE_CACHE_WITH_ACTIVITY": "Elimina elemento nella cache e attività di riproduzione", + "PURGE_LIBRARY_CACHE": "Elimina libreria e elementi nella cache", + "PURGE_LIBRARY_CACHE_WITH_ACTIVITY": "Elimina libreria, elementi e attività nella cache", + "PURGE_LIBRARY_ITEMS_CACHE": "Elimina solo elementi nella libreria", + "PURGE_LIBRARY_ITEMS_CACHE_WITH_ACTIVITY": "Elimina elementi nella libreria e attività", + "PURGE_ACTIVITY": "Sei sicuro di voler eliminare l'attività di riproduzione selezionata?" + }, + "ERROR_MESSAGES": { + "FETCH_THIS_ITEM": "Recupera questo elemento da Jellyfin", + "NO_ACTIVITY": "Nessuna attività trovata", + "NEVER": "Mai", + "N/A": "N/D", + "NO_STATS": "Nessuna statistica da visualizzare", + "NO_BACKUPS": "Nessun backup trovato", + "NO_LOGS": "Nessun log trovato", + "NO_API_KEYS": "Nessuna chiave trovata", + "NETWORK_ERROR": "Impossibile connettersi al server Jellyfin", + "INVALID_LOGIN": "Nome utente o password non validi", + "INVALID_URL": "Errore {STATUS}: L'URL richiesto non è stato trovato.", + "UNAUTHORIZED": "Errore {STATUS}: Non autorizzato", + "PASSWORD_LENGTH": "La password deve essere lunga almeno 6 caratteri", + "USERNAME_REQUIRED": "Il nome utente è obbligatorio" + }, + "SHOW_ARCHIVED_LIBRARIES": "Mostra librerie archiviate", + "HIDE_ARCHIVED_LIBRARIES": "Nascondi librerie archiviate", + "UNITS": { + "MONTH": "Mese", + "MONTHS": "Mesi", + "DAY": "Giorno", + "DAYS": "Giorni", + "HOUR": "Ora", + "HOURS": "Ore", + "MINUTE": "Minuto", + "MINUTES": "Minuti", + "SECOND": "Secondo", + "SECONDS": "Secondi", + "PLAYS": "Riproduzioni", + "ITEMS": "Elementi" + }, + "USERS_PAGE": { + "ALL_USERS": "Tutti gli utenti", + "LAST_CLIENT": "Ultimo client", + "LAST_SEEN": "Ultima visualizzazione", + "AGO": "Fa", + "AGO_ALT": "", + "USER_STATS": "Statistiche utente", + "USER_ACTIVITY": "Attività dell'utente" + }, + "STAT_PAGE": { + "STATISTICS": "Statistiche", + "DAILY_PLAY_PER_LIBRARY": "Numero di riproduzioni giornaliere per libreria", + "PLAY_COUNT_BY": "Conteggio riproduzioni per" + }, + "SETTINGS_PAGE": { + "SETTINGS": "Impostazioni", + "LANGUAGE": "Lingua", + "SELECT_AN_ADMIN": "Seleziona un amministratore preferito", + "LIBRARY_SETTINGS": "Impostazioni libreria", + "BACKUP": "Backup", + "BACKUPS": "Backup", + "CHOOSE_FILE": "Scegli file", + "LOGS": "Log", + "SIZE": "Dimensione", + "JELLYFIN_URL": "URL Jellyfin", + "API_KEY": "Chiave API", + "API_KEYS": "Chiavi API", + "KEY_NAME": "Nome chiave", + "KEY": "Chiave", + "NAME": "Nome", + "ADD_KEY": "Aggiungi chiave", + "DURATION": "Durata", + "EXECUTION_TYPE": "Tipo di esecuzione", + "RESULTS": "Risultati", + "SELECT_ADMIN": "Seleziona account amministratore preferito", + "HOUR_FORMAT": "Formato orario", + "HOUR_FORMAT_12": "12 ore", + "HOUR_FORMAT_24": "24 ore", + "SECURITY": "Sicurezza", + "CURRENT_PASSWORD": "Password attuale", + "NEW_PASSWORD": "Nuova password", + "UPDATE": "Aggiorna", + "REQUIRE_LOGIN": "Richiedi accesso", + "TASK": "Compito", + "TASKS": "Compiti", + "INTERVAL": "Intervallo", + "INTERVALS": { + "15_MIN": "15 minuti", + "30_MIN": "30 minuti", + "1_HOUR": "1 ora", + "12_HOURS": "12 ore", + "1_DAY": "1 giorno", + "1_WEEK": "1 settimana" + }, + "SELECT_LIBRARIES_TO_IMPORT": "Seleziona librerie da importare", + "SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "L'attività per gli elementi in queste librerie viene ancora tracciata, anche se non importata.", + "DATE_ADDED": "Data aggiunta" + }, + "TASK_TYPE": { + "JOB": "Attività", + "IMPORT": "Importa" + }, + "TASK_DESCRIPTION": { + "PartialJellyfinSync": "Sincronizzazione elementi aggiunti di recente", + "JellyfinSync": "Sincronizzazione completa con Jellyfin", + "Jellyfin_Playback_Reporting_Plugin_Sync": "Importa dati plugin di riproduzione", + "Backup": "Backup Jellystat" + }, + "ABOUT_PAGE": { + "ABOUT_JELLYSTAT": "Informazioni su Jellystat", + "VERSION": "Versione", + "UPDATE_AVAILABLE": "Aggiornamento disponibile", + "GITHUB": "Github", + "Backup": "Backup Jellystat" + }, + "SEARCH": "Cerca", + "TOTAL": "Totale", + "LAST": "Ultimo", + "SERIES": "Serie", + "SEASON": "Stagione", + "SEASONS": "Stagioni", + "EPISODE": "Episodio", + "EPISODES": "Episodi", + "MOVIES": "Film", + "MUSIC": "Musica", + "SONGS": "Canzoni", + "FILES": "File", + "LIBRARIES": "Librerie", + "USER": "Utente", + "USERS": "Utenti", + "TYPE": "Tipo", + "NEW_VERSION_AVAILABLE": "Nuova versione disponibile", + "ARCHIVED": "Archiviato", + "NOT_ARCHIVED": "Non archiviato", + "ALL": "Tutti", + "CLOSE": "Chiudi", + "TOTAL_PLAYS": "Riproduzioni totali", + "TITLE": "Titolo", + "VIEWS": "Visualizzazioni", + "WATCH_TIME": "Tempo di visualizzazione", + "LAST_WATCHED": "Ultima visualizzazione", + "MEDIA": "Media", + "SAVE": "Salva", + "YES": "Sì", + "NO": "No", + "FILE_NAME": "Nome file", + "DATE": "Data", + "START": "Inizia", + "DOWNLOAD": "Scarica", + "RESTORE": "Ripristina", + "ACTIONS": "Azioni", + "DELETE": "Elimina", + "BITRATE": "Bitrate", + "CONTAINER": "Contenitore", + "VIDEO": "Video", + "CODEC": "Codec", + "WIDTH": "Larghezza", + "HEIGHT": "Altezza", + "FRAMERATE": "Frequenza fotogrammi", + "DYNAMIC_RANGE": "Gamma dinamica", + "ASPECT_RATIO": "Rapporto d'aspetto", + "AUDIO": "Audio", + "CHANNELS": "Canali", + "LANGUAGE": "Lingua", + "STREAM_DETAILS": "Dettagli flusso", + "SOURCE_DETAILS": "Dettagli sorgente", + "DIRECT": "Diretto", + "TRANSCODE": "Transcodifica", + "USERNAME": "Nome utente", + "PASSWORD": "Password", + "LOGIN": "Accedi", + "FT_SETUP_PROGRESS": "Impostazione iniziale Passo {STEP} di {TOTAL}", + "VALIDATING": "Convalida", + "SAVE_JELLYFIN_DETAILS": "Salva dettagli Jellyfin", + "SETTINGS_SAVED": "Impostazioni salvate", + "SUCCESS": "Successo", + "PASSWORD_UPDATE_SUCCESS": "Password aggiornata con successo", + "CREATE_USER": "Crea utente", + "GEOLOCATION_INFO_FOR": "Informazioni geolocalizzazione per", + "CITY": "Città", + "REGION": "Regione", + "COUNTRY": "Paese", + "ORGANIZATION": "Organizzazione", + "ISP": "ISP", + "LATITUDE": "Latitudine", + "LONGITUDE": "Longitudine", + "TIMEZONE": "Fuso orario", + "POSTCODE": "CAP", + "X_ROWS_SELECTED": "{ROWS} righe selezionate" +} From 239c44b0ddf9c92b3e94fe79e5900292e9819293 Mon Sep 17 00:00:00 2001 From: Gianmarco Novelli Date: Thu, 9 Jan 2025 22:33:07 +0100 Subject: [PATCH 30/40] Update translation.json --- public/locales/it-IT/translation.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/public/locales/it-IT/translation.json b/public/locales/it-IT/translation.json index b80d80a..a283733 100644 --- a/public/locales/it-IT/translation.json +++ b/public/locales/it-IT/translation.json @@ -29,6 +29,7 @@ "MOST_VIEWED_LIBRARIES": "LIBRERIE PIÙ VISTE", "MOST_USED_CLIENTS": "CLIENTS PIÙ UTILIZZATI", "MOST_ACTIVE_USERS": "UTENTI PIÙ ATTIVI" + "CONCURRENT_STREAMS": "FLUSSI SIMULTANEI" }, "LIBRARY_OVERVIEW": { "MOVIE_LIBRARIES": "LIBRERIE FILM", @@ -50,6 +51,8 @@ "LAST_24_HRS": "Ultime 24 ore", "LAST_7_DAYS": "Ultimi 7 giorni", "LAST_30_DAYS": "Ultimi 30 giorni", + "LAST_180_DAYS": "Ultimi 180 giorni", + "LAST_365_DAYS": "Ultimi 365 giorni", "ALL_TIME": "Totale", "ITEM_STATS": "Statistiche elemento" }, @@ -132,6 +135,8 @@ "SHOW_ARCHIVED_LIBRARIES": "Mostra librerie archiviate", "HIDE_ARCHIVED_LIBRARIES": "Nascondi librerie archiviate", "UNITS": { + "YEAR": "Anno", + "YEARS": "Anni", "MONTH": "Mese", "MONTHS": "Mesi", "DAY": "Giorno", @@ -144,6 +149,7 @@ "SECONDS": "Secondi", "PLAYS": "Riproduzioni", "ITEMS": "Elementi" + "STREAMS": "Flussi" }, "USERS_PAGE": { "ALL_USERS": "Tutti gli utenti", @@ -170,6 +176,8 @@ "LOGS": "Log", "SIZE": "Dimensione", "JELLYFIN_URL": "URL Jellyfin", + "EMBY_URL": "URL Emby", + "EXTERNAL_URL": "URL esterno", "API_KEY": "Chiave API", "API_KEYS": "Chiavi API", "KEY_NAME": "Nome chiave", From cfe36ee2477aa481350defdefe512c5864e084d0 Mon Sep 17 00:00:00 2001 From: GrimJu Date: Sat, 11 Jan 2025 03:42:12 +0100 Subject: [PATCH 31/40] Prototype implementation of activity timeline feature --- ...90_create_function_fs_get_user_activity.js | 70 +++ backend/routes/api.js | 25 + package-lock.json | 469 +++++++----------- package.json | 1 + public/locales/en-UK/translation.json | 8 +- src/lib/navdata.jsx | 9 +- src/pages/activity_time_line.jsx | 195 ++++++++ .../activity-timeline-item.jsx | 76 +++ .../activity-timeline/activity-timeline.jsx | 80 +++ .../components/activity-timeline/helpers.jsx | 56 +++ src/pages/css/timeline/activity-timeline.css | 25 + src/routes.jsx | 6 + 12 files changed, 733 insertions(+), 287 deletions(-) create mode 100644 backend/migrations/090_create_function_fs_get_user_activity.js create mode 100644 src/pages/activity_time_line.jsx create mode 100644 src/pages/components/activity-timeline/activity-timeline-item.jsx create mode 100644 src/pages/components/activity-timeline/activity-timeline.jsx create mode 100644 src/pages/components/activity-timeline/helpers.jsx create mode 100644 src/pages/css/timeline/activity-timeline.css diff --git a/backend/migrations/090_create_function_fs_get_user_activity.js b/backend/migrations/090_create_function_fs_get_user_activity.js new file mode 100644 index 0000000..7df42a6 --- /dev/null +++ b/backend/migrations/090_create_function_fs_get_user_activity.js @@ -0,0 +1,70 @@ +exports.up = function(knex) { + return knex.schema.raw(` + CREATE OR REPLACE FUNCTION fs_get_user_activity( + user_id text, + library_ids text[] + ) + RETURNS TABLE ( + "UserName" text, + "Title" text, + "EpisodeCount" bigint, + "FirstActivityDate" timestamptz, + "LastActivityDate" timestamptz, + "TotalPlaybackDuration" bigint, + "SeasonName" text, + "MediaType" text, + "NowPlayingItemId" text + ) AS $$ + BEGIN + RETURN QUERY + SELECT + jp."UserName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" + ELSE jp."NowPlayingItemName" + END AS "Title", + COUNT(DISTINCT jp."EpisodeId") AS "EpisodeCount", + MIN(jp."ActivityDateInserted") AS "FirstActivityDate", + MAX(jp."ActivityDateInserted") AS "LastActivityDate", + SUM(jp."PlaybackDuration")::bigint AS "TotalPlaybackDuration", + ls."Name" AS "SeasonName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN 'Show' + ELSE 'Movie' + END AS "MediaType", + jp."NowPlayingItemId" + FROM + public.jf_playback_activity AS jp + JOIN + public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id" + JOIN + public.jf_libraries AS jl ON jli."ParentId" = jl."Id" + LEFT JOIN + public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id" + WHERE + jp."UserId" = user_id + AND jl."Id" = ANY(library_ids) + GROUP BY + jp."UserName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" + ELSE jp."NowPlayingItemName" + END, + jp."SeriesName", + jp."SeasonId", + ls."Name", + jp."NowPlayingItemId" + HAVING + NOT (MAX(jl."Name") = 'Shows' AND ls."Name" IS NULL) + ORDER BY + MAX(jp."ActivityDateInserted") DESC; + END; + $$ LANGUAGE plpgsql; +`); +}; + +exports.down = function(knex) { + return knex.schema.raw(` + DROP FUNCTION IF EXISTS fs_get_user_activity; + `); +}; \ No newline at end of file diff --git a/backend/routes/api.js b/backend/routes/api.js index e0da1e5..eacaf04 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1432,6 +1432,31 @@ router.post("/deletePlaybackActivity", async (req, res) => { } }); +router.post("/getActivityTimeLine", async (req, res) => { + try { + const { userId, libraries } = req.body; + + if (libraries === undefined || !Array.isArray(libraries)) { + res.status(400); + res.send("A list of IDs is required. EG: [1,2,3]"); + return; + } + + if (userId === undefined) { + res.status(400); + res.send("A userId is required."); + return; + } + + const {rows} = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]); + res.send(rows); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + // Handle other routes router.use((req, res) => { res.status(404).send({ error: "Not Found" }); diff --git a/package-lock.json b/package-lock.json index f0f494f..2d341df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", + "@mui/lab": "^6.0.0-beta.22", "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.3", "@mui/x-date-pickers": "^7.23.3", @@ -3257,6 +3258,44 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -4151,10 +4190,43 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz", + "integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.0.tgz", - "integrity": "sha512-/d8NwSuC3rMwCjswmGB3oXC4sdDuhIUJ8inVQAxGrADJhf0eq/kmy+foFKvpYhHl2siOZR+MLdFttw6/Bzqtqg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz", + "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" @@ -4185,23 +4257,19 @@ } } }, - "node_modules/@mui/material": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.0.tgz", - "integrity": "sha512-qhlTFyRMxfoVPxUtA5e8IvqxP0dWo2Ij7cvot7Orag+etUlZH+3UwD8gZGt+3irOoy7Ms3UNBflYjwEikUXtAQ==", + "node_modules/@mui/lab": { + "version": "6.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.22.tgz", + "integrity": "sha512-9nwUfBj+UzoQJOCbqV+JcCSJ74T+gGWrM1FMlXzkahtYUcMN+5Zmh2ArlttW3zv2dZyCzp7K5askcnKF0WzFQg==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.3.0", - "@mui/system": "^6.3.0", - "@mui/types": "^7.2.20", - "@mui/utils": "^6.3.0", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", + "@mui/base": "5.0.0-beta.68", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^19.0.0", - "react-transition-group": "^4.4.5" + "prop-types": "^15.8.1" }, "engines": { "node": ">=14.0.0" @@ -4213,7 +4281,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.3.0", + "@mui/material": "^6.3.1", + "@mui/material-pigment-css": "^6.3.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4233,13 +4302,69 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.0.tgz", - "integrity": "sha512-tdS8jvqMokltNTXg6ioRCCbVdDmZUJZa/T9VtTnX2Lwww3FTgCakst9tWLZSxm1fEE9Xp0m7onZJmgeUmWQYVw==", + "node_modules/@mui/material": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz", + "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.3.0", + "@mui/core-downloads-tracker": "^6.3.1", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz", + "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.1", "prop-types": "^15.8.1" }, "engines": { @@ -4259,10 +4384,11 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.0.tgz", - "integrity": "sha512-iWA6eyiPkO07AlHxRUvI7dwVRSc+84zV54kLmjUms67GJeOWVuXlu8ZO+UhCnwJxHacghxnabsMEqet5PYQmHg==", + "node_modules/@mui/styled-engine": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz", + "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", "@emotion/cache": "^11.13.5", @@ -4292,16 +4418,17 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.0.tgz", - "integrity": "sha512-L+8hUHMNlfReKSqcnVslFrVhoNfz/jw7Fe9NfDE85R3KarvZ4O3MU9daF/lZeqEAvnYxEilkkTfDwQ7qCgJdFg==", + "node_modules/@mui/system": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz", + "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.3.0", - "@mui/styled-engine": "^6.3.0", - "@mui/types": "^7.2.20", - "@mui/utils": "^6.3.0", + "@mui/private-theming": "^6.3.1", + "@mui/styled-engine": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -4331,13 +4458,28 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", + "@mui/types": "^7.2.21", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -4360,150 +4502,11 @@ } } }, - "node_modules/@mui/material/node_modules/react-is": { + "node_modules/@mui/utils/node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, - "node_modules/@mui/private-theming": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", - "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.14", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.14.tgz", - "integrity": "sha512-auXLXzUaCSSOLqJXmsAaq7P96VPRXg2Rrz6OHNV7lr+kB8lobUF+/N84Vd9C4G/wvCXYPs5TYuuGBRhcGbiBGg==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.14", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "clsx": "^2.1.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/types": { - "version": "7.2.20", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", - "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", - "prop-types": "^15.8.1", - "react-is": "^18.2.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" }, "node_modules/@mui/x-data-grid": { "version": "7.23.3", @@ -4541,40 +4544,6 @@ } } }, - "node_modules/@mui/x-data-grid/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-data-grid/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@mui/x-date-pickers": { "version": "7.23.3", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz", @@ -4640,40 +4609,6 @@ } } }, - "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-date-pickers/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@mui/x-internals": { "version": "7.23.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz", @@ -4693,40 +4628,6 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@mui/x-internals/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-internals/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index af1b69c..3b74515 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", + "@mui/lab": "^6.0.0-beta.22", "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.3", "@mui/x-date-pickers": "^7.23.3", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index eddda06..5b87ec1 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -8,7 +8,8 @@ "STATISTICS": "Statistics", "SETTINGS": "Settings", "ABOUT": "About", - "LOGOUT": "Logout" + "LOGOUT": "Logout", + "TIMELINE": "Timeline" }, "HOME_PAGE": { "SESSIONS": "Sessions", @@ -230,6 +231,9 @@ "GITHUB": "Github", "Backup": "Backup Jellystat" }, + "TIMELINE_PAGE": { + "TIMELINE": "Timeline" + }, "SEARCH": "Search", "TOTAL": "Total", "LAST": "Last", @@ -305,4 +309,4 @@ "POSTCODE": "Postcode", "X_ROWS_SELECTED": "{ROWS} Rows Selected", "SUBTITLES": "Subtitles" -} +} \ No newline at end of file diff --git a/src/lib/navdata.jsx b/src/lib/navdata.jsx index 52deecb..a3c5d43 100644 --- a/src/lib/navdata.jsx +++ b/src/lib/navdata.jsx @@ -7,13 +7,14 @@ import SettingsFillIcon from 'remixicon-react/SettingsFillIcon'; import GalleryFillIcon from 'remixicon-react/GalleryFillIcon'; import UserFillIcon from 'remixicon-react/UserFillIcon'; import InformationFillIcon from 'remixicon-react/InformationFillIcon'; +import TimeLineIcon from 'remixicon-react/TimeLineIcon'; import { Trans } from 'react-i18next'; export const navData = [ { id: 0, - icon: , + icon: , text: , link: "" }, @@ -35,6 +36,12 @@ export const navData = [ text: , link: "activity" }, + { + id: 8, + icon: , + text: , + link: "timeline" + }, { id: 5, icon: , diff --git a/src/pages/activity_time_line.jsx b/src/pages/activity_time_line.jsx new file mode 100644 index 0000000..11e6db8 --- /dev/null +++ b/src/pages/activity_time_line.jsx @@ -0,0 +1,195 @@ +import "./css/stats.css"; + +import { Trans } from "react-i18next"; +import ActivityTimelineComponent from "./components/activity-timeline/activity-timeline"; +import { useEffect, useState } from "react"; + +import axios from "../lib/axios_instance.jsx"; +import Config from "../lib/config.jsx"; +import "./css/timeline/activity-timeline.css"; +import Loading from "./components/general/loading"; +import { Button, FormSelect, Modal } from "react-bootstrap"; +import LibraryFilterModal from "./components/library/library-filter-modal"; + +function ActivityTimeline() { + const [users, setUsers] = useState(); + const [selectedUser, setSelectedUser] = useState( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ?? "" + ); + const [libraries, setLibraries] = useState(); + const [config, setConfig] = useState(null); + const [showLibraryFilters, setShowLibraryFilters] = useState(false); + const [selectedLibraries, setSelectedLibraries] = useState( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") != + undefined + ? JSON.parse( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") + ) + : [] + ); + + const handleLibraryFilter = (selectedOptions) => { + setSelectedLibraries(selectedOptions); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify(selectedOptions) + ); + }; + const handleUserSelection = (selectedUser) => { + console.log(selectedUser); + + setSelectedUser(selectedUser); + localStorage.setItem("PREF_ACTIVITY_TIMELINE_selectedUser", selectedUser); + }; + + const toggleSelectAll = () => { + if (selectedLibraries.length > 0) { + setSelectedLibraries([]); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify([]) + ); + } else { + setSelectedLibraries(libraries.map((library) => library.Id)); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify(libraries.map((library) => library.Id)) + ); + } + }; + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + if (error.code === "ERR_NETWORK") { + console.log(error); + } + } + }; + + if (!config) { + fetchConfig(); + } + }, [config]); + + useEffect(() => { + if (config) { + const url = `/stats/getAllUserActivity`; + axios + .get(url, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }) + .then((users) => { + setUsers(users.data); + }) + .catch((error) => { + console.log(error); + }); + } + }, [config]); + + useEffect(() => { + if (config) { + const url = `/stats/getLibraryMetadata`; + axios + .get(url, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }) + .then((libraries) => { + setLibraries(libraries.data); + }) + .catch((error) => { + console.log(error); + }); + } + }, [config]); + + return users?.length > 0 && libraries?.length > 0 ? ( +
+
+

+ +

+
+
+
+
+ +
+ handleUserSelection(e.target.value)} + value={selectedUser} + className="w-md-75 rounded-0 rounded-end" + > + {users.map((user) => ( + + ))} + +
+
+
+ + + setShowLibraryFilters(false)} + > + + + + + + + + + + + +
+
+
+
+ {selectedUser && selectedLibraries?.length > 0 && ( + + )} +
+
+ ) : ( + + ); +} + +export default ActivityTimeline; diff --git a/src/pages/components/activity-timeline/activity-timeline-item.jsx b/src/pages/components/activity-timeline/activity-timeline-item.jsx new file mode 100644 index 0000000..21bf9c6 --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline-item.jsx @@ -0,0 +1,76 @@ +/* eslint-disable react/prop-types */ +import { useState } from "react"; + +import TimelineItem from "@mui/lab/TimelineItem"; +import TimelineSeparator from "@mui/lab/TimelineSeparator"; +import TimelineConnector from "@mui/lab/TimelineConnector"; +import TimelineContent from "@mui/lab/TimelineContent"; +import Typography from "@mui/material/Typography"; +import Card from "react-bootstrap/Card"; +import baseUrl from "../../../lib/baseurl"; + +import "../../css/timeline/activity-timeline.css"; + +import moment from "moment"; +import TvLineIcon from "remixicon-react/TvLineIcon.js"; +import FilmLineIcon from "remixicon-react/FilmLineIcon.js"; +import { MEDIA_TYPES } from "./helpers"; + +function formatEntryDates(entry) { + const { FirstActivityDate, LastActivityDate, MediaType } = entry; + const startDate = moment(FirstActivityDate); + const endDate = moment(LastActivityDate); + + if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) { + return startDate.format("L"); + } else { + return `${startDate.format("L")} - ${endDate.format("L")}`; + } +} +const DefaultImage = (props) => { + const { MediaType } = props; + return ( +
+ {MediaType === MEDIA_TYPES.Shows ? SeriesIcon : MovieIcon} +
+ ); +}; +const SeriesIcon = ; +const MovieIcon = ; + +export default function ActivityTimelineItem(entry) { + const { Title, SeasonName, NowPlayingItemId } = entry; + const [useDefaultImage, setUseDefaultImage] = useState(false); + return ( + + + +
+ {!useDefaultImage ? ( + setUseDefaultImage(true)} + /> + ) : ( + + )} +
+ +
+ + + {Title} + + {SeasonName && {SeasonName}} + {formatEntryDates(entry)} + +
+ ); +} diff --git a/src/pages/components/activity-timeline/activity-timeline.jsx b/src/pages/components/activity-timeline/activity-timeline.jsx new file mode 100644 index 0000000..7e9712c --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline.jsx @@ -0,0 +1,80 @@ +/* eslint-disable react/prop-types */ +import { useEffect, useState } from "react"; +import axios from "../../../lib/axios_instance"; + +import Timeline from "@mui/lab/Timeline"; + +import "../../css/timeline/activity-timeline.css"; + +import Config from "../../../lib/config.jsx"; +import Loading from "../../../pages/components/general/loading.jsx"; + +import ActivityTimelineItem from "./activity-timeline-item.jsx"; +import { groupAdjacentSeasons } from "./helpers.jsx"; + +export default function ActivityTimelineComponent(props) { + const { userId, libraries } = props; + + const [timelineEntries, setTimelineEntries] = useState(); + const [config, setConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + if (error.code === "ERR_NETWORK") { + console.log(error); + } + } + }; + + const fetchLibraries = () => { + if (config) { + const url = `/api/getActivityTimeLine`; + axios + .post( + url, + { userId: userId, libraries: libraries }, + { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + } + ) + .then((timelineEntries) => { + const groupedAdjacentSeasons = groupAdjacentSeasons([ + ...timelineEntries.data, + ]); + setTimelineEntries(groupedAdjacentSeasons); + }) + .catch((error) => { + console.log(error); + }); + } + }; + + if (!config) { + fetchConfig(); + } + + fetchLibraries(); + }, [userId, libraries, config]); + + return timelineEntries?.length > 0 ? ( +
+ + {timelineEntries.map((entry) => ( + + ))} + +
+ ) : ( + + ); +} diff --git a/src/pages/components/activity-timeline/helpers.jsx b/src/pages/components/activity-timeline/helpers.jsx new file mode 100644 index 0000000..99c20a6 --- /dev/null +++ b/src/pages/components/activity-timeline/helpers.jsx @@ -0,0 +1,56 @@ +export const MEDIA_TYPES = { + Movies: "Movie", + Shows: "Show", +}; + +/** + * groups subsequent seasons of shows into single entries with a combined label and timeframe + * @param {*} timelineEntries List of entries as returned by /api/getActivityTimeLine + * @returns Same list of entries, seasons of the same show that follow each other will be merged into one entry + */ +export function groupAdjacentSeasons(timelineEntries) { + return timelineEntries + .reverse() + .map((entry, index, entryArray) => { + if (entry?.MediaType === MEDIA_TYPES.Shows) { + let potentialNextSeasonIndex = index + 1; + //if the next entry is another season of the same show, merge them + if (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) { + let highestSeasonName = entry.SeasonName; + let lastSeasonInSession; + //merge all further seasons as well + while (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) { + const potentialNextSeason = entryArray[potentialNextSeasonIndex]; + if (entry.Title === potentialNextSeason?.Title) { + lastSeasonInSession = potentialNextSeason; + //remove season from list after usage + entryArray[potentialNextSeasonIndex] = undefined; + + //hack: in my db the seasons weren't always sorted correctly. + if ( + highestSeasonName?.localeCompare( + lastSeasonInSession.SeasonName + ) === -1 + ) { + highestSeasonName = lastSeasonInSession.SeasonName; + } + } else { + //all subsequent seasons have been merged into one entry and were removed from the list + break; + } + potentialNextSeasonIndex++; + } + const newSeasonName = `${entry.SeasonName} - ${highestSeasonName}`; + const newLastActivityDate = lastSeasonInSession.LastActivityDate; + return { + ...entry, + SeasonName: newSeasonName, + LastActivityDate: newLastActivityDate, + }; + } + } + return entry; + }) + .filter((entry) => !!entry) + .reverse(); +} diff --git a/src/pages/css/timeline/activity-timeline.css b/src/pages/css/timeline/activity-timeline.css new file mode 100644 index 0000000..fa4851d --- /dev/null +++ b/src/pages/css/timeline/activity-timeline.css @@ -0,0 +1,25 @@ +@import "../variables.module.css"; + +.Heading { + justify-content: space-between; +} +.activity-card { + display: flex; + width: 10rem; + .activity-card-img { + border-radius: var(--bs-border-radius-lg) !important; + object-fit: cover; + background-color: black; + background-repeat: no-repeat; + background-size: cover; + transition: all 0.2s ease-in-out; + } +} + +.MuiTimelineItem-root { + height: 20rem; +} + +.MuiTimelineContent-root { + align-self: center; +} diff --git a/src/routes.jsx b/src/routes.jsx index 8fb7873..5e7788c 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -11,6 +11,7 @@ import About from "./pages/about"; import TestingRoutes from "./pages/testing"; import Activity from "./pages/activity"; import Statistics from "./pages/statistics"; +import ActivityTimeline from "./pages/activity_time_line"; const routes = [ { @@ -58,6 +59,11 @@ const routes = [ element: , exact: true, }, + { + path: "/timeline", + element: , + exact: true, + }, { path: "/about", element: , From 30f3bbe6ee3dfdb178cab876b7a60a1fe9ffe137 Mon Sep 17 00:00:00 2001 From: GrimJu Date: Sat, 11 Jan 2025 20:45:33 +0100 Subject: [PATCH 32/40] Activity timeline improvements: +Turn cards into links that lead to the item +Show EpisodeCount for shows +Filter out activities under 15 minutes +Add timeline to userInfo view --- ...90_create_function_fs_get_user_activity.js | 103 ++++++++++-------- public/locales/en-UK/translation.json | 6 +- src/pages/activity_time_line.jsx | 48 ++++---- .../activity-timeline-item.jsx | 42 ++++--- src/pages/components/user-info.jsx | 25 ++++- src/pages/css/timeline/activity-timeline.css | 5 +- 6 files changed, 141 insertions(+), 88 deletions(-) diff --git a/backend/migrations/090_create_function_fs_get_user_activity.js b/backend/migrations/090_create_function_fs_get_user_activity.js index 7df42a6..c8ecc17 100644 --- a/backend/migrations/090_create_function_fs_get_user_activity.js +++ b/backend/migrations/090_create_function_fs_get_user_activity.js @@ -1,10 +1,11 @@ exports.up = function(knex) { return knex.schema.raw(` - CREATE OR REPLACE FUNCTION fs_get_user_activity( + create or replace + function fs_get_user_activity( user_id text, library_ids text[] ) - RETURNS TABLE ( + returns table ( "UserName" text, "Title" text, "EpisodeCount" bigint, @@ -14,52 +15,58 @@ exports.up = function(knex) { "SeasonName" text, "MediaType" text, "NowPlayingItemId" text - ) AS $$ - BEGIN - RETURN QUERY - SELECT - jp."UserName", - CASE - WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" - ELSE jp."NowPlayingItemName" - END AS "Title", - COUNT(DISTINCT jp."EpisodeId") AS "EpisodeCount", - MIN(jp."ActivityDateInserted") AS "FirstActivityDate", - MAX(jp."ActivityDateInserted") AS "LastActivityDate", - SUM(jp."PlaybackDuration")::bigint AS "TotalPlaybackDuration", - ls."Name" AS "SeasonName", - CASE - WHEN jp."SeriesName" IS NOT NULL THEN 'Show' - ELSE 'Movie' - END AS "MediaType", - jp."NowPlayingItemId" - FROM - public.jf_playback_activity AS jp - JOIN - public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id" - JOIN - public.jf_libraries AS jl ON jli."ParentId" = jl."Id" - LEFT JOIN - public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id" - WHERE - jp."UserId" = user_id - AND jl."Id" = ANY(library_ids) - GROUP BY - jp."UserName", - CASE - WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" - ELSE jp."NowPlayingItemName" - END, - jp."SeriesName", - jp."SeasonId", - ls."Name", - jp."NowPlayingItemId" - HAVING - NOT (MAX(jl."Name") = 'Shows' AND ls."Name" IS NULL) - ORDER BY - MAX(jp."ActivityDateInserted") DESC; - END; - $$ LANGUAGE plpgsql; + ) as $$ + begin + return QUERY + select + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end as "Title", + COUNT(distinct jp."EpisodeId") as "EpisodeCount", + MIN(jp."ActivityDateInserted") as "FirstActivityDate", + MAX(jp."ActivityDateInserted") as "LastActivityDate", + SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration", + ls."Name" as "SeasonName", + case + when jp."SeriesName" is not null then 'Show' + else 'Movie' + end as "MediaType", + jp."NowPlayingItemId" + from + public.jf_playback_activity as jp + join + public.jf_library_items as jli on + jp."NowPlayingItemId" = jli."Id" + join + public.jf_libraries as jl on + jli."ParentId" = jl."Id" + left join + public.jf_library_seasons as ls on + jp."SeasonId" = ls."Id" + where + jp."UserId" = user_id + and jl."Id" = any(library_ids) + group by + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end, + jp."SeriesName", + jp."SeasonId", + ls."Name", + jp."NowPlayingItemId" + having + not (MAX(jl."Name") = 'Shows' + and ls."Name" is null) + and SUM(jp."PlaybackDuration") >= 900 + order by + MAX(jp."ActivityDateInserted") desc; + end; + + $$ language plpgsql; `); }; diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index 5b87ec1..bd7de5e 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -78,7 +78,8 @@ "TAB_CONTROLS": { "OVERVIEW": "Overview", "ACTIVITY": "Activity", - "OPTIONS": "Options" + "OPTIONS": "Options", + "TIMELINE": "Timeline" }, "ITEM_ACTIVITY": "Item Activity", "ACTIVITY_TABLE": { @@ -232,7 +233,8 @@ "Backup": "Backup Jellystat" }, "TIMELINE_PAGE": { - "TIMELINE": "Timeline" + "TIMELINE": "Timeline", + "EPISODES":"Episodes" }, "SEARCH": "Search", "TOTAL": "Total", diff --git a/src/pages/activity_time_line.jsx b/src/pages/activity_time_line.jsx index 11e6db8..9b1c59d 100644 --- a/src/pages/activity_time_line.jsx +++ b/src/pages/activity_time_line.jsx @@ -11,10 +11,13 @@ import Loading from "./components/general/loading"; import { Button, FormSelect, Modal } from "react-bootstrap"; import LibraryFilterModal from "./components/library/library-filter-modal"; -function ActivityTimeline() { +function ActivityTimeline(props) { + const { preselectedUser } = props; const [users, setUsers] = useState(); const [selectedUser, setSelectedUser] = useState( - localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ?? "" + preselectedUser ?? + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ?? + "" ); const [libraries, setLibraries] = useState(); const [config, setConfig] = useState(null); @@ -28,6 +31,9 @@ function ActivityTimeline() { : [] ); + const timelineReady = + (users?.length > 0 || !!preselectedUser) && libraries?.length > 0; + const handleLibraryFilter = (selectedOptions) => { setSelectedLibraries(selectedOptions); localStorage.setItem( @@ -76,7 +82,7 @@ function ActivityTimeline() { }, [config]); useEffect(() => { - if (config) { + if (config && !preselectedUser) { const url = `/stats/getAllUserActivity`; axios .get(url, { @@ -92,7 +98,7 @@ function ActivityTimeline() { console.log(error); }); } - }, [config]); + }, [config, preselectedUser]); useEffect(() => { if (config) { @@ -113,7 +119,7 @@ function ActivityTimeline() { } }, [config]); - return users?.length > 0 && libraries?.length > 0 ? ( + return timelineReady ? (

@@ -125,20 +131,24 @@ function ActivityTimeline() { >
-
- -
- handleUserSelection(e.target.value)} - value={selectedUser} - className="w-md-75 rounded-0 rounded-end" - > - {users.map((user) => ( - - ))} - + {!preselectedUser && ( + <> +
+ +
+ handleUserSelection(e.target.value)} + value={selectedUser} + className="w-md-75 rounded-0 rounded-end" + > + {users.map((user) => ( + + ))} + + + )}
diff --git a/src/pages/components/activity-timeline/activity-timeline-item.jsx b/src/pages/components/activity-timeline/activity-timeline-item.jsx index 21bf9c6..79500ed 100644 --- a/src/pages/components/activity-timeline/activity-timeline-item.jsx +++ b/src/pages/components/activity-timeline/activity-timeline-item.jsx @@ -15,6 +15,8 @@ import moment from "moment"; import TvLineIcon from "remixicon-react/TvLineIcon.js"; import FilmLineIcon from "remixicon-react/FilmLineIcon.js"; import { MEDIA_TYPES } from "./helpers"; +import { Link } from "react-router-dom"; +import { Trans } from "react-i18next"; function formatEntryDates(entry) { const { FirstActivityDate, LastActivityDate, MediaType } = entry; @@ -39,28 +41,31 @@ const SeriesIcon = ; const MovieIcon = ; export default function ActivityTimelineItem(entry) { - const { Title, SeasonName, NowPlayingItemId } = entry; + const { Title, SeasonName, NowPlayingItemId, EpisodeCount, MediaType } = + entry; const [useDefaultImage, setUseDefaultImage] = useState(false); return (
- {!useDefaultImage ? ( - setUseDefaultImage(true)} - /> - ) : ( - - )} + + {!useDefaultImage ? ( + setUseDefaultImage(true)} + /> + ) : ( + + )} +
@@ -70,6 +75,11 @@ export default function ActivityTimelineItem(entry) { {SeasonName && {SeasonName}} {formatEntryDates(entry)} + {MediaType === MEDIA_TYPES.Shows && EpisodeCount && ( + + {EpisodeCount} + + )}
); diff --git a/src/pages/components/user-info.jsx b/src/pages/components/user-info.jsx index 0e4290c..e76510d 100644 --- a/src/pages/components/user-info.jsx +++ b/src/pages/components/user-info.jsx @@ -11,6 +11,7 @@ import "../css/users/user-details.css"; import { Trans } from "react-i18next"; import baseUrl from "../../lib/baseurl"; import GlobalStats from "./general/globalStats"; +import ActivityTimeline from "../activity_time_line"; function UserInfo() { const { UserId } = useParams(); @@ -77,7 +78,12 @@ function UserInfo() { ) : ( @@ -103,11 +109,23 @@ function UserInfo() { > +

- + + + +
); diff --git a/src/pages/css/timeline/activity-timeline.css b/src/pages/css/timeline/activity-timeline.css index fa4851d..a83bc84 100644 --- a/src/pages/css/timeline/activity-timeline.css +++ b/src/pages/css/timeline/activity-timeline.css @@ -6,8 +6,11 @@ .activity-card { display: flex; width: 10rem; - .activity-card-img { + * { + flex-grow: 1; border-radius: var(--bs-border-radius-lg) !important; + } + .activity-card-img { object-fit: cover; background-color: black; background-repeat: no-repeat; From 4ff3449c4dc288b3d74d4a13377c9c143fe242a2 Mon Sep 17 00:00:00 2001 From: GrimJu Date: Sat, 18 Jan 2025 19:55:23 +0100 Subject: [PATCH 33/40] Activity timeline improvements: + Remove sidebar link to activity timeline + use jf_libraries.CollectionType as MediaType instead of calculating it + reduce minimum playback duration to 30 seconds --- ...90_create_function_fs_get_user_activity.js | 134 +++++++++--------- src/lib/navdata.jsx | 9 +- .../components/activity-timeline/helpers.jsx | 5 +- 3 files changed, 69 insertions(+), 79 deletions(-) diff --git a/backend/migrations/090_create_function_fs_get_user_activity.js b/backend/migrations/090_create_function_fs_get_user_activity.js index c8ecc17..acf59c3 100644 --- a/backend/migrations/090_create_function_fs_get_user_activity.js +++ b/backend/migrations/090_create_function_fs_get_user_activity.js @@ -1,77 +1,73 @@ -exports.up = function(knex) { +exports.up = function (knex) { return knex.schema.raw(` create or replace - function fs_get_user_activity( - user_id text, - library_ids text[] - ) - returns table ( - "UserName" text, - "Title" text, - "EpisodeCount" bigint, - "FirstActivityDate" timestamptz, - "LastActivityDate" timestamptz, - "TotalPlaybackDuration" bigint, - "SeasonName" text, - "MediaType" text, - "NowPlayingItemId" text - ) as $$ - begin - return QUERY - select - jp."UserName", - case - when jp."SeriesName" is not null then jp."SeriesName" - else jp."NowPlayingItemName" - end as "Title", - COUNT(distinct jp."EpisodeId") as "EpisodeCount", - MIN(jp."ActivityDateInserted") as "FirstActivityDate", - MAX(jp."ActivityDateInserted") as "LastActivityDate", - SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration", - ls."Name" as "SeasonName", - case - when jp."SeriesName" is not null then 'Show' - else 'Movie' - end as "MediaType", - jp."NowPlayingItemId" - from - public.jf_playback_activity as jp - join - public.jf_library_items as jli on - jp."NowPlayingItemId" = jli."Id" - join - public.jf_libraries as jl on - jli."ParentId" = jl."Id" - left join - public.jf_library_seasons as ls on - jp."SeasonId" = ls."Id" - where - jp."UserId" = user_id - and jl."Id" = any(library_ids) - group by - jp."UserName", - case - when jp."SeriesName" is not null then jp."SeriesName" - else jp."NowPlayingItemName" - end, - jp."SeriesName", - jp."SeasonId", - ls."Name", - jp."NowPlayingItemId" - having - not (MAX(jl."Name") = 'Shows' - and ls."Name" is null) - and SUM(jp."PlaybackDuration") >= 900 - order by - MAX(jp."ActivityDateInserted") desc; - end; - - $$ language plpgsql; + function fs_get_user_activity( + user_id text, + library_ids text[] + ) + returns table ( + "UserName" text, + "Title" text, + "EpisodeCount" bigint, + "FirstActivityDate" timestamptz, + "LastActivityDate" timestamptz, + "TotalPlaybackDuration" bigint, + "SeasonName" text, + "MediaType" text, + "NowPlayingItemId" text + ) as $$ + begin + return QUERY + select + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end as "Title", + COUNT(distinct jp."EpisodeId") as "EpisodeCount", + MIN(jp."ActivityDateInserted") as "FirstActivityDate", + MAX(jp."ActivityDateInserted") as "LastActivityDate", + SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration", + ls."Name" as "SeasonName", + MAX(jl."CollectionType") as "MediaType", + jp."NowPlayingItemId" + from + public.jf_playback_activity as jp + join + public.jf_library_items as jli on + jp."NowPlayingItemId" = jli."Id" + join + public.jf_libraries as jl on + jli."ParentId" = jl."Id" + left join + public.jf_library_seasons as ls on + jp."SeasonId" = ls."Id" + where + jp."UserId" = user_id + and jl."Id" = any(library_ids) + group by + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end, + jp."SeriesName", + jp."SeasonId", + ls."Name", + jp."NowPlayingItemId" + having + not (MAX(jl."Name") = 'Shows' + and ls."Name" is null) + and SUM(jp."PlaybackDuration") >= 30 + order by + MAX(jp."ActivityDateInserted") desc; + end; + $$ language plpgsql; `); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.raw(` DROP FUNCTION IF EXISTS fs_get_user_activity; `); -}; \ No newline at end of file +}; diff --git a/src/lib/navdata.jsx b/src/lib/navdata.jsx index a3c5d43..52deecb 100644 --- a/src/lib/navdata.jsx +++ b/src/lib/navdata.jsx @@ -7,14 +7,13 @@ import SettingsFillIcon from 'remixicon-react/SettingsFillIcon'; import GalleryFillIcon from 'remixicon-react/GalleryFillIcon'; import UserFillIcon from 'remixicon-react/UserFillIcon'; import InformationFillIcon from 'remixicon-react/InformationFillIcon'; -import TimeLineIcon from 'remixicon-react/TimeLineIcon'; import { Trans } from 'react-i18next'; export const navData = [ { id: 0, - icon: , + icon: , text: , link: "" }, @@ -36,12 +35,6 @@ export const navData = [ text: , link: "activity" }, - { - id: 8, - icon: , - text: , - link: "timeline" - }, { id: 5, icon: , diff --git a/src/pages/components/activity-timeline/helpers.jsx b/src/pages/components/activity-timeline/helpers.jsx index 99c20a6..ab6af1a 100644 --- a/src/pages/components/activity-timeline/helpers.jsx +++ b/src/pages/components/activity-timeline/helpers.jsx @@ -1,6 +1,7 @@ export const MEDIA_TYPES = { - Movies: "Movie", - Shows: "Show", + Movies: "movies", + Shows: "tvshows", + Music: "music", }; /** From e428398019157cf4ee87f801d765c76b26469f69 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sun, 26 Jan 2025 13:51:04 +0200 Subject: [PATCH 34/40] added filtering to all activity table fields and api history endpoints --- backend/routes/api.js | 351 +++++++++++++++++- backend/swagger.json | 83 +++++ package-lock.json | 120 +++--- package.json | 2 +- src/pages/activity.jsx | 25 +- .../components/activity/activity-table.jsx | 47 ++- .../components/item-info/item-activity.jsx | 21 +- .../components/library/library-activity.jsx | 21 +- .../components/user-info/user-activity.jsx | 21 +- 9 files changed, 613 insertions(+), 78 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index eacaf04..a302c12 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -39,6 +39,25 @@ const unGroupedSortMap = [ { field: "PlaybackDuration", column: "a.PlaybackDuration" }, ]; +const filterFields = [ + { field: "UserName", column: `LOWER(u."Name")` }, + { field: "RemoteEndPoint", column: `LOWER(a."RemoteEndPoint")` }, + { + field: "NowPlayingItemName", + column: `LOWER( + CASE + WHEN a."SeriesName" is null THEN a."NowPlayingItemName" + ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName") + END + )`, + }, + { field: "Client", column: `LOWER(a."Client")` }, + { field: "DeviceName", column: `LOWER(a."DeviceName")` }, + { field: "ActivityDateInserted", column: "a.ActivityDateInserted", isColumn: true }, + { field: "PlaybackDuration", column: `a.PlaybackDuration`, isColumn: true, applyToCTE: true }, + { field: "TotalPlays", column: `COALESCE("TotalPlays",1)` }, +]; + //Functions function groupRecentlyAdded(rows) { const groupedResults = {}; @@ -121,6 +140,82 @@ async function purgeLibraryItems(id, withActivity, purgeAll = false) { } } +function buildFilterList(query, filtersArray) { + if (filtersArray.length > 0) { + query.where = query.where || []; + filtersArray.forEach((filter) => { + const findField = filterFields.find((item) => item.field === filter.field); + const column = findField?.column || "a.ActivityDateInserted"; + const isColumn = findField?.isColumn || false; + const applyToCTE = findField?.applyToCTE || false; + if (filter.min) { + query.where.push({ + column: column, + operator: ">=", + value: filter.min, + }); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + query.cte.where.push({ + column: column, + operator: ">=", + value: filter.min, + }); + } + } + } + + if (filter.max) { + query.where.push({ + column: column, + operator: "<=", + value: filter.max, + }); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + query.cte.where.push({ + column: column, + operator: "<=", + value: filter.max, + }); + } + } + } + + if (filter.value) { + const whereClause = { + operator: "LIKE", + value: filter.value.toLowerCase(), + }; + if (isColumn) { + whereClause.column = column; + } else { + whereClause.field = column; + } + query.where.push(whereClause); + + if (applyToCTE) { + if (query.cte) { + if (!query.cte.where) { + query.cte.where = []; + } + query.cte.where.push(whereClause); + } + } + } + }); + } +} + +////////////////////////////// router.get("/getconfig", async (req, res) => { try { const config = await new configClass().getConfig(); @@ -1080,7 +1175,55 @@ router.post("/setExcludedBackupTable", async (req, res) => { //DB Queries - History router.get("/getHistory", async (req, res) => { - const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query; + + let filtersArray = []; + if (filters) { + try { + filtersArray = JSON.parse(filters); + } catch (error) { + return res.status(400).json({ + error: "Invalid filters parameter", + example: [ + { + field: "ActivityDateInserted", + min: "2024-12-31T22:00:00.000Z", + max: "2024-12-31T22:00:00.000Z", + }, + { + field: "PlaybackDuration", + min: "1", + max: "10", + }, + { + field: "TotalPlays", + min: "1", + max: "10", + }, + { + field: "DeviceName", + value: "test", + }, + { + field: "Client", + value: "test", + }, + { + field: "NowPlayingItemName", + value: "test", + }, + { + field: "RemoteEndPoint", + value: "127.0.0.1", + }, + { + field: "UserName", + value: "test", + }, + ], + }); + } + } const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; @@ -1130,6 +1273,12 @@ router.get("/getHistory", async (req, res) => { { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, + { + type: "left", + table: "jf_users", + alias: "u", + conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], + }, ], order_by: sortField, @@ -1152,6 +1301,8 @@ router.get("/getHistory", async (req, res) => { }, ]; } + + buildFilterList(query, filtersArray); const result = await dbHelper.query(query); result.results = result.results.map((item) => ({ @@ -1163,6 +1314,10 @@ router.get("/getHistory", async (req, res) => { response.search = search; } + if (filtersArray.length > 0) { + response.filters = filtersArray; + } + res.send(response); } catch (error) { console.log(error); @@ -1171,7 +1326,55 @@ router.get("/getHistory", async (req, res) => { router.post("/getLibraryHistory", async (req, res) => { try { - const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query; + + let filtersArray = []; + if (filters) { + try { + filtersArray = JSON.parse(filters); + } catch (error) { + return res.status(400).json({ + error: "Invalid filters parameter", + example: [ + { + field: "ActivityDateInserted", + min: "2024-12-31T22:00:00.000Z", + max: "2024-12-31T22:00:00.000Z", + }, + { + field: "PlaybackDuration", + min: "1", + max: "10", + }, + { + field: "TotalPlays", + min: "1", + max: "10", + }, + { + field: "DeviceName", + value: "test", + }, + { + field: "Client", + value: "test", + }, + { + field: "NowPlayingItemName", + value: "test", + }, + { + field: "RemoteEndPoint", + value: "127.0.0.1", + }, + { + field: "UserName", + value: "test", + }, + ], + }); + } + } const { libraryid } = req.body; if (libraryid === undefined) { @@ -1236,6 +1439,12 @@ router.post("/getLibraryHistory", async (req, res) => { { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, + { + type: "left", + table: "jf_users", + alias: "u", + conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], + }, ], order_by: sortField, @@ -1259,6 +1468,8 @@ router.post("/getLibraryHistory", async (req, res) => { ]; } + buildFilterList(query, filtersArray); + const result = await dbHelper.query(query); result.results = result.results.map((item) => ({ @@ -1270,6 +1481,9 @@ router.post("/getLibraryHistory", async (req, res) => { if (search && search.length > 0) { response.search = search; } + if (filtersArray.length > 0) { + response.filters = filtersArray; + } res.send(response); } catch (error) { console.log(error); @@ -1280,7 +1494,7 @@ router.post("/getLibraryHistory", async (req, res) => { router.post("/getItemHistory", async (req, res) => { try { - const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query; const { itemid } = req.body; if (itemid === undefined) { @@ -1289,6 +1503,55 @@ router.post("/getItemHistory", async (req, res) => { return; } + let filtersArray = []; + if (filters) { + try { + filtersArray = JSON.parse(filters); + filtersArray = filtersArray.filter((filter) => filter.field !== "TotalPlays"); + } catch (error) { + return res.status(400).json({ + error: "Invalid filters parameter", + example: [ + { + field: "ActivityDateInserted", + min: "2024-12-31T22:00:00.000Z", + max: "2024-12-31T22:00:00.000Z", + }, + { + field: "PlaybackDuration", + min: "1", + max: "10", + }, + { + field: "TotalPlays", + min: "1", + max: "10", + }, + { + field: "DeviceName", + value: "test", + }, + { + field: "Client", + value: "test", + }, + { + field: "NowPlayingItemName", + value: "test", + }, + { + field: "RemoteEndPoint", + value: "127.0.0.1", + }, + { + field: "UserName", + value: "test", + }, + ], + }); + } + } + const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; const query = { @@ -1306,6 +1569,14 @@ router.post("/getItemHistory", async (req, res) => { ], table: "jf_playback_activity_with_metadata", alias: "a", + joins: [ + { + type: "left", + table: "jf_users", + alias: "u", + conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], + }, + ], where: [ [ { column: "a.EpisodeId", operator: "=", value: itemid }, @@ -1334,12 +1605,18 @@ router.post("/getItemHistory", async (req, res) => { ]; } + buildFilterList(query, filtersArray); const result = await dbHelper.query(query); const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; if (search && search.length > 0) { response.search = search; } + + if (filters) { + response.filters = JSON.parse(filters); + } + res.send(response); } catch (error) { console.log(error); @@ -1350,7 +1627,56 @@ router.post("/getItemHistory", async (req, res) => { router.post("/getUserHistory", async (req, res) => { try { - const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true } = req.query; + const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query; + + let filtersArray = []; + if (filters) { + try { + filtersArray = JSON.parse(filters); + filtersArray = filtersArray.filter((filter) => filter.field !== "TotalPlays"); + } catch (error) { + return res.status(400).json({ + error: "Invalid filters parameter", + example: [ + { + field: "ActivityDateInserted", + min: "2024-12-31T22:00:00.000Z", + max: "2024-12-31T22:00:00.000Z", + }, + { + field: "PlaybackDuration", + min: "1", + max: "10", + }, + { + field: "TotalPlays", + min: "1", + max: "10", + }, + { + field: "DeviceName", + value: "test", + }, + { + field: "Client", + value: "test", + }, + { + field: "NowPlayingItemName", + value: "test", + }, + { + field: "RemoteEndPoint", + value: "127.0.0.1", + }, + { + field: "UserName", + value: "test", + }, + ], + }); + } + } const { userid } = req.body; if (userid === undefined) { @@ -1376,6 +1702,14 @@ router.post("/getUserHistory", async (req, res) => { ], table: "jf_playback_activity_with_metadata", alias: "a", + joins: [ + { + type: "left", + table: "jf_users", + alias: "u", + conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], + }, + ], where: [[{ column: "a.UserId", operator: "=", value: userid }]], order_by: sortField, sort_order: desc ? "desc" : "asc", @@ -1397,6 +1731,9 @@ router.post("/getUserHistory", async (req, res) => { }, ]; } + + buildFilterList(query, filtersArray); + const result = await dbHelper.query(query); const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results }; @@ -1405,6 +1742,10 @@ router.post("/getUserHistory", async (req, res) => { response.search = search; } + if (filters) { + response.filters = JSON.parse(filters); + } + res.send(response); } catch (error) { console.log(error); @@ -1448,7 +1789,7 @@ router.post("/getActivityTimeLine", async (req, res) => { return; } - const {rows} = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]); + const { rows } = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]); res.send(rows); } catch (error) { console.log(error); diff --git a/backend/swagger.json b/backend/swagger.json index d994fa4..28fda5c 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -2042,12 +2042,20 @@ "name": "desc", "in": "query", "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" } ], "responses": { "200": { "description": "OK" }, + "400": { + "description": "Bad Request" + }, "401": { "description": "Unauthorized" }, @@ -2117,6 +2125,11 @@ "in": "query", "type": "string" }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2208,6 +2221,11 @@ "in": "query", "type": "string" }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2299,6 +2317,11 @@ "in": "query", "type": "string" }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -2391,6 +2414,66 @@ } } }, + "/api/getActivityTimeLine": { + "post": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "userId": { + "example": "any" + }, + "libraries": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/stats/getLibraryOverview": { "get": { "tags": [ diff --git a/package-lock.json b/package-lock.json index 2d341df..a6230ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "semver": "^7.5.3", "sequelize": "^6.29.0", "socket.io": "^4.7.2", - "socket.io-client": "^4.7.2", + "socket.io-client": "^4.8.1", "swagger-autogen": "^2.23.5", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", @@ -7574,6 +7574,20 @@ "node": ">=4" } }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -9279,23 +9293,23 @@ } }, "node_modules/engine.io-client": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", - "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -9307,29 +9321,9 @@ } }, "node_modules/engine.io-client/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/engine.io-parser": { "version": "5.2.1", @@ -15384,6 +15378,18 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "optional": true, + "peer": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -19660,13 +19666,13 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", - "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -19674,11 +19680,11 @@ } }, "node_modules/socket.io-client/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -19690,9 +19696,9 @@ } }, "node_modules/socket.io-client/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/socket.io-parser": { "version": "4.2.4", @@ -21415,6 +21421,20 @@ "requires-port": "^1.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/utf8": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", @@ -22605,9 +22625,9 @@ } }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, @@ -22635,9 +22655,9 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "engines": { "node": ">=0.4.0" } diff --git a/package.json b/package.json index 3b74515..b279a71 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "semver": "^7.5.3", "sequelize": "^6.29.0", "socket.io": "^4.7.2", - "socket.io-client": "^4.7.2", + "socket.io-client": "^4.8.1", "swagger-autogen": "^2.23.5", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 2b40089..59ffa0d 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -28,6 +28,7 @@ function Activity() { const [showLibraryFilters, setShowLibraryFilters] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); + const [filterParams, setFilterParams] = useState([]); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { @@ -38,6 +39,10 @@ function Activity() { setSorting({ column: sort.column, desc: sort.desc }); }; + const onFilterChange = (filter) => { + setFilterParams(filter); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); @@ -87,9 +92,21 @@ function Activity() { const fetchHistory = () => { setIsBusy(true); - const url = `/api/getHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`; + const url = `/api/getHistory`; + if (filterParams) { + console.log(JSON.stringify(filterParams)); + } + axios .get(url, { + params: { + size: itemCount, + page: currentPage, + search: debouncedSearchQuery, + sort: sorting.column, + desc: sorting.desc, + filters: filterParams != undefined ? JSON.stringify(filterParams) : null, + }, headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", @@ -143,7 +160,8 @@ function Activity() { (data.size && data.size !== itemCount) || (data?.search ?? "") !== debouncedSearchQuery.trim() || (data?.sort ?? "") !== sorting.column || - (data?.desc ?? true) !== sorting.desc + (data?.desc ?? true) !== sorting.desc || + JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? []) ) { fetchHistory(); fetchLibraries(); @@ -156,7 +174,7 @@ function Activity() { const intervalId = setInterval(fetchHistory, 60000 * 60); return () => clearInterval(intervalId); - }, [data, config, itemCount, currentPage, debouncedSearchQuery, sorting]); + }, [data, config, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]); if (!data) { return ; @@ -279,6 +297,7 @@ function Activity() { itemCount={itemCount} onPageChange={handlePageChange} onSortChange={onSortChange} + onFilterChange={onFilterChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index 8afbb04..65ed9c8 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -60,8 +60,6 @@ const token = localStorage.getItem("token"); export default function ActivityTable(props) { const twelve_hr = JSON.parse(localStorage.getItem("12hr")); const [data, setData] = React.useState(props.data ?? []); - const uniqueUserNames = [...new Set(data.map((item) => item.UserName))]; - const uniqueClients = [...new Set(data.map((item) => item.Client))]; const pages = props.pageCount || 1; const isBusy = props.isBusy; @@ -72,6 +70,8 @@ export default function ActivityTable(props) { }); const [sorting, setSorting] = React.useState([{ id: "Date", desc: true }]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [modalState, setModalState] = React.useState(false); const [modalData, setModalData] = React.useState(); @@ -152,8 +152,6 @@ export default function ActivityTable(props) { { accessorKey: "UserName", header: i18next.t("USER"), - filterVariant: "select", - filterSelectOptions: uniqueUserNames, Cell: ({ row }) => { row = row.original; return ( @@ -207,8 +205,6 @@ export default function ActivityTable(props) { { accessorKey: "Client", header: i18next.t("ACTIVITY_TABLE.CLIENT"), - filterVariant: "select", - filterSelectOptions: uniqueClients, Cell: ({ row }) => { row = row.original; return ( @@ -246,8 +242,8 @@ export default function ActivityTable(props) { accessorKey: "PlaybackDuration", header: i18next.t("ACTIVITY_TABLE.TOTAL_PLAYBACK"), minSize: 200, - filterFn: (row, id, filterValue) => formatTotalWatchTime(row.getValue(id)).startsWith(filterValue), - + // filterFn: (row, id, filterValue) => formatTotalWatchTime(row.getValue(id)).startsWith(filterValue), + filterVariant: "range", Cell: ({ cell }) => {formatTotalWatchTime(cell.getValue())}, }, { @@ -276,6 +272,35 @@ export default function ActivityTable(props) { }); }; + const handleFilteringChange = (updater) => { + setColumnFilters((old) => { + const newFilterState = typeof updater === "function" ? updater(old) : updater; + + const modifiedFilterState = newFilterState.map((filter) => ({ ...filter })); + + modifiedFilterState.map((filter) => { + filter.field = fieldMap.find((field) => field.header == filter.id)?.accessorKey ?? filter.id; + delete filter.id; + if (Array.isArray(filter.value)) { + filter.min = filter.value[0]; + filter.max = filter.value[1]; + delete filter.value; + } else { + const val = filter.value; + delete filter.value; + filter.value = val; + } + + return filter; + }); + + if (props.onFilterChange) { + props.onFilterChange(modifiedFilterState); + } + return newFilterState; + }); + }; + useEffect(() => { setData(props.data); }, [props.data]); @@ -300,8 +325,10 @@ export default function ActivityTable(props) { enableExpandAll: false, enableExpanding: true, enableDensityToggle: false, - enableFilters: false, + enableFilters: true, + manualFiltering: true, onSortingChange: handleSortingChange, + onColumnFiltersChange: handleFilteringChange, enableTopToolbar: Object.keys(rowSelection).length > 0, manualPagination: true, manualSorting: true, @@ -378,7 +405,7 @@ export default function ActivityTable(props) { }, }, }, - state: { rowSelection, pagination, sorting }, + state: { rowSelection, pagination, sorting, columnFilters }, filterFromLeafRows: true, getSubRows: (row) => { if (Array.isArray(row.results) && row.results.length == 1) { diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 4cbf38a..ebb52f6 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -16,6 +16,7 @@ function ItemActivity(props) { const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); + const [filterParams, setFilterParams] = useState([]); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { @@ -26,6 +27,10 @@ function ItemActivity(props) { setSorting({ column: sort.column, desc: sort.desc }); }; + const onFilterChange = (filter) => { + setFilterParams(filter); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_ACTIVITY_ItemCount", limit); @@ -59,7 +64,7 @@ function ItemActivity(props) { try { setIsBusy(true); const itemData = await axios.post( - `/api/getItemHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, + `/api/getItemHistory`, { itemid: props.itemid, }, @@ -68,6 +73,14 @@ function ItemActivity(props) { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, + params: { + size: itemCount, + page: currentPage, + search: debouncedSearchQuery, + sort: sorting.column, + desc: sorting.desc, + filters: filterParams != undefined ? JSON.stringify(filterParams) : null, + }, } ); setData(itemData.data); @@ -83,14 +96,15 @@ function ItemActivity(props) { (data.size && data.size !== itemCount) || (data?.search ?? "") !== debouncedSearchQuery.trim() || (data?.sort ?? "") !== sorting.column || - (data?.desc ?? true) !== sorting.desc + (data?.desc ?? true) !== sorting.desc || + JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? []) ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery, sorting]); + }, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]); if (!data || !data.results) { return <>; @@ -177,6 +191,7 @@ function ItemActivity(props) { itemCount={itemCount} onPageChange={handlePageChange} onSortChange={onSortChange} + onFilterChange={onFilterChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index 3e5b7d0..0139f24 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -19,6 +19,7 @@ function LibraryActivity(props) { const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); + const [filterParams, setFilterParams] = useState([]); const [isBusy, setIsBusy] = useState(false); const handlePageChange = (newPage) => { @@ -29,6 +30,10 @@ function LibraryActivity(props) { setSorting({ column: sort.column, desc: sort.desc }); }; + const onFilterChange = (filter) => { + setFilterParams(filter); + }; + function setItemLimit(limit) { setItemCount(parseInt(limit)); localStorage.setItem("PREF_LIBRARY_ACTIVITY_ItemCount", limit); @@ -66,7 +71,7 @@ function LibraryActivity(props) { try { setIsBusy(true); const libraryData = await axios.post( - `/api/getLibraryHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, + `/api/getLibraryHistory`, { libraryid: props.LibraryId, }, @@ -75,6 +80,14 @@ function LibraryActivity(props) { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, + params: { + size: itemCount, + page: currentPage, + search: debouncedSearchQuery, + sort: sorting.column, + desc: sorting.desc, + filters: filterParams != undefined ? JSON.stringify(filterParams) : null, + }, } ); setData(libraryData.data); @@ -90,14 +103,15 @@ function LibraryActivity(props) { (data.size && data.size !== itemCount) || (data?.search ?? "") !== debouncedSearchQuery.trim() || (data?.sort ?? "") !== sorting.column || - (data?.desc ?? true) !== sorting.desc + (data?.desc ?? true) !== sorting.desc || + JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? []) ) { fetchData(); } const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); - }, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery, sorting]); + }, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]); if (!data || !data.results) { return <>; @@ -183,6 +197,7 @@ function LibraryActivity(props) { itemCount={itemCount} onPageChange={handlePageChange} onSortChange={onSortChange} + onFilterChange={onFilterChange} pageCount={data.pages} isBusy={isBusy} /> diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index d46dca8..ccdc360 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -23,6 +23,7 @@ function UserActivity(props) { const [config, setConfig] = useState(); const [currentPage, setCurrentPage] = useState(1); const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true }); + const [filterParams, setFilterParams] = useState([]); const [isBusy, setIsBusy] = useState(false); function setItemLimit(limit) { @@ -78,12 +79,16 @@ function UserActivity(props) { setSorting({ column: sort.column, desc: sort.desc }); }; + const onFilterChange = (filter) => { + setFilterParams(filter); + }; + useEffect(() => { const fetchHistory = async () => { try { setIsBusy(true); const itemData = await axios.post( - `/api/getUserHistory?size=${itemCount}&page=${currentPage}&search=${debouncedSearchQuery}&sort=${sorting.column}&desc=${sorting.desc}`, + `/api/getUserHistory`, { userid: props.UserId, }, @@ -92,6 +97,14 @@ function UserActivity(props) { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, + params: { + size: itemCount, + page: currentPage, + search: debouncedSearchQuery, + sort: sorting.column, + desc: sorting.desc, + filters: filterParams != undefined ? JSON.stringify(filterParams) : null, + }, } ); setData(itemData.data); @@ -132,7 +145,8 @@ function UserActivity(props) { (data.size && data.size !== itemCount) || (data?.search ?? "") !== debouncedSearchQuery.trim() || (data?.sort ?? "") !== sorting.column || - (data?.desc ?? true) !== sorting.desc + (data?.desc ?? true) !== sorting.desc || + JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? []) ) { fetchHistory(); } @@ -141,7 +155,7 @@ function UserActivity(props) { const intervalId = setInterval(fetchHistory, 60000 * 5); return () => clearInterval(intervalId); - }, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery, sorting]); + }, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]); if (!data || !data.results) { return <>; @@ -248,6 +262,7 @@ function UserActivity(props) { itemCount={itemCount} onPageChange={handlePageChange} onSortChange={onSortChange} + onFilterChange={onFilterChange} pageCount={data.pages} isBusy={isBusy} /> From 0d1d0a597be704d7eb6682c32cc0576dd8be50d9 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sun, 26 Jan 2025 14:19:39 +0200 Subject: [PATCH 35/40] fix filter for username, serach on activity username and not on user table --- backend/routes/api.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/backend/routes/api.js b/backend/routes/api.js index a302c12..26eb686 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -40,7 +40,7 @@ const unGroupedSortMap = [ ]; const filterFields = [ - { field: "UserName", column: `LOWER(u."Name")` }, + { field: "UserName", column: `LOWER(a."UserName")` }, { field: "RemoteEndPoint", column: `LOWER(a."RemoteEndPoint")` }, { field: "NowPlayingItemName", @@ -1273,12 +1273,6 @@ router.get("/getHistory", async (req, res) => { { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, - { - type: "left", - table: "jf_users", - alias: "u", - conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], - }, ], order_by: sortField, @@ -1439,12 +1433,6 @@ router.post("/getLibraryHistory", async (req, res) => { { first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" }, ], }, - { - type: "left", - table: "jf_users", - alias: "u", - conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], - }, ], order_by: sortField, @@ -1569,14 +1557,6 @@ router.post("/getItemHistory", async (req, res) => { ], table: "jf_playback_activity_with_metadata", alias: "a", - joins: [ - { - type: "left", - table: "jf_users", - alias: "u", - conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], - }, - ], where: [ [ { column: "a.EpisodeId", operator: "=", value: itemid }, @@ -1702,14 +1682,6 @@ router.post("/getUserHistory", async (req, res) => { ], table: "jf_playback_activity_with_metadata", alias: "a", - joins: [ - { - type: "left", - table: "jf_users", - alias: "u", - conditions: [{ first: "a.UserId", operator: "=", second: "u.Id" }], - }, - ], where: [[{ column: "a.UserId", operator: "=", value: userid }]], order_by: sortField, sort_order: desc ? "desc" : "asc", From f9f061057a0b4b9c40a691c6cdf2b4dabd5354d1 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Mon, 27 Jan 2025 19:21:40 +0200 Subject: [PATCH 36/40] added fix for sql injection in custom query constructor for history --- backend/classes/db-helper.js | 14 ++++---- backend/routes/api.js | 69 ++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js index b636d96..b5f9976 100644 --- a/backend/classes/db-helper.js +++ b/backend/classes/db-helper.js @@ -1,4 +1,5 @@ const { pool } = require("../db.js"); +const pgp = require("pg-promise")(); function wrapField(field) { if (field === "*") { @@ -43,9 +44,9 @@ function buildWhereClause(conditions) { const { column, field, operator, value, type } = condition; const conjunction = index === 0 ? "" : type ? type.toUpperCase() : "AND"; if (operator == "LIKE") { - return `${conjunction} ${column ? wrapField(column) : field} ${operator} '%${value}%'`; + return `${conjunction} ${column ? wrapField(column) : field} ${operator} ${value}`; } - return `${conjunction} ${column ? wrapField(column) : field} ${operator} '${value}'`; + return `${conjunction} ${column ? wrapField(column) : field} ${operator} ${value}`; } return ""; }) @@ -67,7 +68,7 @@ function buildCTE(cte) { .map((condition, index) => { const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND"; return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${ - condition.second ? wrapField(condition.second) : `'${condition.value}'` + condition.second ? wrapField(condition.second) : `${condition.value}` }`; }) .join(" "); @@ -102,6 +103,7 @@ async function query({ alias, joins = [], where = [], + values = [], order_by = "Id", sort_order = "desc", pageNumber = 1, @@ -119,7 +121,7 @@ async function query({ .map((condition, index) => { const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND"; return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${ - condition.second ? wrapField(condition.second) : `'${condition.value}'` + condition.second ? wrapField(condition.second) : `${condition.value}` }`; }) .join(" "); @@ -140,10 +142,10 @@ async function query({ query += ` LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}`; // Execute the query - const result = await client.query(query); + const result = await client.query(query, values); // Count total rows - const countResult = await client.query(countQuery); + const countResult = await client.query(countQuery, values); const totalRows = parseInt(countResult.rows[0].count, 10); // Return the structured response diff --git a/backend/routes/api.js b/backend/routes/api.js index 26eb686..f6d8adb 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -152,9 +152,11 @@ function buildFilterList(query, filtersArray) { query.where.push({ column: column, operator: ">=", - value: filter.min, + value: `$${query.values.length + 1}`, }); + query.values.push(filter.min); + if (applyToCTE) { if (query.cte) { if (!query.cte.where) { @@ -163,8 +165,10 @@ function buildFilterList(query, filtersArray) { query.cte.where.push({ column: column, operator: ">=", - value: filter.min, + value: `$${query.values.length + 1}`, }); + + query.values.push(filter.min); } } } @@ -173,9 +177,11 @@ function buildFilterList(query, filtersArray) { query.where.push({ column: column, operator: "<=", - value: filter.max, + value: `$${query.values.length + 1}`, }); + query.values.push(filter.max); + if (applyToCTE) { if (query.cte) { if (!query.cte.where) { @@ -184,8 +190,10 @@ function buildFilterList(query, filtersArray) { query.cte.where.push({ column: column, operator: "<=", - value: filter.max, + value: `$${query.values.length + 1}`, }); + + query.values.push(filter.max); } } } @@ -193,8 +201,11 @@ function buildFilterList(query, filtersArray) { if (filter.value) { const whereClause = { operator: "LIKE", - value: filter.value.toLowerCase(), + value: `$${query.values.length + 1}`, }; + + query.values.push(`%${filter.value.toLowerCase()}%`); + if (isColumn) { whereClause.column = column; } else { @@ -207,7 +218,10 @@ function buildFilterList(query, filtersArray) { if (!query.cte.where) { query.cte.where = []; } + whereClause.value = `$${query.values.length + 1}`; query.cte.where.push(whereClause); + + query.values.push(`%${filter.value.toLowerCase()}%`); } } } @@ -1227,6 +1241,8 @@ router.get("/getHistory", async (req, res) => { const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const values = []; + try { const cte = { cteAlias: "activity_results", @@ -1291,11 +1307,15 @@ router.get("/getHistory", async (req, res) => { END )`, operator: "LIKE", - value: `${search.toLowerCase()}`, + value: `$${values.length + 1}`, }, ]; + + values.push(`%${search.toLowerCase()}%`); } + query.values = values; + buildFilterList(query, filtersArray); const result = await dbHelper.query(query); @@ -1378,6 +1398,7 @@ router.post("/getLibraryHistory", async (req, res) => { } const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const values = []; const cte = { cteAlias: "activity_results", @@ -1420,7 +1441,7 @@ router.post("/getLibraryHistory", async (req, res) => { alias: "i", conditions: [ { first: "i.Id", operator: "=", second: "a.NowPlayingItemId" }, - { first: "i.ParentId", operator: "=", value: libraryid }, + { first: "i.ParentId", operator: "=", value: `$${values.length + 1}` }, ], }, { @@ -1441,6 +1462,8 @@ router.post("/getLibraryHistory", async (req, res) => { pageSize: size, }; + values.push(libraryid); + if (search && search.length > 0) { query.where = [ { @@ -1451,11 +1474,15 @@ router.post("/getLibraryHistory", async (req, res) => { END )`, operator: "LIKE", - value: `${search.toLowerCase()}`, + value: `$${values.length + 1}`, }, ]; + + values.push(`%${search.toLowerCase()}%`); } + query.values = values; + buildFilterList(query, filtersArray); const result = await dbHelper.query(query); @@ -1541,7 +1568,7 @@ router.post("/getItemHistory", async (req, res) => { } const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; - + const values = []; const query = { select: [ "a.*", @@ -1559,9 +1586,9 @@ router.post("/getItemHistory", async (req, res) => { alias: "a", where: [ [ - { column: "a.EpisodeId", operator: "=", value: itemid }, - { column: "a.SeasonId", operator: "=", value: itemid, type: "or" }, - { column: "a.NowPlayingItemId", operator: "=", value: itemid, type: "or" }, + { column: "a.EpisodeId", operator: "=", value: `$${values.length + 1}` }, + { column: "a.SeasonId", operator: "=", value: `$${values.length + 2}`, type: "or" }, + { column: "a.NowPlayingItemId", operator: "=", value: `$${values.length + 3}`, type: "or" }, ], ], order_by: sortField, @@ -1570,6 +1597,10 @@ router.post("/getItemHistory", async (req, res) => { pageSize: size, }; + values.push(itemid); + values.push(itemid); + values.push(itemid); + if (search && search.length > 0) { query.where = [ { @@ -1580,11 +1611,13 @@ router.post("/getItemHistory", async (req, res) => { END )`, operator: "LIKE", - value: `${search.toLowerCase()}`, + value: `$${values.length + 1}`, }, ]; + values.push(`%${search.toLowerCase()}%`); } + query.values = values; buildFilterList(query, filtersArray); const result = await dbHelper.query(query); @@ -1667,6 +1700,7 @@ router.post("/getUserHistory", async (req, res) => { const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted"; + const values = []; const query = { select: [ "a.*", @@ -1682,13 +1716,15 @@ router.post("/getUserHistory", async (req, res) => { ], table: "jf_playback_activity_with_metadata", alias: "a", - where: [[{ column: "a.UserId", operator: "=", value: userid }]], + where: [[{ column: "a.UserId", operator: "=", value: `$${values.length + 1}` }]], order_by: sortField, sort_order: desc ? "desc" : "asc", pageNumber: page, pageSize: size, }; + values.push(userid); + if (search && search.length > 0) { query.where = [ { @@ -1699,11 +1735,14 @@ router.post("/getUserHistory", async (req, res) => { END )`, operator: "LIKE", - value: `${search.toLowerCase()}`, + value: `$${values.length + 1}`, }, ]; + values.push(`%${search.toLowerCase()}%`); } + query.values = values; + buildFilterList(query, filtersArray); const result = await dbHelper.query(query); From e6cf5aa433cb1c521a47161403c5fee9cd638946 Mon Sep 17 00:00:00 2001 From: Nishant Jain Date: Mon, 27 Jan 2025 22:15:54 -0800 Subject: [PATCH 37/40] sample fix --- backend/routes/backup.js | 136 ++++++++++++++++++++++--------------- backend/utils/sanitizer.js | 5 ++ 2 files changed, 86 insertions(+), 55 deletions(-) create mode 100644 backend/utils/sanitizer.js diff --git a/backend/routes/backup.js b/backend/routes/backup.js index d24ae44..e786cd4 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -1,18 +1,18 @@ -const express = require("express"); -const { Pool } = require("pg"); -const fs = require("fs"); -const path = require("path"); -const { randomUUID } = require("crypto"); -const multer = require("multer"); +const express = require('express'); +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); +const { randomUUID } = require('crypto'); +const multer = require('multer'); -const Logging = require("../classes/logging"); -const backup = require("../classes/backup"); -const triggertype = require("../logging/triggertype"); -const taskstate = require("../logging/taskstate"); -const taskName = require("../logging/taskName"); +const Logging = require('../classes/logging'); +const backup = require('../classes/backup'); +const triggertype = require('../logging/triggertype'); +const taskstate = require('../logging/taskstate'); +const taskName = require('../logging/taskName'); -const { sendUpdate } = require("../ws"); -const db = require("../db"); +const { sendUpdate } = require('../ws'); +const db = require('../db'); const router = express.Router(); @@ -21,14 +21,14 @@ const postgresUser = process.env.POSTGRES_USER; 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 backupfolder = "backup-data"; +const postgresDatabase = process.env.POSTGRES_DB || 'jfstat'; +const backupfolder = 'backup-data'; // Restore function function readFile(path) { return new Promise((resolve, reject) => { - fs.readFile(path, "utf8", (err, data) => { + fs.readFile(path, 'utf8', (err, data) => { if (err) { reject(err); return; @@ -40,8 +40,11 @@ function readFile(path) { } async function restore(file, refLog) { - refLog.logData.push({ color: "lawngreen", Message: "Starting Restore" }); - refLog.logData.push({ color: "yellow", Message: "Restoring from Backup: " + file }); + refLog.logData.push({ color: 'lawngreen', Message: 'Starting Restore' }); + refLog.logData.push({ + color: 'yellow', + Message: 'Restoring from Backup: ' + file, + }); const pool = new Pool({ user: postgresUser, password: postgresPassword, @@ -58,49 +61,57 @@ async function restore(file, refLog) { // Use await to wait for the Promise to resolve jsonData = await readFile(backupPath); } catch (err) { - refLog.logData.push({ color: "red", key: tableName, Message: `Failed to read backup file` }); + refLog.logData.push({ + color: 'red', + key: tableName, + Message: `Failed to read backup file`, + }); Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED); console.error(err); } // console.log(jsonData); if (!jsonData) { - console.log("No Data"); + console.log('No Data'); return; } for (let table of jsonData) { const data = Object.values(table)[0]; const tableName = Object.keys(table)[0]; - refLog.logData.push({ color: "dodgerblue", key: tableName, Message: `Restoring ${tableName}` }); + refLog.logData.push({ + color: 'dodgerblue', + key: tableName, + Message: `Restoring ${tableName}`, + }); for (let index in data) { - const keysWithQuotes = Object.keys(data[index]).map((key) => `"${key}"`); - const keyString = keysWithQuotes.join(", "); + const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`); + const keyString = keysWithQuotes.join(', '); - const valuesWithQuotes = Object.values(data[index]).map((col) => { + const valuesWithQuotes = Object.values(data[index]).map(col => { if (col === null) { - return "NULL"; - } else if (typeof col === "string") { + return 'NULL'; + } else if (typeof col === 'string') { return `'${col.replace(/'/g, "''")}'`; - } else if (typeof col === "object") { + } else if (typeof col === 'object') { return `'${JSON.stringify(col).replace(/'/g, "''")}'`; } else { return `'${col}'`; } }); - const valueString = valuesWithQuotes.join(", "); + const valueString = valuesWithQuotes.join(', '); const query = `INSERT INTO ${tableName} (${keyString}) VALUES(${valueString}) ON CONFLICT DO NOTHING`; const { rows } = await pool.query(query); } } await pool.end(); - refLog.logData.push({ color: "lawngreen", Message: "Restore Complete" }); + refLog.logData.push({ color: 'lawngreen', Message: 'Restore Complete' }); } // Route handler for backup endpoint -router.get("/beginBackup", async (req, res) => { +router.get('/beginBackup', async (req, res) => { try { const last_execution = await db .query( @@ -110,11 +121,11 @@ router.get("/beginBackup", async (req, res) => { ORDER BY "TimeRun" DESC LIMIT 1` ) - .then((res) => res.rows); + .then(res => res.rows); if (last_execution.length !== 0) { if (last_execution[0].Result === taskstate.RUNNING) { - sendUpdate("TaskError", "Error: Backup is already running"); + sendUpdate('TaskError', 'Error: Backup is already running'); res.send(); return; } @@ -125,43 +136,48 @@ router.get("/beginBackup", async (req, res) => { await Logging.insertLog(uuid, triggertype.Manual, taskName.backup); await backup(refLog); Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS); - res.send("Backup completed successfully"); - sendUpdate("TaskComplete", { message: triggertype + " Backup Completed" }); + res.send('Backup completed successfully'); + sendUpdate('TaskComplete', { message: triggertype + ' Backup Completed' }); } catch (error) { console.error(error); - res.status(500).send("Backup failed"); + res.status(500).send('Backup failed'); } }); -router.get("/restore/:filename", async (req, res) => { +router.get('/restore/:filename', async (req, res) => { try { const uuid = randomUUID(); let refLog = { logData: [], uuid: uuid }; Logging.insertLog(uuid, triggertype.Manual, taskName.restore); - const filePath = path.join(__dirname, "..", backupfolder, req.params.filename); + const filePath = path.join( + __dirname, + '..', + backupfolder, + req.params.filename + ); await restore(filePath, refLog); Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS); - res.send("Restore completed successfully"); - sendUpdate("TaskComplete", { message: "Restore completed successfully" }); + res.send('Restore completed successfully'); + sendUpdate('TaskComplete', { message: 'Restore completed successfully' }); } catch (error) { console.error(error); - res.status(500).send("Restore failed"); + res.status(500).send('Restore failed'); } }); -router.get("/files", (req, res) => { +router.get('/files', (req, res) => { try { - const directoryPath = path.join(__dirname, "..", backupfolder); + const directoryPath = path.join(__dirname, '..', backupfolder); fs.readdir(directoryPath, (err, files) => { if (err) { - res.status(500).send("Unable to read directory"); + res.status(500).send('Unable to read directory'); } else { const fileData = files - .filter((file) => file.endsWith(".json")) - .map((file) => { + .filter(file => file.endsWith('.json')) + .map(file => { const filePath = path.join(directoryPath, file); const stats = fs.statSync(filePath); return { @@ -179,20 +195,30 @@ router.get("/files", (req, res) => { }); //download backup file -router.get("/files/:filename", (req, res) => { - const filePath = path.join(__dirname, "..", backupfolder, req.params.filename); +router.get('/files/:filename', (req, res) => { + const filePath = path.join( + __dirname, + '..', + backupfolder, + req.params.filename + ); res.download(filePath); }); //delete backup -router.delete("/files/:filename", (req, res) => { +router.delete('/files/:filename', (req, res) => { try { - const filePath = path.join(__dirname, "..", backupfolder, req.params.filename); + const filePath = path.join( + __dirname, + '..', + backupfolder, + req.params.filename + ); - fs.unlink(filePath, (err) => { + fs.unlink(filePath, err => { if (err) { console.error(err); - res.status(500).send("An error occurred while deleting the file."); + res.status(500).send('An error occurred while deleting the file.'); return; } @@ -200,13 +226,13 @@ router.delete("/files/:filename", (req, res) => { res.status(200).send(`${filePath} has been deleted.`); }); } catch (error) { - res.status(500).send("An error occurred while deleting the file."); + res.status(500).send('An error occurred while deleting the file.'); } }); const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, path.join(__dirname, "..", backupfolder)); // Set the destination folder for uploaded files + cb(null, path.join(__dirname, '..', backupfolder)); // Set the destination folder for uploaded files }, filename: function (req, file, cb) { cb(null, file.originalname); // Set the file name @@ -215,7 +241,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage }); -router.post("/upload", upload.single("file"), (req, res) => { +router.post('/upload', upload.single('file'), (req, res) => { // Handle the uploaded file here res.json({ fileName: req.file.originalname, @@ -225,7 +251,7 @@ router.post("/upload", upload.single("file"), (req, res) => { // Handle other routes router.use((req, res) => { - res.status(404).send({ error: "Not Found" }); + res.status(404).send({ error: 'Not Found' }); }); module.exports = router; diff --git a/backend/utils/sanitizer.js b/backend/utils/sanitizer.js new file mode 100644 index 0000000..9ad46bd --- /dev/null +++ b/backend/utils/sanitizer.js @@ -0,0 +1,5 @@ +const sanitizer = require('sanitize-filename'); + +export const sanitizeFilename = filename => { + return sanitizer(filename); +}; From 740365723bec8a75f2ac012e133d60d5d28545c3 Mon Sep 17 00:00:00 2001 From: Nishant Jain Date: Mon, 27 Jan 2025 22:22:19 -0800 Subject: [PATCH 38/40] add the function call lol --- backend/routes/backup.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/routes/backup.js b/backend/routes/backup.js index e786cd4..9fb99ea 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -10,6 +10,7 @@ const backup = require('../classes/backup'); const triggertype = require('../logging/triggertype'); const taskstate = require('../logging/taskstate'); const taskName = require('../logging/taskName'); +const sanitizeFilename = require('../utils/sanitizer'); const { sendUpdate } = require('../ws'); const db = require('../db'); @@ -150,11 +151,13 @@ router.get('/restore/:filename', async (req, res) => { let refLog = { logData: [], uuid: uuid }; Logging.insertLog(uuid, triggertype.Manual, taskName.restore); + const filename = sanitizeFilename(req.params.filename); const filePath = path.join( + process.cwd(), __dirname, '..', backupfolder, - req.params.filename + filename ); await restore(filePath, refLog); @@ -196,11 +199,13 @@ router.get('/files', (req, res) => { //download backup file router.get('/files/:filename', (req, res) => { + const filename = sanitizeFilename(req.params.filename); const filePath = path.join( + process.cwd(), __dirname, '..', backupfolder, - req.params.filename + filename ); res.download(filePath); }); @@ -208,11 +213,13 @@ router.get('/files/:filename', (req, res) => { //delete backup router.delete('/files/:filename', (req, res) => { try { + const filename = sanitizeFilename(req.params.filename); const filePath = path.join( + process.cwd(), __dirname, '..', backupfolder, - req.params.filename + filename ); fs.unlink(filePath, err => { From 801653100aa3c555d00ab40ef340c97b6f09ab11 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 1 Feb 2025 18:17:20 +0200 Subject: [PATCH 39/40] sanitizer uitl import fix --- backend/utils/sanitizer.js | 6 ++++-- package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/utils/sanitizer.js b/backend/utils/sanitizer.js index 9ad46bd..169beaf 100644 --- a/backend/utils/sanitizer.js +++ b/backend/utils/sanitizer.js @@ -1,5 +1,7 @@ -const sanitizer = require('sanitize-filename'); +const sanitizer = require("sanitize-filename"); -export const sanitizeFilename = filename => { +const sanitizeFilename = (filename) => { return sanitizer(filename); }; + +module.export = { sanitizeFilename }; diff --git a/package-lock.json b/package-lock.json index a6230ff..cec26f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "react-toastify": "^9.1.3", "recharts": "^2.5.0", "remixicon-react": "^1.0.0", + "sanitize-filename": "^1.6.3", "semver": "^7.5.3", "sequelize": "^6.29.0", "socket.io": "^4.7.2", @@ -19175,6 +19176,14 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/sanitize.css": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", @@ -21056,6 +21065,14 @@ "tree-kill": "cli.js" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -21440,6 +21457,11 @@ "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==" }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index b279a71..0d10185 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-toastify": "^9.1.3", "recharts": "^2.5.0", "remixicon-react": "^1.0.0", + "sanitize-filename": "^1.6.3", "semver": "^7.5.3", "sequelize": "^6.29.0", "socket.io": "^4.7.2", From d2aed2e0cfb0394f62ae5d6f21d74610d1fc7a61 Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sun, 2 Feb 2025 17:04:06 +0200 Subject: [PATCH 40/40] add migration to fix and enhance fs_get_user_activity function --- .../091_fix_function_fs_get_user_activity.js | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 backend/migrations/091_fix_function_fs_get_user_activity.js diff --git a/backend/migrations/091_fix_function_fs_get_user_activity.js b/backend/migrations/091_fix_function_fs_get_user_activity.js new file mode 100644 index 0000000..23d6f12 --- /dev/null +++ b/backend/migrations/091_fix_function_fs_get_user_activity.js @@ -0,0 +1,177 @@ +exports.up = function (knex) { + return knex.schema.raw(` + DROP FUNCTION IF EXISTS fs_get_user_activity; + + CREATE OR REPLACE FUNCTION fs_get_user_activity( + user_id TEXT, + library_ids TEXT[] + ) + RETURNS TABLE ( + "UserName" TEXT, + "Title" TEXT, + "EpisodeCount" BIGINT, + "FirstActivityDate" TIMESTAMPTZ, + "LastActivityDate" TIMESTAMPTZ, + "TotalPlaybackDuration" BIGINT, + "SeasonName" TEXT, + "MediaType" TEXT, + "NowPlayingItemId" TEXT + ) AS $$ + BEGIN + RETURN QUERY + WITH DateDifferences AS ( + SELECT + jp."UserName" AS "UserNameCol", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" + ELSE jp."NowPlayingItemName" + END AS "TitleCol", + jp."EpisodeId" AS "EpisodeIdCol", + jp."ActivityDateInserted" AS "ActivityDateInsertedCol", + jp."PlaybackDuration" AS "PlaybackDurationCol", + ls."Name" AS "SeasonNameCol", + jl."CollectionType" AS "MediaTypeCol", + jp."NowPlayingItemId" AS "NowPlayingItemIdCol", + LAG(jp."ActivityDateInserted") OVER (PARTITION BY jp."UserName", CASE WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" ELSE jp."NowPlayingItemName" END ORDER BY jp."ActivityDateInserted") AS prev_date + FROM + public.jf_playback_activity AS jp + JOIN + public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id" + JOIN + public.jf_libraries AS jl ON jli."ParentId" = jl."Id" + LEFT JOIN + public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id" + WHERE + jp."UserId" = user_id + AND jl."Id" = ANY(library_ids) + ), + GroupedEntries AS ( + SELECT + "UserNameCol", + "TitleCol", + "EpisodeIdCol", + "ActivityDateInsertedCol", + "PlaybackDurationCol", + "SeasonNameCol", + "MediaTypeCol", + "NowPlayingItemIdCol", + prev_date, + CASE + WHEN prev_date IS NULL OR "ActivityDateInsertedCol" > prev_date + INTERVAL '1 month' THEN 1 -- Pick whatever interval you want here, I'm biased as I don't monitor music / never intended this feature to be used for music + ELSE 0 + END AS new_group + FROM + DateDifferences + ), + FinalGroups AS ( + SELECT + "UserNameCol", + "TitleCol", + "EpisodeIdCol", + "ActivityDateInsertedCol", + "PlaybackDurationCol", + "SeasonNameCol", + "MediaTypeCol", + "NowPlayingItemIdCol", + SUM(new_group) OVER (PARTITION BY "UserNameCol", "TitleCol" ORDER BY "ActivityDateInsertedCol") AS grp + FROM + GroupedEntries + ) + SELECT + "UserNameCol" AS "UserName", + "TitleCol" AS "Title", + COUNT(DISTINCT "EpisodeIdCol") AS "EpisodeCount", + MIN("ActivityDateInsertedCol") AS "FirstActivityDate", + MAX("ActivityDateInsertedCol") AS "LastActivityDate", + SUM("PlaybackDurationCol")::bigint AS "TotalPlaybackDuration", + "SeasonNameCol" AS "SeasonName", + MAX("MediaTypeCol") AS "MediaType", + "NowPlayingItemIdCol" AS "NowPlayingItemId" + FROM + FinalGroups + GROUP BY + "UserNameCol", + "TitleCol", + "SeasonNameCol", + "NowPlayingItemIdCol", + grp + HAVING + NOT (MAX("MediaTypeCol") = 'Shows' AND "SeasonNameCol" IS NULL) + AND SUM("PlaybackDurationCol") >= 20 + ORDER BY + MAX("ActivityDateInsertedCol") DESC; + END; + $$ LANGUAGE plpgsql; + +`); +}; + +exports.down = function (knex) { + return knex.schema.raw(` + + DROP FUNCTION IF EXISTS fs_get_user_activity; + create or replace + function fs_get_user_activity( + user_id text, + library_ids text[] + ) + returns table ( + "UserName" text, + "Title" text, + "EpisodeCount" bigint, + "FirstActivityDate" timestamptz, + "LastActivityDate" timestamptz, + "TotalPlaybackDuration" bigint, + "SeasonName" text, + "MediaType" text, + "NowPlayingItemId" text + ) as $$ + begin + return QUERY + select + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end as "Title", + COUNT(distinct jp."EpisodeId") as "EpisodeCount", + MIN(jp."ActivityDateInserted") as "FirstActivityDate", + MAX(jp."ActivityDateInserted") as "LastActivityDate", + SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration", + ls."Name" as "SeasonName", + MAX(jl."CollectionType") as "MediaType", + jp."NowPlayingItemId" + from + public.jf_playback_activity as jp + join + public.jf_library_items as jli on + jp."NowPlayingItemId" = jli."Id" + join + public.jf_libraries as jl on + jli."ParentId" = jl."Id" + left join + public.jf_library_seasons as ls on + jp."SeasonId" = ls."Id" + where + jp."UserId" = user_id + and jl."Id" = any(library_ids) + group by + jp."UserName", + case + when jp."SeriesName" is not null then jp."SeriesName" + else jp."NowPlayingItemName" + end, + jp."SeriesName", + jp."SeasonId", + ls."Name", + jp."NowPlayingItemId" + having + not (MAX(jl."Name") = 'Shows' + and ls."Name" is null) + and SUM(jp."PlaybackDuration") >= 30 + order by + MAX(jp."ActivityDateInserted") desc; + end; + $$ language plpgsql; + `); +};