mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-03-18 21:30:35 +01:00
v1.0.4.10 Beta
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/backend/backup-data
|
||||
.vscode
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
10
README.md
10
README.md
@@ -20,7 +20,14 @@
|
||||
- Jellyfin Statistics Plugin Integration
|
||||
- More to come
|
||||
|
||||
## Getting Started
|
||||
## Getting Started with Development
|
||||
- Clone the project from git
|
||||
- set your env variables before strating the server (Variable names as per the docker compose file).
|
||||
- Run `npm init` to install necessary packages
|
||||
- Run `npm run start-server` to only run the backend nodejs server
|
||||
- Run `npm run start` to only run the frontend React UI
|
||||
- Run `npm run start-app` to run both backend and frontend at the same time
|
||||
|
||||
|
||||
### Launching Jellystat using Docker
|
||||
|
||||
@@ -37,6 +44,7 @@ https://hub.docker.com/r/cyfershepard/jellystat
|
||||
## Support
|
||||
|
||||
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/CyferShepard/Jellystat/issues).
|
||||
- Join us in our [Discord](https://discord.gg/9SMBj2RyEe)
|
||||
|
||||
## API Documentation
|
||||
|
||||
|
||||
484
backend/api.js
484
backend/api.js
@@ -1,8 +1,20 @@
|
||||
// api.js
|
||||
const express = require("express");
|
||||
const axios = require("axios");
|
||||
const ActivityMonitor=require('./watchdog/ActivityMonitor');
|
||||
const ActivityMonitor=require('./tasks/ActivityMonitor');
|
||||
const db = require("./db");
|
||||
const https = require('https');
|
||||
const { checkForUpdates } = require('./version-control');
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
|
||||
});
|
||||
|
||||
|
||||
|
||||
const axios_instance = axios.create({
|
||||
httpsAgent: agent
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,7 +25,7 @@ router.get("/test", async (req, res) => {
|
||||
|
||||
router.get("/getconfig", async (req, res) => {
|
||||
try{
|
||||
const { rows } = await db.query('SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1');
|
||||
const { rows } = await db.query('SELECT "JF_HOST","APP_USER","REQUIRE_LOGIN" FROM app_config where "ID"=1');
|
||||
res.send(rows);
|
||||
|
||||
}catch(error)
|
||||
@@ -50,6 +62,46 @@ router.post("/setconfig", async (req, res) => {
|
||||
console.log(`ENDPOINT CALLED: /setconfig: `);
|
||||
});
|
||||
|
||||
router.post("/setRequireLogin", async (req, res) => {
|
||||
try{
|
||||
const { REQUIRE_LOGIN } = req.body;
|
||||
|
||||
if(REQUIRE_LOGIN===undefined)
|
||||
{
|
||||
res.status(503);
|
||||
res.send(rows);
|
||||
}
|
||||
|
||||
let query='UPDATE app_config SET "REQUIRE_LOGIN"=$1 where "ID"=1';
|
||||
|
||||
console.log(`ENDPOINT CALLED: /setRequireLogin: `+REQUIRE_LOGIN);
|
||||
|
||||
const { rows } = await db.query(
|
||||
query,
|
||||
[REQUIRE_LOGIN]
|
||||
);
|
||||
res.send(rows);
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
router.get("/CheckForUpdates", async (req, res) => {
|
||||
try{
|
||||
|
||||
let result=await checkForUpdates();
|
||||
res.send(result);
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
router.get("/getLibraries", async (req, res) => {
|
||||
try{
|
||||
@@ -69,12 +121,11 @@ router.get("/getLibraries", async (req, res) => {
|
||||
|
||||
router.post("/getLibraryItems", async (req, res) => {
|
||||
try{
|
||||
const Id = req.headers['id'];
|
||||
|
||||
const {libraryid} = req.body;
|
||||
console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid);
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM jf_library_items where "ParentId"='${Id}'`
|
||||
`SELECT * FROM jf_library_items where "ParentId"='${libraryid}'`
|
||||
);
|
||||
console.log({ Id: Id });
|
||||
res.send(rows);
|
||||
|
||||
|
||||
@@ -83,7 +134,7 @@ router.post("/getLibraryItems", async (req, res) => {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
console.log(`ENDPOINT CALLED: /getLibraryItems: `);
|
||||
|
||||
});
|
||||
|
||||
router.post("/getSeasons", async (req, res) => {
|
||||
@@ -155,7 +206,14 @@ router.post("/getItemDetails", async (req, res) => {
|
||||
query
|
||||
);
|
||||
|
||||
if(episodes.length!==0)
|
||||
{
|
||||
res.send(episodes);
|
||||
}else
|
||||
{
|
||||
res.status(404).send('Item not found');
|
||||
}
|
||||
|
||||
|
||||
}else{
|
||||
|
||||
@@ -199,9 +257,18 @@ router.get("/getHistory", async (req, res) => {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
// Update GroupedResults with playbackDurationSum
|
||||
Object.values(groupedResults).forEach(row => {
|
||||
if (row.results && row.results.length > 0) {
|
||||
row.PlaybackDuration = row.results.reduce((acc, item) => acc + parseInt(item.PlaybackDuration), 0);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
|
||||
|
||||
@@ -212,11 +279,101 @@ router.get("/getHistory", async (req, res) => {
|
||||
|
||||
});
|
||||
|
||||
router.post("/getLibraryHistory", async (req, res) => {
|
||||
try {
|
||||
const { libraryid } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`select a.* from jf_playback_activity a join jf_library_items i on i."Id"=a."NowPlayingItemId" where i."ParentId"='${libraryid}' order by "ActivityDateInserted" desc`
|
||||
);
|
||||
const groupedResults = {};
|
||||
rows.forEach(row => {
|
||||
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
} else {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post("/getItemHistory", async (req, res) => {
|
||||
try {
|
||||
const { itemid } = req.body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`select jf_playback_activity.*
|
||||
from jf_playback_activity jf_playback_activity
|
||||
where
|
||||
("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}');`
|
||||
);
|
||||
|
||||
|
||||
|
||||
const groupedResults = rows.map(item => ({
|
||||
...item,
|
||||
results: []
|
||||
}));
|
||||
|
||||
|
||||
|
||||
res.send(groupedResults);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/getUserHistory", async (req, res) => {
|
||||
try {
|
||||
const { userid } = req.body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`select jf_playback_activity.*
|
||||
from jf_playback_activity jf_playback_activity
|
||||
where "UserId"='${userid}';`
|
||||
);
|
||||
|
||||
const groupedResults = {};
|
||||
rows.forEach(row => {
|
||||
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
} else {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
router.get("/getAdminUsers", async (req, res) => {
|
||||
try {
|
||||
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
|
||||
const url = `${config[0].JF_HOST}/Users`;
|
||||
const response = await axios.get(url, {
|
||||
const response = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY,
|
||||
},
|
||||
@@ -227,6 +384,7 @@ router.get("/getAdminUsers", async (req, res) => {
|
||||
res.send(adminUser);
|
||||
} catch (error) {
|
||||
console.log( error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
|
||||
}
|
||||
@@ -248,6 +406,316 @@ router.get("/runWatchdog", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/getSessions", async (req, res) => {
|
||||
try {
|
||||
|
||||
|
||||
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
|
||||
|
||||
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
|
||||
res.status(503);
|
||||
res.send({ error: "Config Details Not Found" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
let url=`${config[0].JF_HOST}/sessions`;
|
||||
|
||||
const response_data = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
res.send(response_data.data);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/validateSettings", async (req, res) => {
|
||||
const { url,apikey } = req.body;
|
||||
|
||||
let isValid = false;
|
||||
let errorMessage = "";
|
||||
try
|
||||
{
|
||||
await axios_instance
|
||||
.get(url + "/system/configuration", {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": apikey,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
isValid = true;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
isValid = false;
|
||||
errorMessage = `Error : Unable to connect to Jellyfin Server`;
|
||||
} else if (error.code === "ECONNREFUSED") {
|
||||
isValid = false;
|
||||
errorMessage = `Error : Unable to connect to Jellyfin Server`;
|
||||
}
|
||||
else if (error.response && error.response.status === 401) {
|
||||
isValid = false;
|
||||
errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`;
|
||||
} else if (error.response && error.response.status === 404) {
|
||||
isValid = false;
|
||||
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
|
||||
} else {
|
||||
isValid = false;
|
||||
errorMessage = `${error}`;
|
||||
}
|
||||
});
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
isValid = false;
|
||||
errorMessage = `Error: ${error}`;
|
||||
}
|
||||
|
||||
|
||||
res.send({isValid:isValid,errorMessage:errorMessage });
|
||||
|
||||
|
||||
});
|
||||
|
||||
router.post("/updatePassword", async (req, res) => {
|
||||
const { current_password,new_password } = req.body;
|
||||
|
||||
let result={isValid:true,errorMessage:""};
|
||||
|
||||
|
||||
try{
|
||||
const { rows } = await db.query(`SELECT "JF_HOST","JF_API_KEY","APP_USER" FROM app_config where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
|
||||
|
||||
if(rows && rows.length>0)
|
||||
{
|
||||
if(current_password===new_password)
|
||||
{
|
||||
result.isValid=false;
|
||||
result.errorMessage = "New Password cannot be the same as Old Password";
|
||||
}else{
|
||||
|
||||
await db.query(`UPDATE app_config SET "APP_PASSWORD"='${new_password}' where "ID"=1 AND "APP_PASSWORD"='${current_password}' `);
|
||||
|
||||
|
||||
}
|
||||
|
||||
}else{
|
||||
result.isValid=false;
|
||||
result.errorMessage = "Old Password is Invalid";
|
||||
}
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
result.errorMessage = error;
|
||||
}
|
||||
|
||||
|
||||
|
||||
res.send(result);
|
||||
|
||||
|
||||
});
|
||||
|
||||
router.post("/getLibraries", async (req, res) => {
|
||||
try {
|
||||
const { itemid } = req.body;
|
||||
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
|
||||
|
||||
let payload=
|
||||
{
|
||||
existing_library_count:0,
|
||||
existing_movie_count:0,
|
||||
existing_music_count:0,
|
||||
existing_show_count:0,
|
||||
existing_season_count:0,
|
||||
existing_episode_count:0,
|
||||
api_library_count:0,
|
||||
api_movie_count:0,
|
||||
api_music_count:0,
|
||||
api_show_count:0,
|
||||
api_season_count:0,
|
||||
api_episode_count:0,
|
||||
missing_api_library_data:{},
|
||||
missing_api_music_data:{},
|
||||
missing_api_movies_data:{},
|
||||
missing_api_shows_data:{},
|
||||
missing_api_season_data:{},
|
||||
missing_api_episode_data:{},
|
||||
raw_library_data:{},
|
||||
raw_item_data:{},
|
||||
raw_season_data:{},
|
||||
raw_episode_data:{},
|
||||
count_from_api:{},
|
||||
};
|
||||
|
||||
/////////////////////////Get Admin
|
||||
|
||||
const adminurl = `${config[0].JF_HOST}/Users`;
|
||||
const response = await axios_instance.get(adminurl, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY,
|
||||
},
|
||||
});
|
||||
const adminUser = await response.data.filter(
|
||||
(user) => user.Policy.IsAdministrator === true
|
||||
);
|
||||
|
||||
////////////////////////
|
||||
const db_libraries=await db.query('SELECT "Id" FROM jf_libraries').then((res) => res.rows.map((row) => row.Id));
|
||||
const db_music=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Audio'`).then((res) => res.rows.map((row) => row.Id));
|
||||
const db_movies=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Movie'`).then((res) => res.rows.map((row) => row.Id));
|
||||
const db_shows=await db.query(`SELECT "Id" FROM jf_library_items where "Type"='Series'`).then((res) => res.rows.map((row) => row.Id));
|
||||
const db_seasons=await db.query('SELECT "Id" FROM jf_library_seasons').then((res) => res.rows.map((row) => row.Id));
|
||||
const db_episodes=await db.query('SELECT "EpisodeId" FROM jf_library_episodes').then((res) => res.rows.map((row) => row.EpisodeId));
|
||||
|
||||
let count_url=`${config[0].JF_HOST}/items/counts`;
|
||||
|
||||
const response_api_count = await axios_instance.get(count_url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
payload.count_from_api=response_api_count.data;
|
||||
//get libraries
|
||||
let url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items`;
|
||||
|
||||
const response_data = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
let libraries=response_data.data.Items;
|
||||
let raw_library_data=response_data.data;
|
||||
|
||||
payload.raw_library_data=raw_library_data;
|
||||
|
||||
//get items
|
||||
const show_data = [];
|
||||
const movie_data = [];
|
||||
const music_data = [];
|
||||
const raw_item_data=[];
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const library = libraries[i];
|
||||
|
||||
let item_url=`${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items?ParentID=${library.Id}`;
|
||||
const response_data_item = await axios_instance.get(item_url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
const libraryItemsWithParent = response_data_item.data.Items.map((items) => ({
|
||||
...items,
|
||||
...{ ParentId: library.Id },
|
||||
}));
|
||||
movie_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Movie'));
|
||||
show_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Series'));
|
||||
music_data.push(...libraryItemsWithParent.filter((item) => item.Type==='Audio'));
|
||||
raw_item_data.push(response_data_item.data);
|
||||
}
|
||||
|
||||
payload.existing_library_count=db_libraries.length;
|
||||
payload.api_library_count=libraries.length;
|
||||
|
||||
payload.existing_movie_count=db_movies.length;
|
||||
payload.api_movie_count=movie_data.length;
|
||||
|
||||
payload.existing_music_count=db_music.length;
|
||||
payload.api_music_count=music_data.length;
|
||||
|
||||
payload.existing_show_count=db_shows.length;
|
||||
payload.api_show_count=show_data.length;
|
||||
|
||||
payload.raw_item_data=raw_item_data;
|
||||
|
||||
|
||||
|
||||
//SHows
|
||||
let allSeasons = [];
|
||||
let allEpisodes =[];
|
||||
|
||||
let raw_allSeasons = [];
|
||||
let raw_allEpisodes =[];
|
||||
|
||||
const { rows: shows } = await db.query(`SELECT "Id" FROM public.jf_library_items where "Type"='Series'`);
|
||||
//loop for each show
|
||||
|
||||
for (const show of shows) {
|
||||
|
||||
let season_url = `${config[0].JF_HOST}/shows/${show.Id}/Seasons`;
|
||||
let episodes_url = `${config[0].JF_HOST}/shows/${show.Id}/Episodes`;
|
||||
|
||||
const response_data_seasons = await axios_instance.get(season_url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
const response_data_episodes = await axios_instance.get(episodes_url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
allSeasons.push(...response_data_seasons.data.Items);
|
||||
allEpisodes.push(...response_data_episodes.data.Items);
|
||||
|
||||
raw_allSeasons.push(response_data_seasons.data);
|
||||
raw_allEpisodes.push(response_data_episodes.data);
|
||||
}
|
||||
|
||||
payload.existing_season_count=db_seasons.length;
|
||||
payload.api_season_count=allSeasons.length;
|
||||
|
||||
payload.existing_episode_count=db_episodes.length;
|
||||
payload.api_episode_count=allEpisodes.length;
|
||||
|
||||
payload.raw_season_data=raw_allSeasons;
|
||||
payload.raw_episode_data=raw_allEpisodes;
|
||||
|
||||
//missing data section
|
||||
let missing_libraries=libraries.filter(library => !db_libraries.includes(library.Id));
|
||||
|
||||
let missing_movies=movie_data.filter(item => !db_movies.includes(item.Id) && item.Type==='Movie');
|
||||
let missing_shows=show_data.filter(item => !db_shows.includes(item.Id) && item.Type==='Series');
|
||||
let missing_music=music_data.filter(item => !db_music.includes(item.Id) && item.Type==='Audio');
|
||||
|
||||
let missing_seasons=allSeasons.filter(season => !db_seasons.includes(season.Id));
|
||||
let missing_episodes=allEpisodes.filter(episode => !db_episodes.includes(episode.Id));
|
||||
|
||||
payload.missing_api_library_data=missing_libraries;
|
||||
|
||||
payload.missing_api_movies_data=missing_movies;
|
||||
payload.missing_api_music_data=missing_music;
|
||||
payload.missing_api_shows_data=missing_shows;
|
||||
|
||||
payload.missing_api_season_data=missing_seasons;
|
||||
payload.missing_api_episode_data=missing_episodes;
|
||||
|
||||
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
37
backend/logging.js
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
const db = require("./db");
|
||||
|
||||
|
||||
|
||||
const {jf_logging_columns,jf_logging_mapping,} = require("./models/jf_logging");
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/getLogs", async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query(`SELECT * FROM jf_logging order by "TimeRun" desc LIMIT 50 `);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function insertLog(logItem)
|
||||
{
|
||||
try {
|
||||
|
||||
|
||||
await db.insertBulk("jf_logging",logItem,jf_logging_columns);
|
||||
// console.log(result);
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
module.exports =
|
||||
{router,insertLog};
|
||||
66
backend/migrations/029_jf_all_user_activity_view.js
Normal file
66
backend/migrations/029_jf_all_user_activity_view.js
Normal 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;`);
|
||||
};
|
||||
|
||||
29
backend/migrations/030_jf_logging_table.js
Normal file
29
backend/migrations/030_jf_logging_table.js
Normal file
@@ -0,0 +1,29 @@
|
||||
exports.up = async function(knex) {
|
||||
try {
|
||||
const hasTable = await knex.schema.hasTable('jf_logging');
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable('jf_logging', function(table) {
|
||||
table.text('Id').primary();
|
||||
table.text('Name').notNullable();
|
||||
table.text('Type').notNullable();
|
||||
table.text('ExecutionType');
|
||||
table.text('Duration').notNullable();
|
||||
table.timestamp('TimeRun').defaultTo(knex.fn.now());
|
||||
table.json('Log');
|
||||
table.text('Result');
|
||||
});
|
||||
await knex.raw(`ALTER TABLE jf_logging OWNER TO ${process.env.POSTGRES_USER};`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
try {
|
||||
await knex.schema.dropTableIfExists('jf_logging');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
43
backend/migrations/031_jd_remove_orphaned_data.js
Normal file
43
backend/migrations/031_jd_remove_orphaned_data.js
Normal 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;
|
||||
`);
|
||||
};
|
||||
|
||||
23
backend/migrations/032_app_config_table_add_auth_flag.js
Normal file
23
backend/migrations/032_app_config_table_add_auth_flag.js
Normal 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);
|
||||
}
|
||||
};
|
||||
123
backend/migrations/033_js_library_stats_overview_view.js
Normal file
123
backend/migrations/033_js_library_stats_overview_view.js
Normal 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;
|
||||
`);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
37
backend/migrations/035_ju_update_library_stats_data.js
Normal file
37
backend/migrations/035_ju_update_library_stats_data.js
Normal 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;
|
||||
`);
|
||||
};
|
||||
|
||||
126
backend/migrations/036_js_library_stats_overview_view.js
Normal file
126
backend/migrations/036_js_library_stats_overview_view.js
Normal 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;
|
||||
`);
|
||||
};
|
||||
|
||||
@@ -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;`);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
17
backend/models/bulk_insert_update_handler.js
Normal file
17
backend/models/bulk_insert_update_handler.js
Normal 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
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
: null,
|
||||
SeriesName: item.SeriesName,
|
||||
SeriesId: item.SeriesId,
|
||||
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
|
||||
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag : null,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
||||
26
backend/models/jf_logging.js
Normal file
26
backend/models/jf_logging.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const jf_logging_columns = [
|
||||
"Id",
|
||||
"Name",
|
||||
"Type",
|
||||
"ExecutionType",
|
||||
"Duration",
|
||||
"TimeRun",
|
||||
"Log",
|
||||
"Result"
|
||||
];
|
||||
|
||||
const jf_logging_mapping = (item) => ({
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
ExecutionType: item.ExecutionType,
|
||||
Duration: item.Duration,
|
||||
TimeRun: item.TimeRun,
|
||||
Log: item.Log,
|
||||
Result: item.Result,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
jf_logging_columns,
|
||||
jf_logging_mapping,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
149
backend/proxy.js
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
106
backend/stats.js
106
backend/stats.js
@@ -1,14 +1,28 @@
|
||||
// api.js
|
||||
const express = require("express");
|
||||
const db = require("./db");
|
||||
const axios=require("axios");
|
||||
|
||||
const router = express.Router();
|
||||
const https = require('https');
|
||||
|
||||
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
|
||||
});
|
||||
const axios_instance = axios.create({
|
||||
httpsAgent: agent
|
||||
});
|
||||
|
||||
|
||||
|
||||
router.get("/getLibraryOverview", async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query("SELECT * FROM jf_library_count_view");
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -25,6 +39,7 @@ router.post("/getMostViewedSeries", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -43,6 +58,7 @@ router.post("/getMostViewedMovies", async (req, res) => {
|
||||
} catch (error) {
|
||||
console.log('/getMostViewedMovies');
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -59,6 +75,7 @@ router.post("/getMostViewedMusic", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -75,6 +92,7 @@ router.post("/getMostViewedLibraries", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -91,6 +109,7 @@ router.post("/getMostUsedClient", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -107,6 +126,7 @@ router.post("/getMostActiveUsers", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -123,6 +143,7 @@ router.post("/getMostPopularMovies", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -139,6 +160,7 @@ router.post("/getMostPopularSeries", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -155,6 +177,7 @@ router.post("/getMostPopularMusic", async (req, res) => {
|
||||
);
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -164,6 +187,7 @@ router.get("/getPlaybackActivity", async (req, res) => {
|
||||
const { rows } = await db.query("SELECT * FROM jf_playback_activity");
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -186,6 +210,7 @@ router.post("/getUserDetails", async (req, res) => {
|
||||
res.send(rows[0]);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -203,6 +228,7 @@ router.post("/getGlobalUserStats", async (req, res) => {
|
||||
res.send(rows[0]);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -216,6 +242,7 @@ router.post("/getUserLastPlayed", async (req, res) => {
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -229,6 +256,7 @@ router.post("/getLibraryDetails", async (req, res) => {
|
||||
res.send(rows[0]);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -246,6 +274,7 @@ router.post("/getGlobalLibraryStats", async (req, res) => {
|
||||
res.send(rows[0]);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -256,6 +285,7 @@ router.get("/getLibraryCardStats", async (req, res) => {
|
||||
const { rows } = await db.query("select * from js_library_stats_overview");
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -265,10 +295,29 @@ router.get("/getLibraryMetadata", async (req, res) => {
|
||||
const { rows } = await db.query("select * from js_library_metadata");
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/getLibraryItemsWithStats", async (req, res) => {
|
||||
try{
|
||||
const {libraryid} = req.body;
|
||||
console.log(`ENDPOINT CALLED: /getLibraryItems: `+libraryid);
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"='${libraryid}'`
|
||||
);
|
||||
res.send(rows);
|
||||
|
||||
|
||||
}catch(error)
|
||||
{
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
router.post("/getLibraryLastPlayed", async (req, res) => {
|
||||
try {
|
||||
@@ -279,10 +328,63 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
|
||||
res.send(rows);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/getRecentlyAdded", async (req, res) => {
|
||||
try {
|
||||
|
||||
const { libraryid } = req.query;
|
||||
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
|
||||
|
||||
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
|
||||
res.status(503);
|
||||
res.send({ error: "Config Details Not Found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const adminurl = `${config[0].JF_HOST}/Users`;
|
||||
|
||||
const response = await axios_instance.get(adminurl, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
|
||||
if(!response || typeof response.data !== 'object' || !Array.isArray(response.data))
|
||||
{
|
||||
res.status(503);
|
||||
res.send({ error: "Invalid Response from Users API Call.", user_response:response });
|
||||
return;
|
||||
}
|
||||
|
||||
const adminUser = response.data.filter(
|
||||
(user) => user.Policy.IsAdministrator === true
|
||||
);
|
||||
|
||||
|
||||
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest`;
|
||||
if(libraryid)
|
||||
{
|
||||
url+=`?parentId=${libraryid}`;
|
||||
}
|
||||
|
||||
const response_data = await axios_instance.get(url, {
|
||||
headers: {
|
||||
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
|
||||
},
|
||||
});
|
||||
res.send(response_data.data);
|
||||
} catch (error) {
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
router.post("/getViewsOverTime", async (req, res) => {
|
||||
try {
|
||||
@@ -324,6 +426,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
|
||||
res.send(finalData);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -364,6 +467,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
|
||||
res.send(finalData);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -405,6 +509,7 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
|
||||
res.send(finalData);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
@@ -427,6 +532,7 @@ router.post("/getGlobalItemStats", async (req, res) => {
|
||||
res.send(rows[0]);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(503);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
850
backend/sync.js
850
backend/sync.js
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
61
backend/tasks/BackupTask.js
Normal file
61
backend/tasks/BackupTask.js
Normal 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
35
backend/tasks/SyncTask.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const db = require("../db");
|
||||
|
||||
const sync = require("../sync");
|
||||
|
||||
async function SyncTask(interval) {
|
||||
console.log("LibraryMonitor Interval: " + interval);
|
||||
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const { rows: config } = await db.query(
|
||||
'SELECT * FROM app_config where "ID"=1'
|
||||
);
|
||||
|
||||
|
||||
|
||||
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
sync.fullSync();
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
return [];
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SyncTask,
|
||||
};
|
||||
43
backend/version-control.js
Normal file
43
backend/version-control.js
Normal 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 };
|
||||
11
package.json
11
package.json
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "jfstat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.4.10",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/material": "^5.11.10",
|
||||
"@jellyfin/sdk": "^0.8.2",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@mui/x-data-grid": "^6.2.1",
|
||||
"@nivo/api": "^0.74.1",
|
||||
"@nivo/bar": "^0.80.0",
|
||||
"@nivo/core": "^0.80.0",
|
||||
@@ -16,12 +18,16 @@
|
||||
"antd": "^5.3.0",
|
||||
"axios": "^1.3.4",
|
||||
"bootstrap": "^5.2.3",
|
||||
"compare-versions": "^6.0.0-rc.1",
|
||||
"concurrently": "^7.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"github-api": "^3.4.0",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"knex": "^2.4.2",
|
||||
"moment": "^2.29.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.9.0",
|
||||
@@ -31,6 +37,7 @@
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-bootstrap": "^2.7.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^2.5.0",
|
||||
|
||||
89
src/App.css
89
src/App.css
@@ -1,9 +1,11 @@
|
||||
|
||||
|
||||
@import 'pages/css/variables.module.css';
|
||||
main{
|
||||
margin-inline: 20px;
|
||||
margin-inline: 20px;
|
||||
/* width: 100%; */
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
@@ -17,7 +19,7 @@ main{
|
||||
|
||||
body
|
||||
{
|
||||
background-color: #1e1c22 !important;
|
||||
background-color: var(--background-color) !important;
|
||||
/* background-color: #17151a; */
|
||||
color: white;
|
||||
}
|
||||
@@ -42,6 +44,7 @@ h2{
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -80,3 +83,81 @@ h2{
|
||||
}
|
||||
|
||||
|
||||
.btn-outline-primary
|
||||
{
|
||||
color: white!important;
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: var(--background-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary.active
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.btn-outline-primary:focus
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.btn-primary
|
||||
{
|
||||
color: white!important;
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover
|
||||
{
|
||||
background-color: var(--primary-dark-color) !important;
|
||||
}
|
||||
|
||||
.btn-primary.active
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.btn-primary:focus
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
|
||||
}
|
||||
|
||||
.form-select
|
||||
{
|
||||
background-color:var(--secondary-background-color) !important;
|
||||
border-color:var(--secondary-background-color) !important ;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.form-select:focus
|
||||
{
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.form-select option {
|
||||
background-color: var(--secondary-background-color) !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
|
||||
.form-select option:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
44
src/App.js
44
src/App.js
@@ -1,10 +1,7 @@
|
||||
// import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
} from "react-router-dom";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import axios from 'axios';
|
||||
|
||||
import Config from './lib/config';
|
||||
@@ -25,15 +22,17 @@ import Libraries from './pages/libraries';
|
||||
import LibraryInfo from './pages/components/library-info';
|
||||
import ItemInfo from './pages/components/item-info';
|
||||
import ErrorPage from './pages/components/general/error';
|
||||
import About from './pages/about';
|
||||
|
||||
|
||||
import Testing from './pages/testing';
|
||||
import Activity from './pages/activity';
|
||||
import Statistics from './pages/statistics';
|
||||
import Datadebugger from './pages/data-debugger';
|
||||
|
||||
function App() {
|
||||
|
||||
const [isConfigured, setisConfigured] = useState(false);
|
||||
const [setupState, setSetupState] = useState(0);
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorFlag, seterrorFlag] = useState(false);
|
||||
@@ -61,16 +60,15 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
if(!isConfigured)
|
||||
if(setupState===0)
|
||||
{
|
||||
setLoading(false);
|
||||
axios
|
||||
.get("/auth/isConfigured")
|
||||
.then(async (response) => {
|
||||
console.log(response);
|
||||
if(response.status===200)
|
||||
{
|
||||
setisConfigured(true);
|
||||
setSetupState(response.data.state);
|
||||
|
||||
}
|
||||
|
||||
@@ -84,11 +82,11 @@ function App() {
|
||||
|
||||
}
|
||||
|
||||
if (!config && isConfigured) {
|
||||
if (!config && setupState>=1) {
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
}, [config,isConfigured]);
|
||||
}, [config,setupState]);
|
||||
|
||||
if (loading) {
|
||||
return <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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
|
||||
export const clientData = ["android","ios","safari","chrome","firefox","edge"]
|
||||
export const clientData = ["android","ios","safari","chrome","firefox","edge","opera"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
// }
|
||||
// ,
|
||||
@@ -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
91
src/pages/about.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
// import Button from "react-bootstrap/Button";
|
||||
// import Card from 'react-bootstrap/Card';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Loading from "./components/general/loading";
|
||||
|
||||
|
||||
import "./css/about.css";
|
||||
import { Card } from "react-bootstrap";
|
||||
|
||||
export default function SettingsAbout() {
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const [data, setData] = useState();
|
||||
useEffect(() => {
|
||||
|
||||
const fetchVersion = () => {
|
||||
if (token) {
|
||||
const url = `/api/CheckForUpdates`;
|
||||
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
setData(data.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if(!data)
|
||||
{
|
||||
fetchVersion();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(fetchVersion, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [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>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
src/pages/components/activity/stream_info.js
Normal file
176
src/pages/components/activity/stream_info.js
Normal 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;
|
||||
28
src/pages/components/general/ErrorBoundary.js
Normal file
28
src/pages/components/general/ErrorBoundary.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/pages/components/general/version-card.js
Normal file
71
src/pages/components/general/version-card.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
|
||||
import "../../css/settings/version.css";
|
||||
import { Card } from "react-bootstrap";
|
||||
|
||||
export default function VersionCard() {
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const [data, setData] = useState();
|
||||
useEffect(() => {
|
||||
|
||||
const fetchVersion = () => {
|
||||
if (token) {
|
||||
const url = `/api/CheckForUpdates`;
|
||||
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
setData(data.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
if(!data)
|
||||
{
|
||||
fetchVersion();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(fetchVersion, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [data,token]);
|
||||
|
||||
|
||||
if(!data)
|
||||
{
|
||||
return <></>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Card className="d-none d-md-block version rounded-0 border-0" >
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col>Jellystat {data.current_version}</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{data.update_available?
|
||||
<Row>
|
||||
<Col ><a href="https://github.com/CyferShepard/Jellystat" target="_blank" rel="noreferrer" style={{color:'#00A4DC'}}>New version available: {data.latest_version}</a></Col>
|
||||
</Row>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/pages/components/item-info/item-activity.js
Normal file
64
src/pages/components/item-info/item-activity.js
Normal 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;
|
||||
@@ -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> :<></>}
|
||||
|
||||
55
src/pages/components/item-info/item-not-found.js
Normal file
55
src/pages/components/item-info/item-not-found.js
Normal 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;
|
||||
@@ -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}/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
65
src/pages/components/library/library-activity.js
Normal file
65
src/pages/components/library/library-activity.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
163
src/pages/components/library/library-items.js
Normal file
163
src/pages/components/library/library-items.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
src/pages/components/settings/Tasks.js
Normal file
107
src/pages/components/settings/Tasks.js
Normal 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>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
215
src/pages/components/settings/logs.js
Normal file
215
src/pages/components/settings/logs.js
Normal 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>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
207
src/pages/components/settings/security.js
Normal file
207
src/pages/components/settings/security.js
Normal 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>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
62
src/pages/components/user-info/user-activity.js
Normal file
62
src/pages/components/user-info/user-activity.js
Normal 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
20
src/pages/css/about.css
Normal file
@@ -0,0 +1,20 @@
|
||||
@import './variables.module.css';
|
||||
.about
|
||||
{
|
||||
background-color: var(--second-background-color) !important;
|
||||
border-color: transparent !important;
|
||||
color: white !important;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.about a
|
||||
{
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about a:hover
|
||||
{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
10
src/pages/css/activity/stream-info.css
Normal file
10
src/pages/css/activity/stream-info.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.Home
|
||||
{
|
||||
color: white;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
} */
|
||||
@@ -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;
|
||||
}
|
||||
48
src/pages/css/library/media-items.css
Normal file
48
src/pages/css/library/media-items.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
98
src/pages/css/radius_breakpoint_css.css
Normal file
98
src/pages/css/radius_breakpoint_css.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
46
src/pages/css/settings/version.css
Normal file
46
src/pages/css/settings/version.css
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '../variables.module.css';
|
||||
.version
|
||||
{
|
||||
background-color: var(--background-color) !important;
|
||||
|
||||
color: white !important;
|
||||
position: fixed !important;
|
||||
bottom: 0;
|
||||
max-width: 200px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
.version a
|
||||
{
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version a:hover
|
||||
{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav-pills > .nav-item , .nav-pills > .nav-item > .nav-link
|
||||
{
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.nav-pills > .nav-item .active
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.nav-pills > .nav-item :hover
|
||||
{
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.nav-pills > .nav-item .active> .nav-link
|
||||
{
|
||||
color: white;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user