From 76363ea0bab39bc23f2cee10d2571dadd1a14cdc Mon Sep 17 00:00:00 2001 From: CyferShepard Date: Sat, 4 Jan 2025 03:50:39 +0200 Subject: [PATCH] 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} />