mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
Automated Tasks + Logging +misc
New: Added automated tasks for Sync and Backups - Backups run every 24hrs, Sync runs every 10 minutes Added switcher to switch between 12hr/24hr time formats Added sorting to Activity Tables Added Version Checking and indicators Added logging on Tasks + Log Page Added Recently Added view to Home page Added background images to item banners Added Proxy for Device Images in session card Changed Navbar to be a side bar Fixes: Fixed Jellyfin API returning Empty folder as library item CSS File to add breakpoints to bootstrap-width Other: Various CSS changes Temporarily removed Websockets due to Proxy Errors Changed Activity View Playback conversion function to more accurately display Playback Duration Backend changes to sum Playback Durations to show more accurate information in thee collapsed summarized view
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
# testing
|
||||
/coverage
|
||||
/backend/backup-data
|
||||
.vscode
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// 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'
|
||||
@@ -61,6 +62,19 @@ router.post("/setconfig", async (req, res) => {
|
||||
console.log(`ENDPOINT CALLED: /setconfig: `);
|
||||
});
|
||||
|
||||
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{
|
||||
@@ -219,6 +233,14 @@ router.get("/getHistory", async (req, res) => {
|
||||
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));
|
||||
|
||||
|
||||
@@ -3,9 +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();
|
||||
|
||||
@@ -21,7 +23,8 @@ const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_lib
|
||||
|
||||
|
||||
// Backup function
|
||||
async function backup() {
|
||||
async function backup(logData,result) {
|
||||
logData.push({ color: "lawngreen", Message: "Starting Backup" });
|
||||
const pool = new Pool({
|
||||
user: postgresUser,
|
||||
password: postgresPassword,
|
||||
@@ -39,20 +42,19 @@ async function backup() {
|
||||
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 });
|
||||
logData.push({ color: "red", Message: "Backup Failed: "+error });
|
||||
result='Failed';
|
||||
throw new Error(error);
|
||||
});
|
||||
const backup_data=[];
|
||||
|
||||
wss.clearMessages();
|
||||
wss.sendMessageToClients({ color: "yellow", Message: "Begin Backup "+backupPath });
|
||||
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}`});
|
||||
logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
|
||||
|
||||
backup_data.push({[table]:rows});
|
||||
|
||||
@@ -61,12 +63,13 @@ async function backup() {
|
||||
|
||||
await stream.write(JSON.stringify(backup_data));
|
||||
stream.end();
|
||||
wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" });
|
||||
logData.push({ color: "lawngreen", Message: "Backup Complete" });
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
|
||||
logData.push({ color: "red", Message: "Backup Failed: "+error });
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +164,28 @@ async function restore(file) {
|
||||
// Route handler for backup endpoint
|
||||
router.get('/backup', async (req, res) => {
|
||||
try {
|
||||
await backup();
|
||||
let startTime = moment();
|
||||
let logData=[];
|
||||
let result='Success';
|
||||
await backup(logData,result);
|
||||
|
||||
let endTime = moment();
|
||||
let diffInSeconds = endTime.diff(startTime, 'seconds');
|
||||
const uuid = randomUUID();
|
||||
const log=
|
||||
{
|
||||
"Id":uuid,
|
||||
"Name":"Backup",
|
||||
"Type":"Task",
|
||||
"ExecutionType":"Manual",
|
||||
"Duration":diffInSeconds,
|
||||
"TimeRun":startTime,
|
||||
"Log":JSON.stringify(logData),
|
||||
"Result": result
|
||||
|
||||
};
|
||||
|
||||
Logging.insertLog(log);
|
||||
res.send('Backup completed successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -272,4 +296,8 @@ router.get('/restore/:filename', async (req, res) => {
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
module.exports =
|
||||
{
|
||||
router,
|
||||
backup
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ async function insertBulk(table_name, data,columns) {
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
message=(data.length + " Rows Inserted.");
|
||||
message=((data.length||1) + " Rows Inserted.");
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
37
backend/logging.js
Normal file
37
backend/logging.js
Normal file
@@ -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};
|
||||
29
backend/migrations/030_jf_logging_table.js
Normal file
29
backend/migrations/030_jf_logging_table.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
26
backend/models/jf_logging.js
Normal file
26
backend/models/jf_logging.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -14,8 +14,43 @@ const axios_instance = axios.create({
|
||||
|
||||
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.send(response.data.toString());
|
||||
}
|
||||
|
||||
return; // Add this line
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
res.status(500).send('Error fetching image');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
const { id,fillWidth,quality } = req.query; // Get the image URL from the query string
|
||||
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) {
|
||||
@@ -24,7 +59,7 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
}
|
||||
|
||||
|
||||
let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 100}&quality=${quality || 100}`;
|
||||
let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 800}&quality=${quality || 100}&blur=${blur || 0}`;
|
||||
|
||||
|
||||
axios_instance.get(url, {
|
||||
@@ -56,7 +91,7 @@ router.get('/Items/Images/Backdrop/', async(req, res) => {
|
||||
}
|
||||
|
||||
|
||||
let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 100}&quality=${quality || 100}`;
|
||||
let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 400}&quality=${quality || 100}`;
|
||||
|
||||
axios_instance.get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
|
||||
@@ -8,12 +8,13 @@ const knexConfig = require('./migrations');
|
||||
const authRouter= require('./auth');
|
||||
const apiRouter = require('./api');
|
||||
const proxyRouter = require('./proxy');
|
||||
const syncRouter = require('./sync');
|
||||
const {router: syncRouter} = require('./sync');
|
||||
const statsRouter = require('./stats');
|
||||
const backupRouter = require('./backup');
|
||||
const ActivityMonitor = require('./watchdog/ActivityMonitor');
|
||||
|
||||
const { checkForUpdates } = require('./version-control');
|
||||
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');
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +58,7 @@ app.use('/proxy', proxyRouter); // mount the API router at /api, with JWT middle
|
||||
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) => {
|
||||
@@ -67,8 +69,10 @@ try{
|
||||
db.migrate.latest().then(() => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`Server listening on http://${LISTEN_IP}:${PORT}`);
|
||||
checkForUpdates();
|
||||
|
||||
ActivityMonitor.ActivityMonitor(1000);
|
||||
SyncTask.SyncTask(60000*10);
|
||||
BackupTask.BackupTask(60000*60*24);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,7 +308,11 @@ router.get("/getRecentlyAdded", async (req, res) => {
|
||||
);
|
||||
|
||||
|
||||
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest?parentId=${libraryid}`;
|
||||
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest`;
|
||||
if(libraryid)
|
||||
{
|
||||
url+=`?parentId=${libraryid}`;
|
||||
}
|
||||
|
||||
const response_data = await axios.get(url, {
|
||||
headers: {
|
||||
|
||||
191
backend/sync.js
191
backend/sync.js
@@ -4,6 +4,8 @@ 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'
|
||||
});
|
||||
@@ -17,6 +19,9 @@ const axios_instance = axios.create({
|
||||
const wss = require("./WebsocketHandler");
|
||||
const socket=wss;
|
||||
|
||||
const moment = require('moment');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -39,7 +44,6 @@ class sync {
|
||||
async getUsers() {
|
||||
try {
|
||||
const url = `${this.hostUrl}/Users`;
|
||||
console.log("getAdminUser: ", url);
|
||||
const response = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": this.apiKey,
|
||||
@@ -55,7 +59,6 @@ class sync {
|
||||
async getAdminUser() {
|
||||
try {
|
||||
const url = `${this.hostUrl}/Users`;
|
||||
console.log("getAdminUser: ", url);
|
||||
const response = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": this.apiKey,
|
||||
@@ -90,7 +93,7 @@ class sync {
|
||||
["tvshows", "movies","music"].includes(type.CollectionType)
|
||||
);
|
||||
} else {
|
||||
return results;
|
||||
return results.filter((item)=> item.ImageTags.Primary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
@@ -141,12 +144,13 @@ class sync {
|
||||
}
|
||||
////////////////////////////////////////API Methods
|
||||
|
||||
async function syncUserData()
|
||||
async function syncUserData(loggedData,result)
|
||||
{
|
||||
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!" });
|
||||
loggedData.push({ Message: "Error: Config details not found!" });
|
||||
result='Failed';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,12 +176,13 @@ async function syncUserData()
|
||||
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.");
|
||||
loggedData.push(dataToInsert.length + " Rows Inserted.");
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,21 +190,24 @@ async function syncUserData()
|
||||
if (toDeleteIds.length > 0) {
|
||||
let result = await db.deleteBulk("jf_users",toDeleteIds);
|
||||
if (result.Result === "SUCCESS") {
|
||||
socket.sendMessageToClients(toDeleteIds.length + " Rows Removed.");
|
||||
loggedData.push(toDeleteIds.length + " Rows Removed.");
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function syncLibraryFolders()
|
||||
async function syncLibraryFolders(loggedData,result)
|
||||
{
|
||||
|
||||
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!" });
|
||||
loggedData.push({ Message: "Error: Config details not found!" });
|
||||
result='Failed';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,12 +233,13 @@ async function syncLibraryFolders()
|
||||
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.");
|
||||
loggedData.push(dataToInsert.length + " Rows Inserted.");
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,27 +247,28 @@ async function syncLibraryFolders()
|
||||
if (toDeleteIds.length > 0) {
|
||||
let result = await db.deleteBulk("jf_libraries",toDeleteIds);
|
||||
if (result.Result === "SUCCESS") {
|
||||
socket.sendMessageToClients(toDeleteIds.length + " Rows Removed.");
|
||||
loggedData.push(toDeleteIds.length + " Rows Removed.");
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
async function syncLibraryItems()
|
||||
async function syncLibraryItems(loggedData,result)
|
||||
{
|
||||
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" });
|
||||
result='Failed';
|
||||
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",});
|
||||
loggedData.push({ color: "lawngreen", Message: "Syncing... 1/3" });
|
||||
loggedData.push({color: "yellow",Message: "Beginning Library Item Sync",});
|
||||
|
||||
const admins = await _sync.getAdminUser();
|
||||
const userid = admins[0].Id;
|
||||
@@ -301,10 +311,11 @@ async function syncLibraryItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertCounter += dataToInsert.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,28 +325,28 @@ async function syncLibraryItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
deleteCounter +=toDeleteIds.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
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" });
|
||||
loggedData.push({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",});
|
||||
loggedData.push({color: "orange",Message: deleteCounter + " Library Items Removed.",});
|
||||
loggedData.push({ color: "yellow", Message: "Item Sync Complete" });
|
||||
|
||||
// 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(loggedData,result)
|
||||
{
|
||||
socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/3" });
|
||||
socket.sendMessageToClients({color: "yellow", Message: "Beginning Seasons and Episode sync",});
|
||||
loggedData.push({ color: "lawngreen", Message: "Syncing... 2/3" });
|
||||
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" });
|
||||
result='Failed';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,8 +364,7 @@ async function syncShowItems()
|
||||
const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons');
|
||||
const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes');
|
||||
show_counter++;
|
||||
socket.sendMessageToClients({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
|
||||
|
||||
loggedData.push({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
|
||||
|
||||
const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id));
|
||||
|
||||
@@ -401,10 +411,11 @@ async function syncShowItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertSeasonsCount += seasonsToInsert.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
const toDeleteIds = existingIdsSeasons.filter((id) =>!allSeasons.some((row) => row.Id === id ));
|
||||
@@ -414,7 +425,8 @@ async function syncShowItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
deleteSeasonsCount +=toDeleteIds.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -425,10 +437,11 @@ async function syncShowItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertEpisodeCount += episodesToInsert.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +452,8 @@ async function syncShowItems()
|
||||
if (result.Result === "SUCCESS") {
|
||||
deleteEpisodeCount +=toDeleteEpisodeIds.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -447,22 +461,23 @@ async function syncShowItems()
|
||||
|
||||
}
|
||||
|
||||
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" });
|
||||
loggedData.push({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
|
||||
loggedData.push({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
|
||||
loggedData.push({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
|
||||
loggedData.push({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
|
||||
loggedData.push({ color: "yellow", Message: "Sync Complete" });
|
||||
}
|
||||
|
||||
async function syncItemInfo()
|
||||
async function syncItemInfo(loggedData,result)
|
||||
{
|
||||
socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 3/3" });
|
||||
socket.sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",});
|
||||
loggedData.push({ color: "lawngreen", Message: "Syncing... 3/3" });
|
||||
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" });
|
||||
result='Failed';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -499,10 +514,11 @@ async function syncItemInfo()
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertItemInfoCount += ItemInfoToInsert.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
const toDeleteItemInfoIds = existingItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
|
||||
@@ -512,14 +528,14 @@ async function syncItemInfo()
|
||||
if (result.Result === "SUCCESS") {
|
||||
deleteItemInfoCount +=toDeleteItemInfoIds.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
result='Failed';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//loop for each Episode
|
||||
console.log("Episode")
|
||||
for (const Episode of Episodes) {
|
||||
const data = await _sync.getItemInfo(Episode.EpisodeId,userid);
|
||||
|
||||
@@ -543,10 +559,11 @@ async function syncItemInfo()
|
||||
if (result.Result === "SUCCESS") {
|
||||
insertEpisodeInfoCount += EpisodeInfoToInsert.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({
|
||||
loggedData.push({
|
||||
color: "red",
|
||||
Message: "Error performing bulk insert:" + result.message,
|
||||
});
|
||||
result='Failed';
|
||||
}
|
||||
}
|
||||
const toDeleteEpisodeInfoIds = existingEpisodeItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
|
||||
@@ -556,18 +573,19 @@ async function syncItemInfo()
|
||||
if (result.Result === "SUCCESS") {
|
||||
deleteEpisodeInfoCount +=toDeleteEpisodeInfoIds.length;
|
||||
} else {
|
||||
socket.sendMessageToClients({color: "red",Message: result.message,});
|
||||
loggedData.push({color: "red",Message: result.message,});
|
||||
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" });
|
||||
loggedData.push({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted.",});
|
||||
loggedData.push({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
|
||||
loggedData.push({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes Info inserted.",});
|
||||
loggedData.push({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",});
|
||||
loggedData.push({ color: "lawngreen", Message: "Sync Complete" });
|
||||
}
|
||||
|
||||
async function syncPlaybackPluginData()
|
||||
@@ -633,20 +651,74 @@ async function syncPlaybackPluginData()
|
||||
|
||||
}
|
||||
|
||||
async function fullSync()
|
||||
{
|
||||
let startTime = moment();
|
||||
let loggedData=[];
|
||||
let result='Success';
|
||||
await syncUserData(loggedData,result);
|
||||
await syncLibraryFolders(loggedData,result);
|
||||
await syncLibraryItems(loggedData,result);
|
||||
await syncShowItems(loggedData,result);
|
||||
await syncItemInfo(loggedData,result);
|
||||
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(loggedData),
|
||||
"Result":result
|
||||
|
||||
};
|
||||
logging.insertLog(log);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////API Calls
|
||||
|
||||
///////////////////////////////////////Sync All
|
||||
router.get("/beingSync", async (req, res) => {
|
||||
socket.clearMessages();
|
||||
let loggedData=[];
|
||||
let result='Success';
|
||||
|
||||
await syncUserData();
|
||||
await syncLibraryFolders();
|
||||
await syncLibraryItems();
|
||||
await syncShowItems();
|
||||
await syncItemInfo();
|
||||
let startTime = moment();
|
||||
await syncUserData(loggedData,result);
|
||||
await syncLibraryFolders(loggedData,result);
|
||||
await syncLibraryItems(loggedData,result);
|
||||
await syncShowItems(loggedData,result);
|
||||
await syncItemInfo(loggedData,result);
|
||||
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(loggedData),
|
||||
"Result":result
|
||||
|
||||
};
|
||||
|
||||
logging.insertLog(log);
|
||||
res.send();
|
||||
|
||||
});
|
||||
@@ -702,4 +774,5 @@ router.get("/syncPlaybackPluginData", async (req, res) => {
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
module.exports =
|
||||
{router,fullSync};
|
||||
|
||||
62
backend/tasks/BackupTask.js
Normal file
62
backend/tasks/BackupTask.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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 logData=[];
|
||||
let result='Success';
|
||||
|
||||
await backup.backup(logData,result);
|
||||
|
||||
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(logData),
|
||||
"Result":result
|
||||
|
||||
};
|
||||
Logging.insertLog(log);
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
return [];
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BackupTask,
|
||||
};
|
||||
35
backend/tasks/SyncTask.js
Normal file
35
backend/tasks/SyncTask.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -1,33 +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 repoName = 'Jellystat';
|
||||
const gh = new GitHub();
|
||||
const repo = gh.getRepo(repoOwner, repoName);
|
||||
|
||||
let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false};
|
||||
|
||||
let latestVersion;
|
||||
|
||||
try {
|
||||
const releases = await repo.listReleases();
|
||||
const path = 'package.json';
|
||||
|
||||
if (releases.data.length > 0) {
|
||||
latestVersion = releases.data[0].tag_name;
|
||||
console.log(releases.data);
|
||||
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 };
|
||||
}
|
||||
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
console.log(`A new version (${latestVersion}) of ${repoName} is available.`);
|
||||
} else if (latestVersion) {
|
||||
console.log(`${repoName} is up to date.`);
|
||||
}
|
||||
else {
|
||||
console.log(`Unable to retrieve latest version`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = { checkForUpdates };
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jfstat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jfstat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
@@ -22,6 +22,7 @@
|
||||
"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",
|
||||
@@ -7150,6 +7151,11 @@
|
||||
"version": "1.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compare-versions": {
|
||||
"version": "6.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-cFhkjbGY1jLFWIV7KegECbfuyYPxSGvgGkdkfM+ibboQDoPwg2FRHm5BSNTOApiauRBzJIQH7qvOJs2sW5ueKQ=="
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jfstat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
@@ -17,6 +17,7 @@
|
||||
"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",
|
||||
|
||||
11
src/App.css
11
src/App.css
@@ -1,8 +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;
|
||||
@@ -82,26 +85,24 @@ h2{
|
||||
|
||||
.btn-outline-primary
|
||||
{
|
||||
color: grey!important;
|
||||
color: white!important;
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: var(--background-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary.active
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.btn-outline-primary:focus
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -116,9 +117,10 @@ if (config && config.apiKey ===null) {
|
||||
if (config && isConfigured && token!==null){
|
||||
return (
|
||||
<div className="App">
|
||||
<Navbar />
|
||||
<div>
|
||||
<main>
|
||||
|
||||
<div className='d-flex flex-column flex-md-row'>
|
||||
<Navbar/>
|
||||
<main className='w-md-100'>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
@@ -130,6 +132,7 @@ if (config && isConfigured && token!==null){
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/activity" element={<Activity />} />
|
||||
<Route path="/testing" element={<Testing />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: <HomeFillIcon/>,
|
||||
text: "Home",
|
||||
link: "/"
|
||||
link: ""
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
@@ -49,13 +50,14 @@ export const navData = [
|
||||
text: "Settings",
|
||||
link: "settings"
|
||||
}
|
||||
,
|
||||
|
||||
{
|
||||
id: 7,
|
||||
icon: <InformationFillIcon />,
|
||||
text: "About",
|
||||
link: "about"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
// {
|
||||
// id: 5,
|
||||
// icon: <ReactjsFillIcon />,
|
||||
// text: "Component Testing Playground",
|
||||
// link: "testing"
|
||||
// }
|
||||
// ,
|
||||
91
src/pages/about.js
Normal file
91
src/pages/about.js
Normal file
@@ -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);
|
||||
}, [token]);
|
||||
|
||||
|
||||
if(!data)
|
||||
{
|
||||
return <Loading/>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="tasks">
|
||||
<h1 className="py-3">About Jellystat</h1>
|
||||
<Card className="about p-0" >
|
||||
<Card.Body >
|
||||
<Row>
|
||||
<Col className="px-0">
|
||||
Version:
|
||||
</Col>
|
||||
<Col>
|
||||
{data.current_version}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{color:(data.update_available ? "#00A4DC": "White")}}>
|
||||
<Col className="px-0">
|
||||
Update Available:
|
||||
</Col>
|
||||
<Col>
|
||||
{data.message}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{height:'20px'}}></Row>
|
||||
<Row>
|
||||
<Col className="px-0">
|
||||
Github:
|
||||
</Col>
|
||||
<Col>
|
||||
<a href="https://github.com/CyferShepard/Jellystat" target="_blank" > https://github.com/CyferShepard/Jellystat</a>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -10,43 +10,46 @@ 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 '../../css/activity/activity-table.css';
|
||||
// localStorage.setItem('hour12',true);
|
||||
let hour_format = Boolean(localStorage.getItem('hour12'));
|
||||
|
||||
|
||||
function formatTotalWatchTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
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`;
|
||||
let timeString = '';
|
||||
|
||||
if (hours > 0) {
|
||||
timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `;
|
||||
}
|
||||
|
||||
if(!hours && !minutes)
|
||||
{
|
||||
// const seconds = Math.floor(((seconds % 3600) / 60) / 60); // 1 minute = 60 seconds
|
||||
formattedTime+=` ${seconds} seconds`;
|
||||
if (minutes > 0) {
|
||||
timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `;
|
||||
}
|
||||
|
||||
return formattedTime ;
|
||||
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 classes = useRowStyles();
|
||||
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
@@ -55,11 +58,10 @@ function Row(data) {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: hour_format,
|
||||
hour12: twelve_hr,
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
@@ -76,7 +78,8 @@ function Row(data) {
|
||||
<TableCell><Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className='text-decoration-none'>{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell>{row.Client}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 minutes'}</TableCell>
|
||||
{/* <TableCell>{formatTotalWatchTime(row.results && row.results.length>0 ? row.results.reduce((acc, items) => acc +parseInt(items.PlaybackDuration),0): row.PlaybackDuration) || '0 minutes'}</TableCell> */}
|
||||
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'}</TableCell>
|
||||
<TableCell>{row.results.length !==0 ? row.results.length : 1}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
@@ -87,7 +90,7 @@ function Row(data) {
|
||||
<Table aria-label="sub-activity" className='rounded-2'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
@@ -96,14 +99,14 @@ function Row(data) {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{row.results.map((resultRow) => (
|
||||
{row.results.sort((a, b) => new Date(b.ActivityDateInserted) - new Date(a.ActivityDateInserted)).map((resultRow) => (
|
||||
<TableRow key={resultRow.Id}>
|
||||
|
||||
<TableCell><Link to={`/users/${resultRow.UserId}`} className='text-decoration-none'>{resultRow.UserName}</Link></TableCell>
|
||||
<TableCell><Link to={`/libraries/item/${resultRow.EpisodeId || resultRow.NowPlayingItemId}`} className='text-decoration-none'>{!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell>{resultRow.Client}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 minutes'}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'}</TableCell>
|
||||
<TableCell>1</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -117,10 +120,90 @@ function Row(data) {
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell/>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'normal'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</Box>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -137,25 +220,70 @@ export default function ActivityTable(props) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Playback Duration</TableCell>
|
||||
<TableCell>Plays</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<EnhancedTableHead
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}
|
||||
rowCount={rowsPerPage}
|
||||
/>
|
||||
<TableBody>
|
||||
{props.data
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => (
|
||||
{visibleRows.map((row) => (
|
||||
<Row key={row.Id+row.NowPlayingItemId+row.EpisodeId} row={row} />
|
||||
))}
|
||||
{props.data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Activity Found</td></tr> :''}
|
||||
|
||||
@@ -35,7 +35,7 @@ function LastWatchedCard(props) {
|
||||
<div className="last-card">
|
||||
<Link to={`/libraries/item/${props.data.EpisodeId||props.data.Id}`}>
|
||||
<div className="last-card-banner">
|
||||
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'}/>}
|
||||
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/>}
|
||||
<img
|
||||
src={
|
||||
`${
|
||||
|
||||
@@ -1,9 +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 = () => {
|
||||
@@ -11,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 (
|
||||
<BootstrapNavbar variant="dark" expand="md" className="navbar py-0">
|
||||
<Container fluid>
|
||||
<BootstrapNavbar.Brand as={Link} to={"/"}><img src={logo_dark} style={{height:"32px"}} className="px-2"/>Jellystat</BootstrapNavbar.Brand>
|
||||
<BootstrapNavbar.Toggle aria-controls="responsive-navbar-nav" />
|
||||
<BootstrapNavbar.Collapse id="responsive-navbar-nav">
|
||||
<Nav className="ms-auto">
|
||||
{navData.map((item) => {
|
||||
const isActive = location.pathname.toLocaleLowerCase().includes(('/'+item.link).toLocaleLowerCase()); // check if the link is the current path
|
||||
return (
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
key={item.id}
|
||||
className={`navitem${isActive ? " active" : ""}`} // add the "active" class if the link is active
|
||||
to={item.link}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="nav-text">{item.text}</span>
|
||||
</Nav.Link>
|
||||
);
|
||||
})}
|
||||
<Nav.Link className="navitem" href="#logout" onClick={handleLogout}>
|
||||
<LogoutBoxLineIcon />
|
||||
<span className="nav-text">Logout</span>
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
</BootstrapNavbar.Collapse>
|
||||
</Container>
|
||||
<BootstrapNavbar variant="dark" className=" d-flex flex-column py-0 text-center sticky-top">
|
||||
<div className="sticky-top py-md-3">
|
||||
<BootstrapNavbar.Brand as={Link} to={"/"} className="d-none d-md-inline">
|
||||
|
||||
<img src={logo_dark} style={{height:"52px"}} className="px-2" alt=''/>
|
||||
<span>Jellystat</span>
|
||||
</BootstrapNavbar.Brand>
|
||||
|
||||
|
||||
<Nav className="flex-row flex-md-column w-100">
|
||||
{navData.map((item) => {
|
||||
const locationString=location.pathname.toLocaleLowerCase();
|
||||
const isActive = locationString.includes(('/'+item.link).toLocaleLowerCase()) && ((locationString.length>0 && item.link.length>0) || (locationString.length===1 && item.link.length===0)); // check if the link is the current path
|
||||
return (
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
key={item.id}
|
||||
className={`navitem${isActive ? " active" : ""} p-2`} // add the "active" class if the link is active
|
||||
to={item.link}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="d-none d-md-block nav-text">{item.text}</span>
|
||||
</Nav.Link>
|
||||
);
|
||||
})}
|
||||
<Nav.Link className="navitem p-2 logout" href="#logout" onClick={handleLogout}>
|
||||
<LogoutBoxLineIcon />
|
||||
<span className="d-none d-md-block nav-text">Logout</span>
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
|
||||
|
||||
</div>
|
||||
<VersionCard/>
|
||||
|
||||
</BootstrapNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/pages/components/general/version-card.js
Normal file
71
src/pages/components/general/version-card.js
Normal file
@@ -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 (
|
||||
<Card className="d-none d-md-block version rounded-0 border-0" >
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col>Jellystat {data.current_version}</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{data.update_available?
|
||||
<Row>
|
||||
<Col ><a href="https://github.com/CyferShepard/Jellystat" target="_blank" style={{color:'#00A4DC'}}>New version available: {data.latest_version}</a></Col>
|
||||
</Row>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -117,18 +117,30 @@ if(data && data.notfound)
|
||||
return <ErrorPage message={data.message}/>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
|
||||
<div className="item-detail-container">
|
||||
<Row className="justify-content-center justify-content-md-start">
|
||||
<Col className="col-auto my-4 my-md-0">
|
||||
{data.PrimaryImageHash && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'}/> : null}
|
||||
<div className="item-detail-container rounded-3" style={cardStyle}>
|
||||
<Row className="justify-content-center justify-content-md-start rounded-3 g-0 p-4" style={cardBgStyle}>
|
||||
<Col className="col-auto my-4 my-md-0 item-banner-image" >
|
||||
{data.PrimaryImageHash && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'} className="rounded-3 overflow-hidden" style={{display:'block'}}/> : null}
|
||||
<img
|
||||
className="item-image"
|
||||
src={
|
||||
"/Proxy/Items/Images/Primary?id=" +
|
||||
(data.Type==="Episode"? data.SeriesId : data.Id) +
|
||||
(["Episode","Season"].includes(data.Type)? data.SeriesId : data.Id) +
|
||||
"&fillWidth=200&quality=90"
|
||||
}
|
||||
alt=""
|
||||
|
||||
@@ -16,7 +16,7 @@ function MoreItemCards(props) {
|
||||
<div className={props.data.Type==="Episode" ? "last-card episode-card" : "last-card"}>
|
||||
<Link to={`/libraries/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
|
||||
<div className={props.data.Type==="Episode" ? "last-card-banner episode" : "last-card-banner"}>
|
||||
{(props.data.ImageBlurHashes || props.data.PrimaryImageHash )&& !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'}/> : null}
|
||||
{(props.data.ImageBlurHashes || props.data.PrimaryImageHash )&& !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
|
||||
|
||||
{fallback ?
|
||||
<img
|
||||
|
||||
@@ -9,7 +9,7 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
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';
|
||||
|
||||
@@ -84,7 +84,7 @@ function LibraryInfo() {
|
||||
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
|
||||
<Tab eventKey="tabOverview" className='bg-transparent'>
|
||||
<LibraryGlobalStats LibraryId={LibraryId}/>
|
||||
<RecentlyPlayed LibraryId={LibraryId}/>
|
||||
<RecentlyAdded LibraryId={LibraryId}/>
|
||||
<LibraryLastWatched LibraryId={LibraryId}/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabActivity" className='bg-transparent'>
|
||||
|
||||
@@ -12,7 +12,7 @@ function RecentlyAddedCard(props) {
|
||||
<div className="last-card">
|
||||
<Link to={`/libraries/item/${props.data.Id}`}>
|
||||
<div className="last-card-banner">
|
||||
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/>}
|
||||
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/>}
|
||||
<img
|
||||
src={
|
||||
`${"/Proxy/Items/Images/Primary?id=" +
|
||||
|
||||
@@ -74,7 +74,7 @@ function LibraryCard(props) {
|
||||
return `${formattedTime}ago`;
|
||||
}
|
||||
return (
|
||||
<Card className="bg-transparent lib-card border-0">
|
||||
<Card className="bg-transparent lib-card rounded-3">
|
||||
<Link to={`/libraries/${props.data.Id}`}>
|
||||
<div className="library-card-image">
|
||||
<Card.Img
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
function LibraryItems(props) {
|
||||
const [data, setData] = useState();
|
||||
@@ -70,10 +71,10 @@ function LibraryItems(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="last-played">
|
||||
<div className="library-items">
|
||||
<div className="d-md-flex justify-content-between">
|
||||
<h1 className="my-3">Media</h1>
|
||||
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="my-3 w-25" />
|
||||
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="my-3 w-sm-100 w-md-75 w-lg-25" />
|
||||
</div>
|
||||
|
||||
<div className="media-items-container">
|
||||
|
||||
@@ -6,7 +6,7 @@ import RecentlyAddedCard from "./RecentlyAdded/recently-added-card";
|
||||
import Config from "../../../lib/config";
|
||||
import "../../css/users/user-details.css";
|
||||
|
||||
function RecentlyPlayed(props) {
|
||||
function RecentlyAdded(props) {
|
||||
const [data, setData] = useState();
|
||||
const [config, setConfig] = useState();
|
||||
|
||||
@@ -22,33 +22,23 @@ function RecentlyPlayed(props) {
|
||||
}
|
||||
};
|
||||
|
||||
// const fetchAdmin = async () => {
|
||||
// try {
|
||||
// let url=`/api/getAdminUsers`;
|
||||
// const adminData = 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=`/stats/getRecentlyAdded?libraryid=${props.LibraryId}`;
|
||||
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",
|
||||
},
|
||||
});
|
||||
setData(itemData.data);
|
||||
setData(itemData.data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
@@ -75,7 +65,7 @@ function RecentlyPlayed(props) {
|
||||
<div className="last-played">
|
||||
<h1 className="my-3">Recently Added</h1>
|
||||
<div className="last-played-container">
|
||||
{data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)).map((item) => (
|
||||
{data && data.map((item) => (
|
||||
<RecentlyAddedCard data={item} base_url={config.hostUrl} key={item.Id}/>
|
||||
))}
|
||||
|
||||
@@ -85,4 +75,4 @@ function RecentlyPlayed(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default RecentlyPlayed;
|
||||
export default RecentlyAdded;
|
||||
|
||||
@@ -11,8 +11,8 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
|
||||
export default function LibraryOverView() {
|
||||
const token = localStorage.getItem('token');
|
||||
const SeriesIcon=<TvLineIcon size={"80%"} /> ;
|
||||
const MovieIcon=<FilmLineIcon size={"80%"} /> ;
|
||||
const SeriesIcon=<TvLineIcon size={"100%"} /> ;
|
||||
const MovieIcon=<FilmLineIcon size={"100%"} /> ;
|
||||
const [data, setData] = useState();
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function LibraryOverView() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="my-3">Library Statistics</h1>
|
||||
<h1 className="my-3">Library Overview</h1>
|
||||
<div className="overview-container">
|
||||
|
||||
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "movies")} heading={"MOVIE LIBRARIES"} units={"MOVIES"} icon={MovieIcon}/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Row, Col, Card } from "react-bootstrap";
|
||||
|
||||
function LibraryStatComponent(props) {
|
||||
@@ -48,7 +49,7 @@ function LibraryStatComponent(props) {
|
||||
|
||||
<div className="d-flex justify-content-between">
|
||||
<Card.Text className="stat-item-index m-0">{index + 1}</Card.Text>
|
||||
<Card.Text>{item.Name}</Card.Text>
|
||||
<Link to={`/libraries/${item.Id}`}><Card.Text>{item.Name}</Card.Text></Link>
|
||||
</div>
|
||||
|
||||
<Card.Text className="stat-item-count">
|
||||
|
||||
@@ -67,17 +67,13 @@ function sessionCard(props) {
|
||||
<img
|
||||
className="card-device-image"
|
||||
src={
|
||||
props.data.base_url +
|
||||
"/web/assets/img/devices/"
|
||||
"/proxy/web/assets/img/devices/?devicename="
|
||||
+
|
||||
(props.data.session.Client.toLowerCase().includes("web") ?
|
||||
( clientData.find(item => props.data.session.DeviceName.toLowerCase().includes(item)) || "other")
|
||||
:
|
||||
( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other")
|
||||
)
|
||||
+
|
||||
".svg"
|
||||
}
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
</Col>
|
||||
@@ -126,6 +122,22 @@ function sessionCard(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{props.data.session.NowPlayingItem.ParentIndexNumber ?
|
||||
|
||||
<Row>
|
||||
|
||||
<Col className="col-auto">
|
||||
<Card.Text className="text-end">
|
||||
{'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber}
|
||||
</Card.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
:
|
||||
<></>
|
||||
|
||||
}
|
||||
|
||||
|
||||
<Row className="d-flex">
|
||||
<Col className="col-auto">
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ function Sessions() {
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
setData(data.data);
|
||||
setData(data.data.filter(row => row.NowPlayingItem !== undefined));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -79,9 +79,8 @@ function Sessions() {
|
||||
<div>
|
||||
<h1 className="my-3">Sessions</h1>
|
||||
<div className="sessions-container">
|
||||
{data &&
|
||||
{data && data.length>0 &&
|
||||
data
|
||||
.filter(row => row.NowPlayingItem !== undefined)
|
||||
.sort((a, b) =>
|
||||
a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))
|
||||
)
|
||||
|
||||
108
src/pages/components/settings/Tasks.js
Normal file
108
src/pages/components/settings/Tasks.js
Normal file
@@ -0,0 +1,108 @@
|
||||
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();
|
||||
console.log('Button clicked!');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tasks">
|
||||
<h1 className="py-3">Tasks</h1>
|
||||
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Task</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Synchronize with Jellyfin</TableCell>
|
||||
<TableCell>Import</TableCell>
|
||||
<TableCell className="d-flex justify-content-center"> <Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={handleClick}>Start</Button></TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>Backup Jellystat</TableCell>
|
||||
<TableCell>Process</TableCell>
|
||||
<TableCell className="d-flex justify-content-center"><Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={createBackup}>Start</Button></TableCell>
|
||||
</TableRow>
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -1,30 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import '../../css/websocket/websocket.css';
|
||||
|
||||
const TerminalComponent = () => {
|
||||
const [messages, setMessages] = useState([]);
|
||||
function TerminalComponent(props){
|
||||
const [messages] = useState(props.data);
|
||||
|
||||
useEffect(() => {
|
||||
try{
|
||||
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${process.env.WS_PORT || 3004}`);
|
||||
|
||||
// handle incoming messages
|
||||
socket.addEventListener('message', (event) => {
|
||||
let message = JSON.parse(event.data);
|
||||
setMessages(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
// console.log(error);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='my-4'>
|
||||
|
||||
@@ -111,6 +111,8 @@ function Row(file) {
|
||||
|
||||
|
||||
|
||||
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
@@ -118,10 +120,11 @@ function Row(file) {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
hour12: twelve_hr,
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import axios from "axios";
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
|
||||
|
||||
import "../../css/settings/settings.css";
|
||||
|
||||
export default function LibrarySync() {
|
||||
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();
|
||||
console.log('Button clicked!');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tasks">
|
||||
<h1 >Tasks</h1>
|
||||
<Row className="mb-3">
|
||||
|
||||
<Form.Label column sm="2">
|
||||
Synchronize with Jellyfin
|
||||
</Form.Label>
|
||||
|
||||
<Col sm="10">
|
||||
<Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={handleClick}>Start</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row className="mb-3">
|
||||
|
||||
<Form.Label column sm="2">
|
||||
Create Backup
|
||||
</Form.Label>
|
||||
|
||||
<Col sm="10">
|
||||
<Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={createBackup}>Start</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
210
src/pages/components/settings/logs.js
Normal file
210
src/pages/components/settings/logs.js
Normal file
@@ -0,0 +1,210 @@
|
||||
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) {
|
||||
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 (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => {if(data.Log.length>1){setOpen(!open);}}}
|
||||
>
|
||||
{!open ? <AddCircleFillIcon opacity={data.Log.length>1 ?1 : 0} cursor={data.Log.length>1 ? "pointer":"default"}/> : <IndeterminateCircleFillIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{data.Name}</TableCell>
|
||||
<TableCell>{data.Type}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(data.TimeRun))}</TableCell>
|
||||
<TableCell>{formatDurationTime(data.Duration)}</TableCell>
|
||||
<TableCell>{data.ExecutionType}</TableCell>
|
||||
<TableCell><div className={`badge ${ data.Result.toLowerCase() ==='success' ? 'text-bg-success' : 'text-bg-danger '} rounded-pill text-uppercase`} >{data.Result}</div></TableCell>
|
||||
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ margin: 1 }}>
|
||||
|
||||
<Table aria-label="sub-activity" className='rounded-2'>
|
||||
|
||||
<TableBody>
|
||||
<TableRow key={data.Id}>
|
||||
<TableCell colSpan="7" ><TerminalComponent data={data.Log}/></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="my-2">Logs</h1>
|
||||
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell/>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Date Created</TableCell>
|
||||
<TableCell>Duration</TableCell>
|
||||
<TableCell>Execution Type</TableCell>
|
||||
<TableCell>Result</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data && data.sort((a, b) =>new Date(b.TimeRun) - new Date(a.TimeRun)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((log,index) => (
|
||||
<Row key={index} data={log} />
|
||||
))}
|
||||
{data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Logs Found</td></tr> :''}
|
||||
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<div className='d-flex justify-content-end my-2'>
|
||||
|
||||
|
||||
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={()=>setPage(0)} disabled={page === 0}>
|
||||
First
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),data.length)} of ${data.length}`}</div>
|
||||
|
||||
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={()=>setPage(Math.ceil(data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
|
||||
Last
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +23,17 @@ export default function SettingsConfig() {
|
||||
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()
|
||||
@@ -51,7 +64,7 @@ export default function SettingsConfig() {
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
let errorMessage= `Error : ${error}`;
|
||||
// let errorMessage= `Error : ${error}`;
|
||||
});
|
||||
|
||||
let data=result.data;
|
||||
@@ -102,28 +115,34 @@ export default function SettingsConfig() {
|
||||
return <div className="submit critical">{submissionMessage}</div>;
|
||||
}
|
||||
|
||||
|
||||
function toggle12Hr(is_12_hr){
|
||||
set12hr(is_12_hr);
|
||||
localStorage.setItem('12hr',is_12_hr);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>General Settings</h1>
|
||||
<h1>Settings</h1>
|
||||
<Form onSubmit={handleFormSubmit} className="settings-form">
|
||||
<Form.Group as={Row} className="mb-3" >
|
||||
<Form.Label column className="fs-4">
|
||||
<Form.Label column className="">
|
||||
Jellyfin Url
|
||||
</Form.Label>
|
||||
<Col sm="10">
|
||||
<Form.Control id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ""} onChange={handleFormChange} placeholder="http://127.0.0.1:8096 or http://example.jellyfin.server" />
|
||||
<Form.Control id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ""} onChange={handleFormChange} placeholder="http://127.0.0.1:8096 or http://example.jellyfin.server" />
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group as={Row} className="mb-3">
|
||||
<Form.Label column className="fs-4">
|
||||
<Form.Label column className="">
|
||||
API Key
|
||||
</Form.Label>
|
||||
<Col sm="10">
|
||||
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
|
||||
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
|
||||
</Col>
|
||||
</Form.Group>
|
||||
{isSubmitted !== "" ? (
|
||||
@@ -139,14 +158,31 @@ export default function SettingsConfig() {
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-end align-items-sm-center">
|
||||
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
|
||||
<ButtonGroup >
|
||||
<Button variant="outline-success" type="submit"> Save </Button>
|
||||
<Button variant="outline-secondary" type="button" onClick={() => setKeyState(!showKey)}>Show Key</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
|
||||
<Form className="settings-form">
|
||||
<Form.Group as={Row} className="mb-3">
|
||||
<Form.Label column className="">Hour Format</Form.Label>
|
||||
<Col >
|
||||
<ToggleButtonGroup type="checkbox" className="d-flex" >
|
||||
<ToggleButton variant="outline-primary" active={twelve_hr} onClick={()=> {toggle12Hr(true);}}>12 Hours</ToggleButton>
|
||||
<ToggleButton variant="outline-primary" active={!twelve_hr} onClick={()=>{toggle12Hr(false);}}>24 Hours</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
</Form>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ function ItemStatComponent(props) {
|
||||
<>
|
||||
{!loaded && (
|
||||
<div className="position-absolute w-100 h-100">
|
||||
<Blurhash hash={props.data[0].PrimaryImageHash} width="100%" height="100%" />
|
||||
<Blurhash hash={props.data[0].PrimaryImageHash} height={'100%'} className="rounded-3 overflow-hidden"/>
|
||||
</div>
|
||||
)}
|
||||
<Card.Img
|
||||
className="stat-card-image"
|
||||
src={"Proxy/Items/Images/Primary?id=" + props.data[0].Id + "& fillWidth=400&quality=90"}
|
||||
src={"Proxy/Items/Images/Primary?id=" + props.data[0].Id + "&fillWidth=400&quality=90"}
|
||||
style={{ display: loaded ? 'block' : 'none' }}
|
||||
onLoad={handleImageLoad}
|
||||
onError={() => setLoaded(false)}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -68,9 +69,19 @@ function MostActiveUsers(props) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const UserImage = () => {
|
||||
return (
|
||||
<img src={`Proxy/Users/Images/Primary?id=${data[0].UserId}&fillWidth=100&quality=50`}
|
||||
width="100%"
|
||||
style={{borderRadius:'50%'}}
|
||||
alt=""
|
||||
onError={()=>setLoaded(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ItemStatComponent icon={<AccountCircleFillIcon color="white" size={"100%"}/>} data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/>
|
||||
<ItemStatComponent icon={loaded ? <UserImage/> : <AccountCircleFillIcon size="100%" />} data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
20
src/pages/css/about.css
Normal file
20
src/pages/css/about.css
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
td,th, td>button
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--secondary-background-color);
|
||||
background-color: var(--tertiary-background-color);
|
||||
|
||||
}
|
||||
|
||||
@@ -89,4 +89,14 @@ font-size: 1em;
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
.Home
|
||||
{
|
||||
color: white;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
{
|
||||
color:white;
|
||||
background-color: var(--secondary-background-color);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.item-banner-image
|
||||
{
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.item-name
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
.last-played-container {
|
||||
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{
|
||||
color: white;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-gap: 20px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.lib-card{
|
||||
color: white;
|
||||
max-width: 400px;
|
||||
/* max-width: 400px; */
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
.library-card-image
|
||||
{
|
||||
max-height: 170px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
@@ -30,7 +31,7 @@
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
max-height: 170px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
background-color: #88888883; /* set thumb color */
|
||||
}
|
||||
|
||||
.form-control
|
||||
.library-items > div> .form-control
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--secondary-background-color) !important;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
|
||||
.form-control:focus
|
||||
.library-items > div> .form-control:focus
|
||||
{
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import './variables.module.css';
|
||||
.loading {
|
||||
|
||||
margin: 0px;
|
||||
@@ -6,8 +7,7 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,85 @@
|
||||
@import './variables.module.css';
|
||||
.navbar {
|
||||
background-color: var(--primary-color);
|
||||
|
||||
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;
|
||||
}
|
||||
/* .navbar-toggler-icon
|
||||
{
|
||||
width: 100% !important;
|
||||
} */
|
||||
|
||||
|
||||
|
||||
.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: 10px;
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
|
||||
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
|
||||
@@ -31,4 +31,4 @@ td{
|
||||
.upload-file:focus
|
||||
{
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.tasks {
|
||||
|
||||
color: white;
|
||||
margin-inline: 10px;
|
||||
/* margin-inline: 10px; */
|
||||
|
||||
}
|
||||
|
||||
@@ -56,4 +56,19 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.settings-form > div> div> .form-control
|
||||
{
|
||||
color: white !important;
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--background-color) !important;
|
||||
}
|
||||
|
||||
|
||||
.settings-form > div> div> .form-control:focus
|
||||
{
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
46
src/pages/css/settings/version.css
Normal file
46
src/pages/css/settings/version.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -51,8 +51,8 @@ h2{
|
||||
pointer-events: none;
|
||||
transition: .2s;
|
||||
}
|
||||
input:focus ~ label,
|
||||
input:valid ~ label{
|
||||
.form-box> form> .inputbox> input:focus ~ label,
|
||||
.form-box> form> .inputbox> input:valid ~ label{
|
||||
top: -15px;
|
||||
}
|
||||
.inputbox input {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
|
||||
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
|
||||
margin-top: 8px;
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-card{
|
||||
border: 0 !important;
|
||||
@@ -21,6 +24,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.stat-card-image {
|
||||
width: 120px !important;
|
||||
height: 180px;
|
||||
@@ -29,7 +34,12 @@
|
||||
.stat-card-icon
|
||||
{
|
||||
width: 120px !important;
|
||||
height: 180px;
|
||||
|
||||
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 65%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import '../variables.module.css';
|
||||
|
||||
.user-detail-container
|
||||
{
|
||||
color:white;
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
--primary-color: #5a2da5;
|
||||
--secondary-color: #00A4DC;
|
||||
--background-color: #1e1c22;
|
||||
--secondary-background-color: rgba(100, 100, 100,0.2);
|
||||
--secondary-background-color: #2c2a2f;
|
||||
--tertiary-background-color: #2f2e31;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
.console-message {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.console-text {
|
||||
|
||||
142
src/pages/css/width_breakpoint_css.css
Normal file
142
src/pages/css/width_breakpoint_css.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ 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'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<div className='Home'>
|
||||
|
||||
<Sessions />
|
||||
<RecentlyAdded/>
|
||||
<HomeStatisticCards/>
|
||||
<LibraryOverView/>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import "./css/library/libraries.css";
|
||||
|
||||
import Loading from "./components/general/loading";
|
||||
import LibraryCard from "./components/library/library-card";
|
||||
import Row from "react-bootstrap/Row";
|
||||
import Container from "react-bootstrap/Container";
|
||||
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ function Libraries() {
|
||||
<div className="libraries">
|
||||
<h1 className="py-4">Libraries</h1>
|
||||
|
||||
<Row xs={1} md={2} lg={4} className="g-4">
|
||||
<div xs={1} md={2} lg={4} className="g-0 libraries-container">
|
||||
{data &&
|
||||
data.map((item) => (
|
||||
|
||||
@@ -90,7 +90,7 @@ function Libraries() {
|
||||
|
||||
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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 TerminalComponent from "./components/settings/TerminalComponent";
|
||||
import Logs from "./components/settings/logs";
|
||||
|
||||
// import TerminalComponent from "./components/settings/TerminalComponent";
|
||||
|
||||
|
||||
|
||||
@@ -18,19 +19,28 @@ export default function Settings() {
|
||||
return (
|
||||
<div className="settings my-2">
|
||||
<Tabs defaultActiveKey="tabGeneral" variant='pills'>
|
||||
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='General Settings' style={{minHeight:'500px'}}>
|
||||
<SettingsConfig/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabTasks" className='bg-transparent my-2' title='Tasks' style={{minHeight:'500px'}}>
|
||||
<LibrarySync/>
|
||||
|
||||
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='Settings' style={{minHeight:'500px'}}>
|
||||
<SettingsConfig/>
|
||||
|
||||
<Tasks/>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab eventKey="tabBackup" className='bg-transparent my-2' title='Backup' style={{minHeight:'500px'}}>
|
||||
<BackupFiles/>
|
||||
<BackupFiles/>
|
||||
</Tab>
|
||||
|
||||
<Tab eventKey="tabLogs" className='bg-transparent my-2' title='Logs' style={{minHeight:'500px'}}>
|
||||
<Logs/>
|
||||
</Tab>
|
||||
|
||||
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<TerminalComponent/>
|
||||
{/* <TerminalComponent/> */}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className='Activity'>
|
||||
|
||||
<DailyPlayStats/>
|
||||
<MostActiveUsers/>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ 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";
|
||||
|
||||
@@ -18,6 +22,85 @@ import Loading from "./components/general/loading";
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
|
||||
function EnhancedTableHead(props) {
|
||||
const { order, orderBy, onRequestSort } =
|
||||
props;
|
||||
const createSortHandler = (property) => (event) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell/>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? 'right' : 'left'}
|
||||
padding={headCell.disablePadding ? 'none' : 'normal'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</Box>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Row(row) {
|
||||
const { data } = row;
|
||||
|
||||
@@ -80,7 +163,7 @@ function Row(row) {
|
||||
<TableCell><Link to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">{data.LastWatched || 'never'}</Link></TableCell>
|
||||
<TableCell>{data.LastClient || 'n/a'}</TableCell>
|
||||
<TableCell>{data.TotalPlays}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || 0}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || '0 minutes'}</TableCell>
|
||||
<TableCell>{data.LastSeen ? formatLastSeenTime(data.LastSeen) : 'never'}</TableCell>
|
||||
|
||||
</TableRow>
|
||||
@@ -95,6 +178,11 @@ function Users() {
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
|
||||
const [order, setOrder] = React.useState('asc');
|
||||
const [orderBy, setOrderBy] = React.useState('LastSeen');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +242,101 @@ function Users() {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -174,20 +357,14 @@ function Users() {
|
||||
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Last Watched</TableCell>
|
||||
<TableCell>Last Client</TableCell>
|
||||
<TableCell>Plays</TableCell>
|
||||
<TableCell>Watch Time</TableCell>
|
||||
<TableCell>Last Seen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<EnhancedTableHead
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onRequestSort={handleRequestSort}
|
||||
rowCount={rowsPerPage}
|
||||
/>
|
||||
<TableBody>
|
||||
{data && data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => (
|
||||
{visibleRows.map((row) => (
|
||||
<Row key={row.UserId} data={row} hostUrl={config.hostUrl}/>
|
||||
))}
|
||||
{data.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}}>No Users Found</td></tr> :''}
|
||||
|
||||
@@ -43,6 +43,13 @@ module.exports = function(app) {
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
`/logs`,
|
||||
createProxyMiddleware({
|
||||
target: `http://127.0.0.1:${process.env.PORT || 3003}`,
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
`/ws`,
|
||||
createProxyMiddleware({
|
||||
|
||||
Reference in New Issue
Block a user