From 550e1d3f7c68ad90e302191356a81a4816f6394b Mon Sep 17 00:00:00 2001 From: Thegan Govender Date: Sun, 18 Jun 2023 21:02:18 +0200 Subject: [PATCH] v1.0.4.10 Beta --- .gitignore | 2 + README.md | 10 +- backend/api.js | 484 +++++++++- backend/auth.js | 31 +- backend/backup.js | 156 +++- backend/db.js | 45 +- backend/logging.js | 37 + .../029_jf_all_user_activity_view.js | 66 ++ backend/migrations/030_jf_logging_table.js | 29 + .../migrations/031_jd_remove_orphaned_data.js | 43 + .../032_app_config_table_add_auth_flag.js | 23 + .../033_js_library_stats_overview_view.js | 123 +++ ...034_jf_libraries_table_add_stat_columns.js | 29 + .../035_ju_update_library_stats_data.js | 37 + .../036_js_library_stats_overview_view.js | 126 +++ ...f_library_items_with_playcount_playtime.js | 42 + ...ayback_activity_add_stream_data_columns.js | 29 + ...tivity_watchdog_add_stream_data_columns.js | 31 + ..._app_config_add_general_settings_column.js | 23 + backend/models/bulk_insert_update_handler.js | 17 + backend/models/jf_activity_watchdog.js | 10 + backend/models/jf_libraries.js | 2 +- backend/models/jf_library_items.js | 2 +- backend/models/jf_library_seasons.js | 2 +- backend/models/jf_logging.js | 26 + backend/models/jf_playback_activity.js | 11 +- backend/models/jf_users.js | 2 +- backend/proxy.js | 149 +++ backend/server.js | 18 +- backend/stats.js | 106 +++ backend/sync.js | 850 ++++++++++++------ .../{watchdog => tasks}/ActivityMonitor.js | 16 +- backend/tasks/BackupTask.js | 61 ++ backend/tasks/SyncTask.js | 35 + backend/version-control.js | 43 + package.json | 11 +- src/App.css | 89 +- src/App.js | 44 +- src/classes/jellyfin-api.js | 123 --- src/index.css | 8 + src/lib/config.js | 6 +- src/lib/devices.js | 2 +- src/lib/navdata.js | 18 +- src/models/libraryItem.js | 7 - src/pages/about.js | 91 ++ .../components/activity/activity-table.js | 470 +++++++--- src/pages/components/activity/stream_info.js | 176 ++++ src/pages/components/general/ErrorBoundary.js | 28 + .../components/general/last-watched-card.js | 9 +- src/pages/components/general/navbar.js | 69 +- src/pages/components/general/version-card.js | 71 ++ src/pages/components/item-info.js | 170 +++- .../components/item-info/item-activity.js | 64 ++ .../components/item-info/item-details.js | 4 +- .../components/item-info/item-not-found.js | 55 ++ src/pages/components/item-info/more-items.js | 2 +- .../item-info/more-items/more-items-card.js | 17 +- src/pages/components/library-info.js | 88 +- .../RecentlyAdded/recently-added-card.js | 10 +- .../components/library/library-activity.js | 65 ++ src/pages/components/library/library-card.js | 99 +- src/pages/components/library/library-items.js | 163 ++++ .../components/library/recently-added.js | 52 +- src/pages/components/libraryOverview.js | 13 +- .../libraryStatCard/library-stat-component.js | 16 +- src/pages/components/sessions/session-card.js | 165 ++-- src/pages/components/sessions/sessions.js | 64 +- src/pages/components/settings/Tasks.js | 107 +++ .../components/settings/TerminalComponent.js | 25 +- src/pages/components/settings/backupfiles.js | 229 +++-- src/pages/components/settings/logs.js | 215 +++++ src/pages/components/settings/security.js | 207 +++++ .../components/settings/settingsConfig.js | 102 ++- .../components/statCards/ItemStatComponent.js | 41 +- .../components/statCards/most_active_users.js | 19 +- .../components/statCards/mv_libraries.js | 9 +- src/pages/components/user-info.js | 98 +- .../components/user-info/user-activity.js | 62 ++ src/pages/css/about.css | 20 + src/pages/css/activity/activity-table.css | 183 ++-- src/pages/css/activity/stream-info.css | 10 + src/pages/css/error.css | 21 +- src/pages/css/globalstats.css | 6 +- src/pages/css/home.css | 1 + src/pages/css/items/item-details.css | 30 +- src/pages/css/lastplayed.css | 39 +- src/pages/css/library/libraries.css | 10 +- src/pages/css/library/library-card.css | 19 +- src/pages/css/library/media-items.css | 48 + src/pages/css/libraryOverview.css | 11 +- src/pages/css/loading.css | 24 +- src/pages/css/navbar.css | 84 +- src/pages/css/radius_breakpoint_css.css | 98 ++ src/pages/css/recent.css | 9 - src/pages/css/sessions.css | 76 +- src/pages/css/settings/backups.css | 16 +- src/pages/css/settings/settings.css | 67 +- src/pages/css/settings/version.css | 46 + src/pages/css/setup.css | 76 +- src/pages/css/statCard.css | 35 +- src/pages/css/stats.css | 5 +- src/pages/css/users/user-details.css | 8 +- src/pages/css/users/users.css | 175 ---- src/pages/css/variables.module.css | 8 + src/pages/css/websocket/websocket.css | 8 +- src/pages/css/width_breakpoint_css.css | 142 +++ src/pages/data-debugger.js | 200 +++++ src/pages/home.js | 13 +- src/pages/images/icon-b-512.png | Bin 0 -> 17420 bytes src/pages/images/icon-b-512.svg | 6 + src/pages/images/icon-w-512.png | Bin 0 -> 16860 bytes src/pages/images/icon-w-512.svg | 13 + src/pages/libraries.js | 15 +- src/pages/login.js | 106 ++- src/pages/settings.js | 39 +- src/pages/setup.js | 105 +-- src/pages/signup.js | 78 +- src/pages/testing.js | 4 +- src/pages/users.js | 375 ++++++-- src/setupProxy.js | 41 +- 120 files changed, 6630 insertions(+), 1739 deletions(-) create mode 100644 backend/logging.js create mode 100644 backend/migrations/029_jf_all_user_activity_view.js create mode 100644 backend/migrations/030_jf_logging_table.js create mode 100644 backend/migrations/031_jd_remove_orphaned_data.js create mode 100644 backend/migrations/032_app_config_table_add_auth_flag.js create mode 100644 backend/migrations/033_js_library_stats_overview_view.js create mode 100644 backend/migrations/034_jf_libraries_table_add_stat_columns.js create mode 100644 backend/migrations/035_ju_update_library_stats_data.js create mode 100644 backend/migrations/036_js_library_stats_overview_view.js create mode 100644 backend/migrations/037_jf_library_items_with_playcount_playtime.js create mode 100644 backend/migrations/038_jf_playback_activity_add_stream_data_columns.js create mode 100644 backend/migrations/039_jf_activity_watchdog_add_stream_data_columns.js create mode 100644 backend/migrations/040_app_config_add_general_settings_column.js create mode 100644 backend/models/bulk_insert_update_handler.js create mode 100644 backend/models/jf_logging.js create mode 100644 backend/proxy.js rename backend/{watchdog => tasks}/ActivityMonitor.js (94%) create mode 100644 backend/tasks/BackupTask.js create mode 100644 backend/tasks/SyncTask.js create mode 100644 backend/version-control.js delete mode 100644 src/classes/jellyfin-api.js delete mode 100644 src/models/libraryItem.js create mode 100644 src/pages/about.js create mode 100644 src/pages/components/activity/stream_info.js create mode 100644 src/pages/components/general/ErrorBoundary.js create mode 100644 src/pages/components/general/version-card.js create mode 100644 src/pages/components/item-info/item-activity.js create mode 100644 src/pages/components/item-info/item-not-found.js create mode 100644 src/pages/components/library/library-activity.js create mode 100644 src/pages/components/library/library-items.js create mode 100644 src/pages/components/settings/Tasks.js create mode 100644 src/pages/components/settings/logs.js create mode 100644 src/pages/components/settings/security.js create mode 100644 src/pages/components/user-info/user-activity.js create mode 100644 src/pages/css/about.css create mode 100644 src/pages/css/activity/stream-info.css create mode 100644 src/pages/css/library/media-items.css create mode 100644 src/pages/css/radius_breakpoint_css.css create mode 100644 src/pages/css/settings/version.css create mode 100644 src/pages/css/variables.module.css create mode 100644 src/pages/css/width_breakpoint_css.css create mode 100644 src/pages/data-debugger.js create mode 100644 src/pages/images/icon-b-512.png create mode 100644 src/pages/images/icon-b-512.svg create mode 100644 src/pages/images/icon-w-512.png create mode 100644 src/pages/images/icon-w-512.svg diff --git a/.gitignore b/.gitignore index 4d29575..58d4d79 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/backend/backup-data +.vscode # production /build diff --git a/README.md b/README.md index bfd1785..cf3d9ff 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,14 @@ - Jellyfin Statistics Plugin Integration - More to come -## Getting Started +## Getting Started with Development +- Clone the project from git +- set your env variables before strating the server (Variable names as per the docker compose file). +- Run `npm init` to install necessary packages +- Run `npm run start-server` to only run the backend nodejs server +- Run `npm run start` to only run the frontend React UI +- Run `npm run start-app` to run both backend and frontend at the same time + ### Launching Jellystat using Docker @@ -37,6 +44,7 @@ https://hub.docker.com/r/cyfershepard/jellystat ## Support - Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/CyferShepard/Jellystat/issues). +- Join us in our [Discord](https://discord.gg/9SMBj2RyEe) ## API Documentation diff --git a/backend/api.js b/backend/api.js index 13b4cae..04fbac3 100644 --- a/backend/api.js +++ b/backend/api.js @@ -1,8 +1,20 @@ // api.js const express = require("express"); const axios = require("axios"); -const ActivityMonitor=require('./watchdog/ActivityMonitor'); +const ActivityMonitor=require('./tasks/ActivityMonitor'); const db = require("./db"); +const https = require('https'); +const { checkForUpdates } = require('./version-control'); + +const agent = new https.Agent({ + rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true' +}); + + + +const axios_instance = axios.create({ + httpsAgent: agent +}); const router = express.Router(); @@ -13,7 +25,7 @@ router.get("/test", async (req, res) => { router.get("/getconfig", async (req, res) => { try{ - const { rows } = await db.query('SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1'); + const { rows } = await db.query('SELECT "JF_HOST","APP_USER","REQUIRE_LOGIN" FROM app_config where "ID"=1'); res.send(rows); }catch(error) @@ -50,6 +62,46 @@ router.post("/setconfig", async (req, res) => { console.log(`ENDPOINT CALLED: /setconfig: `); }); +router.post("/setRequireLogin", async (req, res) => { + try{ + const { REQUIRE_LOGIN } = req.body; + + if(REQUIRE_LOGIN===undefined) + { + res.status(503); + res.send(rows); + } + + let query='UPDATE app_config SET "REQUIRE_LOGIN"=$1 where "ID"=1'; + + console.log(`ENDPOINT CALLED: /setRequireLogin: `+REQUIRE_LOGIN); + + const { rows } = await db.query( + query, + [REQUIRE_LOGIN] + ); + res.send(rows); + }catch(error) + { + console.log(error); + } + + +}); + +router.get("/CheckForUpdates", async (req, res) => { + try{ + + let result=await checkForUpdates(); + res.send(result); + + }catch(error) + { + console.log(error); + } + +}); + router.get("/getLibraries", async (req, res) => { try{ @@ -69,12 +121,11 @@ router.get("/getLibraries", async (req, res) => { router.post("/getLibraryItems", async (req, res) => { try{ - const Id = req.headers['id']; - + const {libraryid} = req.body; + console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid); const { rows } = await db.query( - `SELECT * FROM jf_library_items where "ParentId"='${Id}'` + `SELECT * FROM jf_library_items where "ParentId"='${libraryid}'` ); - console.log({ Id: Id }); res.send(rows); @@ -83,7 +134,7 @@ router.post("/getLibraryItems", async (req, res) => { console.log(error); } - console.log(`ENDPOINT CALLED: /getLibraryItems: `); + }); router.post("/getSeasons", async (req, res) => { @@ -155,7 +206,14 @@ router.post("/getItemDetails", async (req, res) => { query ); + if(episodes.length!==0) + { res.send(episodes); + }else + { + res.status(404).send('Item not found'); + } + }else{ @@ -199,9 +257,18 @@ router.get("/getHistory", async (req, res) => { ...row, results: [] }; + groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row); } }); + // 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); + } + }); + + res.send(Object.values(groupedResults)); @@ -212,11 +279,101 @@ router.get("/getHistory", async (req, res) => { }); +router.post("/getLibraryHistory", async (req, res) => { + try { + const { libraryid } = req.body; + const { rows } = await db.query( + `select a.* from jf_playback_activity a join jf_library_items i on i."Id"=a."NowPlayingItemId" where i."ParentId"='${libraryid}' order by "ActivityDateInserted" desc` + ); + const groupedResults = {}; + rows.forEach(row => { + if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) { + groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row); + } else { + groupedResults[row.NowPlayingItemId+row.EpisodeId] = { + ...row, + results: [] + }; + groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row); + } + }); + + res.send(Object.values(groupedResults)); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + + +router.post("/getItemHistory", async (req, res) => { + try { + const { itemid } = req.body; + + const { rows } = await db.query( + `select jf_playback_activity.* + from jf_playback_activity jf_playback_activity + where + ("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}');` + ); + + + + const groupedResults = rows.map(item => ({ + ...item, + results: [] + })); + + + + res.send(groupedResults); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + +router.post("/getUserHistory", async (req, res) => { + try { + const { userid } = req.body; + + const { rows } = await db.query( + `select jf_playback_activity.* + from jf_playback_activity jf_playback_activity + where "UserId"='${userid}';` + ); + + const groupedResults = {}; + rows.forEach(row => { + if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) { + groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row); + } else { + groupedResults[row.NowPlayingItemId+row.EpisodeId] = { + ...row, + results: [] + }; + groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row); + } + }); + + res.send(Object.values(groupedResults)); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + + + + router.get("/getAdminUsers", async (req, res) => { try { const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1'); const url = `${config[0].JF_HOST}/Users`; - const response = await axios.get(url, { + const response = await axios_instance.get(url, { headers: { "X-MediaBrowser-Token": config[0].JF_API_KEY, }, @@ -227,6 +384,7 @@ router.get("/getAdminUsers", async (req, res) => { res.send(adminUser); } catch (error) { console.log( error); + res.status(503); res.send(error); } @@ -248,6 +406,316 @@ router.get("/runWatchdog", async (req, res) => { } }); +router.get("/getSessions", async (req, res) => { + try { + + + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.status(503); + res.send({ error: "Config Details Not Found" }); + return; + } + + + + let url=`${config[0].JF_HOST}/sessions`; + + const response_data = await axios_instance.get(url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + res.send(response_data.data); + } catch (error) { + res.status(503); + res.send(error); + } +}); + +router.post("/validateSettings", async (req, res) => { + const { url,apikey } = req.body; + + let isValid = false; + let errorMessage = ""; + try + { + await axios_instance + .get(url + "/system/configuration", { + headers: { + "X-MediaBrowser-Token": apikey, + }, + }) + .then((response) => { + if (response.status === 200) { + isValid = true; + } + }) + .catch((error) => { + if (error.code === "ERR_NETWORK") { + isValid = false; + errorMessage = `Error : Unable to connect to Jellyfin Server`; + } else if (error.code === "ECONNREFUSED") { + isValid = false; + errorMessage = `Error : Unable to connect to Jellyfin Server`; + } + else if (error.response && error.response.status === 401) { + isValid = false; + errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`; + } else if (error.response && error.response.status === 404) { + isValid = false; + errorMessage = `Error ${error.response.status}: The requested URL was not found.`; + } else { + isValid = false; + errorMessage = `${error}`; + } + }); + + }catch(error) + { + isValid = false; + errorMessage = `Error: ${error}`; + } + + + res.send({isValid:isValid,errorMessage:errorMessage }); + + +}); + +router.post("/updatePassword", async (req, res) => { + const { current_password,new_password } = req.body; + + let result={isValid:true,errorMessage:""}; + + + try{ + const { rows } = await db.query(`SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1 AND "APP_PASSWORD"='${current_password}' `); + + if(rows && rows.length>0) + { + if(current_password===new_password) + { + result.isValid=false; + result.errorMessage = "New Password cannot be the same as Old Password"; + }else{ + + await db.query(`UPDATE app_config SET "APP_PASSWORD"='${new_password}' where "ID"=1 AND "APP_PASSWORD"='${current_password}' `); + + + } + + }else{ + result.isValid=false; + result.errorMessage = "Old Password is Invalid"; + } + + }catch(error) + { + console.log(error); + result.errorMessage = error; + } + + + + res.send(result); + + +}); + +router.post("/getLibraries", async (req, res) => { + try { + const { itemid } = req.body; + const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + let payload= + { + existing_library_count:0, + existing_movie_count:0, + existing_music_count:0, + existing_show_count:0, + existing_season_count:0, + existing_episode_count:0, + api_library_count:0, + api_movie_count:0, + api_music_count:0, + api_show_count:0, + api_season_count:0, + api_episode_count:0, + missing_api_library_data:{}, + missing_api_music_data:{}, + missing_api_movies_data:{}, + missing_api_shows_data:{}, + missing_api_season_data:{}, + missing_api_episode_data:{}, + raw_library_data:{}, + raw_item_data:{}, + raw_season_data:{}, + raw_episode_data:{}, + count_from_api:{}, + }; + + /////////////////////////Get Admin + + const adminurl = `${config[0].JF_HOST}/Users`; + const response = await axios_instance.get(adminurl, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY, + }, + }); + const adminUser = await response.data.filter( + (user) => user.Policy.IsAdministrator === true + ); + + //////////////////////// + const db_libraries=await db.query('SELECT "Id" FROM jf_libraries').then((res) => res.rows.map((row) => row.Id)); + const db_music=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Audio'`).then((res) => res.rows.map((row) => row.Id)); + const db_movies=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Movie'`).then((res) => res.rows.map((row) => row.Id)); + const db_shows=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Series'`).then((res) => res.rows.map((row) => row.Id)); + const db_seasons=await db.query('SELECT "Id" FROM jf_library_seasons').then((res) => res.rows.map((row) => row.Id)); + const db_episodes=await db.query('SELECT "EpisodeId" FROM jf_library_episodes').then((res) => res.rows.map((row) => row.EpisodeId)); + + let count_url=`${config[0].JF_HOST}/items/counts`; + + const response_api_count = await axios_instance.get(count_url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + payload.count_from_api=response_api_count.data; +//get libraries + let url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items`; + + const response_data = await axios_instance.get(url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + + let libraries=response_data.data.Items; + let raw_library_data=response_data.data; + + payload.raw_library_data=raw_library_data; + + //get items + const show_data = []; + const movie_data = []; + const music_data = []; + const raw_item_data=[]; + for (let i = 0; i < libraries.length; i++) { + const library = libraries[i]; + + let item_url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items?ParentID=${library.Id}`; + const response_data_item = await axios_instance.get(item_url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + const libraryItemsWithParent = response_data_item.data.Items.map((items) => ({ + ...items, + ...{ ParentId: library.Id }, + })); + movie_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Movie')); + show_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Series')); + music_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Audio')); + raw_item_data.push(response_data_item.data); + } + + payload.existing_library_count=db_libraries.length; + payload.api_library_count=libraries.length; + + payload.existing_movie_count=db_movies.length; + payload.api_movie_count=movie_data.length; + + payload.existing_music_count=db_music.length; + payload.api_music_count=music_data.length; + + payload.existing_show_count=db_shows.length; + payload.api_show_count=show_data.length; + + payload.raw_item_data=raw_item_data; + + + + //SHows + let allSeasons = []; + let allEpisodes =[]; + + let raw_allSeasons = []; + let raw_allEpisodes =[]; + + const { rows: shows } = await db.query(`SELECT "Id" FROM public.jf_library_items where "Type"='Series'`); + //loop for each show + + for (const show of shows) { + + let season_url = `${config[0].JF_HOST}/shows/${show.Id}/Seasons`; + let episodes_url = `${config[0].JF_HOST}/shows/${show.Id}/Episodes`; + + const response_data_seasons = await axios_instance.get(season_url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + const response_data_episodes = await axios_instance.get(episodes_url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + allSeasons.push(...response_data_seasons.data.Items); + allEpisodes.push(...response_data_episodes.data.Items); + + raw_allSeasons.push(response_data_seasons.data); + raw_allEpisodes.push(response_data_episodes.data); + } + + payload.existing_season_count=db_seasons.length; + payload.api_season_count=allSeasons.length; + + payload.existing_episode_count=db_episodes.length; + payload.api_episode_count=allEpisodes.length; + + payload.raw_season_data=raw_allSeasons; + payload.raw_episode_data=raw_allEpisodes; + + //missing data section + let missing_libraries=libraries.filter(library => !db_libraries.includes(library.Id)); + + let missing_movies=movie_data.filter(item => !db_movies.includes(item.Id) && item.Type==='Movie'); + let missing_shows=show_data.filter(item => !db_shows.includes(item.Id) && item.Type==='Series'); + let missing_music=music_data.filter(item => !db_music.includes(item.Id) && item.Type==='Audio'); + + let missing_seasons=allSeasons.filter(season => !db_seasons.includes(season.Id)); + let missing_episodes=allEpisodes.filter(episode => !db_episodes.includes(episode.Id)); + + payload.missing_api_library_data=missing_libraries; + + payload.missing_api_movies_data=missing_movies; + payload.missing_api_music_data=missing_music; + payload.missing_api_shows_data=missing_shows; + + payload.missing_api_season_data=missing_seasons; + payload.missing_api_episode_data=missing_episodes; + + + res.send(payload); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + + + + + diff --git a/backend/auth.js b/backend/auth.js index dd2a1e4..d5ea615 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -3,7 +3,11 @@ const db = require("./db"); const jwt = require('jsonwebtoken'); -const JWT_SECRET = process.env.JWT_SECRET ||'my-secret-jwt-key'; +const JWT_SECRET = process.env.JWT_SECRET; +if (JWT_SECRET === undefined) { + console.log('JWT Secret cannot be undefined'); + process.exit(1); // end the program with error status code +} const router = express.Router(); @@ -13,8 +17,9 @@ router.post('/login', async (req, res) => { try{ const { username, password } = req.body; - const { rows : login } = await db.query(`SELECT * FROM app_config where "APP_USER"='${username}' and "APP_PASSWORD"='${password}'`); - + const query = 'SELECT * FROM app_config WHERE ("APP_USER" = $1 AND "APP_PASSWORD" = $2) OR "REQUIRE_LOGIN" = false'; + const values = [username, password]; + const { rows: login } = await db.query(query, values); if(login.length>0) { const user = { id: 1, username: username }; @@ -44,9 +49,25 @@ router.post('/login', async (req, res) => { if(Configured.length>0) { - res.sendStatus(200); + if(Configured[0].JF_API_KEY && Configured[0].APP_USER && Configured[0].JF_API_KEY!==null && Configured[0].APP_USER!==null) + { + + res.status(200); + res.send({state:2}); + }else + if(Configured[0].APP_USER && Configured[0].APP_USER!==null) + { + + res.status(200); + res.send({state:1}); + }else + { + res.status(200); + res.send({state:0}); + } }else{ - res.sendStatus(204); + res.status(200); + res.send({state:0}); } }catch(error) diff --git a/backend/backup.js b/backend/backup.js index e839516..f9c2b05 100644 --- a/backend/backup.js +++ b/backend/backup.js @@ -3,8 +3,11 @@ const { Pool } = require('pg'); const fs = require('fs'); const path = require('path'); const moment = require('moment'); +const { randomUUID } = require('crypto'); +const multer = require('multer'); -const wss = require("./WebsocketHandler"); +// const wss = require("./WebsocketHandler"); +const Logging =require('./logging'); const router = Router(); @@ -18,9 +21,19 @@ const postgresDatabase = process.env.POSTGRES_DATABASE || 'jfstat'; // Tables to back up const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_library_episodes','jf_users','jf_playback_activity','jf_playback_reporting_plugin_data','jf_item_info']; - +function checkFolderWritePermission(folderPath) { + try { + const testFile = `${folderPath}/.writableTest`; + fs.writeFileSync(testFile, ''); + fs.unlinkSync(testFile); + return true; + } catch (error) { + return false; + } +} // Backup function -async function backup() { +async function backup(refLog) { + refLog.logData.push({ color: "lawngreen", Message: "Starting Backup" }); const pool = new Pool({ user: postgresUser, password: postgresPassword, @@ -35,23 +48,39 @@ async function backup() { try{ let now = moment(); + const backupfolder='./backup-data'; + + if (!fs.existsSync(backupfolder)) { + fs.mkdirSync(backupfolder); + console.log('Directory created successfully!'); + } + if (!checkFolderWritePermission(backupfolder)) { + console.error('No write permissions for the folder:', backupfolder); + refLog.logData.push({ color: "red", Message: "Backup Failed: No write permissions for the folder: "+backupfolder }); + refLog.logData.push({ color: "red", Message: "Backup Failed with errors"}); + refLog.result='Failed'; + await pool.end(); + return; + + } + + const backupPath = `./backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`; const stream = fs.createWriteStream(backupPath, { flags: 'a' }); stream.on('error', (error) => { - console.error(error); - wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error }); - throw new Error(error); + refLog.logData.push({ color: "red", Message: "Backup Failed: "+error }); + refLog.result='Failed'; + return; }); const backup_data=[]; - wss.clearMessages(); - wss.sendMessageToClients({ color: "yellow", Message: "Begin Backup "+backupPath }); + refLog.logData.push({ color: "yellow", Message: "Begin Backup "+backupPath }); for (let table of tables) { const query = `SELECT * FROM ${table}`; const { rows } = await pool.query(query); console.log(`Reading ${rows.length} rows for table ${table}`); - wss.sendMessageToClients({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`}); + refLog.logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`}); backup_data.push({[table]:rows}); @@ -60,12 +89,13 @@ async function backup() { await stream.write(JSON.stringify(backup_data)); stream.end(); - wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" }); + refLog.logData.push({ color: "lawngreen", Message: "Backup Complete" }); }catch(error) { console.log(error); - wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error }); + refLog.logData.push({ color: "red", Message: "Backup Failed: "+error }); + refLog.result='Failed'; } @@ -88,9 +118,10 @@ function readFile(path) { }); } -async function restore(file) { - wss.clearMessages(); - wss.sendMessageToClients({ color: "yellow", Message: "Restoring from Backup: "+file }); +async function restore(file,logData,result) { + + logData.push({ color: "lawngreen", Message: "Starting Restore" }); + logData.push({ color: "yellow", Message: "Restoring from Backup: "+file }); const pool = new Pool({ user: postgresUser, password: postgresPassword, @@ -108,6 +139,9 @@ async function restore(file) { jsonData = await readFile(backupPath); } catch (err) { + logData.push({ color: "red",key:tableName ,Message: `Failed to read backup file`}); + + result='Failed'; console.error(err); } @@ -126,8 +160,7 @@ async function restore(file) { for(let index in data) { - wss.sendMessageToClients({ color: "dodgerblue",key:tableName ,Message: `Restoring ${tableName} ${(((index)/(data.length-1))*100).toFixed(2)}%`}); - + logData.push({ color: "dodgerblue",key:tableName ,Message: `Restoring ${tableName} ${(((index)/(data.length-1))*100).toFixed(2)}%`}); const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`); const keyString = keysWithQuotes.join(", "); @@ -149,18 +182,40 @@ async function restore(file) { const query=`INSERT INTO ${tableName} (${keyString}) VALUES(${valueString}) ON CONFLICT DO NOTHING`; const { rows } = await pool.query( query ); + } } await pool.end(); + logData.push({ color: "lawngreen", Message: "Restore Complete" }); } // Route handler for backup endpoint router.get('/backup', async (req, res) => { try { - await backup(); + let startTime = moment(); + let refLog={logData:[],result:'Success'}; + await backup(refLog); + + let endTime = moment(); + let diffInSeconds = endTime.diff(startTime, 'seconds'); + const uuid = randomUUID(); + const log= + { + "Id":uuid, + "Name":"Backup", + "Type":"Task", + "ExecutionType":"Manual", + "Duration":diffInSeconds || 0, + "TimeRun":startTime, + "Log":JSON.stringify(refLog.logData), + "Result": refLog.result + + }; + + Logging.insertLog(log); res.send('Backup completed successfully'); } catch (error) { console.error(error); @@ -169,16 +224,39 @@ router.get('/backup', async (req, res) => { }); router.get('/restore/:filename', async (req, res) => { + let startTime = moment(); + let logData=[]; + let result='Success'; try { const filePath = path.join(__dirname, backupfolder, req.params.filename); - await restore(filePath); - wss.sendMessageToClients({ color: "lawngreen", Message: `Restoring Complete` }); + + await restore(filePath,logData,result); + res.send('Restore completed successfully'); } catch (error) { console.error(error); - wss.sendMessageToClients({ color: "red", Message: error }); res.status(500).send('Restore failed'); } + + let endTime = moment(); + let diffInSeconds = endTime.diff(startTime, 'seconds'); + const uuid = randomUUID(); + + const log= + { + "Id":uuid, + "Name":"Restore", + "Type":"Task", + "ExecutionType":"Manual", + "Duration":diffInSeconds, + "TimeRun":startTime, + "Log":JSON.stringify(logData), + "Result": result + + }; + + + Logging.insertLog(log); }); //list backup files @@ -186,6 +264,8 @@ router.get('/restore/:filename', async (req, res) => { router.get('/files', (req, res) => { + try + { const directoryPath = path.join(__dirname, backupfolder); fs.readdir(directoryPath, (err, files) => { if (err) { @@ -204,6 +284,12 @@ router.get('/restore/:filename', async (req, res) => { res.json(fileData); } }); + + }catch(error) + { + console.log(error); + } + }); @@ -215,9 +301,10 @@ router.get('/restore/:filename', async (req, res) => { //delete backup router.delete('/files/:filename', (req, res) => { + + try{ const filePath = path.join(__dirname, backupfolder, req.params.filename); - try{ fs.unlink(filePath, (err) => { if (err) { console.error(err); @@ -235,10 +322,35 @@ router.get('/restore/:filename', async (req, res) => { } }); + + + const storage = multer.diskStorage({ + destination: function (req, file, cb) { + 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 + }, + }); + + const upload = multer({ storage: storage }); + + + router.post("/upload", upload.single("file"), (req, res) => { + // Handle the uploaded file here + res.json({ + fileName: req.file.originalname, + filePath: req.file.path, + }); + }); -module.exports = router; +module.exports = +{ + router, + backup +}; diff --git a/backend/db.js b/backend/db.js index f8f5810..ed9b678 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,5 +1,6 @@ const { Pool } = require('pg'); const pgp = require("pg-promise")(); +const {update_query : update_query_map} = require("./models/bulk_insert_update_handler"); const _POSTGRES_USER=process.env.POSTGRES_USER; @@ -57,12 +58,12 @@ async function deleteBulk(table_name, data) { } catch (error) { await client.query('ROLLBACK'); - message=('Error: '+ error); + message=(''+ error); result='ERROR'; } finally { client.release(); } - return ({Result:result,message:message}); + return ({Result:result,message:'Bulk delete error:'+message}); } async function insertBulk(table_name, data,columns) { @@ -71,26 +72,20 @@ async function insertBulk(table_name, data,columns) { let message=''; try { await client.query("BEGIN"); - - const query = pgp.helpers.insert( - data, - columns, - table_name - ); - await client.query(query); - + const update_query= update_query_map.find(query => query.table === table_name).query; await client.query("COMMIT"); - - message=(data.length + " Rows Inserted."); + const cs = new pgp.helpers.ColumnSet(columns, { table: table_name }); + const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly + await client.query(query); } catch (error) { await client.query('ROLLBACK'); - message=('Error: '+ error); + message=(''+ error); result='ERROR'; } finally { client.release(); } - return ({Result:result,message:message}); + return ({Result:result,message:message?'Bulk insert error: '+message:''}); } async function query(text, params) { @@ -98,7 +93,27 @@ async function query(text, params) { const result = await pool.query(text, params); return result; } catch (error) { - console.error('Error occurred while executing query:', error); + + if(error?.routine==='auth_failed') + { + console.log('Error 401: Unable to Authenticate with Postgres DB'); + }else + if(error?.code==='ENOTFOUND') + { + console.log('Error: Unable to Connect to Postgres DB'); + }else + if(error?.code==='ERR_SOCKET_BAD_PORT') + { + console.log('Error: Invalid Postgres DB Port Range. Port should be >= 0 and < 65536.'); + }else + if(error?.code==='ECONNREFUSED') + { + console.log('Error: Postgres DB Connection refused at '+error.address+':'+error.port); + }else + { + console.error('Error occurred while executing query:', error); + } + return []; // throw error; } } diff --git a/backend/logging.js b/backend/logging.js new file mode 100644 index 0000000..36a9300 --- /dev/null +++ b/backend/logging.js @@ -0,0 +1,37 @@ + +const db = require("./db"); + + + +const {jf_logging_columns,jf_logging_mapping,} = require("./models/jf_logging"); +const express = require("express"); +const router = express.Router(); + +router.get("/getLogs", async (req, res) => { + try { + const { rows } = await db.query(`SELECT * FROM jf_logging order by "TimeRun" desc LIMIT 50 `); + res.send(rows); + } catch (error) { + res.send(error); + } +}); + + +async function insertLog(logItem) +{ + try { + + + await db.insertBulk("jf_logging",logItem,jf_logging_columns); + // console.log(result); + + } catch (error) { + console.log(error); + return []; + } + +} + + +module.exports = +{router,insertLog}; diff --git a/backend/migrations/029_jf_all_user_activity_view.js b/backend/migrations/029_jf_all_user_activity_view.js new file mode 100644 index 0000000..334b1d1 --- /dev/null +++ b/backend/migrations/029_jf_all_user_activity_view.js @@ -0,0 +1,66 @@ +exports.up = async function(knex) { + await knex.raw(` + DROP VIEW jf_all_user_activity; + CREATE OR REPLACE VIEW jf_all_user_activity AS + SELECT u."Id" AS "UserId", + u."PrimaryImageTag", + u."Name" AS "UserName", + CASE + WHEN j."SeriesName" IS NULL THEN j."NowPlayingItemName" + ELSE (j."SeriesName" || ' - '::text) || j."NowPlayingItemName" + END AS "LastWatched", + CASE + WHEN j."SeriesName" IS NULL THEN j."NowPlayingItemId" + ELSE j."EpisodeId" + END AS "NowPlayingItemId", + j."ActivityDateInserted" AS "LastActivityDate", + (j."Client" || ' - '::text) || j."DeviceName" AS "LastClient", + plays."TotalPlays", + plays."TotalWatchTime", + now() - j."ActivityDateInserted" AS "LastSeen" + FROM ( + SELECT jf_users."Id", + jf_users."Name", + jf_users."PrimaryImageTag", + jf_users."LastLoginDate", + jf_users."LastActivityDate", + jf_users."IsAdministrator" + FROM jf_users + ) u + LEFT JOIN LATERAL ( + SELECT 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" + FROM jf_playback_activity + WHERE jf_playback_activity."UserId" = u."Id" + ORDER BY jf_playback_activity."ActivityDateInserted" DESC + LIMIT 1 + ) j ON true + LEFT JOIN LATERAL ( + SELECT count(*) AS "TotalPlays", + sum(jf_playback_activity."PlaybackDuration") AS "TotalWatchTime" + FROM jf_playback_activity + WHERE jf_playback_activity."UserId" = u."Id" + ) plays ON true + ORDER BY (now() - j."ActivityDateInserted"); + `).catch(function(error) { + console.error(error); + }); + }; + + exports.down = async function(knex) { + await knex.raw(`DROP VIEW jf_all_user_activity;`); + }; + \ No newline at end of file diff --git a/backend/migrations/030_jf_logging_table.js b/backend/migrations/030_jf_logging_table.js new file mode 100644 index 0000000..e45a741 --- /dev/null +++ b/backend/migrations/030_jf_logging_table.js @@ -0,0 +1,29 @@ +exports.up = async function(knex) { + try { + const hasTable = await knex.schema.hasTable('jf_logging'); + if (!hasTable) { + await knex.schema.createTable('jf_logging', function(table) { + table.text('Id').primary(); + table.text('Name').notNullable(); + table.text('Type').notNullable(); + table.text('ExecutionType'); + table.text('Duration').notNullable(); + table.timestamp('TimeRun').defaultTo(knex.fn.now()); + table.json('Log'); + table.text('Result'); + }); + await knex.raw(`ALTER TABLE jf_logging OWNER TO ${process.env.POSTGRES_USER};`); + } + } catch (error) { + console.error(error); + } + }; + + exports.down = async function(knex) { + try { + await knex.schema.dropTableIfExists('jf_logging'); + } catch (error) { + console.error(error); + } + }; + \ No newline at end of file diff --git a/backend/migrations/031_jd_remove_orphaned_data.js b/backend/migrations/031_jd_remove_orphaned_data.js new file mode 100644 index 0000000..d875bbf --- /dev/null +++ b/backend/migrations/031_jd_remove_orphaned_data.js @@ -0,0 +1,43 @@ +exports.up = function(knex) { + return knex.schema.raw(` + CREATE OR REPLACE PROCEDURE jd_remove_orphaned_data() AS $$ + BEGIN + DELETE FROM public.jf_library_episodes + WHERE "SeriesId" NOT IN ( + SELECT "Id" + FROM public.jf_library_items + ); + + DELETE FROM public.jf_library_seasons + WHERE "SeriesId" NOT IN ( + SELECT "Id" + FROM public.jf_library_items + ); + + DELETE FROM public.jf_item_info + WHERE "Id" NOT IN ( + SELECT "Id" + FROM public.jf_library_items + ) + AND "Type" = 'Item'; + + DELETE FROM public.jf_item_info + WHERE "Id" NOT IN ( + SELECT "EpisodeId" + FROM public.jf_library_episodes + ) + AND "Type" = 'Episode'; + END; + $$ LANGUAGE plpgsql; + + `).catch(function(error) { + console.error(error); + }); + }; + + exports.down = function(knex) { + return knex.schema.raw(` + DROP PROCEDURE jd_remove_orphaned_data; + `); + }; + \ No newline at end of file diff --git a/backend/migrations/032_app_config_table_add_auth_flag.js b/backend/migrations/032_app_config_table_add_auth_flag.js new file mode 100644 index 0000000..b15064f --- /dev/null +++ b/backend/migrations/032_app_config_table_add_auth_flag.js @@ -0,0 +1,23 @@ +exports.up = async function(knex) { + try + { + const hasTable = await knex.schema.hasTable('app_config'); + if (hasTable) { + await knex.schema.alterTable('app_config', function(table) { + table.boolean('REQUIRE_LOGIN').defaultTo(true); + }); + } +}catch (error) { + console.error(error); +} +}; + +exports.down = async function(knex) { + try { + await knex.schema.alterTable('app_config', function(table) { + table.dropColumn('REQUIRE_LOGIN'); + }); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/033_js_library_stats_overview_view.js b/backend/migrations/033_js_library_stats_overview_view.js new file mode 100644 index 0000000..e99a910 --- /dev/null +++ b/backend/migrations/033_js_library_stats_overview_view.js @@ -0,0 +1,123 @@ +exports.up = function(knex) { + const query = ` + 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, + COALESCE(cv."Library_Count",0)"Library_Count", + COALESCE(cv."Season_Count",0)"Season_Count", + COALESCE(cv."Episode_Count",0)"Episode_Count", + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + left JOIN jf_library_count_view cv ON cv."Id" = l."Id" + LEFT JOIN ( SELECT 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 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; + `; + + return knex.schema.raw(query).catch(function(error) { + console.error(error); + }); +}; + + + + exports.down = function(knex) { + return knex.schema.raw(` + CREATE VIEW 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, + cv."Library_Count", + cv."Season_Count", + cv."Episode_Count", + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + JOIN jf_library_count_view cv ON cv."Id" = l."Id" + LEFT JOIN ( SELECT 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 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; + `); + }; + \ No newline at end of file diff --git a/backend/migrations/034_jf_libraries_table_add_stat_columns.js b/backend/migrations/034_jf_libraries_table_add_stat_columns.js new file mode 100644 index 0000000..1ae2ac5 --- /dev/null +++ b/backend/migrations/034_jf_libraries_table_add_stat_columns.js @@ -0,0 +1,29 @@ +exports.up = async function(knex) { + try + { + const hasTable = await knex.schema.hasTable('jf_libraries'); + if (hasTable) { + await knex.schema.alterTable('jf_libraries', function(table) { + table.bigInteger('total_play_time'); + table.bigInteger('item_count'); + table.bigInteger('season_count'); + table.bigInteger('episode_count'); + }); + } +}catch (error) { + console.error(error); +} +}; + +exports.down = async function(knex) { + try { + await knex.schema.alterTable('jf_libraries', function(table) { + table.dropColumn('total_play_time'); + table.dropColumn('item_count'); + table.dropColumn('season_count'); + table.dropColumn('episode_count'); + }); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/035_ju_update_library_stats_data.js b/backend/migrations/035_ju_update_library_stats_data.js new file mode 100644 index 0000000..7496419 --- /dev/null +++ b/backend/migrations/035_ju_update_library_stats_data.js @@ -0,0 +1,37 @@ +exports.up = function(knex) { + return knex.schema.raw(` + CREATE OR REPLACE PROCEDURE ju_update_library_stats_data() + LANGUAGE plpgsql + AS $$ + BEGIN + UPDATE jf_libraries l + SET + total_play_time = ( + SELECT COALESCE(SUM(COALESCE(i_1."RunTimeTicks", e_1."RunTimeTicks")), 0) AS sum + FROM jf_library_items i_1 + LEFT JOIN jf_library_episodes e_1 ON i_1."Id" = e_1."SeriesId" + WHERE i_1."ParentId" = l."Id" + AND ( + (i_1."Type" <> 'Series'::text AND e_1."Id" IS NULL) + OR (i_1."Type" = 'Series'::text AND e_1."Id" IS NOT NULL) + ) + ), + item_count = COALESCE(cv."Library_Count", 0::bigint), + season_count = COALESCE(cv."Season_Count", 0::bigint), + episode_count = COALESCE(cv."Episode_Count", 0::bigint) + FROM jf_library_count_view cv + WHERE cv."Id" = l."Id"; + END; + $$; + + `).catch(function(error) { + console.error(error); + }); + }; + + exports.down = function(knex) { + return knex.schema.raw(` + DROP PROCEDURE ju_update_library_stats_data; + `); + }; + \ No newline at end of file diff --git a/backend/migrations/036_js_library_stats_overview_view.js b/backend/migrations/036_js_library_stats_overview_view.js new file mode 100644 index 0000000..e703eab --- /dev/null +++ b/backend/migrations/036_js_library_stats_overview_view.js @@ -0,0 +1,126 @@ +exports.up = function(knex) { + const query = ` + DROP VIEW 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", + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + LEFT JOIN jf_library_count_view cv ON cv."Id" = l."Id" + LEFT JOIN ( SELECT 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 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; + `; + + return knex.schema.raw(query).catch(function(error) { + console.error(error); + }); +}; + + + + exports.down = function(knex) { + return knex.schema.raw(` + 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, + COALESCE(cv."Library_Count",0)"Library_Count", + COALESCE(cv."Season_Count",0)"Season_Count", + COALESCE(cv."Episode_Count",0)"Episode_Count", + now() - latest_activity."ActivityDateInserted" AS "LastActivity" + FROM jf_libraries l + left JOIN jf_library_count_view cv ON cv."Id" = l."Id" + LEFT JOIN ( SELECT 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 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; + `); + }; + \ No newline at end of file diff --git a/backend/migrations/037_jf_library_items_with_playcount_playtime.js b/backend/migrations/037_jf_library_items_with_playcount_playtime.js new file mode 100644 index 0000000..3f1bdc9 --- /dev/null +++ b/backend/migrations/037_jf_library_items_with_playcount_playtime.js @@ -0,0 +1,42 @@ +exports.up = function(knex) { + const query = ` + CREATE OR REPLACE VIEW jf_library_items_with_playcount_playtime AS + SELECT + i."Id", + i."Name", + i."ServerId", + i."PremiereDate", + i."EndDate", + i."CommunityRating", + i."RunTimeTicks", + i."ProductionYear", + i."IsFolder", + i."Type", + i."Status", + i."ImageTagsPrimary", + i."ImageTagsBanner", + i."ImageTagsLogo", + i."ImageTagsThumb", + i."BackdropImageTags", + i."ParentId", + i."PrimaryImageHash", + count(a."NowPlayingItemId") times_played, + coalesce(sum(a."PlaybackDuration"),0) total_play_time + FROM jf_library_items i + left join jf_playback_activity a + on i."Id"=a."NowPlayingItemId" + group by i."Id" + order by times_played desc + `; + + return knex.schema.raw(query).catch(function(error) { + console.error(error); + }); +}; + + + + exports.down = function(knex) { + return knex.schema.raw(`DROP VIEW public.jf_library_items_with_playcount_playtime;`); + }; + \ No newline at end of file diff --git a/backend/migrations/038_jf_playback_activity_add_stream_data_columns.js b/backend/migrations/038_jf_playback_activity_add_stream_data_columns.js new file mode 100644 index 0000000..6787d6c --- /dev/null +++ b/backend/migrations/038_jf_playback_activity_add_stream_data_columns.js @@ -0,0 +1,29 @@ +exports.up = async function(knex) { + try + { + const hasTable = await knex.schema.hasTable('jf_playback_activity'); + if (hasTable) { + await knex.schema.alterTable('jf_playback_activity', function(table) { + table.json('MediaStreams'); + table.json('TranscodingInfo'); + table.text('OriginalContainer'); + table.text('RemoteEndPoint'); + }); + } +}catch (error) { + console.error(error); +} +}; + +exports.down = async function(knex) { + try { + await knex.schema.alterTable('jf_playback_activity', function(table) { + table.dropColumn('MediaStreams'); + table.dropColumn('TranscodingInfo'); + table.dropColumn('OriginalContainer'); + table.dropColumn('RemoteEndPoint'); + }); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/039_jf_activity_watchdog_add_stream_data_columns.js b/backend/migrations/039_jf_activity_watchdog_add_stream_data_columns.js new file mode 100644 index 0000000..2d2834e --- /dev/null +++ b/backend/migrations/039_jf_activity_watchdog_add_stream_data_columns.js @@ -0,0 +1,31 @@ +exports.up = async function(knex) { + try + { + const hasTable = await knex.schema.hasTable('jf_activity_watchdog'); + if (hasTable) { + await knex.schema.alterTable('jf_activity_watchdog', function(table) { + table.json('MediaStreams'); + table.json('TranscodingInfo'); + table.json('PlayState'); + table.text('OriginalContainer'); + table.text('RemoteEndPoint'); + }); + } +}catch (error) { + console.error(error); +} +}; + +exports.down = async function(knex) { + try { + await knex.schema.alterTable('jf_activity_watchdog', function(table) { + table.dropColumn('MediaStreams'); + table.dropColumn('TranscodingInfo'); + table.dropColumn('PlayState'); + table.dropColumn('OriginalContainer'); + table.dropColumn('RemoteEndPoint'); + }); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/migrations/040_app_config_add_general_settings_column.js b/backend/migrations/040_app_config_add_general_settings_column.js new file mode 100644 index 0000000..22bab8c --- /dev/null +++ b/backend/migrations/040_app_config_add_general_settings_column.js @@ -0,0 +1,23 @@ +exports.up = async function(knex) { + try + { + const hasTable = await knex.schema.hasTable('app_config'); + if (hasTable) { + await knex.schema.alterTable('app_config', function(table) { + table.json('settings').defaultTo({settings:{time_format:'12hr'}}); + }); + } +}catch (error) { + console.error(error); +} +}; + +exports.down = async function(knex) { + try { + await knex.schema.alterTable('jf_activity_watchdog', function(table) { + table.dropColumn('settings'); + }); + } catch (error) { + console.error(error); + } +}; diff --git a/backend/models/bulk_insert_update_handler.js b/backend/models/bulk_insert_update_handler.js new file mode 100644 index 0000000..28166cb --- /dev/null +++ b/backend/models/bulk_insert_update_handler.js @@ -0,0 +1,17 @@ + + + const update_query = [ + {table:'jf_activity_watchdog',query:' ON CONFLICT ("Id") DO UPDATE SET "TranscodingInfo" = EXCLUDED."TranscodingInfo", "MediaStreams" = EXCLUDED."MediaStreams", "PlayMethod" = EXCLUDED."PlayMethod"'}, + {table:'jf_item_info',query:' ON CONFLICT ("Id") DO UPDATE SET "Path" = EXCLUDED."Path", "Name" = EXCLUDED."Name", "Size" = EXCLUDED."Size", "Bitrate" = EXCLUDED."Bitrate", "MediaStreams" = EXCLUDED."MediaStreams"'}, + {table:'jf_libraries',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "Type" = EXCLUDED."Type", "CollectionType" = EXCLUDED."CollectionType", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary"'}, + {table:'jf_library_episodes',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "OfficialRating" = EXCLUDED."OfficialRating", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "IndexNumber" = EXCLUDED."IndexNumber", "ParentIndexNumber" = EXCLUDED."ParentIndexNumber", "Type" = EXCLUDED."Type", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesId" = EXCLUDED."SeriesId", "SeasonId" = EXCLUDED."SeasonId", "SeasonName" = EXCLUDED."SeasonName", "SeriesName" = EXCLUDED."SeriesName"'}, + {table:'jf_library_items',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "EndDate" = EXCLUDED."EndDate", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "Type" = EXCLUDED."Type", "Status" = EXCLUDED."Status", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary", "ImageTagsBanner" = EXCLUDED."ImageTagsBanner", "ImageTagsLogo" = EXCLUDED."ImageTagsLogo", "ImageTagsThumb" = EXCLUDED."ImageTagsThumb", "BackdropImageTags" = EXCLUDED."BackdropImageTags", "ParentId" = EXCLUDED."ParentId", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash"'}, + {table:'jf_library_seasons',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesPrimaryImageTag" = EXCLUDED."SeriesPrimaryImageTag"'}, + {table:'jf_logging',query:' ON CONFLICT DO NOTHING'}, + {table:'jf_playback_activity',query:' ON CONFLICT DO NOTHING'}, + {table:'jf_playback_reporting_plugin_data',query:' ON CONFLICT DO NOTHING'}, + {table:'jf_users',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PrimaryImageTag" = EXCLUDED."PrimaryImageTag", "LastLoginDate" = EXCLUDED."LastLoginDate", "LastActivityDate" = EXCLUDED."LastActivityDate"'} + ]; + module.exports = { + update_query + }; \ No newline at end of file diff --git a/backend/models/jf_activity_watchdog.js b/backend/models/jf_activity_watchdog.js index 0fd36b8..95085c3 100644 --- a/backend/models/jf_activity_watchdog.js +++ b/backend/models/jf_activity_watchdog.js @@ -19,6 +19,11 @@ const jf_activity_watchdog_columns = [ "PlaybackDuration", "PlayMethod", "ActivityDateInserted", + { name: 'MediaStreams', mod: ':json' }, + { name: 'TranscodingInfo', mod: ':json' }, + { name: 'PlayState', mod: ':json' }, + "OriginalContainer", + "RemoteEndPoint", ]; @@ -39,6 +44,11 @@ const jf_activity_watchdog_columns = [ PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0, PlayMethod:item.PlayState.PlayMethod, ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: moment().format('YYYY-MM-DD HH:mm:ss.SSSZ'), + MediaStreams: item.NowPlayingItem.MediaStreams ? item.NowPlayingItem.MediaStreams : null , + TranscodingInfo: item.TranscodingInfo? item.TranscodingInfo : null, + PlayState: item.PlayState? item.PlayState : null, + OriginalContainer: item.NowPlayingItem && item.NowPlayingItem.Container ? item.NowPlayingItem.Container : null, + RemoteEndPoint: item.RemoteEndPoint || null, }); module.exports = { diff --git a/backend/models/jf_libraries.js b/backend/models/jf_libraries.js index bed869e..aeab06f 100644 --- a/backend/models/jf_libraries.js +++ b/backend/models/jf_libraries.js @@ -15,7 +15,7 @@ ServerId: item.ServerId, IsFolder: item.IsFolder, Type: item.Type, - CollectionType: item.CollectionType, + CollectionType: item.CollectionType? item.CollectionType : 'mixed', ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null, }); diff --git a/backend/models/jf_library_items.js b/backend/models/jf_library_items.js index 76c64bf..c996423 100644 --- a/backend/models/jf_library_items.js +++ b/backend/models/jf_library_items.js @@ -42,7 +42,7 @@ item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null, BackdropImageTags: item.BackdropImageTags[0], ParentId: item.ParentId, - PrimaryImageHash: item.ImageTags.Primary? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null, + PrimaryImageHash: item.ImageTags && item.ImageTags.Primary && item.ImageBlurHashes && item.ImageBlurHashes.Primary && item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null, }); module.exports = { diff --git a/backend/models/jf_library_seasons.js b/backend/models/jf_library_seasons.js index 5f77b59..b92eed9 100644 --- a/backend/models/jf_library_seasons.js +++ b/backend/models/jf_library_seasons.js @@ -27,7 +27,7 @@ : null, SeriesName: item.SeriesName, SeriesId: item.SeriesId, - SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, + SeriesPrimaryImageTag: item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag : null, }); module.exports = { diff --git a/backend/models/jf_logging.js b/backend/models/jf_logging.js new file mode 100644 index 0000000..63f602a --- /dev/null +++ b/backend/models/jf_logging.js @@ -0,0 +1,26 @@ + const jf_logging_columns = [ + "Id", + "Name", + "Type", + "ExecutionType", + "Duration", + "TimeRun", + "Log", + "Result" + ]; + + const jf_logging_mapping = (item) => ({ + Id: item.Id, + Name: item.Name, + Type: item.Type, + ExecutionType: item.ExecutionType, + Duration: item.Duration, + TimeRun: item.TimeRun, + Log: item.Log, + Result: item.Result, + }); + + module.exports = { + jf_logging_columns, + jf_logging_mapping, + }; \ No newline at end of file diff --git a/backend/models/jf_playback_activity.js b/backend/models/jf_playback_activity.js index f396474..fd60018 100644 --- a/backend/models/jf_playback_activity.js +++ b/backend/models/jf_playback_activity.js @@ -1,4 +1,3 @@ - ////////////////////////// pn delete move to playback const columnsPlayback = [ "Id", "IsPaused", @@ -16,6 +15,11 @@ "PlaybackDuration", "PlayMethod", "ActivityDateInserted", + { name: 'MediaStreams', mod: ':json' }, + { name: 'TranscodingInfo', mod: ':json' }, + { name: 'PlayState', mod: ':json' }, + "OriginalContainer", + "RemoteEndPoint" ]; @@ -36,6 +40,11 @@ PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0, PlayMethod: item.PlayState.PlayMethod !== undefined ? item.PlayState.PlayMethod : item.PlayMethod , ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: new Date().toISOString(), + MediaStreams: item.MediaStreams ? item.MediaStreams : null , + TranscodingInfo: item.TranscodingInfo? item.TranscodingInfo : null, + PlayState: item.PlayState? item.PlayState : null, + OriginalContainer: item.OriginalContainer ? item.OriginalContainer : null, + RemoteEndPoint: item.RemoteEndPoint ? item.RemoteEndPoint : null }); module.exports = { diff --git a/backend/models/jf_users.js b/backend/models/jf_users.js index 7eba662..1e882ba 100644 --- a/backend/models/jf_users.js +++ b/backend/models/jf_users.js @@ -14,7 +14,7 @@ PrimaryImageTag: item.PrimaryImageTag, LastLoginDate: item.LastLoginDate, LastActivityDate: item.LastActivityDate, - IsAdministrator: item.Policy.IsAdministrator, + IsAdministrator: item.Policy && item.Policy.IsAdministrator ? item.Policy.IsAdministrator : false, }); module.exports = { diff --git a/backend/proxy.js b/backend/proxy.js new file mode 100644 index 0000000..4559bb7 --- /dev/null +++ b/backend/proxy.js @@ -0,0 +1,149 @@ +const express = require('express'); +const axios = require("axios"); +const db = require("./db"); +const https = require('https'); + +const agent = new https.Agent({ + rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true' +}); + + +const axios_instance = axios.create({ + httpsAgent: agent +}); + +const router = express.Router(); + +router.get('/web/assets/img/devices/', async(req, res) => { + const { devicename } = req.query; // Get the image URL from the query string + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config[0].JF_HOST === null || config[0].JF_API_KEY === null || devicename===undefined) { + res.send({ error: "Config Details Not Found" }); + return; + } + + let url=`${config[0].JF_HOST}/web/assets/img/devices/${devicename}.svg`; + + axios_instance.get(url, { + responseType: 'arraybuffer' + }) + .then((response) => { + res.set('Content-Type', 'image/svg+xml'); + res.status(200); + + if (response.headers['content-type'].startsWith('image/')) { + res.send(response.data); + } else { + res.status(500).send('Error fetching image'); + } + + return; // Add this line + }) + .catch((error) => { + console.error(error); + res.status(500).send('Error fetching image: '+error); + }); + +}); + + + +router.get('/Items/Images/Backdrop/', async(req, res) => { + const { id,fillWidth,quality,blur } = req.query; // Get the image URL from the query string + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + return; + } + + + let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 800}&quality=${quality || 100}&blur=${blur || 0}`; + + + axios_instance.get(url, { + responseType: 'arraybuffer' + }) + .then((response) => { + res.set('Content-Type', 'image/jpeg'); + res.status(200); + + if (response.headers['content-type'].startsWith('image/')) { + res.send(response.data); + } else { + res.status(500).send('Error fetching image'); + } + }) + .catch((error) => { + // console.error(error); + res.status(500).send('Error fetching image: '+error); + }); + }); + + router.get('/Items/Images/Primary/', async(req, res) => { + const { id,fillWidth,quality } = req.query; // Get the image URL from the query string + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + return; + } + + + let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 400}&quality=${quality || 100}`; + + axios_instance.get(url, { + responseType: 'arraybuffer' + }) + .then((response) => { + res.set('Content-Type', 'image/jpeg'); + res.status(200); + + if (response.headers['content-type'].startsWith('image/')) { + res.send(response.data); + } else { + res.status(500).send('Error fetching image'); + } + }) + .catch((error) => { + // console.error(error); + res.status(500).send('Error fetching image: '+error); + }); + }); + + + router.get('/Users/Images/Primary/', async(req, res) => { + const { id,fillWidth,quality } = req.query; // Get the image URL from the query string + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + return; + } + + + let url=`${config[0].JF_HOST}/Users/${id}/Images/Primary?fillWidth=${fillWidth || 100}&quality=${quality || 100}`; + + axios_instance.get(url, { + responseType: 'arraybuffer' + }) + .then((response) => { + res.set('Content-Type', 'image/jpeg'); + res.status(200); + + if (response.headers['content-type'].startsWith('image/')) { + res.send(response.data); + } else { + res.status(500).send('Error fetching image'); + } + }) + .catch((error) => { + // console.error(error); + res.status(500).send('Error fetching image: '+error); + }); + }); + + + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 9a59194..4db97a1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,10 +7,15 @@ const knexConfig = require('./migrations'); const authRouter= require('./auth'); const apiRouter = require('./api'); -const syncRouter = require('./sync'); +const proxyRouter = require('./proxy'); +const {router: syncRouter} = require('./sync'); const statsRouter = require('./stats'); -const backupRouter = require('./backup'); -const ActivityMonitor = require('./watchdog/ActivityMonitor'); +const {router: backupRouter} = require('./backup'); +const ActivityMonitor = require('./tasks/ActivityMonitor'); +const SyncTask = require('./tasks/SyncTask'); +const BackupTask = require('./tasks/BackupTask'); +const {router: logRouter} = require('./logging'); + const app = express(); @@ -18,7 +23,7 @@ const db = knex(knexConfig.development); const PORT = process.env.PORT || 3003; const LISTEN_IP = '127.0.0.1'; -const JWT_SECRET = process.env.JWT_SECRET ||'my-secret-jwt-key'; +const JWT_SECRET = process.env.JWT_SECRET; if (JWT_SECRET === undefined) { console.log('JWT Secret cannot be undefined'); @@ -49,9 +54,11 @@ function verifyToken(req, res, next) { app.use('/auth', authRouter); // mount the API router at /api, with JWT middleware app.use('/api', verifyToken, apiRouter); // mount the API router at /api, with JWT middleware +app.use('/proxy', proxyRouter); // mount the API router at /api, with JWT middleware app.use('/sync', verifyToken, syncRouter); // mount the API router at /sync, with JWT middleware app.use('/stats', verifyToken, statsRouter); // mount the API router at /stats, with JWT middleware app.use('/data', verifyToken, backupRouter); // mount the API router at /stats, with JWT middleware +app.use('/logs', verifyToken, logRouter); // mount the API router at /stats, with JWT middleware try{ createdb.createDatabase().then((result) => { @@ -62,7 +69,10 @@ try{ db.migrate.latest().then(() => { app.listen(PORT, async () => { console.log(`Server listening on http://${LISTEN_IP}:${PORT}`); + ActivityMonitor.ActivityMonitor(1000); + SyncTask.SyncTask(60000*10); + BackupTask.BackupTask(60000*60*24); }); }); }); diff --git a/backend/stats.js b/backend/stats.js index bd47456..4cea722 100644 --- a/backend/stats.js +++ b/backend/stats.js @@ -1,14 +1,28 @@ // api.js const express = require("express"); const db = require("./db"); +const axios=require("axios"); const router = express.Router(); +const https = require('https'); + + + +const agent = new https.Agent({ + rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true' +}); +const axios_instance = axios.create({ + httpsAgent: agent +}); + + router.get("/getLibraryOverview", async (req, res) => { try { const { rows } = await db.query("SELECT * FROM jf_library_count_view"); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -25,6 +39,7 @@ router.post("/getMostViewedSeries", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -43,6 +58,7 @@ router.post("/getMostViewedMovies", async (req, res) => { } catch (error) { console.log('/getMostViewedMovies'); console.log(error); + res.status(503); res.send(error); } }); @@ -59,6 +75,7 @@ router.post("/getMostViewedMusic", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -75,6 +92,7 @@ router.post("/getMostViewedLibraries", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -91,6 +109,7 @@ router.post("/getMostUsedClient", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -107,6 +126,7 @@ router.post("/getMostActiveUsers", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -123,6 +143,7 @@ router.post("/getMostPopularMovies", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -139,6 +160,7 @@ router.post("/getMostPopularSeries", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -155,6 +177,7 @@ router.post("/getMostPopularMusic", async (req, res) => { ); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -164,6 +187,7 @@ router.get("/getPlaybackActivity", async (req, res) => { const { rows } = await db.query("SELECT * FROM jf_playback_activity"); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -186,6 +210,7 @@ router.post("/getUserDetails", async (req, res) => { res.send(rows[0]); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -203,6 +228,7 @@ router.post("/getGlobalUserStats", async (req, res) => { res.send(rows[0]); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -216,6 +242,7 @@ router.post("/getUserLastPlayed", async (req, res) => { res.send(rows); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -229,6 +256,7 @@ router.post("/getLibraryDetails", async (req, res) => { res.send(rows[0]); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -246,6 +274,7 @@ router.post("/getGlobalLibraryStats", async (req, res) => { res.send(rows[0]); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -256,6 +285,7 @@ router.get("/getLibraryCardStats", async (req, res) => { const { rows } = await db.query("select * from js_library_stats_overview"); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); @@ -265,10 +295,29 @@ router.get("/getLibraryMetadata", async (req, res) => { const { rows } = await db.query("select * from js_library_metadata"); res.send(rows); } catch (error) { + res.status(503); res.send(error); } }); +router.post("/getLibraryItemsWithStats", async (req, res) => { + try{ + const {libraryid} = req.body; + console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid); + const { rows } = await db.query( + `SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"='${libraryid}'` + ); + res.send(rows); + + + }catch(error) + { + console.log(error); + } + + +}); + router.post("/getLibraryLastPlayed", async (req, res) => { try { @@ -279,10 +328,63 @@ router.post("/getLibraryLastPlayed", async (req, res) => { res.send(rows); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); +router.get("/getRecentlyAdded", async (req, res) => { + try { + + const { libraryid } = req.query; + const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); + + if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.status(503); + res.send({ error: "Config Details Not Found" }); + return; + } + + const adminurl = `${config[0].JF_HOST}/Users`; + + const response = await axios_instance.get(adminurl, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + + if(!response || typeof response.data !== 'object' || !Array.isArray(response.data)) + { + res.status(503); + res.send({ error: "Invalid Response from Users API Call.", user_response:response }); + return; + } + + const adminUser = response.data.filter( + (user) => user.Policy.IsAdministrator === true + ); + + + let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest`; + if(libraryid) + { + url+=`?parentId=${libraryid}`; + } + + const response_data = await axios_instance.get(url, { + headers: { + "X-MediaBrowser-Token": config[0].JF_API_KEY , + }, + }); + res.send(response_data.data); + } catch (error) { + res.status(503); + res.send(error); + } +}); + + + router.post("/getViewsOverTime", async (req, res) => { try { @@ -324,6 +426,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)}; res.send(finalData); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -364,6 +467,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)}; res.send(finalData); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -405,6 +509,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)}; res.send(finalData); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); @@ -427,6 +532,7 @@ router.post("/getGlobalItemStats", async (req, res) => { res.send(rows[0]); } catch (error) { console.log(error); + res.status(503); res.send(error); } }); diff --git a/backend/sync.js b/backend/sync.js index 8b43234..94b3c48 100644 --- a/backend/sync.js +++ b/backend/sync.js @@ -2,10 +2,26 @@ const express = require("express"); const pgp = require("pg-promise")(); const db = require("./db"); const axios = require("axios"); +const https = require('https'); + +const logging=require("./logging"); + +const agent = new https.Agent({ + rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true' +}); + + + +const axios_instance = axios.create({ + httpsAgent: agent +}); const wss = require("./WebsocketHandler"); const socket=wss; +const moment = require('moment'); +const { randomUUID } = require('crypto'); + const router = express.Router(); @@ -19,6 +35,17 @@ const {columnsPlaybackReporting,mappingPlaybackReporting}= require("./models/jf_ const {jf_users_columns,jf_users_mapping,} = require("./models/jf_users"); /////////////////////////////////////////Functions + +function getErrorLineNumber(error) { + const stackTrace = error.stack.split('\n'); + const errorLine = stackTrace[1].trim(); + const lineNumber = errorLine.substring( + errorLine.lastIndexOf('\\') + 1, + errorLine.lastIndexOf(')') + ); + return lineNumber; +} + class sync { constructor(hostUrl, apiKey) { this.hostUrl = hostUrl; @@ -28,8 +55,7 @@ class sync { async getUsers() { try { const url = `${this.hostUrl}/Users`; - console.log("getAdminUser: ", url); - const response = await axios.get(url, { + const response = await axios_instance.get(url, { headers: { "X-MediaBrowser-Token": this.apiKey, }, @@ -41,45 +67,112 @@ class sync { } } - async getAdminUser() { + async getAdminUser(refLog) { try { const url = `${this.hostUrl}/Users`; - console.log("getAdminUser: ", url); - const response = await axios.get(url, { + const response = await axios_instance.get(url, { headers: { "X-MediaBrowser-Token": this.apiKey, }, }); + + if(!response || typeof response.data !== 'object' || !Array.isArray(response.data)) + { + res.status(503); + res.send({ error: "Invalid Response from Users API Call.", user_response:response }); + return; + } + const adminUser = response.data.filter( (user) => user.Policy.IsAdministrator === true ); return adminUser || null; } catch (error) { console.log(error); + refLog.loggedData.push({ Message: "Error Getting AdminId: "+error}); + refLog.result='Failed'; return []; } } - async getItem(itemID,userid) { + async getItem(ids,params) { try { - let url = `${this.hostUrl}/users/${userid}/Items`; - if (itemID !== undefined) { - url += `?ParentID=${itemID}`; - } - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": this.apiKey, - }, - }); - const results = response.data.Items; - if (itemID === undefined) { - return results.filter((type) => - ["tvshows", "movies","music"].includes(type.CollectionType) - ); + let url = `${this.hostUrl}/Items?ids=${ids}`; + let startIndex=params && params.startIndex ? params.startIndex :0; + let increment=params && params.increment ? params.startIndex :200; + let recursive=params && params.recursive!==undefined ? params.recursive :true; + let total=200; + + let final_response=[]; + while(startIndex !["boxsets","playlists"].includes(type.CollectionType)); } else { - return results; + // return final_response.filter((item) => item.ImageTags.Primary); + return final_response; } } catch (error) { console.log(error); @@ -87,34 +180,13 @@ class sync { } } - async getSeasonsAndEpisodes(itemID, type) { - try { - - let url = `${this.hostUrl}/shows/${itemID}/${type}`; - if (itemID !== undefined) { - url += `?ParentID=${itemID}`; - } - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": this.apiKey, - }, - }); - - return response.data.Items; - } catch (error) { - console.log(error); - return []; - } - } - - async getItemInfo(itemID,userid) { try { let url = `${this.hostUrl}/Items/${itemID}/playbackinfo?userId=${userid}`; - const response = await axios.get(url, { + const response = await axios_instance.get(url, { headers: { "X-MediaBrowser-Token": this.apiKey, }, @@ -130,141 +202,132 @@ class sync { } ////////////////////////////////////////API Methods -async function syncUserData() +async function syncUserData(refLog) { - const { rows } = await db.query('SELECT * FROM app_config where "ID"=1'); - if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) { - res.send({ error: "Config Details Not Found" }); - socket.sendMessageToClients({ Message: "Error: Config details not found!" }); - return; - } - - const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY); - - const data = await _sync.getUsers(); - - const existingIds = await db - .query('SELECT "Id" FROM jf_users') - .then((res) => res.rows.map((row) => row.Id)); // get existing library Ids from the db - - let dataToInsert = []; - //filter fix if jf_libraries is empty - - if (existingIds.length === 0) { - dataToInsert = await data.map(jf_users_mapping); - } else { - dataToInsert = await data - .filter((row) => !existingIds.includes(row.Id)) - .map(jf_users_mapping); - } - - if (dataToInsert.length !== 0) { - let result = await db.insertBulk("jf_users",dataToInsert,jf_users_columns); - if (result.Result === "SUCCESS") { - socket.sendMessageToClients(dataToInsert.length + " Rows Inserted."); - } else { - socket.sendMessageToClients({ - color: "red", - Message: "Error performing bulk insert:" + result.message, - }); - } - } - - const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id )); - if (toDeleteIds.length > 0) { - let result = await db.deleteBulk("jf_users",toDeleteIds); - if (result.Result === "SUCCESS") { - socket.sendMessageToClients(toDeleteIds.length + " Rows Removed."); - } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + try + { + const { rows } = await db.query('SELECT * FROM app_config where "ID"=1'); + if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + refLog.loggedData.push({ Message: "Error: Config details not found!" }); + refLog.result='Failed'; + return; } + const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY); + + const data = await _sync.getUsers(); + + const existingIds = await db + .query('SELECT "Id" FROM jf_users') + .then((res) => res.rows.map((row) => row.Id)); // get existing library Ids from the db + + let dataToInsert = []; + //filter fix if jf_libraries is empty + + if (existingIds.length === 0) { + dataToInsert = await data.map(jf_users_mapping); + } else { + dataToInsert = await data + .filter((row) => !existingIds.includes(row.Id)) + .map(jf_users_mapping); + } + + if (dataToInsert.length !== 0) { + let result = await db.insertBulk("jf_users",dataToInsert,jf_users_columns); + if (result.Result === "SUCCESS") { + refLog.loggedData.push(dataToInsert.length + " Rows Inserted."); + } else { + refLog.loggedData.push({ + color: "red", + Message: "Error performing bulk insert:" + result.message, + }); + refLog.result='Failed'; + } + } + + const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id )); + if (toDeleteIds.length > 0) { + let result = await db.deleteBulk("jf_users",toDeleteIds); + if (result.Result === "SUCCESS") { + refLog.loggedData.push(toDeleteIds.length + " Rows Removed."); + } else { + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; + } + + } + + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,}); + refLog.result='Failed'; } + + } -async function syncLibraryFolders() +async function syncLibraryFolders(refLog,data) { + try + { + + const existingIds = await db + .query('SELECT "Id" FROM jf_libraries') + .then((res) => res.rows.map((row) => row.Id)); - const { rows } = await db.query('SELECT * FROM app_config where "ID"=1'); - if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) { - res.send({ error: "Config Details Not Found" }); - socket.sendMessageToClients({ Message: "Error: Config details not found!" }); - return; - } - - const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY); - const admins = await _sync.getAdminUser(); - const userid = admins[0].Id; - const data = await _sync.getItem(undefined,userid); //getting all root folders aka libraries - - const existingIds = await db - .query('SELECT "Id" FROM jf_libraries') - .then((res) => res.rows.map((row) => row.Id)); - - - let dataToInsert = []; - //filter fix if jf_libraries is empty - - if (existingIds.length === 0) { - dataToInsert = await data.map(jf_libraries_mapping); - } else { - dataToInsert = await data.filter((row) => !existingIds.includes(row.Id)).map(jf_libraries_mapping); - } - - if (dataToInsert.length !== 0) { - let result = await db.insertBulk("jf_libraries",dataToInsert,jf_libraries_columns); - if (result.Result === "SUCCESS") { - socket.sendMessageToClients(dataToInsert.length + " Rows Inserted."); + + let dataToInsert = []; + //filter fix if jf_libraries is empty + + if (existingIds.length === 0) { + dataToInsert = await data.map(jf_libraries_mapping); } else { - socket.sendMessageToClients({ - color: "red", - Message: "Error performing bulk insert:" + result.message, - }); - } - } - - const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id )); - if (toDeleteIds.length > 0) { - let result = await db.deleteBulk("jf_libraries",toDeleteIds); - if (result.Result === "SUCCESS") { - socket.sendMessageToClients(toDeleteIds.length + " Rows Removed."); - } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + dataToInsert = await data.filter((row) => !existingIds.includes(row.Id)).map(jf_libraries_mapping); } - } + if (dataToInsert.length !== 0) { + let result = await db.insertBulk("jf_libraries",dataToInsert,jf_libraries_columns); + if (result.Result === "SUCCESS") { + refLog.loggedData.push(dataToInsert.length + " Rows Inserted."); + } else { + refLog.loggedData.push({ + color: "red", + Message: "Error performing bulk insert:" + result.message, + }); + refLog.result='Failed'; + } + } + + const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id )); + if (toDeleteIds.length > 0) { + let result = await db.deleteBulk("jf_libraries",toDeleteIds); + if (result.Result === "SUCCESS") { + refLog.loggedData.push(toDeleteIds.length + " Rows Removed."); + } else { + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; + } + + } + } + catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,}); + refLog.result='Failed'; + } + } -async function syncLibraryItems() +async function syncLibraryItems(refLog,data) { - const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1' ); + try{ + + refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/3" }); + refLog.loggedData.push({color: "yellow",Message: "Beginning Library Item Sync",}); - if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { - res.send({ error: "Config Details Not Found" }); - return; - } - - const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY); - socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 1/3" }); - - socket.sendMessageToClients({color: "yellow",Message: "Beginning Library Item Sync",}); - - const admins = await _sync.getAdminUser(); - const userid = admins[0].Id; - const libraries = await _sync.getItem(undefined,userid); - const data = []; - let insertCounter = 0; + let insertMessage=''; let deleteCounter = 0; - //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) - for (let i = 0; i < libraries.length; i++) { - const item = libraries[i]; - let libraryItems = await _sync.getItem(item.Id,userid); - const libraryItemsWithParent = libraryItems.map((items) => ({ - ...items, - ...{ ParentId: item.Id }, - })); - data.push(...libraryItemsWithParent); - } const existingIds = await db @@ -274,26 +337,19 @@ async function syncLibraryItems() let dataToInsert = []; //filter fix if jf_libraries is empty - - if (existingIds.length === 0) { - dataToInsert = await data.map(jf_library_items_mapping); - } else { - dataToInsert = await data - .filter((row) => !existingIds.includes(row.Id)) - .map(jf_library_items_mapping); - } - + dataToInsert = await data.map(jf_library_items_mapping); + dataToInsert=dataToInsert.filter((item)=>item.Id !== undefined); if (dataToInsert.length !== 0) { - let result = await db.insertBulk("jf_library_items",dataToInsert,jf_library_items_columns); if (result.Result === "SUCCESS") { - insertCounter += dataToInsert.length; + insertMessage = `${dataToInsert.length-existingIds.length} Rows Inserted. ${existingIds.length} Rows Updated.`; } else { - socket.sendMessageToClients({ + refLog.loggedData.push({ color: "red", Message: "Error performing bulk insert:" + result.message, }); + refLog.result='Failed'; } } @@ -303,48 +359,57 @@ async function syncLibraryItems() if (result.Result === "SUCCESS") { deleteCounter +=toDeleteIds.length; } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; } } - socket.sendMessageToClients({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",}); - socket.sendMessageToClients({color: "orange",Message: deleteCounter + " Library Items Removed.",}); - socket.sendMessageToClients({ color: "yellow", Message: "Item Sync Complete" }); + refLog.loggedData.push({color: "dodgerblue",Message: insertMessage,}); + refLog.loggedData.push({color: "orange",Message: deleteCounter + " Library Items Removed.",}); + refLog.loggedData.push({ color: "yellow", Message: "Item Sync Complete" }); + + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,}); + refLog.result='Failed'; + } + - // const { rows: cleanup } = await db.query('DELETE FROM jf_playback_activity where "NowPlayingItemId" not in (select "Id" from jf_library_items)' ); - // socket.sendMessageToClients({ color: "orange", Message: cleanup.length+" orphaned activity logs removed" }); } -async function syncShowItems() +async function syncShowItems(refLog,data) { - socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/3" }); - socket.sendMessageToClients({color: "yellow", Message: "Beginning Seasons and Episode sync",}); + try{ + refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/3" }); + refLog.loggedData.push({color: "yellow", Message: "Beginning Seasons and Episode sync",}); const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { res.send({ error: "Config Details Not Found" }); + refLog.result='Failed'; return; } - const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY); + // const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY); const { rows: shows } = await db.query(`SELECT * FROM public.jf_library_items where "Type"='Series'`); let insertSeasonsCount = 0; let insertEpisodeCount = 0; + let updateSeasonsCount = 0; + let updateEpisodeCount = 0; + + let deleteSeasonsCount = 0; let deleteEpisodeCount = 0; //loop for each show for (const show of shows) { - const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons'); - const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes'); - - + const allSeasons = data.filter((item) => item.Type==='Season' && item.SeriesId===show.Id); + const allEpisodes =data.filter((item) => item.Type==='Episode' && item.SeriesId===show.Id); const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id)); - let existingIdsEpisodes = []; if (existingIdsSeasons.length > 0) { existingIdsEpisodes = await db @@ -357,41 +422,26 @@ async function syncShowItems() .then((res) => res.rows.map((row) => row.EpisodeId)); } - // + let seasonsToInsert = []; let episodesToInsert = []; - //filter fix if jf_libraries is empty - if (existingIdsSeasons.length === 0) { - // if there are no existing Ids in the table, map all items in the data array to the expected format - seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping); - } else { - // otherwise, filter only new data to insert - seasonsToInsert = await allSeasons - .filter((row) => !existingIdsSeasons.includes(row.Id)) - .map(jf_library_seasons_mapping); - } + seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping); + episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping); - if (existingIdsEpisodes.length === 0) { - // if there are no existing Ids in the table, map all items in the data array to the expected format - episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping); - } else { - // otherwise, filter only new data to insert - episodesToInsert = await allEpisodes.filter((row) => !existingIdsEpisodes.includes(row.Id)).map(jf_library_episodes_mapping); - } - - ///insert delete seasons //Bulkinsert new data not on db if (seasonsToInsert.length !== 0) { let result = await db.insertBulk("jf_library_seasons",seasonsToInsert,jf_library_seasons_columns); if (result.Result === "SUCCESS") { - insertSeasonsCount += seasonsToInsert.length; - } else { - socket.sendMessageToClients({ + insertSeasonsCount+=seasonsToInsert.length-existingIdsSeasons.length; + updateSeasonsCount+=existingIdsSeasons.length; + } else { + refLog.loggedData.push({ color: "red", Message: "Error performing bulk insert:" + result.message, }); + refLog.result='Failed'; } } const toDeleteIds = existingIdsSeasons.filter((id) =>!allSeasons.some((row) => row.Id === id )); @@ -401,7 +451,8 @@ async function syncShowItems() if (result.Result === "SUCCESS") { deleteSeasonsCount +=toDeleteIds.length; } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; } } @@ -410,12 +461,14 @@ async function syncShowItems() if (episodesToInsert.length !== 0) { let result = await db.insertBulk("jf_library_episodes",episodesToInsert,jf_library_episodes_columns); if (result.Result === "SUCCESS") { - insertEpisodeCount += episodesToInsert.length; + insertEpisodeCount+=episodesToInsert.length-existingIdsEpisodes.length; + updateEpisodeCount+=existingIdsEpisodes.length; } else { - socket.sendMessageToClients({ + refLog.loggedData.push({ color: "red", Message: "Error performing bulk insert:" + result.message, }); + refLog.result='Failed'; } } @@ -426,43 +479,54 @@ async function syncShowItems() if (result.Result === "SUCCESS") { deleteEpisodeCount +=toDeleteEpisodeIds.length; } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; } } - socket.sendMessageToClients({ Message: "Sync complete for " + show.Name }); + } - socket.sendMessageToClients({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",}); - socket.sendMessageToClients({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",}); - socket.sendMessageToClients({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",}); - socket.sendMessageToClients({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",}); - socket.sendMessageToClients({ color: "yellow", Message: "Sync Complete" }); + refLog.loggedData.push({color: "dodgerblue",Message: `Seasons: ${insertSeasonsCount} Rows Inserted. ${updateSeasonsCount} Rows Updated.`}); + refLog.loggedData.push({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",}); + refLog.loggedData.push({color: "dodgerblue",Message: `Episodes: ${insertEpisodeCount} Rows Inserted. ${updateEpisodeCount} Rows Updated.`}); + refLog.loggedData.push({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",}); + refLog.loggedData.push({ color: "yellow", Message: "Sync Complete" }); + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,}); + refLog.result='Failed'; + } } -async function syncItemInfo() +async function syncItemInfo(refLog) { - socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 3/3" }); - socket.sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",}); + try{ + refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 3/4" }); + refLog.loggedData.push({color: "yellow", Message: "Beginning File Info Sync",}); const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1'); if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { res.send({ error: "Config Details Not Found" }); + refLog.result='Failed'; return; } const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY); - const { rows: Items } = await db.query(`SELECT li.* FROM public.jf_library_items li left join jf_item_info ii on ii."Id"=li."Id" where li."Type" not in ('Series','Folder') and ii."Id" is null`); - const { rows: Episodes } = await db.query(`SELECT le.* FROM public.jf_library_episodes le left join jf_item_info ii on ii."Id"=le."EpisodeId" where ii."Id" is null`); + const { rows: Items } = await db.query(`SELECT * FROM public.jf_library_items where "Type" not in ('Series','Folder')`); + const { rows: Episodes } = await db.query(`SELECT * FROM public.jf_library_episodes`); let insertItemInfoCount = 0; let insertEpisodeInfoCount = 0; + let updateItemInfoCount = 0; + let updateEpisodeInfoCount = 0; + let deleteItemInfoCount = 0; let deleteEpisodeInfoCount = 0; - const admins = await _sync.getAdminUser(); + const admins = await _sync.getAdminUser(refLog); const userid = admins[0].Id; //loop for each Movie for (const Item of Items) { @@ -470,26 +534,21 @@ async function syncItemInfo() const existingItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Item.Id}'`).then((res) => res.rows.map((row) => row.Id)); - let ItemInfoToInsert = []; - //filter fix if jf_libraries is empty + let ItemInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Item')); - if (existingItemInfo.length === 0) { - // if there are no existing Ids in the table, map all items in the data array to the expected format - ItemInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Item')); - } else { - ItemInfoToInsert = await data.filter((row) => !existingItemInfo.includes(row.Id)) - .map(item => jf_item_info_mapping(item, 'Item')); - } if (ItemInfoToInsert.length !== 0) { let result = await db.insertBulk("jf_item_info",ItemInfoToInsert,jf_item_info_columns); if (result.Result === "SUCCESS") { - insertItemInfoCount += ItemInfoToInsert.length; + insertItemInfoCount +=ItemInfoToInsert.length- existingItemInfo.length; + updateItemInfoCount+=existingItemInfo.length; + } else { - socket.sendMessageToClients({ + refLog.loggedData.push({ color: "red", Message: "Error performing bulk insert:" + result.message, }); + refLog.result='Failed'; } } const toDeleteItemInfoIds = existingItemInfo.filter((id) =>!data.some((row) => row.Id === id )); @@ -499,14 +558,14 @@ async function syncItemInfo() if (result.Result === "SUCCESS") { deleteItemInfoCount +=toDeleteItemInfoIds.length; } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; } } } //loop for each Episode - console.log("Episode") for (const Episode of Episodes) { const data = await _sync.getItemInfo(Episode.EpisodeId,userid); @@ -514,26 +573,21 @@ async function syncItemInfo() const existingEpisodeItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Episode.EpisodeId}'`).then((res) => res.rows.map((row) => row.Id)); - let EpisodeInfoToInsert = []; + let EpisodeInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Episode')); //filter fix if jf_libraries is empty - if (existingEpisodeItemInfo.length === 0) { - // if there are no existing Ids in the table, map all items in the data array to the expected format - EpisodeInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Episode')); - } else { - EpisodeInfoToInsert = await data.filter((row) => !existingEpisodeItemInfo.includes(row.Id)) - .map(item => jf_item_info_mapping(item, 'Episode')); - } if (EpisodeInfoToInsert.length !== 0) { let result = await db.insertBulk("jf_item_info",EpisodeInfoToInsert,jf_item_info_columns); if (result.Result === "SUCCESS") { - insertEpisodeInfoCount += EpisodeInfoToInsert.length; + insertEpisodeInfoCount += EpisodeInfoToInsert.length-existingEpisodeItemInfo.length; + updateEpisodeInfoCount+= existingEpisodeItemInfo.length; } else { - socket.sendMessageToClients({ + refLog.loggedData.push({ color: "red", Message: "Error performing bulk insert:" + result.message, }); + refLog.result='Failed'; } } const toDeleteEpisodeInfoIds = existingEpisodeItemInfo.filter((id) =>!data.some((row) => row.Id === id )); @@ -543,23 +597,29 @@ async function syncItemInfo() if (result.Result === "SUCCESS") { deleteEpisodeInfoCount +=toDeleteEpisodeInfoIds.length; } else { - socket.sendMessageToClients({color: "red",Message: result.message,}); + refLog.loggedData.push({color: "red",Message: "Error: "+result.message,}); + refLog.result='Failed'; } } console.log(Episode.Name) } - socket.sendMessageToClients({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted.",}); - socket.sendMessageToClients({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",}); - socket.sendMessageToClients({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes Info inserted.",}); - socket.sendMessageToClients({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",}); - socket.sendMessageToClients({ color: "lawngreen", Message: "Sync Complete" }); + refLog.loggedData.push({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted. "+updateItemInfoCount +" Item Info Updated"}); + refLog.loggedData.push({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",}); + refLog.loggedData.push({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes Info inserted. "+updateEpisodeInfoCount +" Episodes Info Updated"}); + refLog.loggedData.push({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",}); + refLog.loggedData.push({ color: "lawngreen", Message: "Info Sync Complete" }); + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,}); + refLog.result='Failed'; + } } async function syncPlaybackPluginData() { - socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 3/3" }); + socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 5/5" }); socket.sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",}); try { @@ -595,7 +655,7 @@ async function syncPlaybackPluginData() const url = `${base_url}/user_usage_stats/submit_custom_query`; - const response = await axios.post(url, { + const response = await axios_instance.post(url, { CustomQueryString: query, }, { headers: { @@ -614,67 +674,258 @@ async function syncPlaybackPluginData() } } catch (error) { - console.log(error); + console.log(getErrorLineNumber(error)+ ": "+error); return []; } } +async function removeOrphanedData(refLog) +{ + try{ + refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/4" }); + refLog.loggedData.push({color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records",}); + + await db.query('CALL jd_remove_orphaned_data()'); + + refLog.loggedData.push({color: "dodgerblue",Message: "Orphaned FileInfo/Episode/Season Removed.",}); + + refLog.loggedData.push({ color: "lawngreen", Message: "Sync Complete" }); + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ': Error:'+error,}); + refLog.loggedData.push({ color: "red", Message: getErrorLineNumber(error)+ ": Cleanup Failed with errors" }); + refLog.result='Failed'; + } + +} + +async function updateLibraryStatsData(refLog) +{ + try{ + refLog.loggedData.push({color: "yellow", Message: "Updating Library Stats",}); + + await db.query('CALL ju_update_library_stats_data()'); + + refLog.loggedData.push({color: "dodgerblue",Message: "Library Stats Updated.",}); + + }catch(error) + { + refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ': Error:'+error,}); + refLog.loggedData.push({ color: "red", Message: getErrorLineNumber(error)+ ": Stats update Failed with errors" }); + refLog.result='Failed'; + } + +} + + +async function fullSync() +{ + try + { + let startTime = moment(); + let refLog={loggedData:[],result:'Success'}; + + const { rows } = await db.query('SELECT * FROM app_config where "ID"=1'); + if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + refLog.loggedData.push({ Message: "Error: Config details not found!" }); + refLog.result='Failed'; + return; + } + + const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY); + + const admins = await _sync.getAdminUser(refLog); + const userid = admins[0].Id; + const libraries = await _sync.getItems('userid',userid,{recursive:false}); //getting all root folders aka libraries + items + const data=[]; + + //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) + for (let i = 0; i < libraries.length; i++) { + const item = libraries[i]; + let libraryItems = await _sync.getItems('parentId',item.Id); + const libraryItemsWithParent = libraryItems.map((items) => ({ + ...items, + ...{ ParentId: item.Id }, + })); + data.push(...libraryItemsWithParent); + } + const library_items=data.filter((item) => ['Movie','Audio','Series'].includes(item.Type)); + const seasons_and_episodes=data.filter((item) => ['Season','Episode'].includes(item.Type)); + + await syncUserData(refLog); + + await syncLibraryFolders(refLog,libraries); + await syncLibraryItems(refLog,library_items); + await syncShowItems(refLog,seasons_and_episodes); + await syncItemInfo(refLog); + await updateLibraryStatsData(refLog); + await removeOrphanedData(refLog); + const uuid = randomUUID(); + + let endTime = moment(); + + let diffInSeconds = endTime.diff(startTime, 'seconds'); + + const log= + { + "Id":uuid, + "Name":"Jellyfin Sync", + "Type":"Task", + "ExecutionType":"Automatic", + "Duration":diffInSeconds, + "TimeRun":startTime, + "Log":JSON.stringify(refLog.loggedData), + "Result":refLog.result + + }; + logging.insertLog(log); + + + }catch(error) + { + console.log(error); + } + + +} + ////////////////////////////////////////API Calls ///////////////////////////////////////Sync All router.get("/beingSync", async (req, res) => { socket.clearMessages(); + let refLog={loggedData:[],result:'Success'}; + let startTime = moment(); - await syncUserData(); - await syncLibraryFolders(); - await syncLibraryItems(); - await syncShowItems(); - await syncItemInfo(); + const { rows } = await db.query('SELECT * FROM app_config where "ID"=1'); + if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) { + res.send({ error: "Config Details Not Found" }); + refLog.loggedData.push({ Message: "Error: Config details not found!" }); + refLog.result='Failed'; + return; + } + const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY); + + const admins = await _sync.getAdminUser(refLog); + const userid = admins[0].Id; + const libraries = await _sync.getItems('userid',userid,{recursive:false}); //getting all root folders aka libraries + items + const data=[]; + + //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) + for (let i = 0; i < libraries.length; i++) { + const item = libraries[i]; + let libraryItems = await _sync.getItems('parentId',item.Id); + const libraryItemsWithParent = libraryItems.map((items) => ({ + ...items, + ...{ ParentId: item.Id }, + })); + data.push(...libraryItemsWithParent); + } + + const library_items=data.filter((item) => ['Movie','Audio','Series'].includes(item.Type)); + const seasons_and_episodes=data.filter((item) => ['Season','Episode'].includes(item.Type)); + + await syncUserData(refLog); + + await syncLibraryFolders(refLog,libraries); + await syncLibraryItems(refLog,library_items); + await syncShowItems(refLog,seasons_and_episodes); + await syncItemInfo(refLog); + await updateLibraryStatsData(refLog); + await removeOrphanedData(refLog); + const uuid = randomUUID(); + let endTime = moment(); + let diffInSeconds = endTime.diff(startTime, 'seconds'); + + const log= + { + "Id":uuid, + "Name":"Jellyfin Sync", + "Type":"Task", + "ExecutionType":"Manual", + "Duration":diffInSeconds, + "TimeRun":startTime, + "Log":JSON.stringify(refLog.loggedData), + "Result":refLog.result + + }; + + logging.insertLog(log); res.send(); }); ///////////////////////////////////////Write Users -router.get("/writeUsers", async (req, res) => { - await syncUserData(); - res.send(); +router.post("/fetchItem", async (req, res) => { + try{ + const { itemId } = req.body; + if(itemId===undefined) + { + res.status(400); + res.send('The itemId field is required.'); + } + + const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1'); + const { rows:temp_lib_id } = await db.query('SELECT "Id" FROM jf_libraries limit 1'); + + if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + res.status(503); + res.send({ error: "Config Details Not Found" }); + return; + } + + const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY); + const admins = await _sync.getAdminUser(); + const userid = admins[0].Id; + + let item=await _sync.getItem(itemId); + const libraryItemWithParent = item.map((items) => ({ + ...items, + ...{ ParentId: temp_lib_id[0].Id }, + })); + + + let item_info= await _sync.getItemInfo(itemId,userid); + + + let itemToInsert = await libraryItemWithParent.map(jf_library_items_mapping); + let itemInfoToInsert = await item_info.map(jf_item_info_mapping); + + if (itemToInsert.length !== 0) { + let result = await db.insertBulk("jf_library_items",itemToInsert,jf_library_items_columns); + if (result.Result === "SUCCESS") { + let result_info = await db.insertBulk("jf_item_info",itemInfoToInsert,jf_item_info_columns); + if (result_info.Result === "SUCCESS") { + res.send('Item Synced'); + } else { + res.status(500); + res.send('Unable to insert Item Info: '+result_info.message); + } + } else { + res.status(500); + res.send('Unable to insert Item: '+result.message); + } + }else + { + res.status(404); + res.send('Unable to find Item'); + } + + }catch(error) + { + // console.log(error); + res.status(500); + res.send(error); + } + }); -///////////////////////////////////////writeLibraries -router.get("/writeLibraries", async (req, res) => { - await syncLibraryFolders(); - res.send(); - -}); - -//////////////////////////////////////////////////////writeLibraryItems -router.get("/writeLibraryItems", async (req, res) => { - - await syncLibraryItems(); - res.send(); - -}); - -//////////////////////////////////////////////////////writeSeasonsAndEpisodes -router.get("/writeSeasonsAndEpisodes", async (req, res) => { - await syncShowItems(); - res.send(); - -}); - -////////////////////////////////////// - -//////////////////////////////////////////////////////writeMediaInfo -router.get("/writeMediaInfo", async (req, res) => { - await syncItemInfo(); - res.send(); - -}); ////////////////////////////////////// @@ -689,4 +940,5 @@ router.get("/syncPlaybackPluginData", async (req, res) => { -module.exports = router; +module.exports = +{router,fullSync}; diff --git a/backend/watchdog/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js similarity index 94% rename from backend/watchdog/ActivityMonitor.js rename to backend/tasks/ActivityMonitor.js index 17a4480..db08de5 100644 --- a/backend/watchdog/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -18,7 +18,7 @@ async function ActivityMonitor(interval) { ); - if(config.length===0) + if(!config || config.length===0) { return; } @@ -171,7 +171,7 @@ async function ActivityMonitor(interval) { if(playbackToInsert.length>0) { let result=await db.insertBulk('jf_playback_activity',playbackToInsert,columnsPlayback); - console.log(result); + // console.log(result); } @@ -180,7 +180,17 @@ async function ActivityMonitor(interval) { } catch (error) { - // console.log(error); + + if(error?.code==='ECONNREFUSED') + { + console.error('Error: Unable to connect to Jellyfin'); + }else if(error?.code==='ERR_BAD_RESPONSE') + { + console.warn(error.response?.data); + }else + { + console.error(error); + } return []; } }, interval); diff --git a/backend/tasks/BackupTask.js b/backend/tasks/BackupTask.js new file mode 100644 index 0000000..8be45ee --- /dev/null +++ b/backend/tasks/BackupTask.js @@ -0,0 +1,61 @@ +const db = require("../db"); +const Logging = require("../logging"); + +const backup = require("../backup"); +const moment = require('moment'); +const { randomUUID } = require('crypto'); + + +async function BackupTask(interval) { + console.log("Backup Interval: " + interval); + + + setInterval(async () => { + try { + const { rows: config } = await db.query( + 'SELECT * FROM app_config where "ID"=1' + ); + + + + if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + return; + } + + + + let startTime = moment(); + let refLog={logData:[],result:'Success'}; + + await backup.backup(refLog); + + let endTime = moment(); + let diffInSeconds = endTime.diff(startTime, 'seconds'); + const uuid = randomUUID(); + const log= + { + "Id":uuid, + "Name":"Backup", + "Type":"Task", + "ExecutionType":"Automatic", + "Duration":diffInSeconds, + "TimeRun":startTime, + "Log":JSON.stringify(refLog.logData), + "Result":refLog.result + + }; + Logging.insertLog(log); + + + + + } catch (error) { + // console.log(error); + return []; + } + }, interval); +} + +module.exports = { + BackupTask, +}; diff --git a/backend/tasks/SyncTask.js b/backend/tasks/SyncTask.js new file mode 100644 index 0000000..d45b27e --- /dev/null +++ b/backend/tasks/SyncTask.js @@ -0,0 +1,35 @@ +const db = require("../db"); + +const sync = require("../sync"); + +async function SyncTask(interval) { + console.log("LibraryMonitor Interval: " + interval); + + + setInterval(async () => { + try { + const { rows: config } = await db.query( + 'SELECT * FROM app_config where "ID"=1' + ); + + + + if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) { + return; + } + + sync.fullSync(); + + + + + } catch (error) { + // console.log(error); + return []; + } + }, interval); +} + +module.exports = { + SyncTask, +}; diff --git a/backend/version-control.js b/backend/version-control.js new file mode 100644 index 0000000..99a774e --- /dev/null +++ b/backend/version-control.js @@ -0,0 +1,43 @@ +const GitHub = require('github-api'); +const packageJson = require('../package.json'); +const {compareVersions} =require('compare-versions'); + +async function checkForUpdates() { + const currentVersion = packageJson.version; + const repoOwner = 'cyfershepard'; + const repoName = 'Jellystat'; + const gh = new GitHub(); + + let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false}; + + let latestVersion; + + try { + const path = 'package.json'; + + const response = await gh.getRepo(repoOwner, repoName).getContents('main', path); + const content = response.data.content; + const decodedContent = Buffer.from(content, 'base64').toString(); + latestVersion = JSON.parse(decodedContent).version; + + 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) { + // 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 }; + } 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 }; + } + } 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 }; + } + + return result; +} + + + +module.exports = { checkForUpdates }; diff --git a/package.json b/package.json index 03dfc2c..09224bd 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "name": "jfstat", - "version": "0.1.0", + "version": "1.0.4.10", "private": true, "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", - "@mui/material": "^5.11.10", + "@jellyfin/sdk": "^0.8.2", + "@mui/material": "^5.12.2", + "@mui/x-data-grid": "^6.2.1", "@nivo/api": "^0.74.1", "@nivo/bar": "^0.80.0", "@nivo/core": "^0.80.0", @@ -16,12 +18,16 @@ "antd": "^5.3.0", "axios": "^1.3.4", "bootstrap": "^5.2.3", + "compare-versions": "^6.0.0-rc.1", "concurrently": "^7.6.0", "cors": "^2.8.5", "crypto-js": "^4.1.1", + "file-saver": "^2.0.5", + "github-api": "^3.4.0", "http-proxy-middleware": "^2.0.6", "knex": "^2.4.2", "moment": "^2.29.4", + "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pg": "^8.9.0", @@ -31,6 +37,7 @@ "react-blurhash": "^0.3.0", "react-bootstrap": "^2.7.2", "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", "react-router-dom": "^6.8.1", "react-scripts": "5.0.1", "recharts": "^2.5.0", diff --git a/src/App.css b/src/App.css index d3bd3e7..484994f 100644 --- a/src/App.css +++ b/src/App.css @@ -1,9 +1,11 @@ - - +@import 'pages/css/variables.module.css'; main{ - margin-inline: 20px; + margin-inline: 20px; + /* width: 100%; */ + overflow: auto; } + .App-logo { height: 40vmin; pointer-events: none; @@ -17,7 +19,7 @@ main{ body { - background-color: #1e1c22 !important; + background-color: var(--background-color) !important; /* background-color: #17151a; */ color: white; } @@ -42,6 +44,7 @@ h2{ + @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } @@ -80,3 +83,81 @@ h2{ } +.btn-outline-primary +{ + color: white!important; + border-color: var(--primary-color) !important; + background-color: var(--background-color) !important; +} + +.btn-outline-primary:hover +{ + background-color: var(--primary-color) !important; +} + +.btn-outline-primary.active +{ + background-color: var(--primary-color) !important; + +} + +.btn-outline-primary:focus +{ + background-color: var(--primary-color) !important; + +} + +.btn-primary +{ + color: white!important; + border-color: var(--primary-color) !important; + background-color: var(--primary-color) !important; +} + +.btn-primary:hover +{ + background-color: var(--primary-dark-color) !important; +} + +.btn-primary.active +{ + background-color: var(--primary-color) !important; + +} + +.btn-primary:focus +{ + background-color: var(--primary-color) !important; + +} + +.form-select +{ + background-color:var(--secondary-background-color) !important; + border-color:var(--secondary-background-color) !important ; + color: white !important; +} + +.form-select:focus +{ + box-shadow: none !important; + border-color: var(--primary-color) !important; + color: white !important; +} + +.form-select option { + background-color: var(--secondary-background-color) !important; + padding: 0 !important; + border-radius: 8px !important; + border-color: var(--primary-color) !important; +} + + +.form-select option:hover { + background-color: var(--primary-color) !important; + color: white !important; +} + + + + diff --git a/src/App.js b/src/App.js index 9fab8df..37caf02 100644 --- a/src/App.js +++ b/src/App.js @@ -1,10 +1,7 @@ // import logo from './logo.svg'; import './App.css'; import React, { useState, useEffect } from 'react'; -import { - Routes, - Route, -} from "react-router-dom"; +import { Routes, Route } from "react-router-dom"; import axios from 'axios'; import Config from './lib/config'; @@ -25,15 +22,17 @@ import Libraries from './pages/libraries'; import LibraryInfo from './pages/components/library-info'; import ItemInfo from './pages/components/item-info'; import ErrorPage from './pages/components/general/error'; +import About from './pages/about'; import Testing from './pages/testing'; import Activity from './pages/activity'; import Statistics from './pages/statistics'; +import Datadebugger from './pages/data-debugger'; function App() { - const [isConfigured, setisConfigured] = useState(false); + const [setupState, setSetupState] = useState(0); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [errorFlag, seterrorFlag] = useState(false); @@ -61,16 +60,15 @@ function App() { } }; - if(!isConfigured) + if(setupState===0) { setLoading(false); axios .get("/auth/isConfigured") .then(async (response) => { - console.log(response); if(response.status===200) { - setisConfigured(true); + setSetupState(response.data.state); } @@ -84,11 +82,11 @@ function App() { } - if (!config && isConfigured) { + if (!config && setupState>=1) { fetchConfig(); } -}, [config,isConfigured]); +}, [config,setupState]); if (loading) { return ; @@ -98,30 +96,34 @@ if (errorFlag) { return ; } -if(isConfigured) +if(!config && setupState===2) { if ((token===undefined || token===null) || !config) { return ; } } - else{ + + if (setupState===0) { return ; } + if(setupState===1) + { + return ; + + } -if (config && config.apiKey ===null) { - return ; -} -if (config && isConfigured && token!==null){ +if (config && setupState===2 && token!==null){ return (
- -
-
+ +
+ +
} /> } /> @@ -129,10 +131,12 @@ if (config && isConfigured && token!==null){ } /> } /> } /> - } /> + } /> } /> } /> } /> + } /> + } />
diff --git a/src/classes/jellyfin-api.js b/src/classes/jellyfin-api.js deleted file mode 100644 index 42f2b12..0000000 --- a/src/classes/jellyfin-api.js +++ /dev/null @@ -1,123 +0,0 @@ -import { Component } from "react"; -import axios from "axios"; -import Config from "../lib/config"; - -class API extends Component { - constructor(props) { - super(props); - this.state = { - data: [], - }; - } - - async getSessions() { - try { - const config = await Config(); - const url = `${config.hostUrl}/Sessions`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - return response.data; - } catch (error) { - console.log(error); - return []; - } - } - - async getActivityData(limit) { - if (limit === undefined || limit < 1) { - return []; - } - try { - const config = await Config(); - const url = `${config.hostUrl}/System/ActivityLog/Entries?limit=${limit}`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - return response.data; - } catch (error) { - console.log(error); - return []; - } - } - - async getAdminUser() { - try { - const config = await Config(); - const url = `${config.hostUrl}/Users`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - const adminUser = response.data.filter( - (user) => user.Policy.IsAdministrator === true - ); - return adminUser || null; - } catch (error) { - console.log(error); - return []; - } - } - - async getLibraries() { - try { - const config = await Config(); - const admins = await this.getAdminUser(); - const userid = admins[0].Id; - const url = `${config.hostUrl}/users/${userid}/Items`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - const mediafolders = response.data.Items.filter((type) => - ["tvshows", "movies"].includes(type.CollectionType) - ); - return mediafolders || null; - } catch (error) { - console.log(error); - return []; - } - } - - async getItem(itemID) { - try { - const config = await Config(); - const admins = await this.getAdminUser(); - const userid = admins[0].Id; - const url = `${config.hostUrl}/users/${userid}/Items?ParentID=${itemID}`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - return response.data.Items; - } catch (error) { - console.log(error); - return []; - } - } - - async getRecentlyPlayed(userid, limit) { - try { - const config = await Config(); - const url = `${config.hostUrl}/users/${userid}/Items/Resume?limit=${limit}`; - const response = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - return response.data.Items; - } catch (error) { - console.log(error); - return []; - } - } -} - -export default API; diff --git a/src/index.css b/src/index.css index b23a4ff..f823fdb 100644 --- a/src/index.css +++ b/src/index.css @@ -14,6 +14,14 @@ body { color: white ; } +* +{ + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',sans-serif !important; + + +} + code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; diff --git a/src/lib/config.js b/src/lib/config.js index cbd7261..f0add66 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -11,10 +11,10 @@ async function Config() { if(response.data.length>0) { - const { JF_HOST, JF_API_KEY, APP_USER } = response.data[0]; - return { hostUrl: JF_HOST, apiKey: JF_API_KEY, username: APP_USER, token:token }; + const { JF_HOST, APP_USER,REQUIRE_LOGIN } = response.data[0]; + return { hostUrl: JF_HOST, username: APP_USER, token:token, requireLogin:REQUIRE_LOGIN }; } - return { hostUrl: null, apiKey: null, username: null, token:token }; + return { hostUrl: null, username: null, token:token,requireLogin:true }; } catch (error) { // console.log(error); diff --git a/src/lib/devices.js b/src/lib/devices.js index ab248bc..486b59f 100644 --- a/src/lib/devices.js +++ b/src/lib/devices.js @@ -1,3 +1,3 @@ -export const clientData = ["android","ios","safari","chrome","firefox","edge"] +export const clientData = ["android","ios","safari","chrome","firefox","edge","opera"] diff --git a/src/lib/navdata.js b/src/lib/navdata.js index da09e70..ef87ef3 100644 --- a/src/lib/navdata.js +++ b/src/lib/navdata.js @@ -7,6 +7,7 @@ import HistoryFillIcon from 'remixicon-react/HistoryFillIcon'; 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 ReactjsFillIcon from 'remixicon-react/ReactjsFillIcon'; @@ -16,7 +17,7 @@ export const navData = [ id: 0, icon: , text: "Home", - link: "/" + link: "" }, { id: 1, @@ -49,13 +50,14 @@ export const navData = [ text: "Settings", link: "settings" } + , + + { + id: 7, + icon: , + text: "About", + link: "about" + } ] -// { -// id: 5, -// icon: , -// text: "Component Testing Playground", -// link: "testing" -// } -// , \ No newline at end of file diff --git a/src/models/libraryItem.js b/src/models/libraryItem.js deleted file mode 100644 index 63c9962..0000000 --- a/src/models/libraryItem.js +++ /dev/null @@ -1,7 +0,0 @@ -class libraryItem { - constructor(id, name, email) { - this.id = id; - this.name = name; - this.email = email; - } - } \ No newline at end of file diff --git a/src/pages/about.js b/src/pages/about.js new file mode 100644 index 0000000..131eb9f --- /dev/null +++ b/src/pages/about.js @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +// import Button from "react-bootstrap/Button"; +// import Card from 'react-bootstrap/Card'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import Loading from "./components/general/loading"; + + +import "./css/about.css"; +import { Card } from "react-bootstrap"; + +export default function SettingsAbout() { + + const token = localStorage.getItem('token'); + const [data, setData] = useState(); + useEffect(() => { + + const fetchVersion = () => { + if (token) { + const url = `/api/CheckForUpdates`; + + axios + .get(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((data) => { + setData(data.data); + }) + .catch((error) => { + console.log(error); + }); + } + }; + + if(!data) + { + fetchVersion(); + } + + const intervalId = setInterval(fetchVersion, 60000 * 5); + return () => clearInterval(intervalId); + }, [data,token]); + + + if(!data) + { + return ; + } + + + return ( +
+

About Jellystat

+ + + + + Version: + + + {data.current_version} + + + + + Update Available: + + + {data.message} + + + + + + Github: + + + https://github.com/CyferShepard/Jellystat + + + + +
+ ); + + +} diff --git a/src/pages/components/activity/activity-table.js b/src/pages/components/activity/activity-table.js index b9db805..0ed902d 100644 --- a/src/pages/components/activity/activity-table.js +++ b/src/pages/components/activity/activity-table.js @@ -1,156 +1,348 @@ -import React ,{useState} from 'react'; +import React from 'react'; import { Link } from "react-router-dom"; -// import { useParams } from 'react-router-dom'; +import { Button, ButtonGroup,Modal } from "react-bootstrap"; + + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Collapse from '@mui/material/Collapse'; +import TableSortLabel from '@mui/material/TableSortLabel'; +import IconButton from '@mui/material/IconButton'; +import Box from '@mui/material/Box'; +import { visuallyHidden } from '@mui/utils'; + + +import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon'; +import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon'; + +import StreamInfo from './stream_info'; import '../../css/activity/activity-table.css'; +// localStorage.setItem('hour12',true); -function ActivityTable(props) { +function formatTotalWatchTime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; - const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); - const [currentPage, setCurrentPage] = useState(1); + let timeString = ''; + + if (hours > 0) { + timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `; + } + + if (minutes > 0) { + timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `; + } + + if (remainingSeconds > 0) { + timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`; + } + + return timeString.trim(); +} + +function Row(data) { + const { row } = data; + const [open, setOpen] = React.useState(false); + const twelve_hr = JSON.parse(localStorage.getItem('12hr')); - const [data, setData] = useState(props.data); - function handleSort(key) { - const direction = - sortConfig.key === key && sortConfig.direction === "ascending" - ? "descending" - : "ascending"; - setSortConfig({ key, direction }); - } - - function sortData(data, { key, direction }) { - if (!key) return data; + const [modalState,setModalState]= React.useState(false); + const [modalData,setModalData]= React.useState(); - const sortedData = [...data]; - - sortedData.sort((a, b) => { - if (a[key] < b[key]) return direction === "ascending" ? -1 : 1; - if (a[key] > b[key]) return direction === "ascending" ? 1 : -1; - return 0; - }); - - return sortedData; - } - - const options = { - day: "numeric", - month: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: false, - }; - - - const sortedData = sortData(data, sortConfig); - - const indexOfLastUser = currentPage * props.itemCount; - const indexOfFirstUser = indexOfLastUser - props.itemCount; - const currentData = sortedData.slice(indexOfFirstUser, indexOfLastUser); - - const pageNumbers = []; - for (let i = 1; i <= Math.ceil(sortedData.length / props.itemCount); i++) { - pageNumbers.push(i); - } - - const handleCollapse = (itemId) => { - setData(data.map(item => { - if ((item.NowPlayingItemId+item.EpisodeId) === itemId) { - return { ...item, isCollapsed: !item.isCollapsed }; - } else { - return item; - } - })); - } - function formatTotalWatchTime(seconds) { - const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds - const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds - let formattedTime=''; - if(hours) - { - formattedTime+=`${hours} hours`; - } - if(minutes) - { - formattedTime+=` ${minutes} minutes`; - } - - return formattedTime ; - } - - + const openModal = (data) => { + setModalData(data); + setModalState(!modalState); + }; + + + + + + + const options = { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: twelve_hr, + }; + + return ( -
- -
-
-
handleSort("UserName")}>User
-
handleSort("NowPlayingItemName")}>Title
-
handleSort("ActivityDateInserted")}>Date
-
handleSort("PlaybackDuration")}>Playback Duration
-
handleSort("results")}>Total Plays
-
+ - {currentData.map((item) => ( + setModalState(false)} > + + Stream Info: {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} ({row.UserName}) + + + + + + -
handleCollapse(item.NowPlayingItemId+item.EpisodeId)}> -
-
{item.UserName}
-
{!item.SeriesName ? item.NowPlayingItemName : item.SeriesName+' - '+ item.NowPlayingItemName}
-
{Intl.DateTimeFormat('en-UK', options).format(new Date(item.ActivityDateInserted))}
-
{formatTotalWatchTime(item.PlaybackDuration) || '0 sec'}
-
{item.results.length+1}
-
-
- {item.results.map((sub_item,index) => ( + *': { borderBottom: 'unset' } }}> + + {if(row.results.length>1){setOpen(!open);}}} + > + {!open ? 1 ?1 : 0} cursor={row.results.length>1 ? "pointer":"default"}/> : } + + + {row.UserName} + {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} + openModal(row)}>{row.Client} + {Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))} + {formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'} + {row.results.length !==0 ? row.results.length : 1} + + + + + -
-
{sub_item.UserName}
-
{!sub_item.SeriesName ? sub_item.NowPlayingItemName : sub_item.SeriesName+' - '+ sub_item.NowPlayingItemName}
-
{Intl.DateTimeFormat('en-UK', options).format(new Date(sub_item.ActivityDateInserted))}
-
-
1
-
- ))} -
-
- ))} -
- - - - - - {props.itemCount>0 ? - -
- - - - -
{`Page ${currentPage} of ${pageNumbers.length}`}
- - - - -
- :<> - - } -
+ + + + User + Title + Client + Date + Playback Duration + Plays + + + + {row.results.sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted)).map((resultRow) => ( + + {resultRow.UserName} + {!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName} + openModal(resultRow)}>{resultRow.Client} + {Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))} + {formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'} + 1 + + ))} + +
+ + + + + ); } -export default ActivityTable; + +function EnhancedTableHead(props) { + const { order, orderBy, onRequestSort } = + props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; + + const headCells = [ + { + id: 'UserName', + numeric: false, + disablePadding: true, + label: 'Last User', + }, + { + id: 'NowPlayingItemName', + numeric: false, + disablePadding: false, + label: 'Title', + }, + { + id: 'Client', + numeric: false, + disablePadding: false, + label: 'Last Client', + }, + { + id: 'ActivityDateInserted', + numeric: false, + disablePadding: false, + label: 'Date', + }, + { + id: 'PlaybackDuration', + numeric: false, + disablePadding: false, + label: 'Total Playback', + }, + { + id: 'TotalPlays', + numeric: false, + disablePadding: false, + label: 'TotalPlays', + }, + ]; + + + return ( + + + + {headCells.map((headCell) => ( + + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + + ))} + + + ); +} + +export default function ActivityTable(props) { + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + + const [order, setOrder] = React.useState('desc'); + const [orderBy, setOrderBy] = React.useState('ActivityDateInserted'); + + + if(rowsPerPage!==props.itemCount) + { + setRowsPerPage(props.itemCount); + setPage(0); + } + + + const handleNextPageClick = () => { + setPage((prevPage) => prevPage + 1); + }; + + const handlePreviousPageClick = () => { + setPage((prevPage) => prevPage - 1); + }; + + + function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; + } + + // eslint-disable-next-line + function getComparator(order, orderBy) { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); + } + + + function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); + + stabilizedThis.sort((a, b) => { + + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + + }); + + return stabilizedThis.map((el) => el[0]); + } + + const visibleRows = React.useMemo( + () => + stableSort(props.data, getComparator(order, orderBy)).slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ), + [order, orderBy, page, rowsPerPage, getComparator, props.data], + ); + + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + + + + return ( + <> + + + + + {visibleRows.map((row) => ( + + ))} + {props.data.length===0 ? :''} + + +
No Activity Found
+
+ +
+ + + + + +
{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),props.data.length)} of ${props.data.length}`}
+ + + + +
+
+ + + ); +} \ No newline at end of file diff --git a/src/pages/components/activity/stream_info.js b/src/pages/components/activity/stream_info.js new file mode 100644 index 0000000..a1ca1dc --- /dev/null +++ b/src/pages/components/activity/stream_info.js @@ -0,0 +1,176 @@ +import React from "react"; +import "../../css/activity/stream-info.css"; +// import { Button } from "react-bootstrap"; +import Loading from "../general/loading"; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; + + + +function Row(logs) { + const { data } = logs; + + function convertBitrate(bitrate) { + if(!bitrate) + { + return '-'; + } + const kbps = (bitrate / 1000).toFixed(1); + const mbps = (bitrate / 1000000).toFixed(1); + + if (kbps >= 1000) { + return mbps+' Mbps'; + } else { + return kbps+' Kbps'; + } + } + + if(!data || !data.MediaStreams) + { + return null; + } + + + + + + return ( + + + + Media + + + + Bitrate + {convertBitrate(data.TranscodingInfo ? data.TranscodingInfo.Bitrate : (data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null))} + {convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null)} + + + + Container + {data.TranscodingInfo ? data.TranscodingInfo.Container.toUpperCase() : data.OriginalContainer.toUpperCase()} + {data.OriginalContainer.toUpperCase()} + + + + Video + {data.TranscodingInfo ? (data.TranscodingInfo?.IsVideoDirect ? 'DIRECT' :'TRANSCODE'):'DIRECT'} + + + + Codec + {data.TranscodingInfo ? data.TranscodingInfo.VideoCodec?.toUpperCase() : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Codec?.toUpperCase() : '-'} + + + + Bitrate + {convertBitrate(data.TranscodingInfo ? data.TranscodingInfo.Bitrate : null)} + {convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null)} + + + + Width + {data.TranscodingInfo ? data.TranscodingInfo.Width : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Width : '-'} + + + + Height + {data.TranscodingInfo?.IsVideoDirect ? data.MediaStreams?.find(stream => stream.Type === 'Video')?.Height : data.TranscodingInfo?.Height || '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Height : '-'} + + + + Framerate + {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) : '-'} + + + + Dynamic Range + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.VideoRange : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.VideoRange : '-'} + + + + Aspect Ratio + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'} + + + + Audio + {data.TranscodingInfo ? (data.TranscodingInfo?.IsAudioDirect ? 'DIRECT' :'TRANSCODE'):'DIRECT'} + + + + Codec + {data.TranscodingInfo?.IsAudioDirect ? data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase() : data.TranscodingInfo?.AudioCodec.toUpperCase()|| data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase()} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase() : '-'} + + + + Bitrate + {convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.BitRate : null)} + {convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.BitRate : null)} + + + + Channels + {data.TranscodingInfo?.IsAudioDirect ? data.TranscodingInfo?.AudioChannels: data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Channels} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Channels : null} + + + + Language + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Language?.toUpperCase() : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Language?.toUpperCase() : '-'} + + + + + + ); + } + + +function StreamInfo(props) { + + + if(!props && !props.data) + { + return ; + } + + + + return ( +
+ + + + + + + Stream Details + Source Details + + + + + + +
+
+ +
+ ); +} + +export default StreamInfo; \ No newline at end of file diff --git a/src/pages/components/general/ErrorBoundary.js b/src/pages/components/general/ErrorBoundary.js new file mode 100644 index 0000000..4e7cb4a --- /dev/null +++ b/src/pages/components/general/ErrorBoundary.js @@ -0,0 +1,28 @@ +import React from "react"; +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + // Update state to indicate an error has occurred + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + // You can log the error or perform other actions here + console.error(error, errorInfo); + } + + render() { + if (this.state.hasError) { + // Render an error message or fallback UI + return <>; + } + + // Render the child components as normal + return this.props.children; + } + } + \ No newline at end of file diff --git a/src/pages/components/general/last-watched-card.js b/src/pages/components/general/last-watched-card.js index 23278a3..b18d8c1 100644 --- a/src/pages/components/general/last-watched-card.js +++ b/src/pages/components/general/last-watched-card.js @@ -33,16 +33,15 @@ function LastWatchedCard(props) { const [loaded, setLoaded] = useState(false); return (
- +
- {loaded ? null : } + {!loaded && props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null ? : null} setLoaded(true)} diff --git a/src/pages/components/general/navbar.js b/src/pages/components/general/navbar.js index dcd395e..911cfb9 100644 --- a/src/pages/components/general/navbar.js +++ b/src/pages/components/general/navbar.js @@ -1,8 +1,11 @@ -import { Nav, Navbar as BootstrapNavbar, Container } from "react-bootstrap"; +import { Nav, Navbar as BootstrapNavbar } from "react-bootstrap"; import { Link, useLocation } from "react-router-dom"; import { navData } from "../../../lib/navdata"; import LogoutBoxLineIcon from "remixicon-react/LogoutBoxLineIcon"; +import logo_dark from '../../images/icon-b-512.png'; import "../../css/navbar.css"; +import React from "react"; +import VersionCard from "./version-card"; export default function Navbar() { const handleLogout = () => { @@ -10,36 +13,44 @@ export default function Navbar() { window.location.reload(); }; - const location = useLocation(); // use the useLocation hook from react-router-dom + const location = useLocation(); return ( - - - Jellystat - - - - - + +
+ + + + Jellystat + + + + + + +
+ +
); } diff --git a/src/pages/components/general/version-card.js b/src/pages/components/general/version-card.js new file mode 100644 index 0000000..6dfe845 --- /dev/null +++ b/src/pages/components/general/version-card.js @@ -0,0 +1,71 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; + +import "../../css/settings/version.css"; +import { Card } from "react-bootstrap"; + +export default function VersionCard() { + + const token = localStorage.getItem('token'); + const [data, setData] = useState(); + useEffect(() => { + + const fetchVersion = () => { + if (token) { + const url = `/api/CheckForUpdates`; + + axios + .get(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((data) => { + setData(data.data); + }) + .catch((error) => { + console.log(error); + }); + } + }; + if(!data) + { + fetchVersion(); + } + + const intervalId = setInterval(fetchVersion, 60000 * 5); + return () => clearInterval(intervalId); + }, [data,token]); + + + if(!data) + { + return <>; + } + + + return ( + + + + Jellystat {data.current_version} + + + + {data.update_available? + + New version available: {data.latest_version} + + : + <> + } + + + + ); + + +} diff --git a/src/pages/components/item-info.js b/src/pages/components/item-info.js index 18b4b26..7c4c257 100644 --- a/src/pages/components/item-info.js +++ b/src/pages/components/item-info.js @@ -1,10 +1,19 @@ import React, { useState, useEffect } from "react"; import axios from "axios"; import { useParams } from 'react-router-dom'; +import { Link } from "react-router-dom"; +import { Blurhash } from 'react-blurhash'; +import {Row, Col, Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap'; + +import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon"; import GlobalStats from './item-info/globalStats'; -import ItemDetails from './item-info/item-details'; +import "../css/items/item-details.css"; + import MoreItems from "./item-info/more-items"; +import ItemActivity from "./item-info/item-activity"; +import ItemNotFound from "./item-info/item-not-found"; + import Config from "../../lib/config"; import Loading from "./general/loading"; @@ -16,21 +25,35 @@ function ItemInfo() { const [data, setData] = useState(); const [config, setConfig] = useState(); const [refresh, setRefresh] = useState(true); + const [activeTab, setActiveTab] = useState('tabOverview'); -useEffect(() => { + const [loaded, setLoaded] = useState(false); - const fetchConfig = async () => { - try { - const newConfig = await Config(); - setConfig(newConfig); - } catch (error) { - console.log(error); - } - }; + function formatFileSize(sizeInBytes) { + const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes + if (sizeInMB < 1000) { + return `${sizeInMB.toFixed(2)} MB`; + } else { + const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB + return `${sizeInGB.toFixed(2)} GB`; + } + } + + function ticksToTimeString(ticks) { + const seconds = Math.floor(ticks / 10000000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + const timeString = `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; + + return timeString; + } const fetchData = async () => { - if(config){ + if(config && (!data || data.notfound)){ setRefresh(true); try { const itemData = await axios.post(`/api/getItemDetails`, { @@ -44,15 +67,32 @@ useEffect(() => { setData(itemData.data[0]); - setRefresh(false); + } catch (error) { + setData({notfound:true, message:error.response.data}); console.log(error); } + setRefresh(false); } }; + +useEffect(() => { + + + const fetchConfig = async () => { + try { + const newConfig = await Config(); + setConfig(newConfig); + } catch (error) { + console.log(error); + } + }; + + + fetchData(); if (!config) { @@ -61,34 +101,110 @@ useEffect(() => { const intervalId = setInterval(fetchData, 60000 * 5); return () => clearInterval(intervalId); -}, [config, Id]); +}); -if(!data) -{ - return <>; -} -if(refresh) + +if(!data || refresh) { return ; } - - + +if(data && data.notfound) +{ + return ; + // return ; +} + +const cardStyle = { + backgroundImage: `url(/Proxy/Items/Images/Backdrop?id=${(["Episode","Season"].includes(data.Type)? data.SeriesId : data.Id)}&fillWidth=800&quality=90)`, + height:'100%', + backgroundSize: 'cover', +}; + +const cardBgStyle = { + backgroundColor: 'rgb(0, 0, 0, 0.8)', + +}; + + return (
- - - {["Series","Season"].includes(data && data.Type)? - - : - <> - } + +
+ + + {data.PrimaryImageHash && data.PrimaryImageHash!=null && !loaded ? : null} + setLoaded(true)} + /> + + + +
+
+

+ {data.SeriesId? + {data.SeriesName || data.Name} + : + data.SeriesName || data.Name + } + +

+ +
+ +
+ {data.Type==="Episode"?

{data.SeasonName} Episode {data.IndexNumber} - {data.Name}

: <> } + {data.Type==="Season"?

{data.Name}

: <> } + {data.FileName ?

File Name: {data.FileName}

:<>} + {data.Path ?

File Path: {data.Path}

:<>} + {data.RunTimeTicks ?

{data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(data.RunTimeTicks)}

:<>} + {data.Size ?

File Size: {formatFileSize(data.Size)}

:<>} + +
+ + + + +
+ + +
+ + +
+ + + + + + {["Series","Season"].includes(data && data.Type)? + + : + <> + } + + + + +
); } diff --git a/src/pages/components/item-info/item-activity.js b/src/pages/components/item-info/item-activity.js new file mode 100644 index 0000000..aff713f --- /dev/null +++ b/src/pages/components/item-info/item-activity.js @@ -0,0 +1,64 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import ActivityTable from "../activity/activity-table"; + +function ItemActivity(props) { + const [data, setData] = useState(); + const token = localStorage.getItem('token'); + const [itemCount,setItemCount] = useState(10); + + useEffect(() => { + + const fetchData = async () => { + try { + const itemData = await axios.post(`/api/getItemHistory`, { + itemid: props.itemid, + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(itemData.data); + } catch (error) { + console.log(error); + } + }; + + if (!data) { + fetchData(); + } + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [data, props.itemid,token]); + + + if (!data) { + return <>; + } + + return ( +
+
+

Item Activity

+
+
Items
+ +
+
+
+ + + +
+
+ ); +} + +export default ItemActivity; diff --git a/src/pages/components/item-info/item-details.js b/src/pages/components/item-info/item-details.js index f6fdf2b..cd055e8 100644 --- a/src/pages/components/item-info/item-details.js +++ b/src/pages/components/item-info/item-details.js @@ -70,7 +70,7 @@ function ItemDetails(props) {

{props.data.SeriesId? - {props.data.SeriesName || props.data.Name} + {props.data.SeriesName || props.data.Name} : props.data.SeriesName || props.data.Name } @@ -80,7 +80,7 @@ function ItemDetails(props) {

- {props.data.Type==="Episode"?

{props.data.SeasonName} Episode {props.data.IndexNumber} - {props.data.Name}

: <> } + {props.data.Type==="Episode"?

{props.data.SeasonName} Episode {props.data.IndexNumber} - {props.data.Name}

: <> } {props.data.Type==="Season"?

{props.data.Name}

: <> } {props.data.FileName ?

File Name: {props.data.FileName}

:<>} {props.data.Path ?

File Path: {props.data.Path}

:<>} diff --git a/src/pages/components/item-info/item-not-found.js b/src/pages/components/item-info/item-not-found.js new file mode 100644 index 0000000..5be03af --- /dev/null +++ b/src/pages/components/item-info/item-not-found.js @@ -0,0 +1,55 @@ +import React, {useState} from "react"; +import axios from "axios"; +import "../../css/error.css"; +import { Button } from "react-bootstrap"; +import Loading from "../general/loading"; + +function ItemNotFound(props) { + const [itemId] = useState(props.itemId); + const [loading,setLoading] = useState(false); + const token = localStorage.getItem('token'); + + async function fetchItem() { + setLoading(true); + const result = await axios + .post("/sync/fetchItem", { + itemId:itemId + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .catch((error) => { + setLoading(false); + console.log(error); + }); + + if(result) + { + + await props.fetchdataMethod(); + setLoading(false); + + } + + + } + + if(loading) + { + return ; + } + + + + return ( +
+

{props.message}

+ + +
+ ); +} + +export default ItemNotFound; \ No newline at end of file diff --git a/src/pages/components/item-info/more-items.js b/src/pages/components/item-info/more-items.js index faf7392..46014fa 100644 --- a/src/pages/components/item-info/more-items.js +++ b/src/pages/components/item-info/more-items.js @@ -69,7 +69,7 @@ function MoreItems(props) {

{props.data.Type==="Season" ? "Episodes" : "Seasons"}

- {data.map((item) => ( + {data.sort((a,b) => a.IndexNumber-b.IndexNumber).map((item) => ( ))} diff --git a/src/pages/components/item-info/more-items/more-items-card.js b/src/pages/components/item-info/more-items/more-items-card.js index fcdc23f..1b56e41 100644 --- a/src/pages/components/item-info/more-items/more-items-card.js +++ b/src/pages/components/item-info/more-items/more-items-card.js @@ -11,20 +11,20 @@ function MoreItemCards(props) { const { Id } = useParams(); const [loaded, setLoaded] = useState(false); const [fallback, setFallback] = useState(false); + return ( -
- +
+
- {props.data.ImageBlurHashes && !loaded ? : null} + {((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) ) && !loaded ? : null} {fallback ? setLoaded(true)} @@ -34,10 +34,9 @@ function MoreItemCards(props) { setLoaded(true)} diff --git a/src/pages/components/library-info.js b/src/pages/components/library-info.js index 36277f7..15c6fe5 100644 --- a/src/pages/components/library-info.js +++ b/src/pages/components/library-info.js @@ -1,23 +1,103 @@ import { useParams } from 'react-router-dom'; +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import TvLineIcon from "remixicon-react/TvLineIcon"; +import FilmLineIcon from "remixicon-react/FilmLineIcon"; -import LibraryDetails from './library/library-details'; + +// import LibraryDetails from './library/library-details'; +import Loading from './general/loading'; import LibraryGlobalStats from './library/library-stats'; import LibraryLastWatched from './library/last-watched'; -import RecentlyPlayed from './library/recently-added'; +import RecentlyAdded from './library/recently-added'; +import LibraryActivity from './library/library-activity'; +import LibraryItems from './library/library-items'; +import ErrorBoundary from './general/ErrorBoundary'; + +import { Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap'; + function LibraryInfo() { const { LibraryId } = useParams(); + const [activeTab, setActiveTab] = useState('tabOverview'); + const [data, setData] = useState(); + const token = localStorage.getItem('token'); + + useEffect(() => { + + const fetchData = async () => { + try { + console.log('getdata'); + const libraryrData = await axios.post(`/stats/getLibraryDetails`, { + libraryid: LibraryId, + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(libraryrData.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [LibraryId,token]); + + if(!data) + { + return ; + } return (
- + + +
+
+ {data.CollectionType==="tvshows" ? + + + : + + } +
+
+

{data.Name}

+ + + + + +
+ + + +
+ + - + + + + + + + + + + + +
); } diff --git a/src/pages/components/library/RecentlyAdded/recently-added-card.js b/src/pages/components/library/RecentlyAdded/recently-added-card.js index d958e2f..10670e8 100644 --- a/src/pages/components/library/RecentlyAdded/recently-added-card.js +++ b/src/pages/components/library/RecentlyAdded/recently-added-card.js @@ -10,16 +10,14 @@ function RecentlyAddedCard(props) { const [loaded, setLoaded] = useState(false); return (
- +
- {loaded ? null : } + {loaded ? null : } setLoaded(true)} diff --git a/src/pages/components/library/library-activity.js b/src/pages/components/library/library-activity.js new file mode 100644 index 0000000..aa8f002 --- /dev/null +++ b/src/pages/components/library/library-activity.js @@ -0,0 +1,65 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; + +import ActivityTable from "../activity/activity-table"; + +function LibraryActivity(props) { + const [data, setData] = useState(); + const token = localStorage.getItem('token'); + const [itemCount,setItemCount] = useState(10); + + useEffect(() => { + + const fetchData = async () => { + try { + const libraryrData = await axios.post(`/api/getLibraryHistory`, { + libraryid: props.LibraryId, + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(libraryrData.data); + } catch (error) { + console.log(error); + } + }; + + if (!data) { + fetchData(); + } + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [data, props.LibraryId,token]); + + + if (!data) { + return <>; + } + + return ( +
+
+

Library Activity

+
+
Items
+ +
+
+
+ + + +
+
+ ); +} + +export default LibraryActivity; diff --git a/src/pages/components/library/library-card.js b/src/pages/components/library/library-card.js index f60d2fa..768161d 100644 --- a/src/pages/components/library/library-card.js +++ b/src/pages/components/library/library-card.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, {useState} from "react"; import { Link } from "react-router-dom"; import "../../css/library/library-card.css"; @@ -6,7 +6,19 @@ import Card from 'react-bootstrap/Card'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; +import TvLineIcon from "remixicon-react/TvLineIcon"; +import FilmLineIcon from "remixicon-react/FilmLineIcon"; +import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon"; +import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon"; + function LibraryCard(props) { + const [imageLoaded, setImageLoaded] = useState(true); + const SeriesIcon= ; + const MovieIcon= ; + const MusicIcon= ; + const MixedIcon= ; + + const default_image=
{props.data.CollectionType==='tvshows' ? SeriesIcon : props.data.CollectionType==='movies'? MovieIcon : props.data.CollectionType==='music'? MusicIcon : MixedIcon}
; function formatFileSize(sizeInBytes) { const sizeInKB = sizeInBytes / 1024; // 1 KB = 1024 bytes @@ -36,24 +48,59 @@ function LibraryCard(props) { function formatTotalWatchTime(seconds) { - const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds - const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds - let formattedTime=''; - if(hours) - { - formattedTime+=`${hours} hours`; + const days = Math.floor(seconds / 86400); // 1 day = 86400 seconds + const hours = Math.floor((seconds % 86400) / 3600); // 1 hour = 3600 seconds + const minutes = Math.floor(((seconds % 86400) % 3600) / 60); // 1 minute = 60 seconds + + let formattedTime = ''; + if (days) { + formattedTime += `${days} day${days > 1 ? 's' : ''}`; } - if(minutes) - { - formattedTime+=` ${minutes} minutes`; + + if (hours) { + formattedTime += ` ${hours} hour${hours > 1 ? 's' : ''}`; } - if(!hours && !minutes) - { - formattedTime=`0 minutes`; + + if (minutes) { + formattedTime += ` ${minutes} minute${minutes > 1 ? 's' : ''}`; } - return formattedTime ; + if (!days && !hours && !minutes) { + formattedTime = '0 minutes'; + } + + return formattedTime; + } + function ticksToTimeString(ticks) { + const seconds = Math.floor(ticks / 10000000); + const months = Math.floor(seconds / (86400 * 30)); // 1 month = 86400 seconds + const days = Math.floor((seconds % (86400 * 30)) / 86400); // 1 day = 86400 seconds + const hours = Math.floor((seconds % 86400) / 3600); // 1 hour = 3600 seconds + const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds + + const timeComponents = []; + + if (months) { + timeComponents.push(`${months} Month${months > 1 ? 's' : ''}`); + } + + if (days) { + timeComponents.push(`${days} day${days > 1 ? 's' : ''}`); + } + + if (hours) { + timeComponents.push(`${hours} hour${hours > 1 ? 's' : ''}`); + } + + if (!months && minutes) { + timeComponents.push(`${minutes} minute${minutes > 1 ? 's' : ''}`); + } + + const formattedTime = timeComponents.length > 0 ? timeComponents.join(' ') : '0 minutes'; + return formattedTime; + } + function formatLastActivityTime(time) { const units = { @@ -74,14 +121,21 @@ function LibraryCard(props) { return `${formattedTime}ago`; } return ( - +
+ + {imageLoaded? + setImageLoaded(false)} /> + : + default_image + }
@@ -107,17 +161,22 @@ function LibraryCard(props) { Type - {props.data.CollectionType==='tvshows' ? 'Series' : "Movies"} + {props.data.CollectionType==='tvshows' ? 'Series' : props.data.CollectionType==='movies'? "Movies" : props.data.CollectionType==='music'? "Music" : 'Mixed'} + + + + Total Time + {ticksToTimeString(props.data && props.data.total_play_time ? props.data.total_play_time:0)} Total Files - {props.metadata.files} + {props.metadata && props.metadata.files ? props.metadata.files :0} Library Size - {formatFileSize(props.metadata.Size)} + {formatFileSize(props.metadata && props.metadata.Size ? props.metadata.Size:0)} @@ -141,7 +200,7 @@ function LibraryCard(props) { - {props.data.CollectionType==='tvshows' ? 'Series' : "Movies"} + {props.data.CollectionType==='tvshows' ? 'Series' : props.data.CollectionType==='movies'? "Movies" : props.data.CollectionType==='music'? "Songs" : 'Files'} {props.data.Library_Count} diff --git a/src/pages/components/library/library-items.js b/src/pages/components/library/library-items.js new file mode 100644 index 0000000..9fe752a --- /dev/null +++ b/src/pages/components/library/library-items.js @@ -0,0 +1,163 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import {FormControl, FormSelect, Button } from 'react-bootstrap'; +import SortAscIcon from 'remixicon-react/SortAscIcon'; +import SortDescIcon from 'remixicon-react/SortDescIcon'; + + +import MoreItemCards from "../item-info/more-items/more-items-card"; + + +import Config from "../../../lib/config"; +import "../../css/library/media-items.css"; +import "../../css/width_breakpoint_css.css"; +import "../../css/radius_breakpoint_css.css"; + +function LibraryItems(props) { + const [data, setData] = useState(); + const [config, setConfig] = useState(); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOrder, setSortOrder] = useState('Title'); + const [sortAsc, setSortAsc] = useState(true); + + + useEffect(() => { + + const fetchConfig = async () => { + try { + const newConfig = await Config(); + setConfig(newConfig); + } catch (error) { + console.log(error); + } + }; + + const fetchData = async () => { + try { + const itemData = await axios.post(`/stats/getLibraryItemsWithStats`, { + libraryid: props.LibraryId, + }, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }); + setData(itemData.data); + } catch (error) { + console.log(error); + } + }; + + + if (!config) { + fetchConfig(); + }else{ + fetchData(); + } + + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [config, props.LibraryId]); + + function sortOrderLogic(_sortOrder) + { + if(_sortOrder!=='Title') + { + setSortAsc(false); + }else{ + setSortAsc(true); + } + setSortOrder(_sortOrder); + + } + + let filteredData = data; + + if(searchQuery) + { + filteredData = data.filter((item) => + item.Name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + + + if (!data || !config) { + return <>; + } + + return ( +
+
+

Media

+ + +
+
+ sortOrderLogic(e.target.value) } className="my-md-3 w-100 rounded-0 rounded-start"> + + + + + + +
+ setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" /> + +
+ +
+ + + +
+ {filteredData.sort((a, b) => + { + const titleA = a.Name.replace(/^(A |An |The )/i, ''); + const titleB = b.Name.replace(/^(A |An |The )/i, ''); + + if(sortOrder==='Title') + { + if(sortAsc) + { + return titleA.localeCompare(titleB); + } + return titleB.localeCompare(titleA); + }else if(sortOrder==='Views') + { + if(sortAsc) + { + return a.times_played-b.times_played; + } + return b.times_played-a.times_played; + } + else + { + if(sortAsc) + { + return a.total_play_time-b.total_play_time; + } + return b.total_play_time-a.total_play_time; + } + + + } + ).map((item) => ( + + ))} + +
+ +
+ ); +} + +export default LibraryItems; diff --git a/src/pages/components/library/recently-added.js b/src/pages/components/library/recently-added.js index 1033357..0417b66 100644 --- a/src/pages/components/library/recently-added.js +++ b/src/pages/components/library/recently-added.js @@ -5,8 +5,9 @@ import RecentlyAddedCard from "./RecentlyAdded/recently-added-card"; import Config from "../../../lib/config"; import "../../css/users/user-details.css"; +import ErrorBoundary from "../general/ErrorBoundary"; -function RecentlyPlayed(props) { +function RecentlyAdded(props) { const [data, setData] = useState(); const [config, setConfig] = useState(); @@ -22,32 +23,27 @@ function RecentlyPlayed(props) { } }; - const fetchAdmin = async () => { + + + const fetchData = async () => { try { - let url=`/api/getAdminUsers`; - const adminData = await axios.get(url, { + let url=`/stats/getRecentlyAdded`; + if(props.LibraryId) + { + url+=`?libraryid=${props.LibraryId}`; + } + + const itemData = await axios.get(url, { headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, }); - return adminData.data[0].Id; - // setData(itemData.data); - } catch (error) { - console.log(error); - } - }; - - const fetchData = async () => { - try { - let adminId=await fetchAdmin(); - let url=`${config.hostUrl}/users/${adminId}/Items/latest?parentId=${props.LibraryId}`; - const itemData = await axios.get(url, { - headers: { - "X-MediaBrowser-Token": config.apiKey, - }, - }); - setData(itemData.data); + + if(itemData && typeof itemData.data === 'object' && Array.isArray(itemData.data)) + { + setData(itemData.data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type))); + } } catch (error) { console.log(error); } @@ -66,7 +62,11 @@ function RecentlyPlayed(props) { }, [data,config, props.LibraryId]); - if (!data || !config) { + if (!data && !config) { + return <>; + } + + if (!data && config) { return <>; } @@ -74,8 +74,10 @@ function RecentlyPlayed(props) {

Recently Added

- {data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)).map((item) => ( - + {data && data.map((item) => ( + + + ))}
@@ -84,4 +86,4 @@ function RecentlyPlayed(props) { ); } -export default RecentlyPlayed; +export default RecentlyAdded; diff --git a/src/pages/components/libraryOverview.js b/src/pages/components/libraryOverview.js index 7661be4..cef67ca 100644 --- a/src/pages/components/libraryOverview.js +++ b/src/pages/components/libraryOverview.js @@ -8,11 +8,15 @@ import LibraryStatComponent from "./libraryStatCard/library-stat-component"; import TvLineIcon from "remixicon-react/TvLineIcon"; import FilmLineIcon from "remixicon-react/FilmLineIcon"; +import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon"; +import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon"; export default function LibraryOverView() { const token = localStorage.getItem('token'); - const SeriesIcon= ; - const MovieIcon= ; + const SeriesIcon= ; + const MovieIcon= ; + const MusicIcon= ; + const MixedIcon= ; const [data, setData] = useState(); @@ -41,12 +45,13 @@ export default function LibraryOverView() { return (
-

Library Statistics

+

Library Overview

stat.CollectionType === "movies")} heading={"MOVIE LIBRARIES"} units={"MOVIES"} icon={MovieIcon}/> stat.CollectionType === "tvshows")} heading={"SHOW LIBRARIES"} units={"SERIES / SEASONS / EPISODES"} icon={SeriesIcon}/> - stat.CollectionType === "music")} heading={"MUSIC LIBRARIES"} units={"SONGS"} icon={SeriesIcon}/> + stat.CollectionType === "music")} heading={"MUSIC LIBRARIES"} units={"SONGS"} icon={MusicIcon}/> + stat.CollectionType === "mixed")} heading={"MIXED LIBRARIES"} units={"ITEMS"} icon={MixedIcon}/>
diff --git a/src/pages/components/libraryStatCard/library-stat-component.js b/src/pages/components/libraryStatCard/library-stat-component.js index 2717899..3e4000b 100644 --- a/src/pages/components/libraryStatCard/library-stat-component.js +++ b/src/pages/components/libraryStatCard/library-stat-component.js @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { Row, Col, Card } from "react-bootstrap"; function LibraryStatComponent(props) { @@ -14,25 +15,24 @@ function LibraryStatComponent(props) { }; const cardBgStyle = { - backdropFilter: 'blur(5px)', + // backdropFilter: 'blur(5px)', backgroundColor: 'rgb(0, 0, 0, 0.6)', height:'100%', }; return ( - -
+ +
+ -
{props.icon}
- - - + +
@@ -48,7 +48,7 @@ function LibraryStatComponent(props) {
{index + 1} - {item.Name} + {item.Name}
diff --git a/src/pages/components/sessions/session-card.js b/src/pages/components/sessions/session-card.js index cf391f4..62a4878 100644 --- a/src/pages/components/sessions/session-card.js +++ b/src/pages/components/sessions/session-card.js @@ -10,6 +10,7 @@ import PlayFillIcon from "remixicon-react/PlayFillIcon"; import PauseFillIcon from "remixicon-react/PauseFillIcon"; import { clientData } from "../../../lib/devices"; +import Tooltip from "@mui/material/Tooltip"; function ticksToTimeString(ticks) { @@ -28,12 +29,26 @@ function ticksToTimeString(ticks) { return timeString; } +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) { // Access data passed in as a prop using `props.data` const cardStyle = { - backgroundImage: `url(${props.data.base_url}/Items/${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}/Images/Backdrop?fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`, + backgroundImage: `url(Proxy/Items/Images/Backdrop?id=${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`, height:'100%', backgroundSize: 'cover', }; @@ -47,90 +62,112 @@ function sessionCard(props) { return ( -
+
- + - + + + + + + + + {props.data.session.DeviceName} + {props.data.session.Client + " " + props.data.session.ApplicationVersion} + + {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.DeviceName.toLowerCase().includes(item)) || "other") - : - ( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other") - ) - + - ".svg" - } - alt="" - /> + props.data.session.DeviceName.toLowerCase().includes(item)) || "other") + : + ( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other") + )} + alt="" + /> - - {props.data.session.DeviceName} - {props.data.session.Client + " " + props.data.session.ApplicationVersion} - - - - - - {props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)} - - - + {props.data.session.NowPlayingItem.Type==='Episode' ? + + + + + {props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)} + + + + + : + <> + } - - - - - {props.data.session.UserPrimaryImageTag !== undefined ? ( - - ) : ( - - )} + + + + {props.data.session.NowPlayingItem.Type==='Episode' ? + + + {'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber} + - - - {props.data.session.UserName} - + : + + + + {props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)} + + - - + } + + + + {props.data.session.UserPrimaryImageTag !== undefined ? ( + + ) : ( + + )} + + + {props.data.session.UserName} + + + + - + {props.data.session.PlayState.IsPaused ? diff --git a/src/pages/components/sessions/sessions.js b/src/pages/components/sessions/sessions.js index 6205b96..e10ae78 100644 --- a/src/pages/components/sessions/sessions.js +++ b/src/pages/components/sessions/sessions.js @@ -1,39 +1,58 @@ import React, { useState, useEffect } from "react"; -// import axios from 'axios'; +import axios from 'axios'; import Config from "../../../lib/config"; -import API from "../../../classes/jellyfin-api"; +// import API from "../../../classes/jellyfin-api"; import "../../css/sessions.css"; -// import "../../App.css" - +import ErrorBoundary from "../general/ErrorBoundary"; import SessionCard from "./session-card"; import Loading from "../general/loading"; function Sessions() { const [data, setData] = useState(); - const [base_url, setURL] = useState(""); - // const [errorHandler, seterrorHandler] = useState({ error_count: 0, error_message: '' }) + const [config, setConfig] = useState(); useEffect(() => { - const _api = new API(); - const fetchData = () => { - _api.getSessions().then((SessionData) => { - let results=SessionData.filter((session) => session.NowPlayingItem); - setData(results); - }); + const fetchConfig = async () => { + try { + const newConfig = await Config(); + setConfig(newConfig); + } catch (error) { + console.log(error); + } }; - if (base_url === "") { - Config() - .then((config) => { - setURL(config.hostUrl); + const fetchData = () => { + + if (config) { + const url = `/api/getSessions`; + + axios + .get(url, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }) + .then((data) => { + if(data && typeof data.data === 'object' && Array.isArray(data.data)) + { + setData(data.data.filter(row => row.NowPlayingItem !== undefined)); + } + }) .catch((error) => { console.log(error); }); } + }; + + + if (!config) { + fetchConfig(); + }else if(!data) { fetchData(); @@ -41,13 +60,14 @@ function Sessions() { const intervalId = setInterval(fetchData, 1000); return () => clearInterval(intervalId); - }, [data,base_url]); + }, [data,config]); - if (!data) { + if (!data && !config) { return ; } - if (data.length === 0) { + + if ((!data && config) || data.length === 0) { return(

Sessions

@@ -61,13 +81,15 @@ function Sessions() {

Sessions

- {data && + {data && data.length>0 && data .sort((a, b) => a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0")) ) .map((session) => ( - + + + ))}
diff --git a/src/pages/components/settings/Tasks.js b/src/pages/components/settings/Tasks.js new file mode 100644 index 0000000..341eef3 --- /dev/null +++ b/src/pages/components/settings/Tasks.js @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import axios from "axios"; +import Button from "react-bootstrap/Button"; + + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; + + +import "../../css/settings/settings.css"; + +export default function Tasks() { + const [processing, setProcessing] = useState(false); + const token = localStorage.getItem('token'); + async function beginSync() { + + + setProcessing(true); + + await axios + .get("/sync/beingSync", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((response) => { + if (response.status === 200) { + // isValid = true; + } + }) + .catch((error) => { + console.log(error); + }); + setProcessing(false); + // return { isValid: isValid, errorMessage: errorMessage }; + } + + async function createBackup() { + + + setProcessing(true); + + await axios + .get("/data/backup", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((response) => { + if (response.status === 200) { + // isValid = true; + } + }) + .catch((error) => { + console.log(error); + }); + setProcessing(false); + // return { isValid: isValid, errorMessage: errorMessage }; + } + + const handleClick = () => { + + beginSync(); + } + + return ( +
+

Tasks

+ + + + + + Task + Type + + + + + + + Synchronize with Jellyfin + Import + + + + + Backup Jellystat + Process + + + + +
+
+ +
+ ); + + +} diff --git a/src/pages/components/settings/TerminalComponent.js b/src/pages/components/settings/TerminalComponent.js index e65ccb3..d5eef1a 100644 --- a/src/pages/components/settings/TerminalComponent.js +++ b/src/pages/components/settings/TerminalComponent.js @@ -1,35 +1,18 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useState } from 'react'; import '../../css/websocket/websocket.css'; -const TerminalComponent = () => { - const [messages, setMessages] = useState([]); - const messagesEndRef = useRef(null); +function TerminalComponent(props){ + const [messages] = useState(props.data); - useEffect(() => { - // create a new WebSocket connection - const socket = new WebSocket(`ws://${window.location.hostname+':'+(process.env.WS_PORT || 3004)}/ws`); - - // handle incoming messages - socket.addEventListener('message', (event) => { - let message = JSON.parse(event.data); - setMessages(message); - }); - - // cleanup function to close the WebSocket connection when the component unmounts - return () => { - socket.close(); - } - }, []); return (
- {messages.map((message, index) => ( + {messages && messages.map((message, index) => (
{message.Message}
))} -
); diff --git a/src/pages/components/settings/backupfiles.js b/src/pages/components/settings/backupfiles.js index fc9bc9b..ab3ad31 100644 --- a/src/pages/components/settings/backupfiles.js +++ b/src/pages/components/settings/backupfiles.js @@ -1,42 +1,27 @@ import React, { useState,useEffect } from "react"; import axios from "axios"; -import { DropdownButton, Dropdown, Button } from 'react-bootstrap'; +import {Form, DropdownButton, Dropdown,ButtonGroup, Button } from 'react-bootstrap'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; + + + import Alert from "react-bootstrap/Alert"; - + import "../../css/settings/backups.css"; -import { Table } from "react-bootstrap"; - - -export default function BackupFiles() { - const [files, setFiles] = useState([]); - const [showAlert, setshowAlert] = useState({visible:false,type:'danger',title:'Error',message:''}); - const token = localStorage.getItem('token'); - - useEffect(() => { - - const fetchData = async () => { - try { - const backupFiles = await axios.get(`/data/files`, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setFiles(backupFiles.data); - } catch (error) { - console.log(error); - } - }; - fetchData(); - - const intervalId = setInterval(fetchData, 60000 * 5); - return () => clearInterval(intervalId); - }, [files,token]); +const token = localStorage.getItem('token'); +function Row(file) { + const { data } = file; async function downloadBackup(filename) { const url=`/data/files/${filename}`; @@ -69,10 +54,10 @@ export default function BackupFiles() { }, }) .then((response) => { - setshowAlert({visible:true,title:'Success',type:'success',message:response.data}); + BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data}); }) .catch((error) => { - setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data}); + BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data}); }); @@ -88,10 +73,10 @@ export default function BackupFiles() { }, }) .then((response) => { - setshowAlert({visible:true,title:'Success',type:'success',message:response.data}); + BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data}); }) .catch((error) => { - setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data}); + BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data}); }); @@ -125,6 +110,9 @@ export default function BackupFiles() { + + const twelve_hr = JSON.parse(localStorage.getItem('12hr')); + const options = { day: "numeric", month: "numeric", @@ -132,13 +120,107 @@ export default function BackupFiles() { hour: "numeric", minute: "numeric", second: "numeric", - hour12: false, + hour12: twelve_hr, }; + + + return ( + + *': { borderBottom: 'unset' } }}> + {data.name} + {Intl.DateTimeFormat('en-UK', options).format(new Date(data.datecreated))} + {formatFileSize(data.size)} + +
+ + downloadBackup(data.name)}>Download + restoreBackup(data.name)}>Restore + + deleteBackup(data.name)}>Delete + +
+ +
+ +
+
+ ); +} + + +export default function BackupFiles() { + const [files, setFiles] = useState([]); + const [showAlert, setshowAlert] = useState({visible:false,type:'danger',title:'Error',message:''}); + const [rowsPerPage] = React.useState(10); + const [page, setPage] = React.useState(0); + const [progress, setProgress] = useState(0); + + + + function handleCloseAlert() { setshowAlert({visible:false}); } +const uploadFile = (file, onUploadProgress) => { + const formData = new FormData(); + formData.append("file", file); + + return axios.post("/data/upload", formData, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "multipart/form-data", + }, + onUploadProgress, + }); +}; + + +const handleFileSelect = (event) => { + setProgress(0); + if (event.target.files[0]) { + uploadFile(event.target.files[0], (progressEvent) => { + setProgress(Math.round((progressEvent.loaded / progressEvent.total) * 100)); + }); + } +}; + + + + +useEffect(() => { + const fetchData = async () => { + try { + const backupFiles = await axios.get(`/data/files`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setFiles(backupFiles.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [files]); + + +const handleNextPageClick = () => { + setPage((prevPage) => prevPage + 1); +}; + +const handlePreviousPageClick = () => { + setPage((prevPage) => prevPage - 1); +}; + + + return (

Backups

@@ -150,37 +232,60 @@ export default function BackupFiles() {

)} - - - - - - - - - - - {files && - files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).map((file, index) => ( - - - - - - + +
File NameDate CreatedSize
{file.name}{Intl.DateTimeFormat('en-UK', options).format(new Date(file.datecreated))}{formatFileSize(file.size)} - - downloadBackup(file.name)}>Download - restoreBackup(file.name)}>Restore - - deleteBackup(file.name)}>Delete - -
+ + + File Name + Date Created + Size + + + + + {files && files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((file,index) => ( + ))} - {files.length===0 ? :''} - + {files.length===0 ? :''} + + + + + + + + + +
No Backups Found
No Backups Found
+ + +
+ + + + + + + +
{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),files.length)} of ${files.length}`}
+ + + + +
+
); diff --git a/src/pages/components/settings/logs.js b/src/pages/components/settings/logs.js new file mode 100644 index 0000000..ce1b2dd --- /dev/null +++ b/src/pages/components/settings/logs.js @@ -0,0 +1,215 @@ +import React, { useEffect } from "react"; +import axios from "axios"; +import {ButtonGroup, Button } from 'react-bootstrap'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import Box from '@mui/material/Box'; + +import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon'; +import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon'; + + + +import "../../css/settings/backups.css"; + +import TerminalComponent from "./TerminalComponent"; + +const token = localStorage.getItem('token'); + + +function Row(logs) { + const { data } = logs; + const [open, setOpen] = React.useState(false); + + + const twelve_hr = JSON.parse(localStorage.getItem('12hr')); + + const options = { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: twelve_hr, + }; + + + +function formatDurationTime(seconds) { + if(seconds==='0') + { + return '0 second'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + let timeString = ''; + + if (hours > 0) { + timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `; + } + + if (minutes > 0) { + timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `; + } + + if (remainingSeconds > 0) { + timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`; + } + + return timeString.trim(); +} + + + return ( + + *': { borderBottom: 'unset' } }}> + + {if(data.Log.length>1){setOpen(!open);}}} + > + {!open ? 1 ?1 : 0} cursor={data.Log.length>1 ? "pointer":"default"}/> : } + + + {data.Name} + {data.Type} + {Intl.DateTimeFormat('en-UK', options).format(new Date(data.TimeRun))} + {formatDurationTime(data.Duration)} + {data.ExecutionType} +
{data.Result}
+ +
+ + + + + + + + + + + + +
+
+
+
+
+
+ ); +} + + +export default function Logs() { + + const [data, setData]=React.useState([]); + const [rowsPerPage] = React.useState(10); + const [page, setPage] = React.useState(0); + + + + + +useEffect(() => { + const fetchData = async () => { + try { + const logs = await axios.get(`/logs/getLogs`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(logs.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [data]); + + +const handleNextPageClick = () => { + setPage((prevPage) => prevPage + 1); +}; + +const handlePreviousPageClick = () => { + setPage((prevPage) => prevPage - 1); +}; + + + + return ( +
+

Logs

+ + + + + + + Name + Type + Date Created + Duration + Execution Type + Result + + + + {data && data.sort((a, b) =>new Date(b.TimeRun) - new Date(a.TimeRun)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((log,index) => ( + + ))} + {data.length===0 ? :''} + + + +
No Logs Found
+
+ +
+ + + + + + + + +
{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),data.length)} of ${data.length}`}
+ + + + +
+
+
+ ); + + +} \ No newline at end of file diff --git a/src/pages/components/settings/security.js b/src/pages/components/settings/security.js new file mode 100644 index 0000000..d8c3d5c --- /dev/null +++ b/src/pages/components/settings/security.js @@ -0,0 +1,207 @@ +import React, { useState,useEffect } from "react"; +import axios from "axios"; +import Form from 'react-bootstrap/Form'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import Button from 'react-bootstrap/Button'; +import Alert from 'react-bootstrap/Alert'; +import ToggleButton from 'react-bootstrap/ToggleButton'; +import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup'; +import CryptoJS from 'crypto-js'; +import EyeFillIcon from 'remixicon-react/EyeFillIcon'; +import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon'; + +import Config from "../../../lib/config"; + + + + +import "../../css/settings/settings.css"; +import { InputGroup } from "react-bootstrap"; + +export default function SettingsConfig() { + const [use_password, setuse_password] = useState(true); + const [showPassword, setShowPassword] = useState(false); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [formValues, setFormValues] = useState({}); + const [isSubmitted, setisSubmitted] = useState(""); + + const [submissionMessage, setsubmissionMessage] = useState(""); + const token = localStorage.getItem('token'); + + useEffect(() => { + + + const fetchConfig = async () => { + try { + const newConfig = await Config(); + setuse_password(newConfig.requireLogin); + } catch (error) { + console.log(error); + } + }; + + + + fetchConfig(); + + const intervalId = setInterval(fetchConfig, 60000 * 5); + return () => clearInterval(intervalId); + }, []); + + +async function updatePassword(_current_password, _new_password) { + const result = await axios + .post("/api/updatePassword", { + current_password:_current_password, + new_password: _new_password + + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .catch((error) => { + // let errorMessage= `Error : ${error}`; + }); + + let data=result.data; + return { isValid:data.isValid, errorMessage:data.errorMessage} ; + } + + async function setRequireLogin(requireLogin) { + await axios + .post("/api/setRequireLogin", { + REQUIRE_LOGIN:requireLogin + + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((data)=> + { + setuse_password(requireLogin); + } + ) + .catch((error) => { + // let errorMessage= `Error : ${error}`; + + }); + + } + + + + async function handleFormSubmit(event) { + event.preventDefault(); + setisSubmitted(""); + if(!formValues.JS_PASSWORD || formValues.JS_PASSWORD.length<6) + { + setisSubmitted("Failed"); + setsubmissionMessage("Unable to update password: New Password Must be at least 6 characters long"); + return; + } + let hashedOldPassword= CryptoJS.SHA3(formValues.JS_C_PASSWORD).toString(); + let hashedNewPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString(); + let result = await updatePassword( + hashedOldPassword, + hashedNewPassword + ); + + if (result.isValid) { + setisSubmitted("Success"); + setsubmissionMessage("Successfully updated password"); + return; + }else + { + setisSubmitted("Failed"); + setsubmissionMessage("Unable to update password: "+ result.errorMessage); + return; + } + + } + + function handleFormChange(event) { + setFormValues({ ...formValues, [event.target.name]: event.target.value }); + } + + + function togglePasswordRequired(isRequired){ + // console.log(isRequired); + setRequireLogin(isRequired); + }; + + + + + return ( +
+

Security

+
+ + + Current Password + + + + + + + + + + + + New Password + + + + + + + + + + {isSubmitted !== "" ? ( + + isSubmitted === "Failed" ? + + {submissionMessage} + + : + + {submissionMessage} + + ) : ( + <> + )} +
+ +
+ +
+ +
+ + Require Login + + + {togglePasswordRequired(true);}}>Yes + {togglePasswordRequired(false);}}>No + + + + +
+ + + + +
+ ); + + +} \ No newline at end of file diff --git a/src/pages/components/settings/settingsConfig.js b/src/pages/components/settings/settingsConfig.js index 8371169..5902af0 100644 --- a/src/pages/components/settings/settingsConfig.js +++ b/src/pages/components/settings/settingsConfig.js @@ -7,11 +7,16 @@ import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Button from 'react-bootstrap/Button'; import Alert from 'react-bootstrap/Alert'; +import ToggleButton from 'react-bootstrap/ToggleButton'; +import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup'; + +import EyeFillIcon from 'remixicon-react/EyeFillIcon'; +import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon'; import "../../css/settings/settings.css"; -import { ButtonGroup } from "react-bootstrap"; +import { InputGroup } from "react-bootstrap"; export default function SettingsConfig() { const [config, setConfig] = useState(null); @@ -20,11 +25,23 @@ export default function SettingsConfig() { const [isSubmitted, setisSubmitted] = useState(""); const [loadSate, setloadSate] = useState("Loading"); const [submissionMessage, setsubmissionMessage] = useState(""); + const token = localStorage.getItem('token'); + const [twelve_hr, set12hr] = useState(localStorage.getItem('12hr') === 'true'); + + const storage_12hr = localStorage.getItem('12hr'); + + if(storage_12hr===null) + { + localStorage.setItem('12hr',false); + set12hr(false); + }else if(twelve_hr===null){ + set12hr(Boolean(storage_12hr)); + } useEffect(() => { Config() .then((config) => { - setFormValues({ JF_HOST: config.hostUrl, JF_API_KEY: config.apiKey }); + setFormValues({ JF_HOST: config.hostUrl }); setConfig(config); setloadSate("Loaded"); }) @@ -38,37 +55,23 @@ export default function SettingsConfig() { }, []); async function validateSettings(_url, _apikey) { - let isValid = false; - let errorMessage = ""; - await axios - .get(_url + "/system/configuration", { + const result = await axios + .post("/api/validateSettings", { + url:_url, + apikey: _apikey + + }, { headers: { - "X-MediaBrowser-Token": _apikey, + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, }) - .then((response) => { - if (response.status === 200) { - isValid = true; - } - }) .catch((error) => { - // console.log(error.code); - if (error.code === "ERR_NETWORK") { - isValid = false; - errorMessage = `Error : Unable to connect to Jellyfin Server`; - } else if (error.response.status === 401) { - isValid = false; - errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`; - } else if (error.response.status === 404) { - isValid = false; - errorMessage = `Error ${error.response.status}: The requested URL was not found.`; - } else { - isValid = false; - errorMessage = `Error : ${error.response.status}`; - } + // let errorMessage= `Error : ${error}`; }); - return { isValid: isValid, errorMessage: errorMessage }; + let data=result.data; + return { isValid:data.isValid, errorMessage:data.errorMessage} ; } async function handleFormSubmit(event) { @@ -77,7 +80,7 @@ export default function SettingsConfig() { formValues.JF_HOST, formValues.JF_API_KEY ); - console.log(validation); + if (!validation.isValid) { setisSubmitted("Failed"); setsubmissionMessage(validation.errorMessage); @@ -116,27 +119,36 @@ export default function SettingsConfig() { } + function toggle12Hr(is_12_hr){ + set12hr(is_12_hr); + localStorage.setItem('12hr',is_12_hr); + }; + + return ( -
-

General Settings

+
+

Settings

- + Jellyfin Url - + - + API Key - + + + + {isSubmitted !== "" ? ( @@ -152,14 +164,28 @@ export default function SettingsConfig() { ) : ( <> )} -
- +
- -
+ +
+ + Hour Format + + + {toggle12Hr(true);}}>12 Hours + {toggle12Hr(false);}}>24 Hours + + + + +
+ + + +
); diff --git a/src/pages/components/statCards/ItemStatComponent.js b/src/pages/components/statCards/ItemStatComponent.js index f9e8a77..3cec5c7 100644 --- a/src/pages/components/statCards/ItemStatComponent.js +++ b/src/pages/components/statCards/ItemStatComponent.js @@ -4,6 +4,7 @@ import { Link } from "react-router-dom"; import Card from 'react-bootstrap/Card'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; +import Tooltip from "@mui/material/Tooltip"; function ItemStatComponent(props) { const [loaded, setLoaded] = useState(false); @@ -12,16 +13,16 @@ function ItemStatComponent(props) { setLoaded(true); } - + const backgroundImage=`/proxy/Items/Images/Backdrop?id=${props.data[0].Id}&fillWidth=300&quality=10`; const cardStyle = { - backgroundImage: `url(${props.base_url}/Items/${props.data[0].Id}/Images/Backdrop/?fillWidth=300&quality=10), linear-gradient(to right, #00A4DC, #AA5CC3)`, + backgroundImage: `url(${backgroundImage}), linear-gradient(to right, #00A4DC, #AA5CC3)`, height:'100%', backgroundSize: 'cover', }; const cardBgStyle = { - backdropFilter: 'blur(5px)', + backdropFilter: props.base_url ? 'blur(5px)' : 'blur(0px)', backgroundColor: 'rgb(0, 0, 0, 0.6)', height:'100%', }; @@ -43,14 +44,14 @@ function ItemStatComponent(props) {
: <> - {!loaded && ( + {props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && !loaded && (
- +
)} setLoaded(false)} @@ -59,9 +60,9 @@ function ItemStatComponent(props) { } - + - +
{props.heading}
@@ -76,21 +77,31 @@ function ItemStatComponent(props) {
{index + 1} {item.UserId ? - - {item.Name} + + + {item.Name} + + : !item.Client && !props.icon ? - - {item.Name} + + + + {item.Name} + : !item.Client && props.icon ? - - {item.Name} + + + {item.Name} + : - {item.Client} + + {item.Client} + }
diff --git a/src/pages/components/statCards/most_active_users.js b/src/pages/components/statCards/most_active_users.js index 8d4f3f7..98dc300 100644 --- a/src/pages/components/statCards/most_active_users.js +++ b/src/pages/components/statCards/most_active_users.js @@ -10,6 +10,7 @@ function MostActiveUsers(props) { const [data, setData] = useState(); const [days, setDays] = useState(30); const [config, setConfig] = useState(null); + const [loaded, setLoaded]= useState(true); useEffect(() => { @@ -64,19 +65,23 @@ function MostActiveUsers(props) { }, [data, config, days,props.days]); - - // const handleImageError = () => { - // setImgError(true); - // }; - - if (!data || data.length === 0) { return <>; } + const UserImage = () => { + return ( + setLoaded(false)} + /> + ); + }; return ( - } data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/> + : } data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/> ); } diff --git a/src/pages/components/statCards/mv_libraries.js b/src/pages/components/statCards/mv_libraries.js index fc20769..5dfb8d4 100644 --- a/src/pages/components/statCards/mv_libraries.js +++ b/src/pages/components/statCards/mv_libraries.js @@ -6,6 +6,8 @@ import ItemStatComponent from "./ItemStatComponent"; import TvLineIcon from "remixicon-react/TvLineIcon"; import FilmLineIcon from "remixicon-react/FilmLineIcon"; +import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon"; +import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon"; function MVLibraries(props) { const [data, setData] = useState(); @@ -51,9 +53,14 @@ function MVLibraries(props) { return <>; } + const SeriesIcon= ; + const MovieIcon= ; + const MusicIcon= ; + const MixedIcon= ; + return ( - : } data={data} heading={"MOST VIEWED LIBRARIES"} units={"Plays"}/> + ); } diff --git a/src/pages/components/user-info.js b/src/pages/components/user-info.js index f41b126..91f285e 100644 --- a/src/pages/components/user-info.js +++ b/src/pages/components/user-info.js @@ -1,21 +1,115 @@ import { useParams } from 'react-router-dom'; +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; +import Config from "../../lib/config"; +import {Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap'; import GlobalStats from './user-info/globalStats'; -import UserDetails from './user-info/user-details'; import LastPlayed from './user-info/lastplayed'; +import UserActivity from './user-info/user-activity'; +import "../css/users/user-details.css"; + function UserInfo() { const { UserId } = useParams(); + const [data, setData] = useState(); + const [imgError, setImgError] = useState(false); + const [config, setConfig] = useState(); + const [activeTab, setActiveTab] = useState('tabOverview'); + + useEffect(() => { + + const fetchConfig = async () => { + try { + const newConfig = await Config(); + setConfig(newConfig); + } catch (error) { + console.log(error); + } + }; + + const fetchData = async () => { + if(config){ + try { + const userData = await axios.post(`/stats/getUserDetails`, { + userid: UserId, + }, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }); + setData(userData.data); + } catch (error) { + console.log(error); + } + } + + }; + fetchData(); + + if (!config) { + fetchConfig(); + } + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [config, UserId]); + + const handleImageError = () => { + setImgError(true); + }; + + if (!data || !config) { + return <>; + } + return (
- +
+
+ {imgError ? ( + + ) : ( + + )} +
+ +
+

{data.Name}

+ + + + +
+ +
+ + + + + + + + +
); } diff --git a/src/pages/components/user-info/user-activity.js b/src/pages/components/user-info/user-activity.js new file mode 100644 index 0000000..2725a7f --- /dev/null +++ b/src/pages/components/user-info/user-activity.js @@ -0,0 +1,62 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import ActivityTable from "../activity/activity-table"; + +function UserActivity(props) { + const [data, setData] = useState(); + const token = localStorage.getItem('token'); + const [itemCount,setItemCount] = useState(10); + + useEffect(() => { + + const fetchData = async () => { + try { + const itemData = await axios.post(`/api/getUserHistory`, { + userid: props.UserId, + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(itemData.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [ props.UserId,token]); + + + if (!data) { + return <>; + } + + return ( +
+
+

User Activity

+
+
Items
+ +
+
+
+ + + +
+
+ ); +} + +export default UserActivity; diff --git a/src/pages/css/about.css b/src/pages/css/about.css new file mode 100644 index 0000000..0786f69 --- /dev/null +++ b/src/pages/css/about.css @@ -0,0 +1,20 @@ +@import './variables.module.css'; +.about +{ + background-color: var(--second-background-color) !important; + border-color: transparent !important; + color: white !important; + + +} + +.about a +{ + text-decoration: none; +} + +.about a:hover +{ + text-decoration: underline; +} + diff --git a/src/pages/css/activity/activity-table.css b/src/pages/css/activity/activity-table.css index 75fad16..f5fe6ef 100644 --- a/src/pages/css/activity/activity-table.css +++ b/src/pages/css/activity/activity-table.css @@ -1,99 +1,132 @@ -div a -{ - text-decoration: none; - color: white; -} - -.table-rows:hover -{ - background-color: rgba(0, 0, 0, 0.4); -} - -.table-rows-content div:hover a -{ - color: #00A4DC; -} +@import '../variables.module.css'; .activity-table { - background-color: rgba(100,100, 100, 0.2); - color: white; + background-color: var(--secondary-background-color); + color: white !important; } -.table-rows-content{ - margin-bottom: 10px; -} - - -.table-headers div { - background-color: rgba(0, 0, 0, 0.8); - border-bottom: 1px solid transparent; - border-right: 1px solid rgba(255, 255, 255, 0.05); - font-size: 1.2em; - cursor: pointer; -} - -.table-headers div:hover { - border-bottom: 1px solid rgba(255, 255, 255, 0.5); -} - -.table-headers, .table-rows-content +td,th, td>button { - display: flex; - justify-content: space-between; - /* border-bottom: 1px solid rgba(255, 255, 255, 0.05); */ + color: white !important; + background-color: var(--tertiary-background-color); + } -.table-headers div, -.table-rows-content div +th { - border-right: 1px solid rgba(255, 255, 255, 0.05); + color: white !important; + background-color: rgba(200, 200, 200, 0.2); + +} + +.activity-client:hover +{ + color: var(--secondary-color) !important; + +} +.activity-client:hover > span +{ + + cursor: pointer; +} + + + +td > a +{ + color: white; +} + +td:hover > a +{ + color: var(--secondary-color); +} + +.page-number { + margin-inline: 10px; + color: white; +} + +select option { + background-color: var(--secondary-color); + outline: unset; width: 100%; - padding: 10px; + border: none; + } -.table-headers div:last-child, -.table-rows-content div:last-child +.pagination-range .items { - border-right: none; - +background-color: rgb(255, 255, 255, 0.1); +padding-inline: 10px; } -.sub-table { - overflow: hidden; - max-height: 0; /* set the height to 0 to collapse the div */ - opacity:0; - transition: all 0.3s ease; + .pagination-range .header +{ +padding-inline: 10px; +align-self: center; } - .collapsed { - transition: all 0.3s ease; - opacity: 100; - max-height: min-content; +.pagination-range +{ +width: 130px; +height: 35px; +color: white; +display: flex; +background-color: var(--secondary-background-color); +border-radius: 8px; +font-size: 1.2em; +align-self: flex-end; +justify-content: space-between; } -.sub-row{ - color: darkgray; - margin-bottom: 0; -} - -.sub-row a{ - color: darkgray; -} - -.sub-row a:hover{ - color: #00A4DC; -} - - - -.sub-row:last-child +.pagination-range select { - margin-bottom: 50px; + +height: 35px; +outline: none; +border: none; +border-radius: 8px; +background-color: rgb(255, 255, 255, 0.1); +color:white; +font-size: 1em; + + } -.bg-grey +.page-btn { - background-color: rgb(100, 100, 100,0.2); -} \ No newline at end of file + background-color: var(--primary-color) !important; + border-color: var(--primary-color)!important; +} + +.MuiTableCell-head > .Mui-active, .MuiTableSortLabel-icon +{ + color: var(--secondary-color) !important; +} + +.MuiTableCell-head :hover +{ + color: var(--secondary-color) !important; +} + +.modal-header +{ + color: white; + border-bottom: none !important; +} + + +.modal-footer +{ + border-top: none !important; +} + + +.modal-content +{ + background-color: var(--secondary-background-color) !important; +} + diff --git a/src/pages/css/activity/stream-info.css b/src/pages/css/activity/stream-info.css new file mode 100644 index 0000000..4a3f69c --- /dev/null +++ b/src/pages/css/activity/stream-info.css @@ -0,0 +1,10 @@ +@import '../variables.module.css'; + +.ellipse +{ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/pages/css/error.css b/src/pages/css/error.css index 40274ee..bddc7eb 100644 --- a/src/pages/css/error.css +++ b/src/pages/css/error.css @@ -1,18 +1,29 @@ .error { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; + margin: 0px; + height: calc(100vh - 100px); + display: flex; + flex-direction: column; justify-content: center; align-items: center; + /* z-index: 9999; */ + background-color: #1e1c22; + transition: opacity 800ms ease-in; + opacity: 1; + color: white; } + .error .message { color:crimson; font-size: 1.5em; font-weight: 500; +} + +.error-title +{ + color:crimson; + font-weight: 500; } \ No newline at end of file diff --git a/src/pages/css/globalstats.css b/src/pages/css/globalstats.css index fe25a32..e01ab74 100644 --- a/src/pages/css/globalstats.css +++ b/src/pages/css/globalstats.css @@ -1,9 +1,11 @@ + +@import './variables.module.css'; .global-stats-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 400px)); grid-auto-rows: 120px; - background-color: rgb(100, 100, 100,0.2); + background-color: var(--secondary-background-color); padding: 20px; border-radius: 8px; font-size: 1.3em; @@ -31,7 +33,7 @@ .stat-value { text-align: right; - color: #00A4DC; + color: var(--secondary-color); font-weight: 500; font-size: 1.1em; margin: 0; diff --git a/src/pages/css/home.css b/src/pages/css/home.css index 6b963c6..2eadae4 100644 --- a/src/pages/css/home.css +++ b/src/pages/css/home.css @@ -1,4 +1,5 @@ .Home { color: white; + margin-bottom: 20px; } \ No newline at end of file diff --git a/src/pages/css/items/item-details.css b/src/pages/css/items/item-details.css index 3f82f30..8de0323 100644 --- a/src/pages/css/items/item-details.css +++ b/src/pages/css/items/item-details.css @@ -1,17 +1,23 @@ +@import '../variables.module.css'; .item-detail-container { color:white; - background-color: rgb(100, 100, 100,0.2); - padding: 20px; + background-color: var(--secondary-background-color); margin: 20px 0; - border-radius: 8px; +} + +.item-banner-image +{ + margin-right: 20px; } .item-name { - font-size: 2.5em; - font-weight: 500; - margin: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; } .item-image { @@ -20,3 +26,15 @@ object-fit: cover; } +.item-details div a{ + text-decoration: none !important; + color: white !important; + } + + .item-details div a:hover{ + color: var(--secondary-color) !important; + } + + .hide-tab-titles { + display: none !important; + } \ No newline at end of file diff --git a/src/pages/css/lastplayed.css b/src/pages/css/lastplayed.css index d1d67eb..f2d1186 100644 --- a/src/pages/css/lastplayed.css +++ b/src/pages/css/lastplayed.css @@ -1,17 +1,17 @@ +@import './variables.module.css'; .last-played-container { display: flex; - overflow-x: auto; - background-color: rgb(100, 100, 100,0.2); + overflow-x: scroll; + background-color: var(--secondary-background-color); padding: 20px; border-radius: 8px; color: white; margin-bottom: 20px; min-height: 300px; - - - + } + .last-played-container::-webkit-scrollbar { width: 5px; /* set scrollbar width */ } @@ -42,19 +42,27 @@ width: 150px; border-radius: 8px; + background-color: var(--background-color); } + .episode{ width: 220px !important; height: 128px !important; + } +.episode-card{ + width: 220px !important; + + +} .last-card-banner { width: 150px; - height: 224px; + height: 220px; transition: opacity 0.2s ease-in-out; } @@ -75,19 +83,22 @@ } .last-item-details { - - width: 90%; - /* height: 30%; */ position: relative; - /* padding-top: 10px; */ margin: 10px; - - /* background-color: #f71b1b; */ } +.last-item-details a{ + text-decoration: none !important; + color: white !important; +} + +.last-item-details a:hover{ + color: var(--secondary-color) !important; +} + + .last-item-name { - /* width: 185px; */ overflow: hidden; text-overflow: ellipsis; @@ -111,6 +122,6 @@ .last-last-played{ font-size: 0.8em; margin-bottom: 5px; - color: #00a4dc; + color: var(--secondary-color); } diff --git a/src/pages/css/library/libraries.css b/src/pages/css/library/libraries.css index 80d1f60..f493252 100644 --- a/src/pages/css/library/libraries.css +++ b/src/pages/css/library/libraries.css @@ -2,13 +2,9 @@ { color: white; display: grid; - grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + grid-gap: 20px; + } -/* .library-banner-image -{ - border-radius: 5px; - max-width: 500px; - max-height: 500px; -} */ \ No newline at end of file diff --git a/src/pages/css/library/library-card.css b/src/pages/css/library/library-card.css index 3651974..c086fd5 100644 --- a/src/pages/css/library/library-card.css +++ b/src/pages/css/library/library-card.css @@ -1,12 +1,14 @@ +@import '../variables.module.css'; + .lib-card{ color: white; - max-width: 400px; + /* max-width: 400px; */ } .card-label { - color: #00A4DC; + color: var(--secondary-color); } .card-row .col @@ -18,7 +20,9 @@ .library-card-image { max-height: 170px; + overflow: hidden; + border-radius: 8px 8px 0px 0px; } .library-card-banner @@ -28,7 +32,7 @@ background-repeat: no-repeat; background-size: cover; transition: all 0.2s ease-in-out; - + max-height: 170px; } @@ -39,7 +43,14 @@ .library-card-details { - background-color: rgb(100, 100, 100,0.2) !important; + background-color: var(--secondary-background-color) !important; } +.default_library_image +{ + background-color: var(--secondary-background-color); + width: 100%; + height: 170px; + border-radius: 8px 8px 0px 0px; +} \ No newline at end of file diff --git a/src/pages/css/library/media-items.css b/src/pages/css/library/media-items.css new file mode 100644 index 0000000..68862fb --- /dev/null +++ b/src/pages/css/library/media-items.css @@ -0,0 +1,48 @@ +@import '../variables.module.css'; +.media-items-container { + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 150px)); + grid-gap: 20px; + + background-color: var(--secondary-background-color); + padding: 20px; + border-radius: 8px; + color: white; + margin-bottom: 20px; + min-height: 300px; +} + +.media-items-container::-webkit-scrollbar { + width: 5px; /* set scrollbar width */ + } + + .media-items-container::-webkit-scrollbar-track { + background-color: transparent; /* set track color */ + } + + .media-items-container::-webkit-scrollbar-thumb { + background-color: #8888884d; /* set thumb color */ + border-radius: 5px; /* round corners */ + width: 5px; + } + + + + .media-items-container::-webkit-scrollbar-thumb:hover { + background-color: #88888883; /* set thumb color */ + } + + .library-items > div>div> .form-control + { + color: white !important; + background-color: var(--secondary-background-color) !important; + border-color: var(--secondary-background-color) !important; + } + + + .library-items > div> div>.form-control:focus + { + box-shadow: none !important; + border-color: var(--primary-color) !important; + } \ No newline at end of file diff --git a/src/pages/css/libraryOverview.css b/src/pages/css/libraryOverview.css index 484a420..414f64b 100644 --- a/src/pages/css/libraryOverview.css +++ b/src/pages/css/libraryOverview.css @@ -1,11 +1,12 @@ .overview-container { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 520px)); + grid-template-columns: repeat(auto-fit, minmax(auto, 520px)); grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/ + background-color: var(--secondary-background-color); border-radius: 8px; - /* margin-right: 20px; */ + padding: 20px; } .library-stat-card @@ -84,12 +85,8 @@ .library-banner-image { - - height: 180px; - width: 120px; - - + width: 120px; } diff --git a/src/pages/css/loading.css b/src/pages/css/loading.css index 550b120..0b6c992 100644 --- a/src/pages/css/loading.css +++ b/src/pages/css/loading.css @@ -1,32 +1,22 @@ +@import './variables.module.css'; .loading { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; + + margin: 0px; + height: calc(100vh - 100px); + display: flex; justify-content: center; align-items: center; - z-index: 9999; - background-color: #1e1c22; + background-color: var(--background-color); transition: opacity 800ms ease-in; opacity: 1; } + .loading::before { opacity: 0; } - .component-loading { - - height: inherit; - width: inherit; - display: flex; - justify-content: center; - align-items: center; - background-color: rgba(0, 0, 0, 0.8); - z-index: 9999; - } .loading__spinner { width: 50px; diff --git a/src/pages/css/navbar.css b/src/pages/css/navbar.css index fe8e0eb..1f9b940 100644 --- a/src/pages/css/navbar.css +++ b/src/pages/css/navbar.css @@ -1,42 +1,80 @@ +@import './variables.module.css'; .navbar { - /* display: flex; - justify-content: flex-end; - align-items: center; */ - background-color: #5a2da5; - /* background: linear-gradient(to right, #AA5CC3,#00A4DC); */ - /* height: 50px; */ - /* position: sticky; - top: 0; */ + background-color: var(--secondary-background-color); + border-right: 1px solid #414141 !important; +} + +@media (min-width: 768px) { + .navbar { + min-height: 100vh; + border-bottom: 1px solid #414141 !important; + + } +} + +.navbar .navbar-brand{ + margin-top: 20px; + font-size: 32px; + font-weight: 500; +} + +.navbar .navbar-nav{ + width: 100%; + margin-top: 20px; +} + +.logout +{ + + color: var(--secondary-color) !important; +} +.navbar-toggler > .collapsed +{ + right: 0; } .navitem { - display: flex; - align-items: center; - height: 100%; + color: white; - font-size: 16px; + font-size: 18px !important; text-decoration: none; - padding: 0 20px; + margin-right: 4px; + background-color: var(--background-color); + transition: all 0.4s ease-in-out; + border-radius: 8px; + margin-bottom: 10px; + width: 90%; +} + +@media (min-width: 768px) { + .navitem { + border-radius: 0 8px 8px 0; + } +} + +.navitem:hover { + background-color: var(--primary-color); +} + +.active +{ + background-color: var(--primary-color); transition: background-color 0.2s ease-in-out; } +.nav-link +{ + display: flex !important; +} + + .nav-text { margin-left: 10px; } -.active -{ - /* background-color: #308df046 !important; */ - background-color: rgba(0,0,0,0.6); - transition: background-color 0.2s ease-in-out; -} -.navitem:hover { - /* background-color: #326aa541; */ - background-color: rgba(0,0,0,0.4); -} diff --git a/src/pages/css/radius_breakpoint_css.css b/src/pages/css/radius_breakpoint_css.css new file mode 100644 index 0000000..5721731 --- /dev/null +++ b/src/pages/css/radius_breakpoint_css.css @@ -0,0 +1,98 @@ + +/*based on https://drive.google.com/uc?export=view&id=1yTLwNiCZhIdCWolQldwq4spHQkgZDqkG */ +/* Small devices (landscape phones, 576px and up)*/ +@media (min-width: 576px) { + .rounded-sm { + border-radius: 8px !important; + } + + + .rounded-sm-start { + border-radius: 8px 0px 0px 8px !important; + } + + .rounded-sm-end { + border-radius: 0px 8px 8px 0px !important; + } + + .rounded-sm-top { + border-radius: 8px 8px 0px 0px !important; + } + + .rounded-sm-bottom { + border-radius: 0px 0px 8px 8px !important; + } +} + + +/* Medium devices (tablets, 768px and up)*/ +@media (min-width: 768px) { + .rounded-md { + border-radius: 8px !important; + } + + + .rounded-md-start { + border-radius: 8px 0px 0px 8px !important; + } + + .rounded-md-end { + border-radius: 0px 8px 8px 0px !important; + } + + .rounded-md-top { + border-radius: 8px 8px 0px 0px !important; + } + + .rounded-md-bottom { + border-radius: 0px 0px 8px 8px !important; + } +} + +/* Large devices (desktops, 992px and up)*/ +@media (min-width: 992px) { + .rounded-lg { + border-radius: 8px !important; + } + + + .rounded-lg-start { + border-radius: 8px 0px 0px 8px !important; + } + + .rounded-lg-end { + border-radius: 0px 8px 8px 0px !important; + } + + .rounded-lg-top { + border-radius: 8px 8px 0px 0px !important; + } + + .rounded-lg-bottom { + border-radius: 0px 0px 8px 8px !important; + } +} + +/* Extra large devices (large desktops, 1200px and up)*/ +@media (min-width: 1200px) { + .rounded-xl { + border-radius: 8px !important; + } + + + .rounded-xl-start { + border-radius: 8px 0px 0px 8px !important; + } + + .rounded-xl-end { + border-radius: 0px 8px 8px 0px !important; + } + + .rounded-xl-top { + border-radius: 8px 8px 0px 0px !important; + } + + .rounded-xl-bottom { + border-radius: 0px 0px 8px 8px !important; + } +} \ No newline at end of file diff --git a/src/pages/css/recent.css b/src/pages/css/recent.css index 1bcd718..6bf9900 100644 --- a/src/pages/css/recent.css +++ b/src/pages/css/recent.css @@ -18,15 +18,12 @@ display: flex; flex-direction: column; - /* background-color: grey; */ box-shadow: 0 0 20px rgba(255, 255, 255, 0.05); height: 320px; width: 185px; border-radius: 8px; - - /* Add a third row that takes up remaining space */ } @@ -46,9 +43,7 @@ width: 100%; height: 30%; position: relative; - /* margin: 8px; */ - /* background-color: #f71b1b; */ } .recent-card-item-name { @@ -58,10 +53,6 @@ position: absolute; margin: 0; - -/* - - position: absolute; */ } .recent-card-last-played{ diff --git a/src/pages/css/sessions.css b/src/pages/css/sessions.css index 57e24df..f51be01 100644 --- a/src/pages/css/sessions.css +++ b/src/pages/css/sessions.css @@ -1,25 +1,30 @@ - +@import './variables.module.css'; .sessions-container { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 520px)); + grid-template-columns: repeat(auto-fit, minmax(auto, 520px)); grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/ - /* margin-right: 20px; */ - + background-color: var(--secondary-background-color); + border-radius: 8px; + padding: 20px; } +.truncate-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 10ch; + } + .session-card { display: flex; color: white; background-color: grey; - /* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */ max-height: 180px; max-width: 500px; - /* margin-left: 20px; */ - /* margin-bottom: 10px; */ background-size: cover; border-radius: 8px 8px 0px 8px; @@ -32,8 +37,6 @@ .progress-bar { - /* grid-row: 2 / 3; - grid-column: 1/3; */ height: 5px; background-color: #101010 !important; border-radius: 0px 0px 8px 8px; @@ -43,7 +46,7 @@ .progress-custom { height: 100%; - background-color: #00A4DC; + background-color: var(--secondary-color); transition: width 0.2s ease-in-out; border-radius: 0px 0px 0px 8px; } @@ -73,7 +76,6 @@ .card-banner-image { border-radius: 8px 0px 0px 0px; max-height: inherit; - /* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */ } .card-user { @@ -88,17 +90,15 @@ } -.card-user-image { +.session-card-user-image { border-radius: 50%; - width: 50px; - top: 4vh; - grid-row: 1 / span 2; + max-width: 50px; + } .card-user-image-default { - /* width: 50px !important; */ font-size: large; } @@ -125,7 +125,6 @@ display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; - /* grid-column-gap: 10px; */ } .card-device-name { @@ -135,10 +134,10 @@ } .card-device-image { - max-width: 35px; + max-width: 40px; + margin-top:5px; width: 100%; - /* margin-right: 5px; */ - /* grid-row: 1 / span 2; */ + height: min-content; } .card-client { @@ -161,9 +160,6 @@ .card-playback-position { bottom: 5px; - /* right: 5px; */ - /* text-align: right; */ - /* position: absolute; */ } .device-info { @@ -173,4 +169,36 @@ margin-bottom: 100%; .card-ip { grid-row: 2 / 3; grid-column: 2 / 3; -} \ No newline at end of file +} + +.card-text >a{ + text-decoration: none !important; + color: white !important; + } + + .card-text a:hover{ + color: var(--secondary-color) !important; + } + + + .ellipse +{ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; +} + + + +@media (max-width: 350px) { + .card-device-image { + display: none; + } + + .card-client-version, .session-card-user-image + { + display: none !important; + } + } \ No newline at end of file diff --git a/src/pages/css/settings/backups.css b/src/pages/css/settings/backups.css index b2242f6..beb7964 100644 --- a/src/pages/css/settings/backups.css +++ b/src/pages/css/settings/backups.css @@ -1,3 +1,4 @@ +@import '../variables.module.css'; tr{ color: white; } @@ -9,7 +10,6 @@ th:hover{ th{ border-bottom: none !important; cursor: default !important; - background-color: rgba(0, 0, 0, 0.8) !important; } .backup-file-download @@ -19,4 +19,16 @@ th{ td{ border-bottom: none !important; -} \ No newline at end of file +} + +.upload-file +{ + background-color: var(--secondary-background-color) !important; + border-color: var(--secondary-background-color) !important; + color: white !important; +} + +.upload-file:focus +{ + box-shadow: none !important; +} diff --git a/src/pages/css/settings/settings.css b/src/pages/css/settings/settings.css index c93debb..f12d89c 100644 --- a/src/pages/css/settings/settings.css +++ b/src/pages/css/settings/settings.css @@ -1,29 +1,32 @@ +@import '../variables.module.css'; .show-key { margin-bottom: 20px;; } +.settings{ + background-color: var(--secondary-background-color); + padding: 20px; + border-radius: 8px; +} + +.tasks { + + color: white; + /* margin-inline: 10px; */ + +} + .settings-form { color: white; - /* width: 100%; */ margin-top: 20px; + margin-inline: 10px; } - .settings-submit-button - { - background-color: #2196f3; - color: white; - padding: 10px 20px; - border-radius: 5px; - border: none; - cursor: pointer; - transition: all 0.3s ease-in-out; - margin-bottom: 10px; - } .form-row { @@ -46,20 +49,6 @@ max-width: 700px; } - /* .settings-form button { - background-color: #2196f3; - color: white; - padding: 10px 20px; - border-radius: 5px; - border: none; - cursor: pointer; - transition: all 0.3s ease-in-out; - margin-bottom: 10px; - } */ - - /* .settings-form button:hover { - background-color: #0d8bf2; - } */ .submit { @@ -67,22 +56,26 @@ margin-bottom: 5px; } - .error + + + + .settings-form > div> div> .form-control, + .settings-form > div> div> .input-group> .form-control { - color: #cc0000; + color: white !important; + background-color: var(--background-color) !important; + border-color: var(--background-color) !important; } - .success + .settings-form > div> div> .input-group> .btn { - color: #4BB543; + border: none !important; } - .critical + + .settings-form > div> div> .form-control:focus, + .settings-form > div> div> .input-group> .form-control:focus { - display: flex; - justify-content: center; - align-items: center; - color: #cc0000; - - height: 90vh; + box-shadow: none !important; + border-color: var(--primary-color) !important; } \ No newline at end of file diff --git a/src/pages/css/settings/version.css b/src/pages/css/settings/version.css new file mode 100644 index 0000000..3d9787e --- /dev/null +++ b/src/pages/css/settings/version.css @@ -0,0 +1,46 @@ +@import '../variables.module.css'; +.version +{ + background-color: var(--background-color) !important; + + color: white !important; + position: fixed !important; + bottom: 0; + max-width: 200px; + text-align: center; + width: 100%; + + +} + + +.version a +{ + text-decoration: none; +} + +.version a:hover +{ + text-decoration: underline; +} + +.nav-pills > .nav-item , .nav-pills > .nav-item > .nav-link +{ + color: white !important; +} + +.nav-pills > .nav-item .active +{ + background-color: var(--primary-color) !important; + color: white !important; +} + +.nav-pills > .nav-item :hover +{ + background-color: var(--primary-color) !important; +} + +.nav-pills > .nav-item .active> .nav-link +{ + color: white; +} \ No newline at end of file diff --git a/src/pages/css/setup.css b/src/pages/css/setup.css index f2255d5..605146c 100644 --- a/src/pages/css/setup.css +++ b/src/pages/css/setup.css @@ -1,10 +1,17 @@ @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500&display=swap'); +@import './variables.module.css'; /* *{ margin: 0; padding: 0; font-family: 'poppins',sans-serif; } */ +.login-show-password +{ + background-color: transparent !important; + border: 0 !important; +} + section{ display: flex; @@ -12,8 +19,6 @@ section{ align-items: center; min-height: 100vh; width: 100%; - - /* background: url('background6.jpg')no-repeat; */ background-position: center; background-size: cover; } @@ -41,36 +46,47 @@ h2{ width: 310px; border-bottom: 2px solid #fff; } -.inputbox label{ +.inputbox .form-label{ position: absolute; top: 50%; - left: 5px; transform: translateY(-50%); - color: #fff; + color: #fff !important; font-size: 1em; pointer-events: none; transition: .2s; } -input:focus ~ label, -input:valid ~ label{ -top: -15px; +.inputbox input:focus ~ .form-label, +.inputbox input:not(:placeholder-shown) ~ .form-label +{ +top: -10px; } + + +.inputbox input:hover { + + background: transparent !important; + box-shadow: none !important; + +} +.inputbox input:focus { + + background: transparent !important; + box-shadow: none !important; + color: #fff; + +} + + .inputbox input { - width: 100%; + height: 50px; background: transparent; border: none; outline: none; - font-size: 1em; + color: #fff; } -.inputbox ion-icon{ - position: absolute; - right: 8px; - color: #fff; - font-size: 1.2em; - top: 20px; -} + .forget{ margin: -15px 0 15px ; font-size: .9em; @@ -91,15 +107,27 @@ top: -15px; text-decoration: underline; } .setup-button{ + color: white !important; width: 100%; height: 40px; - border-radius: 40px; - background: #fff; - border: none; - outline: none; - cursor: pointer; - font-size: 1em; - font-weight: 600; + border-radius: 40px !important; + background: var(--primary-color) !important; + border: none !important; + outline: none !important; + font-size: 1em !important; + font-weight: 600 !important; +} + +.setup-button:hover{ + color: black !important; + width: 100%; + height: 40px; + border-radius: 40px !important; + background: var(--secondary-color) !important; + border: none !important; + outline: none !important; + font-size: 1em !important; + font-weight: 600 !important; } .register{ font-size: .9em; diff --git a/src/pages/css/statCard.css b/src/pages/css/statCard.css index 355bc7c..ccd4e52 100644 --- a/src/pages/css/statCard.css +++ b/src/pages/css/statCard.css @@ -1,18 +1,20 @@ +@import './variables.module.css'; .grid-stat-cards { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 520px)); + grid-template-columns: repeat(auto-fit, minmax(auto, 520px)); grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/ - /* margin-right: 20px; */ margin-top: 8px; + background-color: var(--secondary-background-color); + border-radius: 8px; + padding: 20px; } .stat-card{ border: 0 !important; - background-color: rgba(100 100 100 / 0.1) !important; + background-color: var(--background-color)!important; color: white; max-width: 500px; max-height: 180px; - border-radius: 8px !important; } .stat-card-banner @@ -22,6 +24,8 @@ + + .stat-card-image { width: 120px !important; height: 180px; @@ -30,7 +34,12 @@ .stat-card-icon { width: 120px !important; - height: 180px; + + + position: relative; + top: 50%; + left: 65%; + transform: translate(-50%, -50%); } @@ -44,9 +53,8 @@ color: grey; } .stat-item-count { - /* width: 10%; */ text-align: right; - color: #00A4DC; + color: var(--secondary-color); font-weight: 500; font-size: 1.1em; @@ -70,7 +78,7 @@ height: 35px; color: white; display: flex; - background-color: rgb(100, 100, 100,0.3); + background-color: var(--secondary-background-color); border-radius: 8px; font-size: 1.2em; align-self: flex-end; @@ -112,4 +120,13 @@ input[type=number] { { padding-inline: 10px; align-self: center; -} \ No newline at end of file +} + +.stat-items div a{ + text-decoration: none !important; + color: white !important; +} + +.stat-items div a:hover{ + color: var(--secondary-color) !important; +} diff --git a/src/pages/css/stats.css b/src/pages/css/stats.css index 3940fa4..ca7761b 100644 --- a/src/pages/css/stats.css +++ b/src/pages/css/stats.css @@ -1,3 +1,4 @@ +@import './variables.module.css'; .watch-stats { margin-top: 10px; @@ -8,11 +9,9 @@ height: 700px; color:black !important; - background-color:rgba(100,100,100,0.2); + background-color: var(--secondary-background-color); padding:10px; - border-radius:8px; - /* text-align: center; */ } diff --git a/src/pages/css/users/user-details.css b/src/pages/css/users/user-details.css index 77a1846..3439add 100644 --- a/src/pages/css/users/user-details.css +++ b/src/pages/css/users/user-details.css @@ -1,7 +1,9 @@ +@import '../variables.module.css'; + .user-detail-container { color:white; - background-color: rgb(100, 100, 100,0.2); + background-color: var(--secondary-background-color); padding: 20px; margin: 20px 0; border-radius: 8px; @@ -23,14 +25,14 @@ height: 100px; border-radius: 50%; object-fit: cover; - box-shadow: 0 0 10px 5px rgba(100,100,100,0.2); + box-shadow: 0 0 10px 5px var(--secondary-background-color); } .user-image-container { width: 100px; height: 100px; - padding-right: 20px; + margin-right: 20px; } \ No newline at end of file diff --git a/src/pages/css/users/users.css b/src/pages/css/users/users.css index 6727bdd..cc423c2 100644 --- a/src/pages/css/users/users.css +++ b/src/pages/css/users/users.css @@ -1,83 +1,3 @@ -.Users -{ - color: white; - /* padding-right: 20px; */ - padding-bottom: 20px; - /* margin-top: 10px; */ -} - -.user-activity-table { - border-collapse: collapse; - border-radius: 5px; - /* margin: 25px 0; */ - font-size: 0.9em; - font-family: sans-serif; - /* min-width: 400px; */ - /* box-shadow: 0 0 20px rgba(255, 255, 255, 0.15); */ - background-color: rgba(100,100, 100, 0.2); - color: white; - width: 100%; - -} - - - td - { - padding: 15px 15px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - -} - - - -@media screen and (max-width: 576px) { - td[data-cell]::before { - content: attr(data-cell)": "; - font-weight: bold; - text-transform: capitalize; - color: #a8a8a8; - - } -} - - -td a{ - text-decoration: none; - color: white; -} - -td:hover a{ - - color: rgb(0, 164, 219); -} - - - -th { - padding: 15px 15px; - background-color: rgba(0, 0, 0, 0.8); - border-right: 1px solid rgba(255, 255, 255, 0.05); - border-bottom: 1px solid transparent !important; - cursor: pointer; -} - -th:hover { - border-bottom: 1px solid rgba(255, 255, 255, 0.5) !important; -} - -tr:nth-child(even) { - background-color: rgba(100, 100, 100, 0.1); -} - -tr:nth-child(odd) { - background-color: rgba(0, 0, 0, 0.1); -} - -/* tbody tr:last-of-type { - border-bottom: 2px solid #009879; -} */ - - .card-user-image { border-radius: 50%; @@ -87,98 +7,3 @@ tr:nth-child(odd) { } -tbody tr:hover -{ - background-color: rgba(0, 0, 0, 0.4); -} - - -td:first-child { - border-left: none; - } - - td:last-child { - border-right: none; - } - - - .pagination { - display: flex; - align-items: center; - justify-content: flex-end; - margin-top: 1rem; - color: white; - } - - .page-btn { - padding: 5px 10px; - border: none; - border-radius: 5px; - margin-inline: 5px; - background-color: rgb(90 45 165); - color: white; - cursor: pointer; - } - - .page-btn:enabled:hover { - background-color: rgb(66, 35, 114); - color: #fff; - } - - .page-btn:disabled { - opacity: 0.5; - cursor: default; - } - - .page-number { - margin-inline: 10px; - font-weight: bold; - } - - -.pagination-range -{ - width: 130px; - height: 35px; - color: white; - display: flex; - background-color: rgb(100, 100, 100,0.3); - border-radius: 8px; - font-size: 1.2em; - align-self: flex-end; - justify-content: space-between; -} - -.pagination-range select -{ - - height: 35px; - outline: none; - border: none; - border-radius: 8px; - background-color: rgb(255, 255, 255, 0.1); - color:white; - font-size: 1em; - - -} -.pagination-range .header -{ - padding-inline: 10px; - align-self: center; -} - - -select option { - background-color: #4a4a4a; - outline: unset; - width: 100%; - border: none; - -} - -.pagination-range .items -{ - background-color: rgb(255, 255, 255, 0.1); - padding-inline: 10px; -} \ No newline at end of file diff --git a/src/pages/css/variables.module.css b/src/pages/css/variables.module.css new file mode 100644 index 0000000..df228a2 --- /dev/null +++ b/src/pages/css/variables.module.css @@ -0,0 +1,8 @@ +:root { + --primary-color: #5a2da5; + --primary-dark-color: #492385; + --secondary-color: #00A4DC; + --background-color: #1e1c22; + --secondary-background-color: #2c2a2f; + --tertiary-background-color: #2f2e31; + } \ No newline at end of file diff --git a/src/pages/css/websocket/websocket.css b/src/pages/css/websocket/websocket.css index abe9804..0f4181f 100644 --- a/src/pages/css/websocket/websocket.css +++ b/src/pages/css/websocket/websocket.css @@ -10,17 +10,17 @@ .console-message { margin-bottom: 10px; + font-size: 1.1rem; } .console-text { margin: 0; font-family: monospace; + text-overflow: ellipsis; + overflow-x: hidden; } -.console-container { - overflow: auto; /* show scrollbar when needed */ -} .console-container::-webkit-scrollbar { width: 10px; /* set scrollbar width */ @@ -37,4 +37,4 @@ .console-container::-webkit-scrollbar-thumb:hover { background-color: #88888883; /* set thumb color */ -} \ No newline at end of file +} diff --git a/src/pages/css/width_breakpoint_css.css b/src/pages/css/width_breakpoint_css.css new file mode 100644 index 0000000..8fbf6d0 --- /dev/null +++ b/src/pages/css/width_breakpoint_css.css @@ -0,0 +1,142 @@ + +/*sourced from https://drive.google.com/uc?export=view&id=1yTLwNiCZhIdCWolQldwq4spHQkgZDqkG */ +/* Small devices (landscape phones, 576px and up)*/ +@media (min-width: 576px) { + .w-sm-100 { + width: 100% !important; + } + + .w-sm-75 { + width: 75% !important; + } + + .w-sm-50 { + width: 50% !important; + } + + .w-sm-25 { + width: 25% !important; + } + + .h-sm-100 { + height: 100% !important; + } + + .h-sm-75 { + height: 75% !important; + } + + .h-sm-50 { + height: 50% !important; + } + + .h-sm-25 { + height: 25% !important; + } +} + + +/* Medium devices (tablets, 768px and up)*/ +@media (min-width: 768px) { + .w-md-100 { + width: 100% !important; + } + + .w-md-75 { + width: 75% !important; + } + + .w-md-50 { + width: 50% !important; + } + + .w-md-25 { + width: 25% !important; + } + + .h-md-100 { + height: 100% !important; + } + + .h-md-75 { + height: 75% !important; + } + + .h-md-50 { + height: 50% !important; + } + + .h-md-25 { + height: 25% !important; + } +} + +/* Large devices (desktops, 992px and up)*/ +@media (min-width: 992px) { + .w-lg-100 { + width: 100% !important; + } + + .w-lg-75 { + width: 75% !important; + } + + .w-lg-50 { + width: 50% !important; + } + + .w-lg-25 { + width: 25% !important; + } + + .h-lg-100 { + height: 100% !important; + } + + .h-lg-75 { + height: 75% !important; + } + + .h-lg-50 { + height: 50% !important; + } + + .h-lg-25 { + height: 25% !important; + } +} + +/* Extra large devices (large desktops, 1200px and up)*/ +@media (min-width: 1200px) { + .w-xl-100 { + width: 100% !important; + } + + .w-xl-75 { + width: 75% !important; + } + + .w-xl-50 { + width: 50% !important; + } + + .w-xl-25 { + width: 25% !important; + } + + .h-xl-100 { + height: 100% !important; + } + + .h-xl-75 { + height: 75% !important; + } + + .h-xl-50 { + height: 50% !important; + } + + .h-xl-25 { + height: 25% !important; + } +} \ No newline at end of file diff --git a/src/pages/data-debugger.js b/src/pages/data-debugger.js new file mode 100644 index 0000000..a92aa57 --- /dev/null +++ b/src/pages/data-debugger.js @@ -0,0 +1,200 @@ +import React, { useState, useEffect } from "react"; +import axios from 'axios'; + +import {Button, ButtonGroup } from 'react-bootstrap'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; + + +import { saveAs } from 'file-saver'; + + + + + + +function Datadebugger() { + const [data, setData] = useState(); + const token = localStorage.getItem('token'); + + + useEffect(() => { + + const fetchData = async () => { + try { + + const libraryData = await axios.post(`/api/getLibraries`, { + itemid: undefined, + }, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setData(libraryData.data); + console.log(libraryData.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, [token]); + + const handleDownload = (jsonData,filename) => { + // const jsonData = { /* Your JSON object */ }; + + const jsonString = JSON.stringify(jsonData); + const blob = new Blob([jsonString], { type: 'application/json' }); + + saveAs(blob, filename+'.json'); + }; + + + return ( +
+

Data Debugger

+
+ {/*

{data? JSON.stringify(data):''}

*/} + + + + + + Data Type + Database Count + API Count + Difference + Counts from Jellyfin* + Difference + Export Missing Data + + + + + + Libraries + {data ? data.existing_library_count:''} + {data ? data.api_library_count:''} + {data ? data.api_library_count-data.existing_library_count:''} + + + {data && data.api_library_count>data.existing_library_count ? + : + ''} + + + + + Movies + {data ? data.existing_movie_count:''} + {data ? data.api_movie_count:''} + {data ? data.api_movie_count-data.existing_movie_count:''} + {data ? data.count_from_api.MovieCount:''} + {data ? data.count_from_api.MovieCount-data.existing_movie_count:''} + {data && data.api_movie_count>data.existing_movie_count ? + : + ''} + + + + + Shows + {data ? data.existing_show_count:''} + {data ? data.api_show_count:''} + {data ? data.api_show_count-data.existing_show_count:''} + {data ? data.count_from_api.SeriesCount:''} + {data ? data.count_from_api.SeriesCount-data.existing_show_count:''} + {data && data.api_show_count>data.existing_show_count ? + : + ''} + + + + + Music + {data ? data.existing_music_count:''} + {data ? data.api_music_count:''} + {data ? data.api_music_count-data.existing_music_count:''} + {data ? data.count_from_api.SongCount:''} + {data ? data.count_from_api.SongCount-data.existing_music_count:''} + {data && data.api_music_count>data.existing_music_count ? + : + ''} + + + + + Seasons + {data ? data.existing_season_count:''} + {data ? data.api_season_count:''} + {data ? data.api_season_count-data.existing_season_count:''} + + + {data && data.api_season_count>data.existing_season_count ? + : + ''} + + + + + Episodes + {data ? data.existing_episode_count:''} + {data ? data.api_episode_count:''} + {data ? data.api_episode_count-data.existing_episode_count:''} + {data ? data.count_from_api.EpisodeCount:''} + {data ? data.count_from_api.EpisodeCount-data.existing_episode_count:''} + {data && data.api_episode_count>data.existing_episode_count ? + : + ''} + + + + +
+
+ + + + + + + + + + + +
+ + ); +} + +export default Datadebugger; diff --git a/src/pages/home.js b/src/pages/home.js index 42801f2..31f30b0 100644 --- a/src/pages/home.js +++ b/src/pages/home.js @@ -5,13 +5,20 @@ import './css/home.css' import Sessions from './components/sessions/sessions' import HomeStatisticCards from './components/HomeStatisticCards' import LibraryOverView from './components/libraryOverview' - +import RecentlyAdded from './components/library/recently-added' +import ErrorBoundary from './components/general/ErrorBoundary' export default function Home() { return ( -
+
- + + + + + + + diff --git a/src/pages/images/icon-b-512.png b/src/pages/images/icon-b-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d6857ed12c6adf3f85118c0f4828c0eafc738668 GIT binary patch literal 17420 zcmY&=cUV(R(C;Qh0xBI5=}JIEkq$~rR9ff=f`Sx5K{^Ns(h@<%LNoM^5~)g&s+538 zQF`yi0!T+X1d@C3{l5F$%Rl7VGiP>oc6N4mW`5^|fu07#QLdu^00yk4>U99%&`&s^ zqlNx$`1b7qAONtcSB!mAmd23@>}q?>eJiWCledSd5)($qxI|nE!%gdNV-J2M0vd5VzP6+&-Ug}Zb8A^ z_EO%wBMj>9|NFxyi2P866?bV~_rxk{Ii7I{m3fy&q$N0N<=IlJJ|B3R;|86y^UB7r z6uvI?%cow9-3TXc5^AGMwR+63lOt4$u|7$@T-lfaivSF~EUYCeqn2uKIM`EbVrQ4APl=Jv zi4`|?4SKYY^GFm2Jz(aM-mk47G7`3msWQ~@IJt|@Pl+W6I+PQ)Q8iA~U6rC9MiduQeVJ=yjQ8%(%cctr1K7WVx3sCAO0bA z;wZ3s83$v((S9s#R^a_hR;-xi1maBosw}e1G*CPAH4*=c$op$N5{YN(Sx5I&asMhv>byK}HS*4@et{vmhzW)l{Z5(NYny!JUXSb^54SEn&U3oEAu?EBU`OovTT)j~l%mP%~ ztC^@u*x5DL0NOVM3M-WCuF%4A7SD6sQ0Aaxxv4fcnl@`uJLhL+(_|<^awFM-sW6o-i}~9- z=VE@wn=bk=f-`qGqPAi;L{A1B6(ZQw`XAhEV-V13X4yH%ZvWkh;OBV+VW2af_$%8W zMXYzxhaJF6(up323~t7FY$+ZCN2J}R{qfDaA~8cJ)>8vpfRQShNFY|g8Fz*vr-1W&dmgnYNsy;E- zrSg_v(&^c3lzNipd1{swo4O z6ulv_zK{1&7#U=WA4)wU-*aTYL2XiBL+i6z4_~c30tAZ8ox$q&vIpHcS|{F)8W(pE zt>?$zD>$M*?9>n;^AZg)3OO}*xw+P)^t>P%^bAc}o{hu$Mbm1jz^|;8#d~EF4;kT8 z+Kax`-A=mji4&%I(bE;@a&mWR#@`mSyc)i;db{dTm(CWWrm#97%0uu5P)Hc_0Cg;#pb}ow#_|$6HB%+T-_J zFj5k5W}ML0sfn|wrFp0ik-UM29#irJ%go$g;VgJxr#rKIk()KA!4uby7TiTqS&?Vu z;4cd)TaA1O95B43-g5!l+`IQz70!$ZjAd zH}jdrN$a1To+(>@B#toimej5ARe5rNo%_V`-_8v7=l~Sx9{RRC-C0G3?YL$IzUewM z$$iEd1%60(Hbumjmbf7q!Knb@+`@4sy3==9tScj8WJ0Ex!06DhC3oc8TVJlhnbUfO zbYuGJ;%R8{N(oNC5dI%j>3ok0@WhXt+%sPPVGYZ`2n#dBlnzADWjxmeFZ1&Pzph9P zm;;QS)}k>+8XG;VQt1mlXjWR-iwgXD1Vp8W2rUUYyeEd2TC7%{7h_y!q@G3bMt=>V z!87ZYk0J}-c893JnXA>eBuTDVMG5e{<<*vC{d5X`DheQ4omZt_`0UVNrtF3dZ^#`# zN=J`zH^cg=UndiJn878xf^q$Ou(MffnjpA)fEG6~g9MHDEYAfzA?iEKem@P%seUN8 zQxQ&n3@|zYj?wttl%g-iY}V5WKPa>G6u_KUzIEe^&LVU_4C4g#yCnEyBV%wSMF_aOhMJAvp=>+#HmXJWdhqXk0`h`n^ z^r?5wpY@gDuoAQis%8jTqn~g9@84#I=Ob`$;xxBNRv+M>!@q>eS7O3KfXO#ElhBS27hv7$f
    H3G%LT%n|wMf@2VoDV&zmo=o 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 + + + + + diff --git a/src/pages/images/icon-w-512.png b/src/pages/images/icon-w-512.png new file mode 100644 index 0000000000000000000000000000000000000000..615dfa846111a3c1f154f55cb4390538ce27e0d3 GIT binary patch literal 16860 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 literal 0 HcmV?d00001 diff --git a/src/pages/images/icon-w-512.svg b/src/pages/images/icon-w-512.svg new file mode 100644 index 0000000..ecdd62a --- /dev/null +++ b/src/pages/images/icon-w-512.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/pages/libraries.js b/src/pages/libraries.js index c9c3202..9fb7d4c 100644 --- a/src/pages/libraries.js +++ b/src/pages/libraries.js @@ -3,12 +3,9 @@ import axios from "axios"; import Config from "../lib/config"; import "./css/library/libraries.css"; -// import "./css/users/users.css"; - import Loading from "./components/general/loading"; import LibraryCard from "./components/library/library-card"; -import Row from "react-bootstrap/Row"; - +import ErrorBoundary from "./components/general/ErrorBoundary"; function Libraries() { @@ -82,15 +79,15 @@ function Libraries() {

    Libraries

    - +
    {data && data.map((item) => ( - - data.Id === item.Id)} base_url={config.hostUrl}/> - + + data.Id === item.Id)} base_url={config.hostUrl}/> + ))} - +
    ); diff --git a/src/pages/login.js b/src/pages/login.js index 4fb23e6..d5dcd6d 100644 --- a/src/pages/login.js +++ b/src/pages/login.js @@ -3,13 +3,22 @@ import axios from "axios"; import Config from "../lib/config"; import CryptoJS from 'crypto-js'; import "./css/setup.css"; +import Form from 'react-bootstrap/Form'; +import Button from 'react-bootstrap/Button'; +import { InputGroup,Row } from "react-bootstrap"; + +import EyeFillIcon from 'remixicon-react/EyeFillIcon'; +import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon'; + // import LibrarySync from "./components/settings/librarySync"; -// import Loading from './components/loading'; +import Loading from './components/general/loading'; + function Login() { const [config, setConfig] = useState(null); const [formValues, setFormValues] = useState({}); + const [showPassword, setShowPassword] = useState(false); const [processing, setProcessing] = useState(false); const [submitButtonText, setsubmitButtonText] = useState("Login"); @@ -22,14 +31,18 @@ function Login() { setProcessing(true); event.preventDefault(); + let hashedPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString(); - let hashedPassword= CryptoJS.SHA3(formValues.password).toString(); + beginLogin(formValues.JS_USERNAME,hashedPassword); - console.log(hashedPassword); + } + + async function beginLogin(JS_USERNAME,hashedPassword) + { axios .post("/auth/login", { - username:formValues.username, + username:JS_USERNAME, password: hashedPassword }, { @@ -39,11 +52,17 @@ function Login() { }) .then(async (response) => { + localStorage.setItem('token',response.data.token); + setProcessing(false); + if(JS_USERNAME) + { setsubmitButtonText("Success"); - setProcessing(false); window.location.reload(); return; + } + + }) .catch((error) => { let errorMessage= `Error : ${error.response.status}`; @@ -54,11 +73,18 @@ function Login() { } else if (error.response.status === 404) { errorMessage = `Error ${error.response.status}: The requested URL was not found.`; } + if(JS_USERNAME) + { setsubmitButtonText(errorMessage); + } + + + setProcessing(false); }); } + useEffect(() => { const fetchConfig = async () => { try { @@ -66,48 +92,62 @@ function Login() { setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { - console.log(error); + if (error.response.status !== 401 && error.response.status !== 403) { + // console.log(error); + } + + } } }; + if (!config) { fetchConfig(); + beginLogin(); + } }, [config]); + if(!config || config.token) + { + return ; + } + return (
    -
    -
    +

    Login

    -
    - - -
    -
    - - -
    - + + Password + + + + + + + - + + +
    ); diff --git a/src/pages/settings.js b/src/pages/settings.js index e1bfcd3..971a381 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -1,11 +1,14 @@ import React from "react"; +import {Tabs, Tab } from 'react-bootstrap'; import SettingsConfig from "./components/settings/settingsConfig"; -import LibrarySync from "./components/settings/librarySync"; - +import Tasks from "./components/settings/Tasks"; import BackupFiles from "./components/settings/backupfiles"; +import SecuritySettings from "./components/settings/security"; -import TerminalComponent from "./components/settings/TerminalComponent"; +import Logs from "./components/settings/logs"; + +// import TerminalComponent from "./components/settings/TerminalComponent"; @@ -15,12 +18,32 @@ export default function Settings() { return ( -
    - - - +
    + - + + + + + + + + + + + + + + + + + + + + + + + {/* */}
    ); diff --git a/src/pages/setup.js b/src/pages/setup.js index 21cd0de..320ea73 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -1,17 +1,23 @@ import React, { useState, useEffect } from "react"; import axios from "axios"; import Config from "../lib/config"; +import Form from 'react-bootstrap/Form'; +import Button from 'react-bootstrap/Button'; +import { InputGroup,Row } from "react-bootstrap"; + +import EyeFillIcon from 'remixicon-react/EyeFillIcon'; +import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon'; import "./css/setup.css"; -// import LibrarySync from "./components/settings/librarySync"; +const token = localStorage.getItem('token'); -// import Loading from './components/loading'; function Setup() { const [config, setConfig] = useState(null); const [formValues, setFormValues] = useState({}); const [processing, setProcessing] = useState(false); const [submitButtonText, setsubmitButtonText] = useState("Save"); + const [showPassword, setShowPassword] = useState(false); function handleFormChange(event) { setFormValues({ ...formValues, [event.target.name]: event.target.value }); @@ -39,38 +45,23 @@ function Setup() { } async function validateSettings(_url, _apikey) { - // Send a GET request to /system/configuration to test copnnection - let isValid = false; - let errorMessage = ""; - await axios - .get(_url + "/system/configuration", { + const result = await axios + .post("/api/validateSettings", { + url:_url, + apikey: _apikey + + }, { headers: { - "X-MediaBrowser-Token": _apikey, + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, }) - .then((response) => { - if (response.status === 200) { - isValid = true; - } - }) .catch((error) => { - // console.log(error.code); - if (error.code === "ERR_NETWORK") { - isValid = false; - errorMessage = `Unable to connect to Jellyfin Server`; - } else if (error.response.status === 401) { - isValid = false; - errorMessage = `Error ${error.response.status} Unauthorized`; - } else if (error.response.status === 404) { - isValid = false; - errorMessage = `Error ${error.response.status}: The requested URL was not found.`; - } else { - isValid = false; - errorMessage = `Error : ${error.response.status}`; - } + }); - return { isValid: isValid, errorMessage: errorMessage }; + let data=result.data; + return { isValid:data.isValid, errorMessage:data.errorMessage} ; } async function handleFormSubmit(event) { @@ -144,36 +135,38 @@ function Setup() { return (
    -
    -
    -

    Setup

    -
    - - -
    -
    - - -
    +
    +

    Setup Jellyfin

    - + + API Key + + + + + + + - + + +
    ); diff --git a/src/pages/signup.js b/src/pages/signup.js index 8668220..6ef4013 100644 --- a/src/pages/signup.js +++ b/src/pages/signup.js @@ -4,15 +4,22 @@ import axios from "axios"; import Config from "../lib/config"; import CryptoJS from 'crypto-js'; import "./css/setup.css"; -// import LibrarySync from "./components/settings/librarySync"; +import Loading from "./components/general/loading"; +import Form from 'react-bootstrap/Form'; +import Button from 'react-bootstrap/Button'; +import { InputGroup,Row } from "react-bootstrap"; + +import EyeFillIcon from 'remixicon-react/EyeFillIcon'; +import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon'; + -// import Loading from './components/loading'; function Signup() { const [config, setConfig] = useState(null); const [formValues, setFormValues] = useState({}); const [processing, setProcessing] = useState(false); const [submitButtonText, setsubmitButtonText] = useState("Save"); + const [showPassword, setShowPassword] = useState(false); function handleFormChange(event) { setFormValues({ ...formValues, [event.target.name]: event.target.value }); @@ -23,13 +30,13 @@ function Signup() { setProcessing(true); event.preventDefault(); - let hashedPassword= CryptoJS.SHA3(formValues.password).toString(); + let hashedPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString(); // Send a POST request to /api/setconfig/ with the updated configuration axios .post("/auth/createuser", { - username:formValues.username, + username:formValues.JS_USERNAME, password: hashedPassword }, { @@ -76,38 +83,45 @@ function Signup() { } }, [config]); + if(!config) + { + return ; + } + return (
    -
    -
    -

    Create User

    -
    - - -
    -
    - - -
    +
    +

    Sign Up

    - + + Password + + + + + + + - + + +
    ); diff --git a/src/pages/testing.js b/src/pages/testing.js index 578a0ca..9a41a99 100644 --- a/src/pages/testing.js +++ b/src/pages/testing.js @@ -9,7 +9,7 @@ import './css/library/libraries.css'; // import LibraryOverView from './components/libraryOverview'; // import HomeStatisticCards from './components/HomeStatisticCards'; // import Sessions from './components/sessions/sessions'; -import DailyPlayStats from './components/statistics/daily-play-count'; +import MostActiveUsers from './components/statCards/most_active_users'; @@ -52,7 +52,7 @@ function Testing() { return (
    - +
    diff --git a/src/pages/users.js b/src/pages/users.js index 2723d39..c591817 100644 --- a/src/pages/users.js +++ b/src/pages/users.js @@ -3,39 +3,106 @@ import axios from "axios"; import Config from "../lib/config"; import { Link } from 'react-router-dom'; import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; +import {ButtonGroup, Button } from 'react-bootstrap'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import TableSortLabel from '@mui/material/TableSortLabel'; +import Box from '@mui/material/Box'; +import { visuallyHidden } from '@mui/utils'; + import "./css/users/users.css"; import Loading from "./components/general/loading"; -function Users() { - const [data, setData] = useState(); - const [config, setConfig] = useState(null); - const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); - const [currentPage, setCurrentPage] = useState(1); - const [itemCount,setItemCount] = useState(10); +const token = localStorage.getItem('token'); - function handleSort(key) { - const direction = - sortConfig.key === key && sortConfig.direction === "ascending" - ? "descending" - : "ascending"; - setSortConfig({ key, direction }); - } - function sortData(data, { key, direction }) { - if (!key) return data; +function EnhancedTableHead(props) { + const { order, orderBy, onRequestSort } = + props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; - const sortedData = [...data]; + const headCells = [ + { + id: 'UserName', + numeric: false, + disablePadding: true, + label: 'User', + }, + { + id: 'LastWatched', + numeric: false, + disablePadding: false, + label: 'Last Watched', + }, + { + id: 'LastClient', + numeric: false, + disablePadding: false, + label: 'Last Client', + }, + { + id: 'TotalPlays', + numeric: false, + disablePadding: false, + label: 'Plays', + }, + { + id: 'TotalWatchTime', + numeric: false, + disablePadding: false, + label: 'Watch Time', + }, + { + id: 'LastSeen', + numeric: false, + disablePadding: false, + label: 'Last Seen', + }, + ]; - sortedData.sort((a, b) => { - if (a[key] < b[key]) return direction === "ascending" ? -1 : 1; - if (a[key] > b[key]) return direction === "ascending" ? 1 : -1; - return 0; - }); - return sortedData; - } + return ( + + + + {headCells.map((headCell) => ( + + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + + ))} + + + ); +} + + +function Row(row) { + const { data } = row; function formatTotalWatchTime(seconds) { const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds @@ -74,6 +141,50 @@ function Users() { } + return ( + + *': { borderBottom: 'unset' } }}> + + {data.PrimaryImageTag ? ( + + ) : ( + + )} + + {data.UserName} + {data.LastWatched || 'never'} + {data.LastClient || 'n/a'} + {data.TotalPlays} + {formatTotalWatchTime(data.TotalWatchTime) || '0 minutes'} + {data.LastSeen ? formatLastSeenTime(data.LastSeen) : 'never'} + + + + ); +} + +function Users() { + const [data, setData] = useState(); + const [config, setConfig] = useState(null); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + const [page, setPage] = React.useState(0); + const [itemCount,setItemCount] = useState(10); + + const [order, setOrder] = React.useState('asc'); + const [orderBy, setOrderBy] = React.useState('LastSeen'); + + + + + useEffect(() => { const fetchConfig = async () => { @@ -94,7 +205,7 @@ function Users() { axios .get(url, { headers: { - Authorization: `Bearer ${config.token}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }) @@ -109,9 +220,7 @@ function Users() { - if (!data && config) { - fetchData(); - } + fetchData(); if (!config) { fetchConfig(); @@ -119,31 +228,125 @@ function Users() { const intervalId = setInterval(fetchData, 60000); return () => clearInterval(intervalId); - }, [data, config]); + }, [config]); if (!data || data.length === 0) { return ; } - const sortedData = sortData(data, sortConfig); - - const indexOfLastUser = currentPage * itemCount; - const indexOfFirstUser = indexOfLastUser - itemCount; - const currentUsers = sortedData.slice(indexOfFirstUser, indexOfLastUser); + const handleNextPageClick = () => { + setPage((prevPage) => prevPage + 1); + }; - const pageNumbers = []; - for (let i = 1; i <= Math.ceil(sortedData.length / itemCount); i++) { - pageNumbers.push(i); + const handlePreviousPageClick = () => { + setPage((prevPage) => prevPage - 1); + }; + + function formatLastSeenTime(time) { + if(!time) + { + return ' never'; + } + const units = { + days: ['Day', 'Days'], + hours: ['Hour', 'Hours'], + minutes: ['Minute', 'Minutes'], + seconds: ['Second', 'Seconds'] + }; + + let formattedTime = ''; + + for (const unit in units) { + if (time[unit]) { + const unitName = units[unit][time[unit] > 1 ? 1 : 0]; + formattedTime += `${time[unit]} ${unitName} `; + } + } + + return `${formattedTime}ago`; } + + function descendingComparator(a, b, orderBy) { + if (orderBy==='LastSeen') { + let order_a=formatLastSeenTime(a[orderBy]); + let order_b=formatLastSeenTime(b[orderBy]); + if (order_b > order_a) { + return -1; + } + if (order_a< order_b) { + return 1; + } + return 0; + } + + if (orderBy === 'TotalPlays') { + let order_a = parseInt(a[orderBy]); + let order_b = parseInt(b[orderBy]); + + if (order_a < order_b) { + return -1; + } + if (order_a > order_b) { + return 1; + } + return 0; + } + + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; + } + + function getComparator(order, orderBy) { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); + } + + + function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); + + stabilizedThis.sort((a, b) => { + + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + + }); + + return stabilizedThis.map((el) => el[0]); + } + + const visibleRows = stableSort(data, getComparator(order, orderBy)).slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ); + + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + + + return (

    All Users

    Items
    - {setRowsPerPage(event.target.value); setPage(0); setItemCount(event.target.value);}}> @@ -152,62 +355,48 @@ function Users() {
    - - - - - - - - - - - - - - {currentUsers.map((item) => ( - - - - - - - - - - ))} - -
    handleSort("UserName")}>User handleSort("LastWatched")}>Last Watched handleSort("LastClient")}>Last Client handleSort("TotalPlays")}>Total Plays handleSort("TotalWatchTime")}>Total Watch Time handleSort("LastSeen")}>Last Seen
    - {item.PrimaryImageTag ? ( - - ) : ( - - )} - {item.UserName}{item.LastWatched || 'never'}{item.LastClient || 'n/a'}{item.TotalPlays}{formatTotalWatchTime(item.TotalWatchTime) || 0}{item.LastSeen ? formatLastSeenTime(item.LastSeen) : 'never'}
    -
    - - -
    {`Page ${currentPage} of ${pageNumbers.length}`}
    - - -
    + + + + + {visibleRows.map((row) => ( + + ))} + {data.length===0 ? :''} + + +
    No Users Found
    +
    + +
    + + + + + +
    {`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),data.length)} of ${data.length}`}
    + + + + +
    +
    + + +
    ); } diff --git a/src/setupProxy.js b/src/setupProxy.js index 1debbba..54c44b2 100644 --- a/src/setupProxy.js +++ b/src/setupProxy.js @@ -2,48 +2,63 @@ const { createProxyMiddleware } = require('http-proxy-middleware'); module.exports = function(app) { app.use( - '/api', + `/api`, createProxyMiddleware({ - target: 'http://127.0.0.1:3003', + target: `http://127.0.0.1:${process.env.PORT || 3003}`, changeOrigin: true, }) ); app.use( - '/stats', + `/proxy`, createProxyMiddleware({ - target: 'http://127.0.0.1:3003', + target: `http://127.0.0.1:${process.env.PORT || 3003}`, changeOrigin: true, }) ); app.use( - '/sync', + `/stats`, createProxyMiddleware({ - target: 'http://127.0.0.1:3003', + target: `http://127.0.0.1:${process.env.PORT || 3003}`, changeOrigin: true, }) ); app.use( - '/auth', + `/sync`, createProxyMiddleware({ - target: 'http://127.0.0.1:3003', + target: `http://127.0.0.1:${process.env.PORT || 3003}`, changeOrigin: true, }) ); app.use( - '/data', + `/auth`, createProxyMiddleware({ - target: 'http://127.0.0.1:3003', + target: `http://127.0.0.1:${process.env.PORT || 3003}`, changeOrigin: true, }) ); app.use( - '/ws', + `/data`, + createProxyMiddleware({ + target: `http://127.0.0.1:${process.env.PORT || 3003}`, + changeOrigin: true, + }) + ); + app.use( + `/logs`, + createProxyMiddleware({ + target: `http://127.0.0.1:${process.env.PORT || 3003}`, + changeOrigin: true, + }) + ); + app.use( + `/ws`, createProxyMiddleware({ target: `ws://127.0.0.1:${process.env.WS_PORT || 3004}`, - changeOrigin: true, ws: true, + changeOrigin: true, + secure: false, }) ); - console.log('Proxy middleware applied'); + console.log(`Proxy middleware applied`); };