v1.0.4.10 Beta

This commit is contained in:
Thegan Govender
2023-06-18 21:02:18 +02:00
parent b354ae8fae
commit 550e1d3f7c
120 changed files with 6630 additions and 1739 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@
# testing
/coverage
/backend/backup-data
.vscode
# production
/build

View File

@@ -20,7 +20,14 @@
- Jellyfin Statistics Plugin Integration
- More to come
## Getting Started
## Getting Started with Development
- Clone the project from git
- set your env variables before strating the server (Variable names as per the docker compose file).
- Run `npm init` to install necessary packages
- Run `npm run start-server` to only run the backend nodejs server
- Run `npm run start` to only run the frontend React UI
- Run `npm run start-app` to run both backend and frontend at the same time
### Launching Jellystat using Docker
@@ -37,6 +44,7 @@ https://hub.docker.com/r/cyfershepard/jellystat
## Support
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/CyferShepard/Jellystat/issues).
- Join us in our [Discord](https://discord.gg/9SMBj2RyEe)
## API Documentation

View File

@@ -1,8 +1,20 @@
// api.js
const express = require("express");
const axios = require("axios");
const ActivityMonitor=require('./watchdog/ActivityMonitor');
const ActivityMonitor=require('./tasks/ActivityMonitor');
const db = require("./db");
const https = require('https');
const { checkForUpdates } = require('./version-control');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
const router = express.Router();
@@ -13,7 +25,7 @@ router.get("/test", async (req, res) => {
router.get("/getconfig", async (req, res) => {
try{
const { rows } = await db.query('SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1');
const { rows } = await db.query('SELECT "JF_HOST","APP_USER","REQUIRE_LOGIN" FROM app_config where "ID"=1');
res.send(rows);
}catch(error)
@@ -50,6 +62,46 @@ router.post("/setconfig", async (req, res) => {
console.log(`ENDPOINT CALLED: /setconfig: `);
});
router.post("/setRequireLogin", async (req, res) => {
try{
const { REQUIRE_LOGIN } = req.body;
if(REQUIRE_LOGIN===undefined)
{
res.status(503);
res.send(rows);
}
let query='UPDATE app_config SET "REQUIRE_LOGIN"=$1 where "ID"=1';
console.log(`ENDPOINT CALLED: /setRequireLogin: `+REQUIRE_LOGIN);
const { rows } = await db.query(
query,
[REQUIRE_LOGIN]
);
res.send(rows);
}catch(error)
{
console.log(error);
}
});
router.get("/CheckForUpdates", async (req, res) => {
try{
let result=await checkForUpdates();
res.send(result);
}catch(error)
{
console.log(error);
}
});
router.get("/getLibraries", async (req, res) => {
try{
@@ -69,12 +121,11 @@ router.get("/getLibraries", async (req, res) => {
router.post("/getLibraryItems", async (req, res) => {
try{
const Id = req.headers['id'];
const {libraryid} = req.body;
console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid);
const { rows } = await db.query(
`SELECT * FROM jf_library_items where "ParentId"='${Id}'`
`SELECT * FROM jf_library_items where "ParentId"='${libraryid}'`
);
console.log({ Id: Id });
res.send(rows);
@@ -83,7 +134,7 @@ router.post("/getLibraryItems", async (req, res) => {
console.log(error);
}
console.log(`ENDPOINT CALLED: /getLibraryItems: `);
});
router.post("/getSeasons", async (req, res) => {
@@ -155,7 +206,14 @@ router.post("/getItemDetails", async (req, res) => {
query
);
if(episodes.length!==0)
{
res.send(episodes);
}else
{
res.status(404).send('Item not found');
}
}else{
@@ -199,9 +257,18 @@ router.get("/getHistory", async (req, res) => {
...row,
results: []
};
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
}
});
// Update GroupedResults with playbackDurationSum
Object.values(groupedResults).forEach(row => {
if (row.results && row.results.length > 0) {
row.PlaybackDuration = row.results.reduce((acc, item) => acc + parseInt(item.PlaybackDuration), 0);
}
});
res.send(Object.values(groupedResults));
@@ -212,11 +279,101 @@ router.get("/getHistory", async (req, res) => {
});
router.post("/getLibraryHistory", async (req, res) => {
try {
const { libraryid } = req.body;
const { rows } = await db.query(
`select a.* from jf_playback_activity a join jf_library_items i on i."Id"=a."NowPlayingItemId" where i."ParentId"='${libraryid}' order by "ActivityDateInserted" desc`
);
const groupedResults = {};
rows.forEach(row => {
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
} else {
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
...row,
results: []
};
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
}
});
res.send(Object.values(groupedResults));
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
router.post("/getItemHistory", async (req, res) => {
try {
const { itemid } = req.body;
const { rows } = await db.query(
`select jf_playback_activity.*
from jf_playback_activity jf_playback_activity
where
("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}');`
);
const groupedResults = rows.map(item => ({
...item,
results: []
}));
res.send(groupedResults);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
router.post("/getUserHistory", async (req, res) => {
try {
const { userid } = req.body;
const { rows } = await db.query(
`select jf_playback_activity.*
from jf_playback_activity jf_playback_activity
where "UserId"='${userid}';`
);
const groupedResults = {};
rows.forEach(row => {
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
} else {
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
...row,
results: []
};
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
}
});
res.send(Object.values(groupedResults));
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
router.get("/getAdminUsers", async (req, res) => {
try {
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
const url = `${config[0].JF_HOST}/Users`;
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY,
},
@@ -227,6 +384,7 @@ router.get("/getAdminUsers", async (req, res) => {
res.send(adminUser);
} catch (error) {
console.log( error);
res.status(503);
res.send(error);
}
@@ -248,6 +406,316 @@ router.get("/runWatchdog", async (req, res) => {
}
});
router.get("/getSessions", async (req, res) => {
try {
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.status(503);
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/sessions`;
const response_data = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
res.send(response_data.data);
} catch (error) {
res.status(503);
res.send(error);
}
});
router.post("/validateSettings", async (req, res) => {
const { url,apikey } = req.body;
let isValid = false;
let errorMessage = "";
try
{
await axios_instance
.get(url + "/system/configuration", {
headers: {
"X-MediaBrowser-Token": apikey,
},
})
.then((response) => {
if (response.status === 200) {
isValid = true;
}
})
.catch((error) => {
if (error.code === "ERR_NETWORK") {
isValid = false;
errorMessage = `Error : Unable to connect to Jellyfin Server`;
} else if (error.code === "ECONNREFUSED") {
isValid = false;
errorMessage = `Error : Unable to connect to Jellyfin Server`;
}
else if (error.response && error.response.status === 401) {
isValid = false;
errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`;
} else if (error.response && error.response.status === 404) {
isValid = false;
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
} else {
isValid = false;
errorMessage = `${error}`;
}
});
}catch(error)
{
isValid = false;
errorMessage = `Error: ${error}`;
}
res.send({isValid:isValid,errorMessage:errorMessage });
});
router.post("/updatePassword", async (req, res) => {
const { current_password,new_password } = req.body;
let result={isValid:true,errorMessage:""};
try{
const { rows } = await db.query(`SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
if(rows && rows.length>0)
{
if(current_password===new_password)
{
result.isValid=false;
result.errorMessage = "New Password cannot be the same as Old Password";
}else{
await db.query(`UPDATE app_config SET "APP_PASSWORD"='${new_password}' where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
}
}else{
result.isValid=false;
result.errorMessage = "Old Password is Invalid";
}
}catch(error)
{
console.log(error);
result.errorMessage = error;
}
res.send(result);
});
router.post("/getLibraries", async (req, res) => {
try {
const { itemid } = req.body;
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
let payload=
{
existing_library_count:0,
existing_movie_count:0,
existing_music_count:0,
existing_show_count:0,
existing_season_count:0,
existing_episode_count:0,
api_library_count:0,
api_movie_count:0,
api_music_count:0,
api_show_count:0,
api_season_count:0,
api_episode_count:0,
missing_api_library_data:{},
missing_api_music_data:{},
missing_api_movies_data:{},
missing_api_shows_data:{},
missing_api_season_data:{},
missing_api_episode_data:{},
raw_library_data:{},
raw_item_data:{},
raw_season_data:{},
raw_episode_data:{},
count_from_api:{},
};
/////////////////////////Get Admin
const adminurl = `${config[0].JF_HOST}/Users`;
const response = await axios_instance.get(adminurl, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY,
},
});
const adminUser = await response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
////////////////////////
const db_libraries=await db.query('SELECT "Id" FROM jf_libraries').then((res) => res.rows.map((row) => row.Id));
const db_music=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Audio'`).then((res) => res.rows.map((row) => row.Id));
const db_movies=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Movie'`).then((res) => res.rows.map((row) => row.Id));
const db_shows=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Series'`).then((res) => res.rows.map((row) => row.Id));
const db_seasons=await db.query('SELECT "Id" FROM jf_library_seasons').then((res) => res.rows.map((row) => row.Id));
const db_episodes=await db.query('SELECT "EpisodeId" FROM jf_library_episodes').then((res) => res.rows.map((row) => row.EpisodeId));
let count_url=`${config[0].JF_HOST}/items/counts`;
const response_api_count = await axios_instance.get(count_url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
payload.count_from_api=response_api_count.data;
//get libraries
let url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items`;
const response_data = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
let libraries=response_data.data.Items;
let raw_library_data=response_data.data;
payload.raw_library_data=raw_library_data;
//get items
const show_data = [];
const movie_data = [];
const music_data = [];
const raw_item_data=[];
for (let i = 0; i < libraries.length; i++) {
const library = libraries[i];
let item_url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items?ParentID=${library.Id}`;
const response_data_item = await axios_instance.get(item_url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
const libraryItemsWithParent = response_data_item.data.Items.map((items) => ({
...items,
...{ ParentId: library.Id },
}));
movie_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Movie'));
show_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Series'));
music_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Audio'));
raw_item_data.push(response_data_item.data);
}
payload.existing_library_count=db_libraries.length;
payload.api_library_count=libraries.length;
payload.existing_movie_count=db_movies.length;
payload.api_movie_count=movie_data.length;
payload.existing_music_count=db_music.length;
payload.api_music_count=music_data.length;
payload.existing_show_count=db_shows.length;
payload.api_show_count=show_data.length;
payload.raw_item_data=raw_item_data;
//SHows
let allSeasons = [];
let allEpisodes =[];
let raw_allSeasons = [];
let raw_allEpisodes =[];
const { rows: shows } = await db.query(`SELECT "Id" FROM public.jf_library_items where "Type"='Series'`);
//loop for each show
for (const show of shows) {
let season_url = `${config[0].JF_HOST}/shows/${show.Id}/Seasons`;
let episodes_url = `${config[0].JF_HOST}/shows/${show.Id}/Episodes`;
const response_data_seasons = await axios_instance.get(season_url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
const response_data_episodes = await axios_instance.get(episodes_url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
allSeasons.push(...response_data_seasons.data.Items);
allEpisodes.push(...response_data_episodes.data.Items);
raw_allSeasons.push(response_data_seasons.data);
raw_allEpisodes.push(response_data_episodes.data);
}
payload.existing_season_count=db_seasons.length;
payload.api_season_count=allSeasons.length;
payload.existing_episode_count=db_episodes.length;
payload.api_episode_count=allEpisodes.length;
payload.raw_season_data=raw_allSeasons;
payload.raw_episode_data=raw_allEpisodes;
//missing data section
let missing_libraries=libraries.filter(library => !db_libraries.includes(library.Id));
let missing_movies=movie_data.filter(item => !db_movies.includes(item.Id) && item.Type==='Movie');
let missing_shows=show_data.filter(item => !db_shows.includes(item.Id) && item.Type==='Series');
let missing_music=music_data.filter(item => !db_music.includes(item.Id) && item.Type==='Audio');
let missing_seasons=allSeasons.filter(season => !db_seasons.includes(season.Id));
let missing_episodes=allEpisodes.filter(episode => !db_episodes.includes(episode.Id));
payload.missing_api_library_data=missing_libraries;
payload.missing_api_movies_data=missing_movies;
payload.missing_api_music_data=missing_music;
payload.missing_api_shows_data=missing_shows;
payload.missing_api_season_data=missing_seasons;
payload.missing_api_episode_data=missing_episodes;
res.send(payload);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});

View File

@@ -3,7 +3,11 @@ const db = require("./db");
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET ||'my-secret-jwt-key';
const JWT_SECRET = process.env.JWT_SECRET;
if (JWT_SECRET === undefined) {
console.log('JWT Secret cannot be undefined');
process.exit(1); // end the program with error status code
}
const router = express.Router();
@@ -13,8 +17,9 @@ router.post('/login', async (req, res) => {
try{
const { username, password } = req.body;
const { rows : login } = await db.query(`SELECT * FROM app_config where "APP_USER"='${username}' and "APP_PASSWORD"='${password}'`);
const query = 'SELECT * FROM app_config WHERE ("APP_USER" = $1 AND "APP_PASSWORD" = $2) OR "REQUIRE_LOGIN" = false';
const values = [username, password];
const { rows: login } = await db.query(query, values);
if(login.length>0)
{
const user = { id: 1, username: username };
@@ -44,9 +49,25 @@ router.post('/login', async (req, res) => {
if(Configured.length>0)
{
res.sendStatus(200);
if(Configured[0].JF_API_KEY && Configured[0].APP_USER && Configured[0].JF_API_KEY!==null && Configured[0].APP_USER!==null)
{
res.status(200);
res.send({state:2});
}else
if(Configured[0].APP_USER && Configured[0].APP_USER!==null)
{
res.status(200);
res.send({state:1});
}else
{
res.status(200);
res.send({state:0});
}
}else{
res.sendStatus(204);
res.status(200);
res.send({state:0});
}
}catch(error)

View File

@@ -3,8 +3,11 @@ const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const moment = require('moment');
const { randomUUID } = require('crypto');
const multer = require('multer');
const wss = require("./WebsocketHandler");
// const wss = require("./WebsocketHandler");
const Logging =require('./logging');
const router = Router();
@@ -18,9 +21,19 @@ const postgresDatabase = process.env.POSTGRES_DATABASE || 'jfstat';
// Tables to back up
const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_library_episodes','jf_users','jf_playback_activity','jf_playback_reporting_plugin_data','jf_item_info'];
function checkFolderWritePermission(folderPath) {
try {
const testFile = `${folderPath}/.writableTest`;
fs.writeFileSync(testFile, '');
fs.unlinkSync(testFile);
return true;
} catch (error) {
return false;
}
}
// Backup function
async function backup() {
async function backup(refLog) {
refLog.logData.push({ color: "lawngreen", Message: "Starting Backup" });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
@@ -35,23 +48,39 @@ async function backup() {
try{
let now = moment();
const backupfolder='./backup-data';
if (!fs.existsSync(backupfolder)) {
fs.mkdirSync(backupfolder);
console.log('Directory created successfully!');
}
if (!checkFolderWritePermission(backupfolder)) {
console.error('No write permissions for the folder:', backupfolder);
refLog.logData.push({ color: "red", Message: "Backup Failed: No write permissions for the folder: "+backupfolder });
refLog.logData.push({ color: "red", Message: "Backup Failed with errors"});
refLog.result='Failed';
await pool.end();
return;
}
const backupPath = `./backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`;
const stream = fs.createWriteStream(backupPath, { flags: 'a' });
stream.on('error', (error) => {
console.error(error);
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
throw new Error(error);
refLog.logData.push({ color: "red", Message: "Backup Failed: "+error });
refLog.result='Failed';
return;
});
const backup_data=[];
wss.clearMessages();
wss.sendMessageToClients({ color: "yellow", Message: "Begin Backup "+backupPath });
refLog.logData.push({ color: "yellow", Message: "Begin Backup "+backupPath });
for (let table of tables) {
const query = `SELECT * FROM ${table}`;
const { rows } = await pool.query(query);
console.log(`Reading ${rows.length} rows for table ${table}`);
wss.sendMessageToClients({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
refLog.logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
backup_data.push({[table]:rows});
@@ -60,12 +89,13 @@ async function backup() {
await stream.write(JSON.stringify(backup_data));
stream.end();
wss.sendMessageToClients({ color: "lawngreen", Message: "Backup Complete" });
refLog.logData.push({ color: "lawngreen", Message: "Backup Complete" });
}catch(error)
{
console.log(error);
wss.sendMessageToClients({ color: "red", Message: "Backup Failed: "+error });
refLog.logData.push({ color: "red", Message: "Backup Failed: "+error });
refLog.result='Failed';
}
@@ -88,9 +118,10 @@ function readFile(path) {
});
}
async function restore(file) {
wss.clearMessages();
wss.sendMessageToClients({ color: "yellow", Message: "Restoring from Backup: "+file });
async function restore(file,logData,result) {
logData.push({ color: "lawngreen", Message: "Starting Restore" });
logData.push({ color: "yellow", Message: "Restoring from Backup: "+file });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
@@ -108,6 +139,9 @@ async function restore(file) {
jsonData = await readFile(backupPath);
} catch (err) {
logData.push({ color: "red",key:tableName ,Message: `Failed to read backup file`});
result='Failed';
console.error(err);
}
@@ -126,8 +160,7 @@ async function restore(file) {
for(let index in data)
{
wss.sendMessageToClients({ color: "dodgerblue",key:tableName ,Message: `Restoring ${tableName} ${(((index)/(data.length-1))*100).toFixed(2)}%`});
logData.push({ color: "dodgerblue",key:tableName ,Message: `Restoring ${tableName} ${(((index)/(data.length-1))*100).toFixed(2)}%`});
const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`);
const keyString = keysWithQuotes.join(", ");
@@ -149,18 +182,40 @@ async function restore(file) {
const query=`INSERT INTO ${tableName} (${keyString}) VALUES(${valueString}) ON CONFLICT DO NOTHING`;
const { rows } = await pool.query( query );
}
}
await pool.end();
logData.push({ color: "lawngreen", Message: "Restore Complete" });
}
// Route handler for backup endpoint
router.get('/backup', async (req, res) => {
try {
await backup();
let startTime = moment();
let refLog={logData:[],result:'Success'};
await backup(refLog);
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const uuid = randomUUID();
const log=
{
"Id":uuid,
"Name":"Backup",
"Type":"Task",
"ExecutionType":"Manual",
"Duration":diffInSeconds || 0,
"TimeRun":startTime,
"Log":JSON.stringify(refLog.logData),
"Result": refLog.result
};
Logging.insertLog(log);
res.send('Backup completed successfully');
} catch (error) {
console.error(error);
@@ -169,16 +224,39 @@ router.get('/backup', async (req, res) => {
});
router.get('/restore/:filename', async (req, res) => {
let startTime = moment();
let logData=[];
let result='Success';
try {
const filePath = path.join(__dirname, backupfolder, req.params.filename);
await restore(filePath);
wss.sendMessageToClients({ color: "lawngreen", Message: `Restoring Complete` });
await restore(filePath,logData,result);
res.send('Restore completed successfully');
} catch (error) {
console.error(error);
wss.sendMessageToClients({ color: "red", Message: error });
res.status(500).send('Restore failed');
}
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const uuid = randomUUID();
const log=
{
"Id":uuid,
"Name":"Restore",
"Type":"Task",
"ExecutionType":"Manual",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(logData),
"Result": result
};
Logging.insertLog(log);
});
//list backup files
@@ -186,6 +264,8 @@ router.get('/restore/:filename', async (req, res) => {
router.get('/files', (req, res) => {
try
{
const directoryPath = path.join(__dirname, backupfolder);
fs.readdir(directoryPath, (err, files) => {
if (err) {
@@ -204,6 +284,12 @@ router.get('/restore/:filename', async (req, res) => {
res.json(fileData);
}
});
}catch(error)
{
console.log(error);
}
});
@@ -215,9 +301,10 @@ router.get('/restore/:filename', async (req, res) => {
//delete backup
router.delete('/files/:filename', (req, res) => {
try{
const filePath = path.join(__dirname, backupfolder, req.params.filename);
try{
fs.unlink(filePath, (err) => {
if (err) {
console.error(err);
@@ -235,10 +322,35 @@ router.get('/restore/:filename', async (req, res) => {
}
});
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, backupfolder)); // Set the destination folder for uploaded files
},
filename: function (req, file, cb) {
cb(null, file.originalname); // Set the file name
},
});
const upload = multer({ storage: storage });
router.post("/upload", upload.single("file"), (req, res) => {
// Handle the uploaded file here
res.json({
fileName: req.file.originalname,
filePath: req.file.path,
});
});
module.exports = router;
module.exports =
{
router,
backup
};

View File

@@ -1,5 +1,6 @@
const { Pool } = require('pg');
const pgp = require("pg-promise")();
const {update_query : update_query_map} = require("./models/bulk_insert_update_handler");
const _POSTGRES_USER=process.env.POSTGRES_USER;
@@ -57,12 +58,12 @@ async function deleteBulk(table_name, data) {
} catch (error) {
await client.query('ROLLBACK');
message=('Error: '+ error);
message=(''+ error);
result='ERROR';
} finally {
client.release();
}
return ({Result:result,message:message});
return ({Result:result,message:'Bulk delete error:'+message});
}
async function insertBulk(table_name, data,columns) {
@@ -71,26 +72,20 @@ async function insertBulk(table_name, data,columns) {
let message='';
try {
await client.query("BEGIN");
const query = pgp.helpers.insert(
data,
columns,
table_name
);
await client.query(query);
const update_query= update_query_map.find(query => query.table === table_name).query;
await client.query("COMMIT");
message=(data.length + " Rows Inserted.");
const cs = new pgp.helpers.ColumnSet(columns, { table: table_name });
const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly
await client.query(query);
} catch (error) {
await client.query('ROLLBACK');
message=('Error: '+ error);
message=(''+ error);
result='ERROR';
} finally {
client.release();
}
return ({Result:result,message:message});
return ({Result:result,message:message?'Bulk insert error: '+message:''});
}
async function query(text, params) {
@@ -98,7 +93,27 @@ async function query(text, params) {
const result = await pool.query(text, params);
return result;
} catch (error) {
console.error('Error occurred while executing query:', error);
if(error?.routine==='auth_failed')
{
console.log('Error 401: Unable to Authenticate with Postgres DB');
}else
if(error?.code==='ENOTFOUND')
{
console.log('Error: Unable to Connect to Postgres DB');
}else
if(error?.code==='ERR_SOCKET_BAD_PORT')
{
console.log('Error: Invalid Postgres DB Port Range. Port should be >= 0 and < 65536.');
}else
if(error?.code==='ECONNREFUSED')
{
console.log('Error: Postgres DB Connection refused at '+error.address+':'+error.port);
}else
{
console.error('Error occurred while executing query:', error);
}
return [];
// throw error;
}
}

37
backend/logging.js Normal file
View 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};

View File

@@ -0,0 +1,66 @@
exports.up = async function(knex) {
await knex.raw(`
DROP VIEW jf_all_user_activity;
CREATE OR REPLACE VIEW jf_all_user_activity AS
SELECT u."Id" AS "UserId",
u."PrimaryImageTag",
u."Name" AS "UserName",
CASE
WHEN j."SeriesName" IS NULL THEN j."NowPlayingItemName"
ELSE (j."SeriesName" || ' - '::text) || j."NowPlayingItemName"
END AS "LastWatched",
CASE
WHEN j."SeriesName" IS NULL THEN j."NowPlayingItemId"
ELSE j."EpisodeId"
END AS "NowPlayingItemId",
j."ActivityDateInserted" AS "LastActivityDate",
(j."Client" || ' - '::text) || j."DeviceName" AS "LastClient",
plays."TotalPlays",
plays."TotalWatchTime",
now() - j."ActivityDateInserted" AS "LastSeen"
FROM (
SELECT jf_users."Id",
jf_users."Name",
jf_users."PrimaryImageTag",
jf_users."LastLoginDate",
jf_users."LastActivityDate",
jf_users."IsAdministrator"
FROM jf_users
) u
LEFT JOIN LATERAL (
SELECT jf_playback_activity."Id",
jf_playback_activity."IsPaused",
jf_playback_activity."UserId",
jf_playback_activity."UserName",
jf_playback_activity."Client",
jf_playback_activity."DeviceName",
jf_playback_activity."DeviceId",
jf_playback_activity."ApplicationVersion",
jf_playback_activity."NowPlayingItemId",
jf_playback_activity."NowPlayingItemName",
jf_playback_activity."SeasonId",
jf_playback_activity."SeriesName",
jf_playback_activity."EpisodeId",
jf_playback_activity."PlaybackDuration",
jf_playback_activity."ActivityDateInserted"
FROM jf_playback_activity
WHERE jf_playback_activity."UserId" = u."Id"
ORDER BY jf_playback_activity."ActivityDateInserted" DESC
LIMIT 1
) j ON true
LEFT JOIN LATERAL (
SELECT count(*) AS "TotalPlays",
sum(jf_playback_activity."PlaybackDuration") AS "TotalWatchTime"
FROM jf_playback_activity
WHERE jf_playback_activity."UserId" = u."Id"
) plays ON true
ORDER BY (now() - j."ActivityDateInserted");
`).catch(function(error) {
console.error(error);
});
};
exports.down = async function(knex) {
await knex.raw(`DROP VIEW jf_all_user_activity;`);
};

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

View File

@@ -0,0 +1,43 @@
exports.up = function(knex) {
return knex.schema.raw(`
CREATE OR REPLACE PROCEDURE jd_remove_orphaned_data() AS $$
BEGIN
DELETE FROM public.jf_library_episodes
WHERE "SeriesId" NOT IN (
SELECT "Id"
FROM public.jf_library_items
);
DELETE FROM public.jf_library_seasons
WHERE "SeriesId" NOT IN (
SELECT "Id"
FROM public.jf_library_items
);
DELETE FROM public.jf_item_info
WHERE "Id" NOT IN (
SELECT "Id"
FROM public.jf_library_items
)
AND "Type" = 'Item';
DELETE FROM public.jf_item_info
WHERE "Id" NOT IN (
SELECT "EpisodeId"
FROM public.jf_library_episodes
)
AND "Type" = 'Episode';
END;
$$ LANGUAGE plpgsql;
`).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`
DROP PROCEDURE jd_remove_orphaned_data;
`);
};

View File

@@ -0,0 +1,23 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('app_config');
if (hasTable) {
await knex.schema.alterTable('app_config', function(table) {
table.boolean('REQUIRE_LOGIN').defaultTo(true);
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('app_config', function(table) {
table.dropColumn('REQUIRE_LOGIN');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,123 @@
exports.up = function(knex) {
const query = `
CREATE OR REPLACE VIEW public.js_library_stats_overview
AS
SELECT DISTINCT ON (l."Id") l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS total_playback_duration,
COALESCE(cv."Library_Count",0)"Library_Count",
COALESCE(cv."Season_Count",0)"Season_Count",
COALESCE(cv."Episode_Count",0)"Episode_Count",
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
left JOIN jf_library_count_view cv ON cv."Id" = l."Id"
LEFT JOIN ( SELECT jf_playback_activity."Id",
jf_playback_activity."IsPaused",
jf_playback_activity."UserId",
jf_playback_activity."UserName",
jf_playback_activity."Client",
jf_playback_activity."DeviceName",
jf_playback_activity."DeviceId",
jf_playback_activity."ApplicationVersion",
jf_playback_activity."NowPlayingItemId",
jf_playback_activity."NowPlayingItemName",
jf_playback_activity."SeasonId",
jf_playback_activity."SeriesName",
jf_playback_activity."EpisodeId",
jf_playback_activity."PlaybackDuration",
jf_playback_activity."ActivityDateInserted",
jf_playback_activity."PlayMethod",
i_1."ParentId"
FROM jf_playback_activity
JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId"
ORDER BY jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
`;
return knex.schema.raw(query).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`
CREATE VIEW js_library_stats_overview AS
SELECT DISTINCT ON (l."Id") l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS total_playback_duration,
cv."Library_Count",
cv."Season_Count",
cv."Episode_Count",
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
JOIN jf_library_count_view cv ON cv."Id" = l."Id"
LEFT JOIN ( SELECT jf_playback_activity."Id",
jf_playback_activity."IsPaused",
jf_playback_activity."UserId",
jf_playback_activity."UserName",
jf_playback_activity."Client",
jf_playback_activity."DeviceName",
jf_playback_activity."DeviceId",
jf_playback_activity."ApplicationVersion",
jf_playback_activity."NowPlayingItemId",
jf_playback_activity."NowPlayingItemName",
jf_playback_activity."SeasonId",
jf_playback_activity."SeriesName",
jf_playback_activity."EpisodeId",
jf_playback_activity."PlaybackDuration",
jf_playback_activity."ActivityDateInserted",
jf_playback_activity."PlayMethod",
i_1."ParentId"
FROM jf_playback_activity
JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId"
ORDER BY jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
`);
};

View File

@@ -0,0 +1,29 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_libraries');
if (hasTable) {
await knex.schema.alterTable('jf_libraries', function(table) {
table.bigInteger('total_play_time');
table.bigInteger('item_count');
table.bigInteger('season_count');
table.bigInteger('episode_count');
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_libraries', function(table) {
table.dropColumn('total_play_time');
table.dropColumn('item_count');
table.dropColumn('season_count');
table.dropColumn('episode_count');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,37 @@
exports.up = function(knex) {
return knex.schema.raw(`
CREATE OR REPLACE PROCEDURE ju_update_library_stats_data()
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE jf_libraries l
SET
total_play_time = (
SELECT COALESCE(SUM(COALESCE(i_1."RunTimeTicks", e_1."RunTimeTicks")), 0) AS sum
FROM jf_library_items i_1
LEFT JOIN jf_library_episodes e_1 ON i_1."Id" = e_1."SeriesId"
WHERE i_1."ParentId" = l."Id"
AND (
(i_1."Type" <> 'Series'::text AND e_1."Id" IS NULL)
OR (i_1."Type" = 'Series'::text AND e_1."Id" IS NOT NULL)
)
),
item_count = COALESCE(cv."Library_Count", 0::bigint),
season_count = COALESCE(cv."Season_Count", 0::bigint),
episode_count = COALESCE(cv."Episode_Count", 0::bigint)
FROM jf_library_count_view cv
WHERE cv."Id" = l."Id";
END;
$$;
`).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`
DROP PROCEDURE ju_update_library_stats_data;
`);
};

View File

@@ -0,0 +1,126 @@
exports.up = function(knex) {
const query = `
DROP VIEW js_library_stats_overview;
CREATE OR REPLACE VIEW public.js_library_stats_overview
AS
SELECT DISTINCT ON (l."Id") l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN jf_library_count_view cv ON cv."Id" = l."Id"
LEFT JOIN ( SELECT jf_playback_activity."Id",
jf_playback_activity."IsPaused",
jf_playback_activity."UserId",
jf_playback_activity."UserName",
jf_playback_activity."Client",
jf_playback_activity."DeviceName",
jf_playback_activity."DeviceId",
jf_playback_activity."ApplicationVersion",
jf_playback_activity."NowPlayingItemId",
jf_playback_activity."NowPlayingItemName",
jf_playback_activity."SeasonId",
jf_playback_activity."SeriesName",
jf_playback_activity."EpisodeId",
jf_playback_activity."PlaybackDuration",
jf_playback_activity."ActivityDateInserted",
jf_playback_activity."PlayMethod",
i_1."ParentId"
FROM jf_playback_activity
JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId"
ORDER BY jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
`;
return knex.schema.raw(query).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`
CREATE OR REPLACE VIEW public.js_library_stats_overview
AS
SELECT DISTINCT ON (l."Id") l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS total_playback_duration,
COALESCE(cv."Library_Count",0)"Library_Count",
COALESCE(cv."Season_Count",0)"Season_Count",
COALESCE(cv."Episode_Count",0)"Episode_Count",
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
left JOIN jf_library_count_view cv ON cv."Id" = l."Id"
LEFT JOIN ( SELECT jf_playback_activity."Id",
jf_playback_activity."IsPaused",
jf_playback_activity."UserId",
jf_playback_activity."UserName",
jf_playback_activity."Client",
jf_playback_activity."DeviceName",
jf_playback_activity."DeviceId",
jf_playback_activity."ApplicationVersion",
jf_playback_activity."NowPlayingItemId",
jf_playback_activity."NowPlayingItemName",
jf_playback_activity."SeasonId",
jf_playback_activity."SeriesName",
jf_playback_activity."EpisodeId",
jf_playback_activity."PlaybackDuration",
jf_playback_activity."ActivityDateInserted",
jf_playback_activity."PlayMethod",
i_1."ParentId"
FROM jf_playback_activity
JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId"
ORDER BY jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
`);
};

View File

@@ -0,0 +1,42 @@
exports.up = function(knex) {
const query = `
CREATE OR REPLACE VIEW jf_library_items_with_playcount_playtime AS
SELECT
i."Id",
i."Name",
i."ServerId",
i."PremiereDate",
i."EndDate",
i."CommunityRating",
i."RunTimeTicks",
i."ProductionYear",
i."IsFolder",
i."Type",
i."Status",
i."ImageTagsPrimary",
i."ImageTagsBanner",
i."ImageTagsLogo",
i."ImageTagsThumb",
i."BackdropImageTags",
i."ParentId",
i."PrimaryImageHash",
count(a."NowPlayingItemId") times_played,
coalesce(sum(a."PlaybackDuration"),0) total_play_time
FROM jf_library_items i
left join jf_playback_activity a
on i."Id"=a."NowPlayingItemId"
group by i."Id"
order by times_played desc
`;
return knex.schema.raw(query).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`DROP VIEW public.jf_library_items_with_playcount_playtime;`);
};

View File

@@ -0,0 +1,29 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_playback_activity');
if (hasTable) {
await knex.schema.alterTable('jf_playback_activity', function(table) {
table.json('MediaStreams');
table.json('TranscodingInfo');
table.text('OriginalContainer');
table.text('RemoteEndPoint');
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_playback_activity', function(table) {
table.dropColumn('MediaStreams');
table.dropColumn('TranscodingInfo');
table.dropColumn('OriginalContainer');
table.dropColumn('RemoteEndPoint');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,31 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_activity_watchdog');
if (hasTable) {
await knex.schema.alterTable('jf_activity_watchdog', function(table) {
table.json('MediaStreams');
table.json('TranscodingInfo');
table.json('PlayState');
table.text('OriginalContainer');
table.text('RemoteEndPoint');
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_activity_watchdog', function(table) {
table.dropColumn('MediaStreams');
table.dropColumn('TranscodingInfo');
table.dropColumn('PlayState');
table.dropColumn('OriginalContainer');
table.dropColumn('RemoteEndPoint');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,23 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('app_config');
if (hasTable) {
await knex.schema.alterTable('app_config', function(table) {
table.json('settings').defaultTo({settings:{time_format:'12hr'}});
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_activity_watchdog', function(table) {
table.dropColumn('settings');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,17 @@
const update_query = [
{table:'jf_activity_watchdog',query:' ON CONFLICT ("Id") DO UPDATE SET "TranscodingInfo" = EXCLUDED."TranscodingInfo", "MediaStreams" = EXCLUDED."MediaStreams", "PlayMethod" = EXCLUDED."PlayMethod"'},
{table:'jf_item_info',query:' ON CONFLICT ("Id") DO UPDATE SET "Path" = EXCLUDED."Path", "Name" = EXCLUDED."Name", "Size" = EXCLUDED."Size", "Bitrate" = EXCLUDED."Bitrate", "MediaStreams" = EXCLUDED."MediaStreams"'},
{table:'jf_libraries',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "Type" = EXCLUDED."Type", "CollectionType" = EXCLUDED."CollectionType", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary"'},
{table:'jf_library_episodes',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "OfficialRating" = EXCLUDED."OfficialRating", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "IndexNumber" = EXCLUDED."IndexNumber", "ParentIndexNumber" = EXCLUDED."ParentIndexNumber", "Type" = EXCLUDED."Type", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesId" = EXCLUDED."SeriesId", "SeasonId" = EXCLUDED."SeasonId", "SeasonName" = EXCLUDED."SeasonName", "SeriesName" = EXCLUDED."SeriesName"'},
{table:'jf_library_items',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "EndDate" = EXCLUDED."EndDate", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "Type" = EXCLUDED."Type", "Status" = EXCLUDED."Status", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary", "ImageTagsBanner" = EXCLUDED."ImageTagsBanner", "ImageTagsLogo" = EXCLUDED."ImageTagsLogo", "ImageTagsThumb" = EXCLUDED."ImageTagsThumb", "BackdropImageTags" = EXCLUDED."BackdropImageTags", "ParentId" = EXCLUDED."ParentId", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash"'},
{table:'jf_library_seasons',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesPrimaryImageTag" = EXCLUDED."SeriesPrimaryImageTag"'},
{table:'jf_logging',query:' ON CONFLICT DO NOTHING'},
{table:'jf_playback_activity',query:' ON CONFLICT DO NOTHING'},
{table:'jf_playback_reporting_plugin_data',query:' ON CONFLICT DO NOTHING'},
{table:'jf_users',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PrimaryImageTag" = EXCLUDED."PrimaryImageTag", "LastLoginDate" = EXCLUDED."LastLoginDate", "LastActivityDate" = EXCLUDED."LastActivityDate"'}
];
module.exports = {
update_query
};

View File

@@ -19,6 +19,11 @@ const jf_activity_watchdog_columns = [
"PlaybackDuration",
"PlayMethod",
"ActivityDateInserted",
{ name: 'MediaStreams', mod: ':json' },
{ name: 'TranscodingInfo', mod: ':json' },
{ name: 'PlayState', mod: ':json' },
"OriginalContainer",
"RemoteEndPoint",
];
@@ -39,6 +44,11 @@ const jf_activity_watchdog_columns = [
PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0,
PlayMethod:item.PlayState.PlayMethod,
ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: moment().format('YYYY-MM-DD HH:mm:ss.SSSZ'),
MediaStreams: item.NowPlayingItem.MediaStreams ? item.NowPlayingItem.MediaStreams : null ,
TranscodingInfo: item.TranscodingInfo? item.TranscodingInfo : null,
PlayState: item.PlayState? item.PlayState : null,
OriginalContainer: item.NowPlayingItem && item.NowPlayingItem.Container ? item.NowPlayingItem.Container : null,
RemoteEndPoint: item.RemoteEndPoint || null,
});
module.exports = {

View File

@@ -15,7 +15,7 @@
ServerId: item.ServerId,
IsFolder: item.IsFolder,
Type: item.Type,
CollectionType: item.CollectionType,
CollectionType: item.CollectionType? item.CollectionType : 'mixed',
ImageTagsPrimary:
item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
});

View File

@@ -42,7 +42,7 @@
item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
BackdropImageTags: item.BackdropImageTags[0],
ParentId: item.ParentId,
PrimaryImageHash: item.ImageTags.Primary? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null,
PrimaryImageHash: item.ImageTags && item.ImageTags.Primary && item.ImageBlurHashes && item.ImageBlurHashes.Primary && item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null,
});
module.exports = {

View File

@@ -27,7 +27,7 @@
: null,
SeriesName: item.SeriesName,
SeriesId: item.SeriesId,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag : null,
});
module.exports = {

View 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,
};

View File

@@ -1,4 +1,3 @@
////////////////////////// pn delete move to playback
const columnsPlayback = [
"Id",
"IsPaused",
@@ -16,6 +15,11 @@
"PlaybackDuration",
"PlayMethod",
"ActivityDateInserted",
{ name: 'MediaStreams', mod: ':json' },
{ name: 'TranscodingInfo', mod: ':json' },
{ name: 'PlayState', mod: ':json' },
"OriginalContainer",
"RemoteEndPoint"
];
@@ -36,6 +40,11 @@
PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0,
PlayMethod: item.PlayState.PlayMethod !== undefined ? item.PlayState.PlayMethod : item.PlayMethod ,
ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: new Date().toISOString(),
MediaStreams: item.MediaStreams ? item.MediaStreams : null ,
TranscodingInfo: item.TranscodingInfo? item.TranscodingInfo : null,
PlayState: item.PlayState? item.PlayState : null,
OriginalContainer: item.OriginalContainer ? item.OriginalContainer : null,
RemoteEndPoint: item.RemoteEndPoint ? item.RemoteEndPoint : null
});
module.exports = {

View File

@@ -14,7 +14,7 @@
PrimaryImageTag: item.PrimaryImageTag,
LastLoginDate: item.LastLoginDate,
LastActivityDate: item.LastActivityDate,
IsAdministrator: item.Policy.IsAdministrator,
IsAdministrator: item.Policy && item.Policy.IsAdministrator ? item.Policy.IsAdministrator : false,
});
module.exports = {

149
backend/proxy.js Normal file
View File

@@ -0,0 +1,149 @@
const express = require('express');
const axios = require("axios");
const db = require("./db");
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
const router = express.Router();
router.get('/web/assets/img/devices/', async(req, res) => {
const { devicename } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null || devicename===undefined) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/web/assets/img/devices/${devicename}.svg`;
axios_instance.get(url, {
responseType: 'arraybuffer'
})
.then((response) => {
res.set('Content-Type', 'image/svg+xml');
res.status(200);
if (response.headers['content-type'].startsWith('image/')) {
res.send(response.data);
} else {
res.status(500).send('Error fetching image');
}
return; // Add this line
})
.catch((error) => {
console.error(error);
res.status(500).send('Error fetching image: '+error);
});
});
router.get('/Items/Images/Backdrop/', async(req, res) => {
const { id,fillWidth,quality,blur } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/Items/${id}/Images/Backdrop?fillWidth=${fillWidth || 800}&quality=${quality || 100}&blur=${blur || 0}`;
axios_instance.get(url, {
responseType: 'arraybuffer'
})
.then((response) => {
res.set('Content-Type', 'image/jpeg');
res.status(200);
if (response.headers['content-type'].startsWith('image/')) {
res.send(response.data);
} else {
res.status(500).send('Error fetching image');
}
})
.catch((error) => {
// console.error(error);
res.status(500).send('Error fetching image: '+error);
});
});
router.get('/Items/Images/Primary/', async(req, res) => {
const { id,fillWidth,quality } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/Items/${id}/Images/Primary?fillWidth=${fillWidth || 400}&quality=${quality || 100}`;
axios_instance.get(url, {
responseType: 'arraybuffer'
})
.then((response) => {
res.set('Content-Type', 'image/jpeg');
res.status(200);
if (response.headers['content-type'].startsWith('image/')) {
res.send(response.data);
} else {
res.status(500).send('Error fetching image');
}
})
.catch((error) => {
// console.error(error);
res.status(500).send('Error fetching image: '+error);
});
});
router.get('/Users/Images/Primary/', async(req, res) => {
const { id,fillWidth,quality } = req.query; // Get the image URL from the query string
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/Users/${id}/Images/Primary?fillWidth=${fillWidth || 100}&quality=${quality || 100}`;
axios_instance.get(url, {
responseType: 'arraybuffer'
})
.then((response) => {
res.set('Content-Type', 'image/jpeg');
res.status(200);
if (response.headers['content-type'].startsWith('image/')) {
res.send(response.data);
} else {
res.status(500).send('Error fetching image');
}
})
.catch((error) => {
// console.error(error);
res.status(500).send('Error fetching image: '+error);
});
});
module.exports = router;

View File

@@ -7,10 +7,15 @@ const knexConfig = require('./migrations');
const authRouter= require('./auth');
const apiRouter = require('./api');
const syncRouter = require('./sync');
const proxyRouter = require('./proxy');
const {router: syncRouter} = require('./sync');
const statsRouter = require('./stats');
const backupRouter = require('./backup');
const ActivityMonitor = require('./watchdog/ActivityMonitor');
const {router: backupRouter} = require('./backup');
const ActivityMonitor = require('./tasks/ActivityMonitor');
const SyncTask = require('./tasks/SyncTask');
const BackupTask = require('./tasks/BackupTask');
const {router: logRouter} = require('./logging');
const app = express();
@@ -18,7 +23,7 @@ const db = knex(knexConfig.development);
const PORT = process.env.PORT || 3003;
const LISTEN_IP = '127.0.0.1';
const JWT_SECRET = process.env.JWT_SECRET ||'my-secret-jwt-key';
const JWT_SECRET = process.env.JWT_SECRET;
if (JWT_SECRET === undefined) {
console.log('JWT Secret cannot be undefined');
@@ -49,9 +54,11 @@ function verifyToken(req, res, next) {
app.use('/auth', authRouter); // mount the API router at /api, with JWT middleware
app.use('/api', verifyToken, apiRouter); // mount the API router at /api, with JWT middleware
app.use('/proxy', proxyRouter); // mount the API router at /api, with JWT middleware
app.use('/sync', verifyToken, syncRouter); // mount the API router at /sync, with JWT middleware
app.use('/stats', verifyToken, statsRouter); // mount the API router at /stats, with JWT middleware
app.use('/data', verifyToken, backupRouter); // mount the API router at /stats, with JWT middleware
app.use('/logs', verifyToken, logRouter); // mount the API router at /stats, with JWT middleware
try{
createdb.createDatabase().then((result) => {
@@ -62,7 +69,10 @@ try{
db.migrate.latest().then(() => {
app.listen(PORT, async () => {
console.log(`Server listening on http://${LISTEN_IP}:${PORT}`);
ActivityMonitor.ActivityMonitor(1000);
SyncTask.SyncTask(60000*10);
BackupTask.BackupTask(60000*60*24);
});
});
});

View File

@@ -1,14 +1,28 @@
// api.js
const express = require("express");
const db = require("./db");
const axios=require("axios");
const router = express.Router();
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
router.get("/getLibraryOverview", async (req, res) => {
try {
const { rows } = await db.query("SELECT * FROM jf_library_count_view");
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -25,6 +39,7 @@ router.post("/getMostViewedSeries", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -43,6 +58,7 @@ router.post("/getMostViewedMovies", async (req, res) => {
} catch (error) {
console.log('/getMostViewedMovies');
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -59,6 +75,7 @@ router.post("/getMostViewedMusic", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -75,6 +92,7 @@ router.post("/getMostViewedLibraries", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -91,6 +109,7 @@ router.post("/getMostUsedClient", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -107,6 +126,7 @@ router.post("/getMostActiveUsers", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -123,6 +143,7 @@ router.post("/getMostPopularMovies", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -139,6 +160,7 @@ router.post("/getMostPopularSeries", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -155,6 +177,7 @@ router.post("/getMostPopularMusic", async (req, res) => {
);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -164,6 +187,7 @@ router.get("/getPlaybackActivity", async (req, res) => {
const { rows } = await db.query("SELECT * FROM jf_playback_activity");
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -186,6 +210,7 @@ router.post("/getUserDetails", async (req, res) => {
res.send(rows[0]);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -203,6 +228,7 @@ router.post("/getGlobalUserStats", async (req, res) => {
res.send(rows[0]);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -216,6 +242,7 @@ router.post("/getUserLastPlayed", async (req, res) => {
res.send(rows);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -229,6 +256,7 @@ router.post("/getLibraryDetails", async (req, res) => {
res.send(rows[0]);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -246,6 +274,7 @@ router.post("/getGlobalLibraryStats", async (req, res) => {
res.send(rows[0]);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -256,6 +285,7 @@ router.get("/getLibraryCardStats", async (req, res) => {
const { rows } = await db.query("select * from js_library_stats_overview");
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
@@ -265,10 +295,29 @@ router.get("/getLibraryMetadata", async (req, res) => {
const { rows } = await db.query("select * from js_library_metadata");
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
router.post("/getLibraryItemsWithStats", async (req, res) => {
try{
const {libraryid} = req.body;
console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid);
const { rows } = await db.query(
`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"='${libraryid}'`
);
res.send(rows);
}catch(error)
{
console.log(error);
}
});
router.post("/getLibraryLastPlayed", async (req, res) => {
try {
@@ -279,10 +328,63 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
res.send(rows);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
router.get("/getRecentlyAdded", async (req, res) => {
try {
const { libraryid } = req.query;
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.status(503);
res.send({ error: "Config Details Not Found" });
return;
}
const adminurl = `${config[0].JF_HOST}/Users`;
const response = await axios_instance.get(adminurl, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
if(!response || typeof response.data !== 'object' || !Array.isArray(response.data))
{
res.status(503);
res.send({ error: "Invalid Response from Users API Call.", user_response:response });
return;
}
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest`;
if(libraryid)
{
url+=`?parentId=${libraryid}`;
}
const response_data = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
res.send(response_data.data);
} catch (error) {
res.status(503);
res.send(error);
}
});
router.post("/getViewsOverTime", async (req, res) => {
try {
@@ -324,6 +426,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
res.send(finalData);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -364,6 +467,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
res.send(finalData);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -405,6 +509,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
res.send(finalData);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
@@ -427,6 +532,7 @@ router.post("/getGlobalItemStats", async (req, res) => {
res.send(rows[0]);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ async function ActivityMonitor(interval) {
);
if(config.length===0)
if(!config || config.length===0)
{
return;
}
@@ -171,7 +171,7 @@ async function ActivityMonitor(interval) {
if(playbackToInsert.length>0)
{
let result=await db.insertBulk('jf_playback_activity',playbackToInsert,columnsPlayback);
console.log(result);
// console.log(result);
}
@@ -180,7 +180,17 @@ async function ActivityMonitor(interval) {
} catch (error) {
// console.log(error);
if(error?.code==='ECONNREFUSED')
{
console.error('Error: Unable to connect to Jellyfin');
}else if(error?.code==='ERR_BAD_RESPONSE')
{
console.warn(error.response?.data);
}else
{
console.error(error);
}
return [];
}
}, interval);

View File

@@ -0,0 +1,61 @@
const db = require("../db");
const Logging = require("../logging");
const backup = require("../backup");
const moment = require('moment');
const { randomUUID } = require('crypto');
async function BackupTask(interval) {
console.log("Backup Interval: " + interval);
setInterval(async () => {
try {
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
return;
}
let startTime = moment();
let refLog={logData:[],result:'Success'};
await backup.backup(refLog);
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const uuid = randomUUID();
const log=
{
"Id":uuid,
"Name":"Backup",
"Type":"Task",
"ExecutionType":"Automatic",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(refLog.logData),
"Result":refLog.result
};
Logging.insertLog(log);
} catch (error) {
// console.log(error);
return [];
}
}, interval);
}
module.exports = {
BackupTask,
};

35
backend/tasks/SyncTask.js Normal file
View 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,
};

View File

@@ -0,0 +1,43 @@
const GitHub = require('github-api');
const packageJson = require('../package.json');
const {compareVersions} =require('compare-versions');
async function checkForUpdates() {
const currentVersion = packageJson.version;
const repoOwner = 'cyfershepard';
const repoName = 'Jellystat';
const gh = new GitHub();
let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false};
let latestVersion;
try {
const path = 'package.json';
const response = await gh.getRepo(repoOwner, repoName).getContents('main', path);
const content = response.data.content;
const decodedContent = Buffer.from(content, 'base64').toString();
latestVersion = JSON.parse(decodedContent).version;
if (compareVersions(latestVersion,currentVersion) > 0) {
// console.log(`A new version V.${latestVersion} of ${repoName} is available.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} has an update ${latestVersion}`, update_available:true };
} else if (compareVersions(latestVersion,currentVersion) < 0) {
// console.log(`${repoName} is using a beta version.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is using a beta version`, update_available:false };
} else {
// console.log(`${repoName} is up to date.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is up to date`, update_available:false };
}
} catch (error) {
console.error(`Failed to fetch releases for ${repoName}: ${error.message}`);
result = { current_version: packageJson.version, latest_version: 'N/A', message: `Failed to fetch releases for ${repoName}: ${error.message}`, update_available:false };
}
return result;
}
module.exports = { checkForUpdates };

View File

@@ -1,11 +1,13 @@
{
"name": "jfstat",
"version": "0.1.0",
"version": "1.0.4.10",
"private": true,
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/material": "^5.11.10",
"@jellyfin/sdk": "^0.8.2",
"@mui/material": "^5.12.2",
"@mui/x-data-grid": "^6.2.1",
"@nivo/api": "^0.74.1",
"@nivo/bar": "^0.80.0",
"@nivo/core": "^0.80.0",
@@ -16,12 +18,16 @@
"antd": "^5.3.0",
"axios": "^1.3.4",
"bootstrap": "^5.2.3",
"compare-versions": "^6.0.0-rc.1",
"concurrently": "^7.6.0",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"file-saver": "^2.0.5",
"github-api": "^3.4.0",
"http-proxy-middleware": "^2.0.6",
"knex": "^2.4.2",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.9.0",
@@ -31,6 +37,7 @@
"react-blurhash": "^0.3.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"recharts": "^2.5.0",

View File

@@ -1,9 +1,11 @@
@import 'pages/css/variables.module.css';
main{
margin-inline: 20px;
margin-inline: 20px;
/* width: 100%; */
overflow: auto;
}
.App-logo {
height: 40vmin;
pointer-events: none;
@@ -17,7 +19,7 @@ main{
body
{
background-color: #1e1c22 !important;
background-color: var(--background-color) !important;
/* background-color: #17151a; */
color: white;
}
@@ -42,6 +44,7 @@ h2{
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
@@ -80,3 +83,81 @@ h2{
}
.btn-outline-primary
{
color: white!important;
border-color: var(--primary-color) !important;
background-color: var(--background-color) !important;
}
.btn-outline-primary:hover
{
background-color: var(--primary-color) !important;
}
.btn-outline-primary.active
{
background-color: var(--primary-color) !important;
}
.btn-outline-primary:focus
{
background-color: var(--primary-color) !important;
}
.btn-primary
{
color: white!important;
border-color: var(--primary-color) !important;
background-color: var(--primary-color) !important;
}
.btn-primary:hover
{
background-color: var(--primary-dark-color) !important;
}
.btn-primary.active
{
background-color: var(--primary-color) !important;
}
.btn-primary:focus
{
background-color: var(--primary-color) !important;
}
.form-select
{
background-color:var(--secondary-background-color) !important;
border-color:var(--secondary-background-color) !important ;
color: white !important;
}
.form-select:focus
{
box-shadow: none !important;
border-color: var(--primary-color) !important;
color: white !important;
}
.form-select option {
background-color: var(--secondary-background-color) !important;
padding: 0 !important;
border-radius: 8px !important;
border-color: var(--primary-color) !important;
}
.form-select option:hover {
background-color: var(--primary-color) !important;
color: white !important;
}

View File

@@ -1,10 +1,7 @@
// import logo from './logo.svg';
import './App.css';
import React, { useState, useEffect } from 'react';
import {
Routes,
Route,
} from "react-router-dom";
import { Routes, Route } from "react-router-dom";
import axios from 'axios';
import Config from './lib/config';
@@ -25,15 +22,17 @@ import Libraries from './pages/libraries';
import LibraryInfo from './pages/components/library-info';
import ItemInfo from './pages/components/item-info';
import ErrorPage from './pages/components/general/error';
import About from './pages/about';
import Testing from './pages/testing';
import Activity from './pages/activity';
import Statistics from './pages/statistics';
import Datadebugger from './pages/data-debugger';
function App() {
const [isConfigured, setisConfigured] = useState(false);
const [setupState, setSetupState] = useState(0);
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [errorFlag, seterrorFlag] = useState(false);
@@ -61,16 +60,15 @@ function App() {
}
};
if(!isConfigured)
if(setupState===0)
{
setLoading(false);
axios
.get("/auth/isConfigured")
.then(async (response) => {
console.log(response);
if(response.status===200)
{
setisConfigured(true);
setSetupState(response.data.state);
}
@@ -84,11 +82,11 @@ function App() {
}
if (!config && isConfigured) {
if (!config && setupState>=1) {
fetchConfig();
}
}, [config,isConfigured]);
}, [config,setupState]);
if (loading) {
return <Loading />;
@@ -98,30 +96,34 @@ if (errorFlag) {
return <ErrorPage message={"Error: Unable to connect to Jellystat Backend"} />;
}
if(isConfigured)
if(!config && setupState===2)
{
if ((token===undefined || token===null) || !config) {
return <Login />;
}
}
else{
if (setupState===0) {
return <Signup />;
}
if(setupState===1)
{
return <Setup />;
}
if (config && config.apiKey ===null) {
return <Setup />;
}
if (config && isConfigured && token!==null){
if (config && setupState===2 && 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 />} />
@@ -129,10 +131,12 @@ if (config && isConfigured && token!==null){
<Route path="/users/:UserId" element={<UserInfo />} />
<Route path="/libraries" element={<Libraries />} />
<Route path="/libraries/:LibraryId" element={<LibraryInfo />} />
<Route path="/item/:Id" element={<ItemInfo />} />
<Route path="/libraries/item/:Id" element={<ItemInfo />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/activity" element={<Activity />} />
<Route path="/testing" element={<Testing />} />
<Route path="/about" element={<About />} />
<Route path="/debugger/data" element={<Datadebugger />} />
</Routes>
</main>
</div>

View File

@@ -1,123 +0,0 @@
import { Component } from "react";
import axios from "axios";
import Config from "../lib/config";
class API extends Component {
constructor(props) {
super(props);
this.state = {
data: [],
};
}
async getSessions() {
try {
const config = await Config();
const url = `${config.hostUrl}/Sessions`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data;
} catch (error) {
console.log(error);
return [];
}
}
async getActivityData(limit) {
if (limit === undefined || limit < 1) {
return [];
}
try {
const config = await Config();
const url = `${config.hostUrl}/System/ActivityLog/Entries?limit=${limit}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data;
} catch (error) {
console.log(error);
return [];
}
}
async getAdminUser() {
try {
const config = await Config();
const url = `${config.hostUrl}/Users`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
return adminUser || null;
} catch (error) {
console.log(error);
return [];
}
}
async getLibraries() {
try {
const config = await Config();
const admins = await this.getAdminUser();
const userid = admins[0].Id;
const url = `${config.hostUrl}/users/${userid}/Items`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
const mediafolders = response.data.Items.filter((type) =>
["tvshows", "movies"].includes(type.CollectionType)
);
return mediafolders || null;
} catch (error) {
console.log(error);
return [];
}
}
async getItem(itemID) {
try {
const config = await Config();
const admins = await this.getAdminUser();
const userid = admins[0].Id;
const url = `${config.hostUrl}/users/${userid}/Items?ParentID=${itemID}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data.Items;
} catch (error) {
console.log(error);
return [];
}
}
async getRecentlyPlayed(userid, limit) {
try {
const config = await Config();
const url = `${config.hostUrl}/users/${userid}/Items/Resume?limit=${limit}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data.Items;
} catch (error) {
console.log(error);
return [];
}
}
}
export default API;

View File

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

View File

@@ -11,10 +11,10 @@ async function Config() {
if(response.data.length>0)
{
const { JF_HOST, JF_API_KEY, APP_USER } = response.data[0];
return { hostUrl: JF_HOST, apiKey: JF_API_KEY, username: APP_USER, token:token };
const { JF_HOST, APP_USER,REQUIRE_LOGIN } = response.data[0];
return { hostUrl: JF_HOST, username: APP_USER, token:token, requireLogin:REQUIRE_LOGIN };
}
return { hostUrl: null, apiKey: null, username: null, token:token };
return { hostUrl: null, username: null, token:token,requireLogin:true };
} catch (error) {
// console.log(error);

View File

@@ -1,3 +1,3 @@
export const clientData = ["android","ios","safari","chrome","firefox","edge"]
export const clientData = ["android","ios","safari","chrome","firefox","edge","opera"]

View File

@@ -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"
// }
// ,

View File

@@ -1,7 +0,0 @@
class libraryItem {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
}

91
src/pages/about.js Normal file
View 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);
}, [data,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" rel="noreferrer" > https://github.com/CyferShepard/Jellystat</a>
</Col>
</Row>
</Card.Body>
</Card>
</div>
);
}

View File

@@ -1,156 +1,348 @@
import React ,{useState} from 'react';
import React from 'react';
import { Link } from "react-router-dom";
// import { useParams } from 'react-router-dom';
import { Button, ButtonGroup,Modal } from "react-bootstrap";
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Collapse from '@mui/material/Collapse';
import TableSortLabel from '@mui/material/TableSortLabel';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import { visuallyHidden } from '@mui/utils';
import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon';
import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon';
import StreamInfo from './stream_info';
import '../../css/activity/activity-table.css';
// localStorage.setItem('hour12',true);
function ActivityTable(props) {
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const [currentPage, setCurrentPage] = useState(1);
let timeString = '';
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `;
}
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `;
}
if (remainingSeconds > 0) {
timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`;
}
return timeString.trim();
}
function Row(data) {
const { row } = data;
const [open, setOpen] = React.useState(false);
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const [data, setData] = useState(props.data);
function handleSort(key) {
const direction =
sortConfig.key === key && sortConfig.direction === "ascending"
? "descending"
: "ascending";
setSortConfig({ key, direction });
}
function sortData(data, { key, direction }) {
if (!key) return data;
const [modalState,setModalState]= React.useState(false);
const [modalData,setModalData]= React.useState();
const sortedData = [...data];
sortedData.sort((a, b) => {
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
return 0;
});
return sortedData;
}
const options = {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
};
const sortedData = sortData(data, sortConfig);
const indexOfLastUser = currentPage * props.itemCount;
const indexOfFirstUser = indexOfLastUser - props.itemCount;
const currentData = sortedData.slice(indexOfFirstUser, indexOfLastUser);
const pageNumbers = [];
for (let i = 1; i <= Math.ceil(sortedData.length / props.itemCount); i++) {
pageNumbers.push(i);
}
const handleCollapse = (itemId) => {
setData(data.map(item => {
if ((item.NowPlayingItemId+item.EpisodeId) === itemId) {
return { ...item, isCollapsed: !item.isCollapsed };
} else {
return item;
}
}));
}
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
let formattedTime='';
if(hours)
{
formattedTime+=`${hours} hours`;
}
if(minutes)
{
formattedTime+=` ${minutes} minutes`;
}
return formattedTime ;
}
const openModal = (data) => {
setModalData(data);
setModalState(!modalState);
};
const options = {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: twelve_hr,
};
return (
<div>
<div className='activity-table'>
<div className='table-headers'>
<div onClick={() => handleSort("UserName")}>User</div>
<div onClick={() => handleSort("NowPlayingItemName")}>Title </div>
<div onClick={() => handleSort("ActivityDateInserted")}>Date</div>
<div onClick={() => handleSort("PlaybackDuration")}>Playback Duration</div>
<div onClick={() => handleSort("results")}>Total Plays</div>
</div>
<React.Fragment>
{currentData.map((item) => (
<Modal show={modalState} onHide={()=>setModalState(false)} >
<Modal.Header>
<Modal.Title>Stream Info: {!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName} ({row.UserName})</Modal.Title>
</Modal.Header>
<StreamInfo data={modalData}/>
<Modal.Footer>
<Button variant="outline-primary" onClick={()=>setModalState(false)}>
Close
</Button>
</Modal.Footer>
</Modal>
<div className='table-rows' key={item.NowPlayingItemId+item.EpisodeId} onClick={() => handleCollapse(item.NowPlayingItemId+item.EpisodeId)}>
<div className='table-rows-content'>
<div><Link to={`/users/${item.UserId}`}>{item.UserName}</Link></div>
<div><Link to={`/item/${item.EpisodeId || item.NowPlayingItemId}`}>{!item.SeriesName ? item.NowPlayingItemName : item.SeriesName+' - '+ item.NowPlayingItemName}</Link></div>
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(item.ActivityDateInserted))}</div>
<div>{formatTotalWatchTime(item.PlaybackDuration) || '0 sec'}</div>
<div>{item.results.length+1}</div>
</div>
<div className={`sub-table ${item.isCollapsed ? 'collapsed' : ''}`}>
{item.results.map((sub_item,index) => (
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => {if(row.results.length>1){setOpen(!open);}}}
>
{!open ? <AddCircleFillIcon opacity={row.results.length>1 ?1 : 0} cursor={row.results.length>1 ? "pointer":"default"}/> : <IndeterminateCircleFillIcon />}
</IconButton>
</TableCell>
<TableCell><Link to={`/users/${row.UserId}`} className='text-decoration-none'>{row.UserName}</Link></TableCell>
<TableCell><Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className='text-decoration-none'>{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName}</Link></TableCell>
<TableCell className='activity-client' ><span onClick={()=>openModal(row)}>{row.Client}</span></TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 seconds'}</TableCell>
<TableCell>{row.results.length !==0 ? row.results.length : 1}</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1 }}>
<div className='table-rows-content bg-grey sub-row' key={sub_item.EpisodeId+index}>
<div><Link to={`/users/${sub_item.UserId}`}>{sub_item.UserName}</Link></div>
<div><Link to={`/item/${sub_item.EpisodeId || sub_item.NowPlayingItemId}`}>{!sub_item.SeriesName ? sub_item.NowPlayingItemName : sub_item.SeriesName+' - '+ sub_item.NowPlayingItemName}</Link></div>
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(sub_item.ActivityDateInserted))}</div>
<div></div>
<div>1</div>
</div>
))}
</div>
</div>
))}
</div>
{props.itemCount>0 ?
<div className="pagination">
<button className="page-btn" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
First
</button>
<button className="page-btn" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
Previous
</button>
<div className="page-number">{`Page ${currentPage} of ${pageNumbers.length}`}</div>
<button className="page-btn" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === pageNumbers.length}>
Next
</button>
<button className="page-btn" onClick={() => setCurrentPage(pageNumbers.length)} disabled={currentPage === pageNumbers.length}>
Last
</button>
</div>
:<></>
}
</div>
<Table aria-label="sub-activity" className='rounded-2'>
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Title</TableCell>
<TableCell>Client</TableCell>
<TableCell>Date</TableCell>
<TableCell>Playback Duration</TableCell>
<TableCell>Plays</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 className='activity-client' ><span onClick={()=>openModal(resultRow)}>{resultRow.Client}</span></TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))}</TableCell>
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 seconds'}</TableCell>
<TableCell>1</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default ActivityTable;
function EnhancedTableHead(props) {
const { order, orderBy, onRequestSort } =
props;
const createSortHandler = (property) => (event) => {
onRequestSort(event, property);
};
const headCells = [
{
id: 'UserName',
numeric: false,
disablePadding: true,
label: 'Last User',
},
{
id: 'NowPlayingItemName',
numeric: false,
disablePadding: false,
label: 'Title',
},
{
id: 'Client',
numeric: false,
disablePadding: false,
label: 'Last Client',
},
{
id: 'ActivityDateInserted',
numeric: false,
disablePadding: false,
label: 'Date',
},
{
id: 'PlaybackDuration',
numeric: false,
disablePadding: false,
label: 'Total Playback',
},
{
id: 'TotalPlays',
numeric: false,
disablePadding: false,
label: 'TotalPlays',
},
];
return (
<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)
{
setRowsPerPage(props.itemCount);
setPage(0);
}
const handleNextPageClick = () => {
setPage((prevPage) => prevPage + 1);
};
const handlePreviousPageClick = () => {
setPage((prevPage) => prevPage - 1);
};
function descendingComparator(a, b, orderBy) {
if (b[orderBy] < a[orderBy]) {
return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}
// eslint-disable-next-line
function getComparator(order, orderBy) {
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy);
}
function stableSort(array, comparator) {
const stabilizedThis = array.map((el, index) => [el, index]);
stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0]);
if (order !== 0) {
return order;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}
const visibleRows = React.useMemo(
() =>
stableSort(props.data, getComparator(order, orderBy)).slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
),
[order, orderBy, page, rowsPerPage, getComparator, props.data],
);
const handleRequestSort = (event, property) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
return (
<>
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={rowsPerPage}
/>
<TableBody>
{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> :''}
</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),props.data.length)} of ${props.data.length}`}</div>
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
Next
</Button>
<Button className="page-btn" onClick={()=>setPage(Math.ceil(props.data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
Last
</Button>
</ButtonGroup>
</div>
</>
);
}

View File

@@ -0,0 +1,176 @@
import React from "react";
import "../../css/activity/stream-info.css";
// import { Button } from "react-bootstrap";
import Loading from "../general/loading";
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
function Row(logs) {
const { data } = logs;
function convertBitrate(bitrate) {
if(!bitrate)
{
return '-';
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps+' Mbps';
} else {
return kbps+' Kbps';
}
}
if(!data || !data.MediaStreams)
{
return null;
}
return (
<React.Fragment>
<TableRow>
<TableCell colSpan="3"><strong>Media</strong></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Bitrate</TableCell>
<TableCell className="py-0 pb-1" >{convertBitrate(data.TranscodingInfo ? data.TranscodingInfo.Bitrate : (data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null))}</TableCell>
<TableCell className="py-0 pb-1" >{convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Container</TableCell>
<TableCell className="py-0 pb-1" >{data.TranscodingInfo ? data.TranscodingInfo.Container.toUpperCase() : data.OriginalContainer.toUpperCase()}</TableCell>
<TableCell className="py-0 pb-1" >{data.OriginalContainer.toUpperCase()}</TableCell>
</TableRow>
<TableRow>
<TableCell ><strong>Video</strong></TableCell>
<TableCell colSpan="2"><strong>{data.TranscodingInfo ? (data.TranscodingInfo?.IsVideoDirect ? 'DIRECT' :'TRANSCODE'):'DIRECT'}</strong></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Codec</TableCell>
<TableCell className="py-0 pb-1" >{data.TranscodingInfo ? data.TranscodingInfo.VideoCodec?.toUpperCase() : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Codec?.toUpperCase() : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-3" >Bitrate</TableCell>
<TableCell className="py-0 pb-3" >{convertBitrate(data.TranscodingInfo ? data.TranscodingInfo.Bitrate : null)}</TableCell>
<TableCell className="py-0 pb-3" >{convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.BitRate : null)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Width</TableCell>
<TableCell className="py-0 pb-1" >{data.TranscodingInfo ? data.TranscodingInfo.Width : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Width : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-3" >Height</TableCell>
<TableCell className="py-0 pb-3" >{data.TranscodingInfo?.IsVideoDirect ? data.MediaStreams?.find(stream => stream.Type === 'Video')?.Height : data.TranscodingInfo?.Height || '-'}</TableCell>
<TableCell className="py-0 pb-3" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.Height : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Framerate</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Dynamic Range</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.VideoRange : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.VideoRange : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Aspect Ratio</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell ><strong>Audio</strong></TableCell>
<TableCell colSpan="2"><strong>{data.TranscodingInfo ? (data.TranscodingInfo?.IsAudioDirect ? 'DIRECT' :'TRANSCODE'):'DIRECT'}</strong></TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Codec</TableCell>
<TableCell className="py-0 pb-1" >{data.TranscodingInfo?.IsAudioDirect ? data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase() : data.TranscodingInfo?.AudioCodec.toUpperCase()|| data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase()}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Codec?.toUpperCase() : '-'}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" >Bitrate</TableCell>
<TableCell className="py-0 pb-1" >{convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.BitRate : null)}</TableCell>
<TableCell className="py-0 pb-1" >{convertBitrate(data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.BitRate : null)}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1">Channels</TableCell>
<TableCell className="py-0 pb-1">{data.TranscodingInfo?.IsAudioDirect ? data.TranscodingInfo?.AudioChannels: data.MediaStreams?.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Channels}</TableCell>
<TableCell className="py-0 pb-1">{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Channels : null}</TableCell>
</TableRow>
<TableRow>
<TableCell className="py-0 pb-3" >Language</TableCell>
<TableCell className="py-0 pb-3" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Language?.toUpperCase() : '-'}</TableCell>
<TableCell className="py-0 pb-3" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Audio' && stream.Index===data.PlayState?.AudioStreamIndex)?.Language?.toUpperCase() : '-'}</TableCell>
</TableRow>
</React.Fragment>
);
}
function StreamInfo(props) {
if(!props && !props.data)
{
return <Loading/>;
}
return (
<div className="StreamInfo">
<TableContainer className="overflow-hidden">
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>
<TableCell/>
<TableCell>Stream Details</TableCell>
<TableCell>Source Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
<Row data={props.data} />
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export default StreamInfo;

View File

@@ -0,0 +1,28 @@
import React from "react";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state to indicate an error has occurred
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can log the error or perform other actions here
console.error(error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render an error message or fallback UI
return <></>;
}
// Render the child components as normal
return this.props.children;
}
}

View File

@@ -33,16 +33,15 @@ function LastWatchedCard(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<Link to={`/item/${props.data.EpisodeId||props.data.Id}`}>
<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 && props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null ? <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
<img
src={
`${
props.base_url +
"/Items/" +
"/Proxy/Items/Images/Primary?id=" +
props.data.Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -1,8 +1,11 @@
import { Nav, Navbar as BootstrapNavbar, Container } from "react-bootstrap";
import { Nav, Navbar as BootstrapNavbar } from "react-bootstrap";
import { Link, useLocation } from "react-router-dom";
import { navData } from "../../../lib/navdata";
import LogoutBoxLineIcon from "remixicon-react/LogoutBoxLineIcon";
import logo_dark from '../../images/icon-b-512.png';
import "../../css/navbar.css";
import React from "react";
import VersionCard from "./version-card";
export default function Navbar() {
const handleLogout = () => {
@@ -10,36 +13,44 @@ export default function Navbar() {
window.location.reload();
};
const location = useLocation(); // use the useLocation hook from react-router-dom
const location = useLocation();
return (
<BootstrapNavbar variant="dark" expand="md" className="navbar py-0">
<Container fluid>
<BootstrapNavbar.Brand as={Link} to={"/"}>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 = ('/'+item.link).toLocaleLowerCase() === location.pathname.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 justify-content-between">
{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" : ""} `} // 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 logout" href="#logout" onClick={handleLogout}>
<LogoutBoxLineIcon />
<span className="d-none d-md-block nav-text">Logout</span>
</Nav.Link>
</Nav>
</div>
<VersionCard/>
</BootstrapNavbar>
);
}

View 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" rel="noreferrer" style={{color:'#00A4DC'}}>New version available: {data.latest_version}</a></Col>
</Row>
:
<></>
}
</Card.Body>
</Card>
);
}

View File

@@ -1,10 +1,19 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { useParams } from 'react-router-dom';
import { Link } from "react-router-dom";
import { Blurhash } from 'react-blurhash';
import {Row, Col, Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon";
import GlobalStats from './item-info/globalStats';
import ItemDetails from './item-info/item-details';
import "../css/items/item-details.css";
import MoreItems from "./item-info/more-items";
import ItemActivity from "./item-info/item-activity";
import ItemNotFound from "./item-info/item-not-found";
import Config from "../../lib/config";
import Loading from "./general/loading";
@@ -16,21 +25,35 @@ function ItemInfo() {
const [data, setData] = useState();
const [config, setConfig] = useState();
const [refresh, setRefresh] = useState(true);
const [activeTab, setActiveTab] = useState('tabOverview');
useEffect(() => {
const [loaded, setLoaded] = useState(false);
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
function formatFileSize(sizeInBytes) {
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
if (sizeInMB < 1000) {
return `${sizeInMB.toFixed(2)} MB`;
} else {
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
return `${sizeInGB.toFixed(2)} GB`;
}
}
function ticksToTimeString(ticks) {
const seconds = Math.floor(ticks / 10000000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
return timeString;
}
const fetchData = async () => {
if(config){
if(config && (!data || data.notfound)){
setRefresh(true);
try {
const itemData = await axios.post(`/api/getItemDetails`, {
@@ -44,15 +67,32 @@ useEffect(() => {
setData(itemData.data[0]);
setRefresh(false);
} catch (error) {
setData({notfound:true, message:error.response.data});
console.log(error);
}
setRefresh(false);
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
fetchData();
if (!config) {
@@ -61,34 +101,110 @@ useEffect(() => {
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, Id]);
});
if(!data)
{
return <></>;
}
if(refresh)
if(!data || refresh)
{
return <Loading/>;
}
if(data && data.notfound)
{
return <ItemNotFound message="Item not found" itemId={Id} fetchdataMethod={fetchData}/>;
// return <ItemNotFound 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>
<ItemDetails data={data} hostUrl={config.hostUrl}/>
<GlobalStats ItemId={Id}/>
{["Series","Season"].includes(data && data.Type)?
<MoreItems data={data}/>
:
<></>
}
<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 && data.PrimaryImageHash!=null && !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=" +
(["Episode","Season"].includes(data.Type)? data.SeriesId : data.Id) +
"&fillWidth=200&quality=90"
}
alt=""
style={{
display: loaded ? "block" :"none"
}}
onLoad={() => setLoaded(true)}
/>
</Col>
<Col >
<div className="item-details">
<div className="d-flex">
<h1 className="">
{data.SeriesId?
<Link to={`/libraries/item/${data.SeriesId}`}>{data.SeriesName || data.Name}</Link>
:
data.SeriesName || data.Name
}
</h1>
<Link className="px-2" to={ config.hostUrl+"/web/index.html#!/details?id="+ (data.EpisodeId ||data.Id)} title="Open in Jellyfin" target="_blank"><ExternalLinkFillIcon/></Link>
</div>
<div className="my-3">
{data.Type==="Episode"? <p><Link to={`/libraries/item/${data.SeasonId}`} className="fw-bold">{data.SeasonName}</Link> Episode {data.IndexNumber} - {data.Name}</p> : <></> }
{data.Type==="Season"? <p>{data.Name}</p> : <></> }
{data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {data.FileName}</p> :<></>}
{data.Path ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Path: {data.Path}</p> :<></>}
{data.RunTimeTicks ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">{data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(data.RunTimeTicks)}</p> :<></>}
{data.Size ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Size: {formatFileSize(data.Size)}</p> :<></>}
</div>
<ButtonGroup>
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
</ButtonGroup>
</div>
</Col>
</Row>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills' className="hide-tab-titles">
<Tab eventKey="tabOverview" title='' className='bg-transparent'>
<GlobalStats ItemId={Id}/>
{["Series","Season"].includes(data && data.Type)?
<MoreItems data={data}/>
:
<></>
}
</Tab>
<Tab eventKey="tabActivity" title='' className='bg-transparent'>
<ItemActivity itemid={Id}/>
</Tab>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ActivityTable from "../activity/activity-table";
function ItemActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem('token');
const [itemCount,setItemCount] = useState(10);
useEffect(() => {
const fetchData = async () => {
try {
const itemData = await axios.post(`/api/getItemHistory`, {
itemid: props.itemid,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);
} catch (error) {
console.log(error);
}
};
if (!data) {
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, props.itemid,token]);
if (!data) {
return <></>;
}
return (
<div className="Activity">
<div className="Heading">
<h1>Item Activity</h1>
<div className="pagination-range">
<div className="header">Items</div>
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div className="Activity">
<ActivityTable data={data} itemCount={itemCount}/>
</div>
</div>
);
}
export default ItemActivity;

View File

@@ -70,7 +70,7 @@ function ItemDetails(props) {
<div className="d-flex">
<h1 className="">
{props.data.SeriesId?
<Link to={`/item/${props.data.SeriesId}`}>{props.data.SeriesName || props.data.Name}</Link>
<Link to={`/libraries/item/${props.data.SeriesId}`}>{props.data.SeriesName || props.data.Name}</Link>
:
props.data.SeriesName || props.data.Name
}
@@ -80,7 +80,7 @@ function ItemDetails(props) {
</div>
<div className="my-3">
{props.data.Type==="Episode"? <p><Link to={`/item/${props.data.SeasonId}`} className="fw-bold">{props.data.SeasonName}</Link> Episode {props.data.IndexNumber} - {props.data.Name}</p> : <></> }
{props.data.Type==="Episode"? <p><Link to={`/libraries/item/${props.data.SeasonId}`} className="fw-bold">{props.data.SeasonName}</Link> Episode {props.data.IndexNumber} - {props.data.Name}</p> : <></> }
{props.data.Type==="Season"? <p>{props.data.Name}</p> : <></> }
{props.data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {props.data.FileName}</p> :<></>}
{props.data.Path ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Path: {props.data.Path}</p> :<></>}

View File

@@ -0,0 +1,55 @@
import React, {useState} from "react";
import axios from "axios";
import "../../css/error.css";
import { Button } from "react-bootstrap";
import Loading from "../general/loading";
function ItemNotFound(props) {
const [itemId] = useState(props.itemId);
const [loading,setLoading] = useState(false);
const token = localStorage.getItem('token');
async function fetchItem() {
setLoading(true);
const result = await axios
.post("/sync/fetchItem", {
itemId:itemId
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.catch((error) => {
setLoading(false);
console.log(error);
});
if(result)
{
await props.fetchdataMethod();
setLoading(false);
}
}
if(loading)
{
return <Loading/>;
}
return (
<div className="error">
<h1 className="error-title">{props.message}</h1>
<Button variant="primary" className="mt-3" onClick={()=> fetchItem()}>Fetch this item from Jellyfin</Button>
</div>
);
}
export default ItemNotFound;

View File

@@ -69,7 +69,7 @@ function MoreItems(props) {
<h1 className="my-3">{props.data.Type==="Season" ? "Episodes" : "Seasons"}</h1>
<div className="last-played-container">
{data.map((item) => (
{data.sort((a,b) => a.IndexNumber-b.IndexNumber).map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id}/>
))}

View File

@@ -11,20 +11,20 @@ function MoreItemCards(props) {
const { Id } = useParams();
const [loaded, setLoaded] = useState(false);
const [fallback, setFallback] = useState(false);
return (
<div className={props.data.Type==="Episode" ? "last-card episode" : "last-card"}>
<Link to={`/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
<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 && !loaded ? <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/> : null}
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) ) && !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
src={
`${
props.base_url +
"/Items/" +
"/Proxy/Items/Images/Primary?id=" +
Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
@@ -34,10 +34,9 @@ function MoreItemCards(props) {
<img
src={
`${
props.base_url +
"/Items/" +
"/Proxy/Items/Images/Primary?id=" +
(props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -1,23 +1,103 @@
import { useParams } from 'react-router-dom';
import React, { useState, useEffect } from "react";
import axios from "axios";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import LibraryDetails from './library/library-details';
// import LibraryDetails from './library/library-details';
import Loading from './general/loading';
import LibraryGlobalStats from './library/library-stats';
import LibraryLastWatched from './library/last-watched';
import RecentlyPlayed from './library/recently-added';
import RecentlyAdded from './library/recently-added';
import LibraryActivity from './library/library-activity';
import LibraryItems from './library/library-items';
import ErrorBoundary from './general/ErrorBoundary';
import { Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
function LibraryInfo() {
const { LibraryId } = useParams();
const [activeTab, setActiveTab] = useState('tabOverview');
const [data, setData] = useState();
const token = localStorage.getItem('token');
useEffect(() => {
const fetchData = async () => {
try {
console.log('getdata');
const libraryrData = await axios.post(`/stats/getLibraryDetails`, {
libraryid: LibraryId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setData(libraryrData.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [LibraryId,token]);
if(!data)
{
return <Loading/>;
}
return (
<div>
<LibraryDetails LibraryId={LibraryId}/>
<div className="user-detail-container">
<div className="user-image-container">
{data.CollectionType==="tvshows" ?
<TvLineIcon size={'100%'}/>
:
<FilmLineIcon size={'100%'}/>
}
</div>
<div>
<p className="user-name">{data.Name}</p>
<ButtonGroup>
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
<Button onClick={() => setActiveTab('tabItems')} active={activeTab==='tabItems'} variant='outline-primary' type='button'>Media</Button>
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
</ButtonGroup>
</div>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
<Tab eventKey="tabOverview" className='bg-transparent'>
<LibraryGlobalStats LibraryId={LibraryId}/>
<RecentlyPlayed LibraryId={LibraryId}/>
<ErrorBoundary>
<RecentlyAdded LibraryId={LibraryId}/>
</ErrorBoundary>
<LibraryLastWatched LibraryId={LibraryId}/>
</Tab>
<Tab eventKey="tabActivity" className='bg-transparent'>
<LibraryActivity LibraryId={LibraryId}/>
</Tab>
<Tab eventKey="tabItems" className='bg-transparent'>
<LibraryItems LibraryId={LibraryId}/>
</Tab>
</Tabs>
</div>
);
}

View File

@@ -10,16 +10,14 @@ function RecentlyAddedCard(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<Link to={`/item/${props.data.Id}`}>
<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={
`${
props.base_url +
"/Items/" +
`${"/Proxy/Items/Images/Primary?id=" +
props.data.Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -0,0 +1,65 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ActivityTable from "../activity/activity-table";
function LibraryActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem('token');
const [itemCount,setItemCount] = useState(10);
useEffect(() => {
const fetchData = async () => {
try {
const libraryrData = await axios.post(`/api/getLibraryHistory`, {
libraryid: props.LibraryId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setData(libraryrData.data);
} catch (error) {
console.log(error);
}
};
if (!data) {
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, props.LibraryId,token]);
if (!data) {
return <></>;
}
return (
<div className="Activity">
<div className="Heading">
<h1>Library Activity</h1>
<div className="pagination-range">
<div className="header">Items</div>
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div className="Activity">
<ActivityTable data={data} itemCount={itemCount}/>
</div>
</div>
);
}
export default LibraryActivity;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, {useState} from "react";
import { Link } from "react-router-dom";
import "../../css/library/library-card.css";
@@ -6,7 +6,19 @@ import Card from 'react-bootstrap/Card';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
function LibraryCard(props) {
const [imageLoaded, setImageLoaded] = useState(true);
const SeriesIcon=<TvLineIcon size={"50%"} color="white"/> ;
const MovieIcon=<FilmLineIcon size={"50%"} color="white"/> ;
const MusicIcon=<FileMusicLineIcon size={"50%"} color="white"/> ;
const MixedIcon=<CheckboxMultipleBlankLineIcon size={"50%"} color="white"/> ;
const default_image=<div className="default_library_image d-flex justify-content-center align-items-center">{props.data.CollectionType==='tvshows' ? SeriesIcon : props.data.CollectionType==='movies'? MovieIcon : props.data.CollectionType==='music'? MusicIcon : MixedIcon} </div>;
function formatFileSize(sizeInBytes) {
const sizeInKB = sizeInBytes / 1024; // 1 KB = 1024 bytes
@@ -36,24 +48,59 @@ function LibraryCard(props) {
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
let formattedTime='';
if(hours)
{
formattedTime+=`${hours} hours`;
const days = Math.floor(seconds / 86400); // 1 day = 86400 seconds
const hours = Math.floor((seconds % 86400) / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor(((seconds % 86400) % 3600) / 60); // 1 minute = 60 seconds
let formattedTime = '';
if (days) {
formattedTime += `${days} day${days > 1 ? 's' : ''}`;
}
if(minutes)
{
formattedTime+=` ${minutes} minutes`;
if (hours) {
formattedTime += ` ${hours} hour${hours > 1 ? 's' : ''}`;
}
if(!hours && !minutes)
{
formattedTime=`0 minutes`;
if (minutes) {
formattedTime += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
return formattedTime ;
if (!days && !hours && !minutes) {
formattedTime = '0 minutes';
}
return formattedTime;
}
function ticksToTimeString(ticks) {
const seconds = Math.floor(ticks / 10000000);
const months = Math.floor(seconds / (86400 * 30)); // 1 month = 86400 seconds
const days = Math.floor((seconds % (86400 * 30)) / 86400); // 1 day = 86400 seconds
const hours = Math.floor((seconds % 86400) / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
const timeComponents = [];
if (months) {
timeComponents.push(`${months} Month${months > 1 ? 's' : ''}`);
}
if (days) {
timeComponents.push(`${days} day${days > 1 ? 's' : ''}`);
}
if (hours) {
timeComponents.push(`${hours} hour${hours > 1 ? 's' : ''}`);
}
if (!months && minutes) {
timeComponents.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
}
const formattedTime = timeComponents.length > 0 ? timeComponents.join(' ') : '0 minutes';
return formattedTime;
}
function formatLastActivityTime(time) {
const units = {
@@ -74,14 +121,21 @@ function LibraryCard(props) {
return `${formattedTime}ago`;
}
return (
<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">
{imageLoaded?
<Card.Img
variant="top"
className="library-card-banner"
src={props.base_url + "/Items/" + props.data.Id + "/Images/Primary/?fillWidth=800&quality=50"}
src={"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
onError={() =>setImageLoaded(false)}
/>
:
default_image
}
</div>
</Link>
@@ -107,17 +161,22 @@ function LibraryCard(props) {
<Row className="space-between-end card-row">
<Col className="card-label">Type</Col>
<Col className="text-end">{props.data.CollectionType==='tvshows' ? 'Series' : "Movies"}</Col>
<Col className="text-end">{props.data.CollectionType==='tvshows' ? 'Series' : props.data.CollectionType==='movies'? "Movies" : props.data.CollectionType==='music'? "Music" : 'Mixed'}</Col>
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">Total Time</Col>
<Col className="text-end">{ticksToTimeString(props.data && props.data.total_play_time ? props.data.total_play_time:0)}</Col>
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">Total Files</Col>
<Col className="text-end">{props.metadata.files}</Col>
<Col className="text-end">{props.metadata && props.metadata.files ? props.metadata.files :0}</Col>
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">Library Size</Col>
<Col className="text-end">{formatFileSize(props.metadata.Size)}</Col>
<Col className="text-end">{formatFileSize(props.metadata && props.metadata.Size ? props.metadata.Size:0)}</Col>
</Row>
<Row className="space-between-end card-row">
@@ -141,7 +200,7 @@ function LibraryCard(props) {
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">{props.data.CollectionType==='tvshows' ? 'Series' : "Movies"}</Col>
<Col className="card-label">{props.data.CollectionType==='tvshows' ? 'Series' : props.data.CollectionType==='movies'? "Movies" : props.data.CollectionType==='music'? "Songs" : 'Files'}</Col>
<Col className="text-end">{props.data.Library_Count}</Col>
</Row>

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import {FormControl, FormSelect, Button } from 'react-bootstrap';
import SortAscIcon from 'remixicon-react/SortAscIcon';
import SortDescIcon from 'remixicon-react/SortDescIcon';
import MoreItemCards from "../item-info/more-items/more-items-card";
import Config from "../../../lib/config";
import "../../css/library/media-items.css";
import "../../css/width_breakpoint_css.css";
import "../../css/radius_breakpoint_css.css";
function LibraryItems(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
const [searchQuery, setSearchQuery] = useState('');
const [sortOrder, setSortOrder] = useState('Title');
const [sortAsc, setSortAsc] = useState(true);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
try {
const itemData = await axios.post(`/stats/getLibraryItemsWithStats`, {
libraryid: props.LibraryId,
}, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);
} catch (error) {
console.log(error);
}
};
if (!config) {
fetchConfig();
}else{
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, props.LibraryId]);
function sortOrderLogic(_sortOrder)
{
if(_sortOrder!=='Title')
{
setSortAsc(false);
}else{
setSortAsc(true);
}
setSortOrder(_sortOrder);
}
let filteredData = data;
if(searchQuery)
{
filteredData = data.filter((item) =>
item.Name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (!data || !config) {
return <></>;
}
return (
<div className="library-items">
<div className="d-md-flex justify-content-between">
<h1 className="my-3">Media</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-row w-100">
<FormSelect onChange={(e) => sortOrderLogic(e.target.value) } className="my-md-3 w-100 rounded-0 rounded-start">
<option value="Title">Title</option>
<option value="Views">Views</option>
<option value="WatchTime">Watch Time</option>
</FormSelect>
<Button className="my-md-3 rounded-0 rounded-end" onClick={()=>setSortAsc(!sortAsc)}>
{sortAsc ?
<SortAscIcon/>
:
<SortDescIcon/>
}
</Button>
</div>
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" />
</div>
</div>
<div className="media-items-container">
{filteredData.sort((a, b) =>
{
const titleA = a.Name.replace(/^(A |An |The )/i, '');
const titleB = b.Name.replace(/^(A |An |The )/i, '');
if(sortOrder==='Title')
{
if(sortAsc)
{
return titleA.localeCompare(titleB);
}
return titleB.localeCompare(titleA);
}else if(sortOrder==='Views')
{
if(sortAsc)
{
return a.times_played-b.times_played;
}
return b.times_played-a.times_played;
}
else
{
if(sortAsc)
{
return a.total_play_time-b.total_play_time;
}
return b.total_play_time-a.total_play_time;
}
}
).map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
))}
</div>
</div>
);
}
export default LibraryItems;

View File

@@ -5,8 +5,9 @@ import RecentlyAddedCard from "./RecentlyAdded/recently-added-card";
import Config from "../../../lib/config";
import "../../css/users/user-details.css";
import ErrorBoundary from "../general/ErrorBoundary";
function RecentlyPlayed(props) {
function RecentlyAdded(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
@@ -22,32 +23,27 @@ function RecentlyPlayed(props) {
}
};
const fetchAdmin = async () => {
const fetchData = async () => {
try {
let url=`/api/getAdminUsers`;
const adminData = await axios.get(url, {
let url=`/stats/getRecentlyAdded`;
if(props.LibraryId)
{
url+=`?libraryid=${props.LibraryId}`;
}
const itemData = await axios.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
return adminData.data[0].Id;
// setData(itemData.data);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
try {
let adminId=await fetchAdmin();
let url=`${config.hostUrl}/users/${adminId}/Items/latest?parentId=${props.LibraryId}`;
const itemData = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
},
});
setData(itemData.data);
if(itemData && typeof itemData.data === 'object' && Array.isArray(itemData.data))
{
setData(itemData.data.filter((item) => ["Series", "Movie","Audio"].includes(item.Type)));
}
} catch (error) {
console.log(error);
}
@@ -66,7 +62,11 @@ function RecentlyPlayed(props) {
}, [data,config, props.LibraryId]);
if (!data || !config) {
if (!data && !config) {
return <></>;
}
if (!data && config) {
return <></>;
}
@@ -74,8 +74,10 @@ function RecentlyPlayed(props) {
<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) => (
<RecentlyAddedCard data={item} base_url={config.hostUrl} key={item.Id}/>
{data && data.map((item) => (
<ErrorBoundary key={item.Id}>
<RecentlyAddedCard data={item} base_url={config.hostUrl} />
</ErrorBoundary>
))}
</div>
@@ -84,4 +86,4 @@ function RecentlyPlayed(props) {
);
}
export default RecentlyPlayed;
export default RecentlyAdded;

View File

@@ -8,11 +8,15 @@ import LibraryStatComponent from "./libraryStatCard/library-stat-component";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
export default function LibraryOverView() {
const token = localStorage.getItem('token');
const SeriesIcon=<TvLineIcon size={"80%"} /> ;
const MovieIcon=<FilmLineIcon size={"80%"} /> ;
const SeriesIcon=<TvLineIcon size={"100%"} /> ;
const MovieIcon=<FilmLineIcon size={"100%"} /> ;
const MusicIcon=<FileMusicLineIcon size={"100%"} /> ;
const MixedIcon=<CheckboxMultipleBlankLineIcon size={"100%"} /> ;
const [data, setData] = useState();
@@ -41,12 +45,13 @@ 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}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "tvshows")} heading={"SHOW LIBRARIES"} units={"SERIES / SEASONS / EPISODES"} icon={SeriesIcon}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "music")} heading={"MUSIC LIBRARIES"} units={"SONGS"} icon={SeriesIcon}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "music")} heading={"MUSIC LIBRARIES"} units={"SONGS"} icon={MusicIcon}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "mixed")} heading={"MIXED LIBRARIES"} units={"ITEMS"} icon={MixedIcon}/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from "react";
import { Link } from "react-router-dom";
import { Row, Col, Card } from "react-bootstrap";
function LibraryStatComponent(props) {
@@ -14,25 +15,24 @@ function LibraryStatComponent(props) {
};
const cardBgStyle = {
backdropFilter: 'blur(5px)',
// backdropFilter: 'blur(5px)',
backgroundColor: 'rgb(0, 0, 0, 0.6)',
height:'100%',
};
return (
<Card className="stat-card" style={cardStyle}>
<div style={cardBgStyle}>
<Card className="stat-card rounded-3" style={cardStyle}>
<div style={cardBgStyle} className="rounded-3">
<Row className="h-100">
<Col className="d-none d-lg-block stat-card-banner">
<div className="stat-card-icon">
{props.icon}
</div>
</Col>
<Col className="stat-card-info w-100">
<Col className="w-100">
<Card.Body className="w-100" >
<Card.Header className="d-flex justify-content-between border-0 p-0 bg-transparent stat-header">
<div>
@@ -48,7 +48,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">

View File

@@ -10,6 +10,7 @@ import PlayFillIcon from "remixicon-react/PlayFillIcon";
import PauseFillIcon from "remixicon-react/PauseFillIcon";
import { clientData } from "../../../lib/devices";
import Tooltip from "@mui/material/Tooltip";
function ticksToTimeString(ticks) {
@@ -28,12 +29,26 @@ function ticksToTimeString(ticks) {
return timeString;
}
function convertBitrate(bitrate) {
if(!bitrate)
{
return 'N/A';
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps+' Mbps';
} else {
return kbps+' Kbps';
}
}
function sessionCard(props) {
// Access data passed in as a prop using `props.data`
const cardStyle = {
backgroundImage: `url(${props.data.base_url}/Items/${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}/Images/Backdrop?fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
backgroundImage: `url(Proxy/Items/Images/Backdrop?id=${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
};
@@ -47,90 +62,112 @@ function sessionCard(props) {
return (
<Card className="stat-card" style={cardStyle}>
<div style={cardBgStyle}>
<div style={cardBgStyle} className="rounded-top">
<Row className="h-100">
<Col className="stat-card-banner">
<Col className="d-none d-lg-block stat-card-banner">
<Card.Img
variant="top"
className="stat-card-image rounded-0"
src={props.data.base_url + "/Items/" + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + "/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}
className="stat-card-image rounded-0 rounded-start"
src={"/Proxy/Items/Images/Primary?id=" + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + "&fillHeight=320&fillWidth=213&quality=50"}
/>
</Col>
<Col className="stat-card-info w-100 mt-auto ">
<Col className="w-100 h-100">
<Card.Body className="w-100 h-100 p-1 pb-2" >
<Container className="h-100 d-flex flex-column">
<Row className="d-flex flex-row flex-grow-1 justify-content-between">
<Col className="col-auto">
<Row className="ellipse"> {props.data.session.DeviceName}</Row>
<Row className="ellipse card-client-version"> {props.data.session.Client + " " + props.data.session.ApplicationVersion}</Row>
<Row className="d-flex flex-column flex-md-row">
<Col className="px-0 col-auto">{props.data.session.PlayState.PlayMethod}</Col>
<Col className="px-0 px-md-2 col-auto ellipse">{(props.data.session.NowPlayingItem.MediaStreams ? '( '+props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.Codec.toUpperCase()+(props.data.session.TranscodingInfo? ' - '+props.data.session.TranscodingInfo.VideoCodec.toUpperCase() : '')+' - '+convertBitrate(props.data.session.TranscodingInfo ? props.data.session.TranscodingInfo.Bitrate :props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.BitRate)+' )':'')}</Col>
</Row>
</Col>
<Card.Body className="w-100" >
<Container className="p-0">
<Row className="position-absolute top-0">
<Col className="col-auto d-flex justify-content-center">
<img
className="card-device-image"
src={
props.data.base_url +
"/web/assets/img/devices/"
+
(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=""
/>
<img
className="card-device-image"
src={
"/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")
)}
alt=""
/>
</Col>
<Col>
<Row> {props.data.session.DeviceName}</Row>
<Row> {props.data.session.Client + " " + props.data.session.ApplicationVersion}</Row>
</Col>
</Row>
<Row className="justify-content-between">
<Col>
<Card.Text>
<Link to={`/item/${props.data.session.NowPlayingItem.Id}`} target="_blank">
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
</Link>
</Card.Text>
</Col>
{props.data.session.NowPlayingItem.Type==='Episode' ?
<Row className="d-flex flex-row justify-content-between">
<Col className="p-0">
<Card.Text>
<Link to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`} target="_blank" className="item-name">
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
</Link>
</Card.Text>
</Col>
</Row>
:
<></>
}
<Col className="col-auto">
<Row className="d-flex">
<Col className="col-auto px-0">
{props.data.session.UserPrimaryImageTag !== undefined ? (
<img
className="card-user-image"
src={
props.data.base_url +
"/Users/" +
props.data.session.UserId +
"/Images/Primary?tag=" +
props.data.session.UserPrimaryImageTag +
"&quality=50"
}
alt=""
/>
) : (
<AccountCircleFillIcon />
)}
<Row className="d-flex flex-row justify-content-between">
{props.data.session.NowPlayingItem.Type==='Episode' ?
<Col className="col-auto p-0">
<Card.Text >
{'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber}
</Card.Text>
</Col>
<Col className="col-auto">
<Card.Text className="text-end">
<Link to={`/users/${props.data.session.UserId}`}>{props.data.session.UserName}</Link>
</Card.Text>
:
<Col className="p-0">
<Card.Text>
<Link to={`/libraries/item/${props.data.session.NowPlayingItem.Id}`} target="_blank" className="item-name">
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
</Link>
</Card.Text>
</Col>
</Row>
}
<Col className="d-flex flex-row justify-content-end text-end col-auto">
{props.data.session.UserPrimaryImageTag !== undefined ? (
<img
className="session-card-user-image"
src={
"/Proxy/Users/Images/Primary?id=" +
props.data.session.UserId +
"&quality=50"
}
alt=""
/>
) : (
<AccountCircleFillIcon className="session-card-user-image"/>
)}
<Card.Text >
<Tooltip title={props.data.session.UserName} >
<Link to={`/users/${props.data.session.UserId}`} className="item-name" style={{maxWidth:'15ch'}}>{props.data.session.UserName}</Link>
</Tooltip>
</Card.Text>
</Col>
</Row>
<Row className="d-flex">
<Col className="col-auto">
<Col className="col-auto p-0">
{props.data.session.PlayState.IsPaused ?
<PauseFillIcon />

View File

@@ -1,39 +1,58 @@
import React, { useState, useEffect } from "react";
// import axios from 'axios';
import axios from 'axios';
import Config from "../../../lib/config";
import API from "../../../classes/jellyfin-api";
// import API from "../../../classes/jellyfin-api";
import "../../css/sessions.css";
// import "../../App.css"
import ErrorBoundary from "../general/ErrorBoundary";
import SessionCard from "./session-card";
import Loading from "../general/loading";
function Sessions() {
const [data, setData] = useState();
const [base_url, setURL] = useState("");
// const [errorHandler, seterrorHandler] = useState({ error_count: 0, error_message: '' })
const [config, setConfig] = useState();
useEffect(() => {
const _api = new API();
const fetchData = () => {
_api.getSessions().then((SessionData) => {
let results=SessionData.filter((session) => session.NowPlayingItem);
setData(results);
});
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (base_url === "") {
Config()
.then((config) => {
setURL(config.hostUrl);
const fetchData = () => {
if (config) {
const url = `/api/getSessions`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
if(data && typeof data.data === 'object' && Array.isArray(data.data))
{
setData(data.data.filter(row => row.NowPlayingItem !== undefined));
}
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}else
if(!data)
{
fetchData();
@@ -41,13 +60,14 @@ function Sessions() {
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, [data,base_url]);
}, [data,config]);
if (!data) {
if (!data && !config) {
return <Loading />;
}
if (data.length === 0) {
if ((!data && config) || data.length === 0) {
return(
<div>
<h1 className="my-3">Sessions</h1>
@@ -61,13 +81,15 @@ function Sessions() {
<div>
<h1 className="my-3">Sessions</h1>
<div className="sessions-container">
{data &&
{data && data.length>0 &&
data
.sort((a, b) =>
a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))
)
.map((session) => (
<SessionCard key={session.Id} data={{ session: session, base_url: base_url }} />
<ErrorBoundary key={session.Id} >
<SessionCard data={{ session: session, base_url: config.base_url }} />
</ErrorBoundary>
))}
</div>
</div>

View File

@@ -0,0 +1,107 @@
import React, { useState } from "react";
import axios from "axios";
import Button from "react-bootstrap/Button";
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import "../../css/settings/settings.css";
export default function Tasks() {
const [processing, setProcessing] = useState(false);
const token = localStorage.getItem('token');
async function beginSync() {
setProcessing(true);
await axios
.get("/sync/beingSync", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
async function createBackup() {
setProcessing(true);
await axios
.get("/data/backup", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
const handleClick = () => {
beginSync();
}
return (
<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>
);
}

View File

@@ -1,35 +1,18 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useState } from 'react';
import '../../css/websocket/websocket.css';
const TerminalComponent = () => {
const [messages, setMessages] = useState([]);
const messagesEndRef = useRef(null);
function TerminalComponent(props){
const [messages] = useState(props.data);
useEffect(() => {
// create a new WebSocket connection
const socket = new WebSocket(`ws://${window.location.hostname+':'+(process.env.WS_PORT || 3004)}/ws`);
// handle incoming messages
socket.addEventListener('message', (event) => {
let message = JSON.parse(event.data);
setMessages(message);
});
// cleanup function to close the WebSocket connection when the component unmounts
return () => {
socket.close();
}
}, []);
return (
<div className='my-4'>
<div className="console-container">
{messages.map((message, index) => (
{messages && messages.map((message, index) => (
<div key={index} className="console-message">
<pre style={{color: message.color || 'white'}} className="console-text">{message.Message}</pre>
</div>
))}
<div ref={messagesEndRef}></div>
</div>
</div>
);

View File

@@ -1,42 +1,27 @@
import React, { useState,useEffect } from "react";
import axios from "axios";
import { DropdownButton, Dropdown, Button } from 'react-bootstrap';
import {Form, DropdownButton, Dropdown,ButtonGroup, Button } from 'react-bootstrap';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Alert from "react-bootstrap/Alert";
import "../../css/settings/backups.css";
import { Table } from "react-bootstrap";
export default function BackupFiles() {
const [files, setFiles] = useState([]);
const [showAlert, setshowAlert] = useState({visible:false,type:'danger',title:'Error',message:''});
const token = localStorage.getItem('token');
useEffect(() => {
const fetchData = async () => {
try {
const backupFiles = await axios.get(`/data/files`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setFiles(backupFiles.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [files,token]);
const token = localStorage.getItem('token');
function Row(file) {
const { data } = file;
async function downloadBackup(filename) {
const url=`/data/files/${filename}`;
@@ -69,10 +54,10 @@ export default function BackupFiles() {
},
})
.then((response) => {
setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
})
.catch((error) => {
setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
});
@@ -88,10 +73,10 @@ export default function BackupFiles() {
},
})
.then((response) => {
setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
})
.catch((error) => {
setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
});
@@ -125,6 +110,9 @@ export default function BackupFiles() {
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const options = {
day: "numeric",
month: "numeric",
@@ -132,13 +120,107 @@ export default function BackupFiles() {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
hour12: twelve_hr,
};
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
<TableCell>{data.name}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(data.datecreated))}</TableCell>
<TableCell>{formatFileSize(data.size)}</TableCell>
<TableCell className="">
<div className="d-flex justify-content-center">
<DropdownButton title="Actions" variant="outline-primary">
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(data.name)}>Download</Dropdown.Item>
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(data.name)}>Restore</Dropdown.Item>
<Dropdown.Divider ></Dropdown.Divider>
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(data.name)}>Delete</Dropdown.Item>
</DropdownButton>
</div>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default function BackupFiles() {
const [files, setFiles] = useState([]);
const [showAlert, setshowAlert] = useState({visible:false,type:'danger',title:'Error',message:''});
const [rowsPerPage] = React.useState(10);
const [page, setPage] = React.useState(0);
const [progress, setProgress] = useState(0);
function handleCloseAlert() {
setshowAlert({visible:false});
}
const uploadFile = (file, onUploadProgress) => {
const formData = new FormData();
formData.append("file", file);
return axios.post("/data/upload", formData, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
onUploadProgress,
});
};
const handleFileSelect = (event) => {
setProgress(0);
if (event.target.files[0]) {
uploadFile(event.target.files[0], (progressEvent) => {
setProgress(Math.round((progressEvent.loaded / progressEvent.total) * 100));
});
}
};
useEffect(() => {
const fetchData = async () => {
try {
const backupFiles = await axios.get(`/data/files`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setFiles(backupFiles.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [files]);
const handleNextPageClick = () => {
setPage((prevPage) => prevPage + 1);
};
const handlePreviousPageClick = () => {
setPage((prevPage) => prevPage - 1);
};
return (
<div>
<h1 className="my-2">Backups</h1>
@@ -150,37 +232,60 @@ export default function BackupFiles() {
</p>
</Alert>
)}
<Table>
<thead>
<tr>
<th>File Name</th>
<th>Date Created</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{files &&
files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).map((file, index) => (
<tr key={index}>
<td>{file.name}</td>
<td>{Intl.DateTimeFormat('en-UK', options).format(new Date(file.datecreated))}</td>
<td>{formatFileSize(file.size)}</td>
<td>
<DropdownButton title="Actions" variant="outline-primary">
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(file.name)}>Download</Dropdown.Item>
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(file.name)}>Restore</Dropdown.Item>
<Dropdown.Divider ></Dropdown.Divider>
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(file.name)}>Delete</Dropdown.Item>
</DropdownButton>
</td>
</tr>
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>
<TableCell>File Name</TableCell>
<TableCell>Date Created</TableCell>
<TableCell>Size</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{files && files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((file,index) => (
<Row key={index} data={file} />
))}
{files.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"gray"}}>No Backups Found</td></tr> :''}
</tbody>
{files.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Backups Found</td></tr> :''}
<TableRow>
<TableCell colSpan="5">
<Form.Group controlId="formFile" onChange={handleFileSelect} className="mx-2">
<Form.Control type="file" accept=".json" className="upload-file" style={{ backgroundColor:"rgb(90 45 165)", borderColor: "rgb(90 45 165)"}}/>
<progress className="w-100" value={progress} max="100" />
</Form.Group>
</TableCell>
</TableRow>
</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),files.length)} of ${files.length}`}</div>
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}>
Next
</Button>
<Button className="page-btn" onClick={()=>setPage(Math.ceil(files.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}>
Last
</Button>
</ButtonGroup>
</div>
</div>
);

View File

@@ -0,0 +1,215 @@
import React, { useEffect } from "react";
import axios from "axios";
import {ButtonGroup, Button } from 'react-bootstrap';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon';
import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon';
import "../../css/settings/backups.css";
import TerminalComponent from "./TerminalComponent";
const token = localStorage.getItem('token');
function Row(logs) {
const { data } = logs;
const [open, setOpen] = React.useState(false);
const twelve_hr = JSON.parse(localStorage.getItem('12hr'));
const options = {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: twelve_hr,
};
function formatDurationTime(seconds) {
if(seconds==='0')
{
return '0 second';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
let timeString = '';
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? 'hr' : 'hrs'} `;
}
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? 'min' : 'mins'} `;
}
if (remainingSeconds > 0) {
timeString += `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`;
}
return timeString.trim();
}
return (
<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>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useState,useEffect } from "react";
import axios from "axios";
import Form from 'react-bootstrap/Form';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import Alert from 'react-bootstrap/Alert';
import ToggleButton from 'react-bootstrap/ToggleButton';
import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup';
import CryptoJS from 'crypto-js';
import EyeFillIcon from 'remixicon-react/EyeFillIcon';
import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon';
import Config from "../../../lib/config";
import "../../css/settings/settings.css";
import { InputGroup } from "react-bootstrap";
export default function SettingsConfig() {
const [use_password, setuse_password] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [formValues, setFormValues] = useState({});
const [isSubmitted, setisSubmitted] = useState("");
const [submissionMessage, setsubmissionMessage] = useState("");
const token = localStorage.getItem('token');
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setuse_password(newConfig.requireLogin);
} catch (error) {
console.log(error);
}
};
fetchConfig();
const intervalId = setInterval(fetchConfig, 60000 * 5);
return () => clearInterval(intervalId);
}, []);
async function updatePassword(_current_password, _new_password) {
const result = await axios
.post("/api/updatePassword", {
current_password:_current_password,
new_password: _new_password
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.catch((error) => {
// let errorMessage= `Error : ${error}`;
});
let data=result.data;
return { isValid:data.isValid, errorMessage:data.errorMessage} ;
}
async function setRequireLogin(requireLogin) {
await axios
.post("/api/setRequireLogin", {
REQUIRE_LOGIN:requireLogin
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((data)=>
{
setuse_password(requireLogin);
}
)
.catch((error) => {
// let errorMessage= `Error : ${error}`;
});
}
async function handleFormSubmit(event) {
event.preventDefault();
setisSubmitted("");
if(!formValues.JS_PASSWORD || formValues.JS_PASSWORD.length<6)
{
setisSubmitted("Failed");
setsubmissionMessage("Unable to update password: New Password Must be at least 6 characters long");
return;
}
let hashedOldPassword= CryptoJS.SHA3(formValues.JS_C_PASSWORD).toString();
let hashedNewPassword= CryptoJS.SHA3(formValues.JS_PASSWORD).toString();
let result = await updatePassword(
hashedOldPassword,
hashedNewPassword
);
if (result.isValid) {
setisSubmitted("Success");
setsubmissionMessage("Successfully updated password");
return;
}else
{
setisSubmitted("Failed");
setsubmissionMessage("Unable to update password: "+ result.errorMessage);
return;
}
}
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
function togglePasswordRequired(isRequired){
// console.log(isRequired);
setRequireLogin(isRequired);
};
return (
<div>
<h1>Security</h1>
<Form onSubmit={handleFormSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3" >
<Form.Label column className="">
Current Password
</Form.Label>
<Col sm="10">
<InputGroup>
<Form.Control id="JS_C_PASSWORD" name="JS_C_PASSWORD" value={formValues.JS_C_PASSWORD || ""} onChange={handleFormChange} type={showCurrentPassword ? "text" : "password"}/>
<Button variant="outline-primary" type="button" onClick={() => setShowCurrentPassword(!showCurrentPassword)}>{showCurrentPassword?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
</InputGroup>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" >
<Form.Label column className="">
New Password
</Form.Label>
<Col sm="10">
<InputGroup>
<Form.Control id="JS_PASSWORD" name="JS_PASSWORD" value={formValues.JS_PASSWORD || ""} onChange={handleFormChange} type={showPassword ? "text" : "password"} />
<Button variant="outline-primary" type="button" onClick={() => setShowPassword(!showPassword)}>{showPassword?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
</InputGroup>
</Col>
</Form.Group>
{isSubmitted !== "" ? (
isSubmitted === "Failed" ?
<Alert variant="danger">
{submissionMessage}
</Alert>
:
<Alert variant="success" >
{submissionMessage}
</Alert>
) : (
<></>
)}
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
<Button variant="outline-success" type="submit"> Update </Button>
</div>
</Form>
<Form className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">Require Login</Form.Label>
<Col >
<ToggleButtonGroup type="checkbox" className="d-flex" >
<ToggleButton variant="outline-primary" active={use_password} onClick={()=> {togglePasswordRequired(true);}}>Yes</ToggleButton>
<ToggleButton variant="outline-primary" active={!use_password} onClick={()=>{togglePasswordRequired(false);}}>No</ToggleButton>
</ToggleButtonGroup>
</Col>
</Form.Group>
</Form>
</div>
);
}

View File

@@ -7,11 +7,16 @@ import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import Alert from 'react-bootstrap/Alert';
import ToggleButton from 'react-bootstrap/ToggleButton';
import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup';
import EyeFillIcon from 'remixicon-react/EyeFillIcon';
import EyeOffFillIcon from 'remixicon-react/EyeOffFillIcon';
import "../../css/settings/settings.css";
import { ButtonGroup } from "react-bootstrap";
import { InputGroup } from "react-bootstrap";
export default function SettingsConfig() {
const [config, setConfig] = useState(null);
@@ -20,11 +25,23 @@ export default function SettingsConfig() {
const [isSubmitted, setisSubmitted] = useState("");
const [loadSate, setloadSate] = useState("Loading");
const [submissionMessage, setsubmissionMessage] = useState("");
const token = localStorage.getItem('token');
const [twelve_hr, set12hr] = useState(localStorage.getItem('12hr') === 'true');
const storage_12hr = localStorage.getItem('12hr');
if(storage_12hr===null)
{
localStorage.setItem('12hr',false);
set12hr(false);
}else if(twelve_hr===null){
set12hr(Boolean(storage_12hr));
}
useEffect(() => {
Config()
.then((config) => {
setFormValues({ JF_HOST: config.hostUrl, JF_API_KEY: config.apiKey });
setFormValues({ JF_HOST: config.hostUrl });
setConfig(config);
setloadSate("Loaded");
})
@@ -38,37 +55,23 @@ export default function SettingsConfig() {
}, []);
async function validateSettings(_url, _apikey) {
let isValid = false;
let errorMessage = "";
await axios
.get(_url + "/system/configuration", {
const result = await axios
.post("/api/validateSettings", {
url:_url,
apikey: _apikey
}, {
headers: {
"X-MediaBrowser-Token": _apikey,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
isValid = true;
}
})
.catch((error) => {
// console.log(error.code);
if (error.code === "ERR_NETWORK") {
isValid = false;
errorMessage = `Error : Unable to connect to Jellyfin Server`;
} else if (error.response.status === 401) {
isValid = false;
errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`;
} else if (error.response.status === 404) {
isValid = false;
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
} else {
isValid = false;
errorMessage = `Error : ${error.response.status}`;
}
// let errorMessage= `Error : ${error}`;
});
return { isValid: isValid, errorMessage: errorMessage };
let data=result.data;
return { isValid:data.isValid, errorMessage:data.errorMessage} ;
}
async function handleFormSubmit(event) {
@@ -77,7 +80,7 @@ export default function SettingsConfig() {
formValues.JF_HOST,
formValues.JF_API_KEY
);
console.log(validation);
if (!validation.isValid) {
setisSubmitted("Failed");
setsubmissionMessage(validation.errorMessage);
@@ -116,27 +119,36 @@ export default function SettingsConfig() {
}
function toggle12Hr(is_12_hr){
set12hr(is_12_hr);
localStorage.setItem('12hr',is_12_hr);
};
return (
<div className="general-settings-page">
<h1>General Settings</h1>
<div>
<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"} />
<InputGroup>
<Form.Control id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ""} onChange={handleFormChange} type={showKey ? "text" : "password"} />
<Button variant="outline-primary" type="button" onClick={() => setKeyState(!showKey)}>{showKey?<EyeFillIcon/>:<EyeOffFillIcon/>}</Button>
</InputGroup>
</Col>
</Form.Group>
{isSubmitted !== "" ? (
@@ -152,14 +164,28 @@ export default function SettingsConfig() {
) : (
<></>
)}
<div className="d-flex flex-column flex-sm-row justify-content-end align-items-sm-center">
<ButtonGroup >
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
<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>
);

View File

@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import Card from 'react-bootstrap/Card';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Tooltip from "@mui/material/Tooltip";
function ItemStatComponent(props) {
const [loaded, setLoaded] = useState(false);
@@ -12,16 +13,16 @@ function ItemStatComponent(props) {
setLoaded(true);
}
const backgroundImage=`/proxy/Items/Images/Backdrop?id=${props.data[0].Id}&fillWidth=300&quality=10`;
const cardStyle = {
backgroundImage: `url(${props.base_url}/Items/${props.data[0].Id}/Images/Backdrop/?fillWidth=300&quality=10), linear-gradient(to right, #00A4DC, #AA5CC3)`,
backgroundImage: `url(${backgroundImage}), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
};
const cardBgStyle = {
backdropFilter: 'blur(5px)',
backdropFilter: props.base_url ? 'blur(5px)' : 'blur(0px)',
backgroundColor: 'rgb(0, 0, 0, 0.6)',
height:'100%',
};
@@ -43,14 +44,14 @@ function ItemStatComponent(props) {
</div>
:
<>
{!loaded && (
{props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && !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={props.base_url + "/Items/" + props.data[0].Id + "/Images/Primary?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)}
@@ -59,9 +60,9 @@ function ItemStatComponent(props) {
}
</Col>
<Col className="stat-card-info w-100">
<Col className="w-100">
<Card.Body className="w-100" >
<Card.Header className="d-flex justify-content-between border-0 p-0 bg-transparent stat-header">
<Card.Header className="d-flex justify-content-between border-0 p-0 bg-transparent">
<div>
<Card.Subtitle className="stat-items">{props.heading}</Card.Subtitle>
</div>
@@ -76,21 +77,31 @@ function ItemStatComponent(props) {
<div className="d-flex justify-content-between" key={item.Id || index}>
<Card.Text className="stat-item-index m-0">{index + 1}</Card.Text>
{item.UserId ?
<Link to={`/users/${item.UserId}`}>
<Card.Text>{item.Name}</Card.Text>
<Link to={`/users/${item.UserId}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
</Tooltip>
</Link>
:
!item.Client && !props.icon ?
<Link to={`/item/${item.Id}`}>
<Card.Text>{item.Name}</Card.Text>
<Link to={`/libraries/item/${item.Id}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
</Tooltip>
</Link>
:
!item.Client && props.icon ?
<Link to={`/libraries/${item.Id}`}>
<Card.Text>{item.Name}</Card.Text>
<Link to={`/libraries/${item.Id}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
</Tooltip>
</Link>
:
<Card.Text>{item.Client}</Card.Text>
<Tooltip title={item.Client} >
<Card.Text>{item.Client}</Card.Text>
</Tooltip>
}
</div>

View File

@@ -10,6 +10,7 @@ function MostActiveUsers(props) {
const [data, setData] = useState();
const [days, setDays] = useState(30);
const [config, setConfig] = useState(null);
const [loaded, setLoaded]= useState(true);
useEffect(() => {
@@ -64,19 +65,23 @@ function MostActiveUsers(props) {
}, [data, config, days,props.days]);
// const handleImageError = () => {
// setImgError(true);
// };
if (!data || data.length === 0) {
return <></>;
}
const UserImage = () => {
return (
<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 base_url={config.hostUrl} 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"}/>
);
}

View File

@@ -6,6 +6,8 @@ import ItemStatComponent from "./ItemStatComponent";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
function MVLibraries(props) {
const [data, setData] = useState();
@@ -51,9 +53,14 @@ function MVLibraries(props) {
return <></>;
}
const SeriesIcon=<TvLineIcon size={"100%"} /> ;
const MovieIcon=<FilmLineIcon size={"100%"} /> ;
const MusicIcon=<FileMusicLineIcon size={"100%"} /> ;
const MixedIcon=<CheckboxMultipleBlankLineIcon size={"100%"} /> ;
return (
<ItemStatComponent icon={data[0].CollectionType==="tvshows"? <TvLineIcon color="white" size={'100%'}/> : <FilmLineIcon color="white" size={'100%'}/> } data={data} heading={"MOST VIEWED LIBRARIES"} units={"Plays"}/>
<ItemStatComponent icon={data[0].CollectionType==="tvshows"? SeriesIcon: data[0].CollectionType==="movies"? MovieIcon : data[0].CollectionType==="music"? MusicIcon :MixedIcon} data={data} heading={"MOST VIEWED LIBRARIES"} units={"Plays"}/>
);
}

View File

@@ -1,21 +1,115 @@
import { useParams } from 'react-router-dom';
import React, { useState, useEffect } from "react";
import axios from "axios";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import Config from "../../lib/config";
import {Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
import GlobalStats from './user-info/globalStats';
import UserDetails from './user-info/user-details';
import LastPlayed from './user-info/lastplayed';
import UserActivity from './user-info/user-activity';
import "../css/users/user-details.css";
function UserInfo() {
const { UserId } = useParams();
const [data, setData] = useState();
const [imgError, setImgError] = useState(false);
const [config, setConfig] = useState();
const [activeTab, setActiveTab] = useState('tabOverview');
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
if(config){
try {
const userData = await axios.post(`/stats/getUserDetails`, {
userid: UserId,
}, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(userData.data);
} catch (error) {
console.log(error);
}
}
};
fetchData();
if (!config) {
fetchConfig();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, UserId]);
const handleImageError = () => {
setImgError(true);
};
if (!data || !config) {
return <></>;
}
return (
<div>
<UserDetails UserId={UserId}/>
<div className="user-detail-container">
<div className="user-image-container">
{imgError ? (
<AccountCircleFillIcon size={"100%"} />
) : (
<img
className="user-image"
src={
"/Proxy/Users/Images/Primary?id=" +
UserId+
"&quality=100"
}
onError={handleImageError}
alt=""
></img>
)}
</div>
<div>
<p className="user-name">{data.Name}</p>
<ButtonGroup>
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
</ButtonGroup>
</div>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
<Tab eventKey="tabOverview" className='bg-transparent'>
<GlobalStats UserId={UserId}/>
<LastPlayed UserId={UserId}/>
</Tab>
<Tab eventKey="tabActivity" className='bg-transparent'>
<UserActivity UserId={UserId}/>
</Tab>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ActivityTable from "../activity/activity-table";
function UserActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem('token');
const [itemCount,setItemCount] = useState(10);
useEffect(() => {
const fetchData = async () => {
try {
const itemData = await axios.post(`/api/getUserHistory`, {
userid: props.UserId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [ props.UserId,token]);
if (!data) {
return <></>;
}
return (
<div className="Activity">
<div className="Heading">
<h1>User Activity</h1>
<div className="pagination-range">
<div className="header">Items</div>
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div className="Activity">
<ActivityTable data={data} itemCount={itemCount}/>
</div>
</div>
);
}
export default UserActivity;

20
src/pages/css/about.css Normal file
View 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;
}

View File

@@ -1,99 +1,132 @@
div a
{
text-decoration: none;
color: white;
}
.table-rows:hover
{
background-color: rgba(0, 0, 0, 0.4);
}
.table-rows-content div:hover a
{
color: #00A4DC;
}
@import '../variables.module.css';
.activity-table
{
background-color: rgba(100,100, 100, 0.2);
color: white;
background-color: var(--secondary-background-color);
color: white !important;
}
.table-rows-content{
margin-bottom: 10px;
}
.table-headers div {
background-color: rgba(0, 0, 0, 0.8);
border-bottom: 1px solid transparent;
border-right: 1px solid rgba(255, 255, 255, 0.05);
font-size: 1.2em;
cursor: pointer;
}
.table-headers div:hover {
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
}
.table-headers, .table-rows-content
td,th, td>button
{
display: flex;
justify-content: space-between;
/* border-bottom: 1px solid rgba(255, 255, 255, 0.05); */
color: white !important;
background-color: var(--tertiary-background-color);
}
.table-headers div,
.table-rows-content div
th
{
border-right: 1px solid rgba(255, 255, 255, 0.05);
color: white !important;
background-color: rgba(200, 200, 200, 0.2);
}
.activity-client:hover
{
color: var(--secondary-color) !important;
}
.activity-client:hover > span
{
cursor: pointer;
}
td > a
{
color: white;
}
td:hover > a
{
color: var(--secondary-color);
}
.page-number {
margin-inline: 10px;
color: white;
}
select option {
background-color: var(--secondary-color);
outline: unset;
width: 100%;
padding: 10px;
border: none;
}
.table-headers div:last-child,
.table-rows-content div:last-child
.pagination-range .items
{
border-right: none;
background-color: rgb(255, 255, 255, 0.1);
padding-inline: 10px;
}
.sub-table {
overflow: hidden;
max-height: 0; /* set the height to 0 to collapse the div */
opacity:0;
transition: all 0.3s ease;
.pagination-range .header
{
padding-inline: 10px;
align-self: center;
}
.collapsed {
transition: all 0.3s ease;
opacity: 100;
max-height: min-content;
.pagination-range
{
width: 130px;
height: 35px;
color: white;
display: flex;
background-color: var(--secondary-background-color);
border-radius: 8px;
font-size: 1.2em;
align-self: flex-end;
justify-content: space-between;
}
.sub-row{
color: darkgray;
margin-bottom: 0;
}
.sub-row a{
color: darkgray;
}
.sub-row a:hover{
color: #00A4DC;
}
.sub-row:last-child
.pagination-range select
{
margin-bottom: 50px;
height: 35px;
outline: none;
border: none;
border-radius: 8px;
background-color: rgb(255, 255, 255, 0.1);
color:white;
font-size: 1em;
}
.bg-grey
.page-btn
{
background-color: rgb(100, 100, 100,0.2);
}
background-color: var(--primary-color) !important;
border-color: var(--primary-color)!important;
}
.MuiTableCell-head > .Mui-active, .MuiTableSortLabel-icon
{
color: var(--secondary-color) !important;
}
.MuiTableCell-head :hover
{
color: var(--secondary-color) !important;
}
.modal-header
{
color: white;
border-bottom: none !important;
}
.modal-footer
{
border-top: none !important;
}
.modal-content
{
background-color: var(--secondary-background-color) !important;
}

View File

@@ -0,0 +1,10 @@
@import '../variables.module.css';
.ellipse
{
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,18 +1,29 @@
.error
{
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: 0px;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
/* z-index: 9999; */
background-color: #1e1c22;
transition: opacity 800ms ease-in;
opacity: 1;
color: white;
}
.error .message
{
color:crimson;
font-size: 1.5em;
font-weight: 500;
}
.error-title
{
color:crimson;
font-weight: 500;
}

View File

@@ -1,9 +1,11 @@
@import './variables.module.css';
.global-stats-container
{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 400px));
grid-auto-rows: 120px;
background-color: rgb(100, 100, 100,0.2);
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
font-size: 1.3em;
@@ -31,7 +33,7 @@
.stat-value
{
text-align: right;
color: #00A4DC;
color: var(--secondary-color);
font-weight: 500;
font-size: 1.1em;
margin: 0;

View File

@@ -1,4 +1,5 @@
.Home
{
color: white;
margin-bottom: 20px;
}

View File

@@ -1,17 +1,23 @@
@import '../variables.module.css';
.item-detail-container
{
color:white;
background-color: rgb(100, 100, 100,0.2);
padding: 20px;
background-color: var(--secondary-background-color);
margin: 20px 0;
border-radius: 8px;
}
.item-banner-image
{
margin-right: 20px;
}
.item-name
{
font-size: 2.5em;
font-weight: 500;
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.item-image
{
@@ -20,3 +26,15 @@
object-fit: cover;
}
.item-details div a{
text-decoration: none !important;
color: white !important;
}
.item-details div a:hover{
color: var(--secondary-color) !important;
}
.hide-tab-titles {
display: none !important;
}

View File

@@ -1,17 +1,17 @@
@import './variables.module.css';
.last-played-container {
display: flex;
overflow-x: auto;
background-color: rgb(100, 100, 100,0.2);
overflow-x: scroll;
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
margin-bottom: 20px;
min-height: 300px;
}
.last-played-container::-webkit-scrollbar {
width: 5px; /* set scrollbar width */
}
@@ -42,19 +42,27 @@
width: 150px;
border-radius: 8px;
background-color: var(--background-color);
}
.episode{
width: 220px !important;
height: 128px !important;
}
.episode-card{
width: 220px !important;
}
.last-card-banner {
width: 150px;
height: 224px;
height: 220px;
transition: opacity 0.2s ease-in-out;
}
@@ -75,19 +83,22 @@
}
.last-item-details {
width: 90%;
/* height: 30%; */
position: relative;
/* padding-top: 10px; */
margin: 10px;
/* background-color: #f71b1b; */
}
.last-item-details a{
text-decoration: none !important;
color: white !important;
}
.last-item-details a:hover{
color: var(--secondary-color) !important;
}
.last-item-name {
/* width: 185px; */
overflow: hidden;
text-overflow: ellipsis;
@@ -111,6 +122,6 @@
.last-last-played{
font-size: 0.8em;
margin-bottom: 5px;
color: #00a4dc;
color: var(--secondary-color);
}

View File

@@ -2,13 +2,9 @@
{
color: white;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
grid-gap: 20px;
}
/* .library-banner-image
{
border-radius: 5px;
max-width: 500px;
max-height: 500px;
} */

View File

@@ -1,12 +1,14 @@
@import '../variables.module.css';
.lib-card{
color: white;
max-width: 400px;
/* max-width: 400px; */
}
.card-label
{
color: #00A4DC;
color: var(--secondary-color);
}
.card-row .col
@@ -18,7 +20,9 @@
.library-card-image
{
max-height: 170px;
overflow: hidden;
border-radius: 8px 8px 0px 0px;
}
.library-card-banner
@@ -28,7 +32,7 @@
background-repeat: no-repeat;
background-size: cover;
transition: all 0.2s ease-in-out;
max-height: 170px;
}
@@ -39,7 +43,14 @@
.library-card-details
{
background-color: rgb(100, 100, 100,0.2) !important;
background-color: var(--secondary-background-color) !important;
}
.default_library_image
{
background-color: var(--secondary-background-color);
width: 100%;
height: 170px;
border-radius: 8px 8px 0px 0px;
}

View File

@@ -0,0 +1,48 @@
@import '../variables.module.css';
.media-items-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 150px));
grid-gap: 20px;
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
margin-bottom: 20px;
min-height: 300px;
}
.media-items-container::-webkit-scrollbar {
width: 5px; /* set scrollbar width */
}
.media-items-container::-webkit-scrollbar-track {
background-color: transparent; /* set track color */
}
.media-items-container::-webkit-scrollbar-thumb {
background-color: #8888884d; /* set thumb color */
border-radius: 5px; /* round corners */
width: 5px;
}
.media-items-container::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}
.library-items > div>div> .form-control
{
color: white !important;
background-color: var(--secondary-background-color) !important;
border-color: var(--secondary-background-color) !important;
}
.library-items > div> div>.form-control:focus
{
box-shadow: none !important;
border-color: var(--primary-color) !important;
}

View File

@@ -1,11 +1,12 @@
.overview-container
{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-template-columns: repeat(auto-fit, minmax(auto, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
background-color: var(--secondary-background-color);
border-radius: 8px;
/* margin-right: 20px; */
padding: 20px;
}
.library-stat-card
@@ -84,12 +85,8 @@
.library-banner-image
{
height: 180px;
width: 120px;
width: 120px;
}

View File

@@ -1,32 +1,22 @@
@import './variables.module.css';
.loading {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: 0px;
height: calc(100vh - 100px);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
background-color: #1e1c22;
background-color: var(--background-color);
transition: opacity 800ms ease-in;
opacity: 1;
}
.loading::before
{
opacity: 0;
}
.component-loading {
height: inherit;
width: inherit;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.8);
z-index: 9999;
}
.loading__spinner {
width: 50px;

View File

@@ -1,42 +1,80 @@
@import './variables.module.css';
.navbar {
/* display: flex;
justify-content: flex-end;
align-items: center; */
background-color: #5a2da5;
/* background: linear-gradient(to right, #AA5CC3,#00A4DC); */
/* height: 50px; */
/* position: sticky;
top: 0; */
background-color: var(--secondary-background-color);
border-right: 1px solid #414141 !important;
}
@media (min-width: 768px) {
.navbar {
min-height: 100vh;
border-bottom: 1px solid #414141 !important;
}
}
.navbar .navbar-brand{
margin-top: 20px;
font-size: 32px;
font-weight: 500;
}
.navbar .navbar-nav{
width: 100%;
margin-top: 20px;
}
.logout
{
color: var(--secondary-color) !important;
}
.navbar-toggler > .collapsed
{
right: 0;
}
.navitem {
display: flex;
align-items: center;
height: 100%;
color: white;
font-size: 16px;
font-size: 18px !important;
text-decoration: none;
padding: 0 20px;
margin-right: 4px;
background-color: var(--background-color);
transition: all 0.4s ease-in-out;
border-radius: 8px;
margin-bottom: 10px;
width: 90%;
}
@media (min-width: 768px) {
.navitem {
border-radius: 0 8px 8px 0;
}
}
.navitem:hover {
background-color: var(--primary-color);
}
.active
{
background-color: var(--primary-color);
transition: background-color 0.2s ease-in-out;
}
.nav-link
{
display: flex !important;
}
.nav-text {
margin-left: 10px;
}
.active
{
/* background-color: #308df046 !important; */
background-color: rgba(0,0,0,0.6);
transition: background-color 0.2s ease-in-out;
}
.navitem:hover {
/* background-color: #326aa541; */
background-color: rgba(0,0,0,0.4);
}

View File

@@ -0,0 +1,98 @@
/*based on https://drive.google.com/uc?export=view&id=1yTLwNiCZhIdCWolQldwq4spHQkgZDqkG */
/* Small devices (landscape phones, 576px and up)*/
@media (min-width: 576px) {
.rounded-sm {
border-radius: 8px !important;
}
.rounded-sm-start {
border-radius: 8px 0px 0px 8px !important;
}
.rounded-sm-end {
border-radius: 0px 8px 8px 0px !important;
}
.rounded-sm-top {
border-radius: 8px 8px 0px 0px !important;
}
.rounded-sm-bottom {
border-radius: 0px 0px 8px 8px !important;
}
}
/* Medium devices (tablets, 768px and up)*/
@media (min-width: 768px) {
.rounded-md {
border-radius: 8px !important;
}
.rounded-md-start {
border-radius: 8px 0px 0px 8px !important;
}
.rounded-md-end {
border-radius: 0px 8px 8px 0px !important;
}
.rounded-md-top {
border-radius: 8px 8px 0px 0px !important;
}
.rounded-md-bottom {
border-radius: 0px 0px 8px 8px !important;
}
}
/* Large devices (desktops, 992px and up)*/
@media (min-width: 992px) {
.rounded-lg {
border-radius: 8px !important;
}
.rounded-lg-start {
border-radius: 8px 0px 0px 8px !important;
}
.rounded-lg-end {
border-radius: 0px 8px 8px 0px !important;
}
.rounded-lg-top {
border-radius: 8px 8px 0px 0px !important;
}
.rounded-lg-bottom {
border-radius: 0px 0px 8px 8px !important;
}
}
/* Extra large devices (large desktops, 1200px and up)*/
@media (min-width: 1200px) {
.rounded-xl {
border-radius: 8px !important;
}
.rounded-xl-start {
border-radius: 8px 0px 0px 8px !important;
}
.rounded-xl-end {
border-radius: 0px 8px 8px 0px !important;
}
.rounded-xl-top {
border-radius: 8px 8px 0px 0px !important;
}
.rounded-xl-bottom {
border-radius: 0px 0px 8px 8px !important;
}
}

View File

@@ -18,15 +18,12 @@
display: flex;
flex-direction: column;
/* background-color: grey; */
box-shadow: 0 0 20px rgba(255, 255, 255, 0.05);
height: 320px;
width: 185px;
border-radius: 8px;
/* Add a third row that takes up remaining space */
}
@@ -46,9 +43,7 @@
width: 100%;
height: 30%;
position: relative;
/* margin: 8px; */
/* background-color: #f71b1b; */
}
.recent-card-item-name {
@@ -58,10 +53,6 @@
position: absolute;
margin: 0;
/*
position: absolute; */
}
.recent-card-last-played{

View File

@@ -1,25 +1,30 @@
@import './variables.module.css';
.sessions-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-template-columns: repeat(auto-fit, minmax(auto, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
/* margin-right: 20px; */
background-color: var(--secondary-background-color);
border-radius: 8px;
padding: 20px;
}
.truncate-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 10ch;
}
.session-card {
display: flex;
color: white;
background-color: grey;
/* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */
max-height: 180px;
max-width: 500px;
/* margin-left: 20px; */
/* margin-bottom: 10px; */
background-size: cover;
border-radius: 8px 8px 0px 8px;
@@ -32,8 +37,6 @@
.progress-bar {
/* grid-row: 2 / 3;
grid-column: 1/3; */
height: 5px;
background-color: #101010 !important;
border-radius: 0px 0px 8px 8px;
@@ -43,7 +46,7 @@
.progress-custom {
height: 100%;
background-color: #00A4DC;
background-color: var(--secondary-color);
transition: width 0.2s ease-in-out;
border-radius: 0px 0px 0px 8px;
}
@@ -73,7 +76,6 @@
.card-banner-image {
border-radius: 8px 0px 0px 0px;
max-height: inherit;
/* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */
}
.card-user {
@@ -88,17 +90,15 @@
}
.card-user-image {
.session-card-user-image {
border-radius: 50%;
width: 50px;
top: 4vh;
grid-row: 1 / span 2;
max-width: 50px;
}
.card-user-image-default
{
/* width: 50px !important; */
font-size: large;
}
@@ -125,7 +125,6 @@
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
/* grid-column-gap: 10px; */
}
.card-device-name {
@@ -135,10 +134,10 @@
}
.card-device-image {
max-width: 35px;
max-width: 40px;
margin-top:5px;
width: 100%;
/* margin-right: 5px; */
/* grid-row: 1 / span 2; */
height: min-content;
}
.card-client {
@@ -161,9 +160,6 @@
.card-playback-position {
bottom: 5px;
/* right: 5px; */
/* text-align: right; */
/* position: absolute; */
}
.device-info {
@@ -173,4 +169,36 @@ margin-bottom: 100%;
.card-ip {
grid-row: 2 / 3;
grid-column: 2 / 3;
}
}
.card-text >a{
text-decoration: none !important;
color: white !important;
}
.card-text a:hover{
color: var(--secondary-color) !important;
}
.ellipse
{
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 350px) {
.card-device-image {
display: none;
}
.card-client-version, .session-card-user-image
{
display: none !important;
}
}

View File

@@ -1,3 +1,4 @@
@import '../variables.module.css';
tr{
color: white;
}
@@ -9,7 +10,6 @@ th:hover{
th{
border-bottom: none !important;
cursor: default !important;
background-color: rgba(0, 0, 0, 0.8) !important;
}
.backup-file-download
@@ -19,4 +19,16 @@ th{
td{
border-bottom: none !important;
}
}
.upload-file
{
background-color: var(--secondary-background-color) !important;
border-color: var(--secondary-background-color) !important;
color: white !important;
}
.upload-file:focus
{
box-shadow: none !important;
}

View File

@@ -1,29 +1,32 @@
@import '../variables.module.css';
.show-key
{
margin-bottom: 20px;;
}
.settings{
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
}
.tasks {
color: white;
/* margin-inline: 10px; */
}
.settings-form {
color: white;
/* width: 100%; */
margin-top: 20px;
margin-inline: 10px;
}
.settings-submit-button
{
background-color: #2196f3;
color: white;
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
transition: all 0.3s ease-in-out;
margin-bottom: 10px;
}
.form-row
{
@@ -46,20 +49,6 @@
max-width: 700px;
}
/* .settings-form button {
background-color: #2196f3;
color: white;
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
transition: all 0.3s ease-in-out;
margin-bottom: 10px;
} */
/* .settings-form button:hover {
background-color: #0d8bf2;
} */
.submit
{
@@ -67,22 +56,26 @@
margin-bottom: 5px;
}
.error
.settings-form > div> div> .form-control,
.settings-form > div> div> .input-group> .form-control
{
color: #cc0000;
color: white !important;
background-color: var(--background-color) !important;
border-color: var(--background-color) !important;
}
.success
.settings-form > div> div> .input-group> .btn
{
color: #4BB543;
border: none !important;
}
.critical
.settings-form > div> div> .form-control:focus,
.settings-form > div> div> .input-group> .form-control:focus
{
display: flex;
justify-content: center;
align-items: center;
color: #cc0000;
height: 90vh;
box-shadow: none !important;
border-color: var(--primary-color) !important;
}

View 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;
}

View File

@@ -1,10 +1,17 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500&display=swap');
@import './variables.module.css';
/* *{
margin: 0;
padding: 0;
font-family: 'poppins',sans-serif;
} */
.login-show-password
{
background-color: transparent !important;
border: 0 !important;
}
section{
display: flex;
@@ -12,8 +19,6 @@ section{
align-items: center;
min-height: 100vh;
width: 100%;
/* background: url('background6.jpg')no-repeat; */
background-position: center;
background-size: cover;
}
@@ -41,36 +46,47 @@ h2{
width: 310px;
border-bottom: 2px solid #fff;
}
.inputbox label{
.inputbox .form-label{
position: absolute;
top: 50%;
left: 5px;
transform: translateY(-50%);
color: #fff;
color: #fff !important;
font-size: 1em;
pointer-events: none;
transition: .2s;
}
input:focus ~ label,
input:valid ~ label{
top: -15px;
.inputbox input:focus ~ .form-label,
.inputbox input:not(:placeholder-shown) ~ .form-label
{
top: -10px;
}
.inputbox input:hover {
background: transparent !important;
box-shadow: none !important;
}
.inputbox input:focus {
background: transparent !important;
box-shadow: none !important;
color: #fff;
}
.inputbox input {
width: 100%;
height: 50px;
background: transparent;
border: none;
outline: none;
font-size: 1em;
color: #fff;
}
.inputbox ion-icon{
position: absolute;
right: 8px;
color: #fff;
font-size: 1.2em;
top: 20px;
}
.forget{
margin: -15px 0 15px ;
font-size: .9em;
@@ -91,15 +107,27 @@ top: -15px;
text-decoration: underline;
}
.setup-button{
color: white !important;
width: 100%;
height: 40px;
border-radius: 40px;
background: #fff;
border: none;
outline: none;
cursor: pointer;
font-size: 1em;
font-weight: 600;
border-radius: 40px !important;
background: var(--primary-color) !important;
border: none !important;
outline: none !important;
font-size: 1em !important;
font-weight: 600 !important;
}
.setup-button:hover{
color: black !important;
width: 100%;
height: 40px;
border-radius: 40px !important;
background: var(--secondary-color) !important;
border: none !important;
outline: none !important;
font-size: 1em !important;
font-weight: 600 !important;
}
.register{
font-size: .9em;

View File

@@ -1,18 +1,20 @@
@import './variables.module.css';
.grid-stat-cards
{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-template-columns: repeat(auto-fit, minmax(auto, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
/* margin-right: 20px; */
margin-top: 8px;
background-color: var(--secondary-background-color);
border-radius: 8px;
padding: 20px;
}
.stat-card{
border: 0 !important;
background-color: rgba(100 100 100 / 0.1) !important;
background-color: var(--background-color)!important;
color: white;
max-width: 500px;
max-height: 180px;
border-radius: 8px !important;
}
.stat-card-banner
@@ -22,6 +24,8 @@
.stat-card-image {
width: 120px !important;
height: 180px;
@@ -30,7 +34,12 @@
.stat-card-icon
{
width: 120px !important;
height: 180px;
position: relative;
top: 50%;
left: 65%;
transform: translate(-50%, -50%);
}
@@ -44,9 +53,8 @@
color: grey;
}
.stat-item-count {
/* width: 10%; */
text-align: right;
color: #00A4DC;
color: var(--secondary-color);
font-weight: 500;
font-size: 1.1em;
@@ -70,7 +78,7 @@
height: 35px;
color: white;
display: flex;
background-color: rgb(100, 100, 100,0.3);
background-color: var(--secondary-background-color);
border-radius: 8px;
font-size: 1.2em;
align-self: flex-end;
@@ -112,4 +120,13 @@ input[type=number] {
{
padding-inline: 10px;
align-self: center;
}
}
.stat-items div a{
text-decoration: none !important;
color: white !important;
}
.stat-items div a:hover{
color: var(--secondary-color) !important;
}

Some files were not shown because too many files have changed in this diff Show More