Item Cards and stats

Added drill down views for individual items

Created view to see basic metadata for libraries

Created Table to store item metadata such as file size and codec support

Scrape and display File and library sizes

Removed a redundant view(TBH there's still alot left)

Amended some UI elements

Started work on backup code WIP (Does not work)

Changed out Nivo to recharts for stat graphs to remove <0 behaviour of charts
This commit is contained in:
Thegan Govender
2023-04-15 23:04:25 +02:00
parent 0a948f8eda
commit 692d0c8ec0
44 changed files with 1218 additions and 169 deletions

View File

@@ -86,6 +86,102 @@ router.post("/getLibraryItems", async (req, res) => {
console.log(`ENDPOINT CALLED: /getLibraryItems: `);
});
router.post("/getSeasons", async (req, res) => {
try{
const { Id } = req.body;
const { rows } = await db.query(
`SELECT * FROM jf_library_seasons where "SeriesId"='${Id}'`
);
console.log({ Id: Id });
res.send(rows);
}catch(error)
{
console.log(error);
}
console.log(`ENDPOINT CALLED: /getSeasons: `);
});
router.post("/getEpisodes", async (req, res) => {
try{
const { Id } = req.body;
const { rows } = await db.query(
`SELECT * FROM jf_library_episodes where "SeasonId"='${Id}'`
);
console.log({ Id: Id });
res.send(rows);
}catch(error)
{
console.log(error);
}
console.log(`ENDPOINT CALLED: /getEpisodes: `);
});
router.post("/getItemDetails", async (req, res) => {
try{
const { Id } = req.body;
//
let query= `SELECT im."Name" "FileName",im.*,i.* FROM jf_library_items i left join jf_item_info im on i."Id" = im."Id" where i."Id"='${Id}'`;
const { rows:items } = await db.query(
query
);
if(items.length===0)
{
query=`SELECT im."Name" "FileName",im.*,s.* FROM jf_library_seasons s left join jf_item_info im on s."Id" = im."Id" where s."Id"='${Id}'`;
const { rows:seasons } = await db.query(
query
);
if(seasons.length===0)
{
query=`SELECT im."Name" "FileName",im.*,e.* FROM jf_library_episodes e join jf_item_info im on e."EpisodeId" = im."Id" where e."EpisodeId"='${Id}'`;
const { rows:episodes } = await db.query(
query
);
res.send(episodes);
}else{
res.send(seasons);
}
}else{
res.send(items);
}
}catch(error)
{
console.log(error);
}
console.log(`ENDPOINT CALLED: /getLibraryItems: `);
});
router.get("/getHistory", async (req, res) => {
try{

158
backend/backup.js Normal file
View File

@@ -0,0 +1,158 @@
const { Router } = require('express');
const { Pool } = require('pg');
const fs = require('fs');
const readline = require('readline');
const router = Router();
// Database connection parameters
const postgresUser = process.env.POSTGRES_USER;
const postgresPassword = process.env.POSTGRES_PASSWORD;
const postgresIp = process.env.POSTGRES_IP;
const postgresPort = process.env.POSTGRES_PORT;
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'];
// Backup function
async function backup() {
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
host: postgresIp,
port: postgresPort,
database: postgresDatabase
});
// Get data from each table and append it to the backup file
const backupPath = './backup-data/backup.json';
const stream = fs.createWriteStream(backupPath, { flags: 'a' });
const backup_data=[];
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}`);
backup_data.push({[table]:rows});
// stream.write(JSON.stringify(backup_data));
}
stream.write(JSON.stringify(backup_data));
stream.end();
await pool.end();
}
// Restore function
async function restore() {
// const pool = new Pool({
// user: 'postgres',
// password: 'mypassword',
// host: postgresIp,
// port: 25432,
// database: postgresDatabase
// });
let user='postgres';
let password='mypassword';
let host=postgresIp;
let port=25432;
let database= postgresDatabase;
const client = new Pool({
host,
port,
database,
user,
password
});
const backupPath = './backup-data/backup.json';
let jsonData;
await fs.readFile(backupPath, 'utf8', async (err, data) => {
if (err) {
console.error(err);
return;
}
jsonData = await JSON.parse(data);
});
console.log(jsonData);
if(!jsonData)
{
return;
}
jsonData.forEach((library) => {
console.log(library);
});
// await client.connect();
// let tableStarted = false;
// let tableColumns = '';
// let tableValues = '';
// let tableName = '';
// for await (const line of rl) {
// if (!tableStarted && line.startsWith('COPY ')) {
// tableName = line.match(/COPY (.*) \(/)[1];
// tableStarted = true;
// tableColumns = '';
// tableValues = '';
// } else if (tableStarted && line.startsWith('\.')) {
// const insertStatement = `INSERT INTO ${tableName} (${tableColumns}) VALUES ${tableValues};`;
// await client.query(insertStatement);
// tableStarted = false;
// } else if (tableStarted && tableColumns === '') {
// tableColumns = line.replace(/\(/g, '').replace(/\)/g, '').replace(/"/g, '').trim();
// tableValues = '';
// } else if (tableStarted) {
// const values = line.replace(/\(/g, '').replace(/\)/g, '').split('\t').map(value => {
// if (value === '') {
// return null;
// }
// if (!isNaN(parseFloat(value))) {
// return parseFloat(value);
// }
// return value.replace(/'/g, "''");
// });
// const rowValues = `(${values.join(',')})`;
// tableValues = `${tableValues}${tableValues === '' ? '' : ','}${rowValues}`;
// }
// }
// await client.end();
}
// Route handler for backup endpoint
router.get('/backup', async (req, res) => {
try {
await backup();
res.send('Backup completed successfully');
} catch (error) {
console.error(error);
res.status(500).send('Backup failed');
}
});
router.get('/restore', async (req, res) => {
try {
await restore();
res.send('Backup completed successfully');
} catch (error) {
console.error(error);
res.status(500).send('Backup failed');
}
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
exports.up = async function(knex) {
try {
const tableExists = await knex.schema.hasTable('jf_item_info');
if (!tableExists) {
await knex.schema.createTable('jf_item_info', function(table) {
table.text('Id').notNullable().primary();
table.text('Path');
table.text('Name');
table.bigInteger('Size');
table.bigInteger('Bitrate');
table.json('MediaStreams');
table.text('Type');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_item_info OWNER TO ${process.env.POSTGRES_USER};`);
}
} catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.dropTableIfExists('jf_item_info');
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,25 @@
exports.up = async function(knex) {
await knex.raw(`
CREATE OR REPLACE VIEW public.js_library_metadata AS
select
l."Id",
l."Name",
sum(ii."Size") "Size",
count(*) files
from jf_libraries l
join jf_library_items i
on i."ParentId"=l."Id"
left join jf_library_episodes e
on e."SeriesId"=i."Id"
left join jf_item_info ii
on (ii."Id"=i."Id" or ii."Id"=e."EpisodeId")
group by l."Id",l."Name"
`).catch(function(error) {
console.error(error);
});
};
exports.down = async function(knex) {
await knex.raw(`DROP VIEW js_library_metadata;`);
};

View File

@@ -0,0 +1,25 @@
const jf_item_info_columns = [
"Id",
"Path",
"Name",
"Size",
"Bitrate",
"MediaStreams",
"Type",
];
const jf_item_info_mapping = (item, typeOverride) => ({
Id: item.EpisodeId || item.Id,
Path: item.Path,
Name: item.Name,
Size: item.Size,
Bitrate: item.Bitrate,
MediaStreams:JSON.stringify(item.MediaStreams),
Type: typeOverride !== undefined ? typeOverride : item.Type,
});
module.exports = {
jf_item_info_columns,
jf_item_info_mapping,
};

View File

@@ -22,7 +22,7 @@
];
const jf_library_episodes_mapping = (item) => ({
Id: item.Id + item.ParentId,
Id: item.Id + item.SeasonId,
EpisodeId: item.Id,
Name: item.Name,
ServerId: item.ServerId,
@@ -41,7 +41,7 @@
? item.ParentBackdropImageTags[0]
: null,
SeriesId: item.SeriesId,
SeasonId: item.ParentId,
SeasonId: item.SeasonId,
SeasonName: item.SeasonName,
SeriesName: item.SeriesName,
});

View File

@@ -26,7 +26,7 @@
? item.ParentBackdropImageTags[0]
: null,
SeriesName: item.SeriesName,
SeriesId: item.ParentId,
SeriesId: item.SeriesId,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
});

View File

@@ -9,6 +9,7 @@ const authRouter= require('./auth');
const apiRouter = require('./api');
const syncRouter = require('./sync');
const statsRouter = require('./stats');
const backupRouter = require('./backup');
const ActivityMonitor = require('./watchdog/ActivityMonitor');
@@ -50,6 +51,7 @@ app.use('/auth', authRouter); // mount the API router at /api, with JWT middlewa
app.use('/api', verifyToken, apiRouter); // 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', backupRouter); // mount the API router at /stats, with JWT middleware
try{
createdb.createDatabase().then((result) => {

View File

@@ -4,11 +4,6 @@ const db = require("./db");
const router = express.Router();
router.get("/test", async (req, res) => {
console.log(`ENDPOINT CALLED: /test`);
res.send("Backend Responded Succesfully");
});
router.get("/getLibraryOverview", async (req, res) => {
try {
const { rows } = await db.query("SELECT * FROM jf_library_count_view");
@@ -202,7 +197,6 @@ router.post("/getGlobalUserStats", async (req, res) => {
if (hours === undefined) {
_hours = 24;
}
console.log(`select * from fs_user_stats(${_hours},'${userid}')`);
const { rows } = await db.query(
`select * from fs_user_stats(${_hours},'${userid}')`
);
@@ -246,7 +240,6 @@ router.post("/getGlobalLibraryStats", async (req, res) => {
if (hours === undefined) {
_hours = 24;
}
console.log(`select * from fs_library_stats(${_hours},'${libraryid}')`);
const { rows } = await db.query(
`select * from fs_library_stats(${_hours},'${libraryid}')`
);
@@ -267,6 +260,16 @@ router.get("/getLibraryCardStats", async (req, res) => {
}
});
router.get("/getLibraryMetadata", async (req, res) => {
try {
const { rows } = await db.query("select * from js_library_metadata");
res.send(rows);
} catch (error) {
res.send(error);
}
});
router.post("/getLibraryLastPlayed", async (req, res) => {
try {
const { libraryid } = req.body;
@@ -406,6 +409,28 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
}
});
router.post("/getGlobalItemStats", async (req, res) => {
try {
const { hours,itemid } = req.body;
let _hours = hours;
if (hours === undefined) {
_hours = 24;
}
const { rows } = await db.query(
`select count(*)"Plays",
sum("PlaybackDuration") total_playback_duration
from jf_playback_activity
where
("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}')
AND jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - INTERVAL '1 hour' * ${_hours} AND NOW();`
);
res.send(rows[0]);
} catch (error) {
console.log(error);
res.send(error);
}
});

View File

@@ -12,6 +12,7 @@ const {jf_libraries_columns,jf_libraries_mapping,} = require("./models/jf_librar
const {jf_library_items_columns,jf_library_items_mapping,} = require("./models/jf_library_items");
const {jf_library_seasons_columns,jf_library_seasons_mapping,} = require("./models/jf_library_seasons");
const {jf_library_episodes_columns,jf_library_episodes_mapping,} = require("./models/jf_library_episodes");
const {jf_item_info_columns,jf_item_info_mapping,} = require("./models/jf_item_info");
const {jf_users_columns,jf_users_mapping,} = require("./models/jf_users");
@@ -83,26 +84,46 @@ class sync {
return [];
}
}
async getSeasonsAndEpisodes(showId,userid) {
const allSeasons = [];
const allEpisodes = [];
let seasonItems = await this.getItem(showId,userid);
const seasonWithParent = seasonItems.map((items) => ({
...items,
...{ ParentId: showId },
}));
allSeasons.push(...seasonWithParent);
for (let e = 0; e < seasonItems.length; e++) {
const season = seasonItems[e];
let episodeItems = await this.getItem(season.Id,userid);
const episodeWithParent = episodeItems.map((items) => ({
...items,
...{ ParentId: season.Id },
}));
allEpisodes.push(...episodeWithParent);
}
return { allSeasons: allSeasons, allEpisodes: allEpisodes };
async getSeasonsAndEpisodes(itemID, type) {
try {
let url = `${this.hostUrl}/shows/${itemID}/${type}`;
if (itemID !== undefined) {
url += `?ParentID=${itemID}`;
}
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
return response.data.Items;
} catch (error) {
console.log(error);
return [];
}
}
async getItemInfo(itemID,userid) {
try {
let url = `${this.hostUrl}/Items/${itemID}/playbackinfo?userId=${userid}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
const results = response.data.MediaSources;
return results;
} catch (error) {
console.log(error);
return [];
}
}
}
////////////////////////////////////////API Methods
@@ -222,7 +243,7 @@ async function syncLibraryItems()
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 1/2" });
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 1/3" });
sendMessageToClients({color: "yellow",Message: "Beginning Library Item Sync",});
@@ -288,14 +309,14 @@ async function syncLibraryItems()
sendMessageToClients({color: "orange",Message: deleteCounter + " Library Items Removed.",});
sendMessageToClients({ color: "yellow", Message: "Item Sync Complete" });
const { rows: cleanup } = await db.query('DELETE FROM jf_playback_activity where "NowPlayingItemId" not in (select "Id" from jf_library_items)' );
sendMessageToClients({ color: "orange", Message: cleanup.length+" orphaned activity logs removed" });
// const { rows: cleanup } = await db.query('DELETE FROM jf_playback_activity where "NowPlayingItemId" not in (select "Id" from jf_library_items)' );
// sendMessageToClients({ color: "orange", Message: cleanup.length+" orphaned activity logs removed" });
}
async function syncShowItems()
{
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/2" });
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/3" });
sendMessageToClients({color: "yellow", Message: "Beginning Seasons and Episode sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
@@ -313,11 +334,12 @@ async function syncShowItems()
let deleteSeasonsCount = 0;
let deleteEpisodeCount = 0;
const admins = await _sync.getAdminUser();
const userid = admins[0].Id;
//loop for each show
for (const show of shows) {
const data = await _sync.getSeasonsAndEpisodes(show.Id,userid);
const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons');
const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes');
const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id));
@@ -341,20 +363,20 @@ async function syncShowItems()
if (existingIdsSeasons.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
seasonsToInsert = await data.allSeasons.map(jf_library_seasons_mapping);
seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping);
} else {
// otherwise, filter only new data to insert
seasonsToInsert = await data.allSeasons
seasonsToInsert = await allSeasons
.filter((row) => !existingIdsSeasons.includes(row.Id))
.map(jf_library_seasons_mapping);
}
if (existingIdsEpisodes.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
episodesToInsert = await data.allEpisodes.map(jf_library_episodes_mapping);
episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping);
} else {
// otherwise, filter only new data to insert
episodesToInsert = await data.allEpisodes.filter((row) => !existingIdsEpisodes.includes(row.Id + row.ParentId)).map(jf_library_episodes_mapping);
episodesToInsert = await allEpisodes.filter((row) => !existingIdsEpisodes.includes(row.Id + row.SeasonId)).map(jf_library_episodes_mapping);
}
///insert delete seasons
@@ -370,7 +392,7 @@ async function syncShowItems()
});
}
}
const toDeleteIds = existingIdsSeasons.filter((id) =>!data.allSeasons.some((row) => row.Id === id ));
const toDeleteIds = existingIdsSeasons.filter((id) =>!allSeasons.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_library_seasons",toDeleteIds);
@@ -395,7 +417,7 @@ async function syncShowItems()
}
}
const toDeleteEpisodeIds = existingIdsEpisodes.filter((id) =>!data.allEpisodes.some((row) => (row.Id + row.ParentId) === id ));
const toDeleteEpisodeIds = existingIdsEpisodes.filter((id) =>!allEpisodes.some((row) => (row.Id + row.ParentId) === id ));
//Bulk delete from db thats no longer on api
if (toDeleteEpisodeIds.length > 0) {
let result = await db.deleteBulk("jf_library_episodes",toDeleteEpisodeIds);
@@ -407,8 +429,6 @@ async function syncShowItems()
}
sendMessageToClients({ Message: "Sync complete for " + show.Name });
}
@@ -416,6 +436,122 @@ async function syncShowItems()
sendMessageToClients({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
sendMessageToClients({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
sendMessageToClients({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
sendMessageToClients({ color: "yellow", Message: "Sync Complete" });
}
async function syncItemInfo()
{
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 3/3" });
sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
const { rows: Items } = await db.query(`SELECT * FROM public.jf_library_items where "Type" not in ('Series','Folder')`);
const { rows: Episodes } = await db.query(`SELECT * FROM public.jf_library_episodes`);
let insertItemInfoCount = 0;
let insertEpisodeInfoCount = 0;
let deleteItemInfoCount = 0;
let deleteEpisodeInfoCount = 0;
const admins = await _sync.getAdminUser();
const userid = admins[0].Id;
//loop for each Movie
for (const Item of Items) {
const data = await _sync.getItemInfo(Item.Id,userid);
const existingItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Item.Id}'`).then((res) => res.rows.map((row) => row.Id));
let ItemInfoToInsert = [];
//filter fix if jf_libraries is empty
if (existingItemInfo.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
ItemInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Item'));
} else {
ItemInfoToInsert = await data.filter((row) => !existingItemInfo.includes(row.Id))
.map(item => jf_item_info_mapping(item, 'Item'));
}
if (ItemInfoToInsert.length !== 0) {
let result = await db.insertBulk("jf_item_info",ItemInfoToInsert,jf_item_info_columns);
if (result.Result === "SUCCESS") {
insertItemInfoCount += ItemInfoToInsert.length;
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
const toDeleteItemInfoIds = existingItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteItemInfoIds.length > 0) {
let result = await db.deleteBulk("jf_item_info",toDeleteItemInfoIds);
if (result.Result === "SUCCESS") {
deleteItemInfoCount +=toDeleteItemInfoIds.length;
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
}
//loop for each Episode
console.log("Episode")
for (const Episode of Episodes) {
const data = await _sync.getItemInfo(Episode.EpisodeId,userid);
const existingEpisodeItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Episode.EpisodeId}'`).then((res) => res.rows.map((row) => row.Id));
let EpisodeInfoToInsert = [];
//filter fix if jf_libraries is empty
if (existingEpisodeItemInfo.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
EpisodeInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Episode'));
} else {
EpisodeInfoToInsert = await data.filter((row) => !existingEpisodeItemInfo.includes(row.Id))
.map(item => jf_item_info_mapping(item, 'Episode'));
}
if (EpisodeInfoToInsert.length !== 0) {
let result = await db.insertBulk("jf_item_info",EpisodeInfoToInsert,jf_item_info_columns);
if (result.Result === "SUCCESS") {
insertEpisodeInfoCount += EpisodeInfoToInsert.length;
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
const toDeleteEpisodeInfoIds = existingEpisodeItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteEpisodeInfoIds.length > 0) {
let result = await db.deleteBulk("jf_item_info",toDeleteEpisodeInfoIds);
if (result.Result === "SUCCESS") {
deleteEpisodeInfoCount +=toDeleteEpisodeInfoIds.length;
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
console.log(Episode.Name)
}
sendMessageToClients({color: "dodgerblue",Message: insertItemInfoCount + " Item Info inserted.",});
sendMessageToClients({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
sendMessageToClients({color: "dodgerblue",Message: insertEpisodeInfoCount + " Episodes inserted.",});
sendMessageToClients({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Removed.",});
sendMessageToClients({ color: "lawngreen", Message: "Sync Complete" });
}
@@ -427,6 +563,7 @@ router.get("/beingSync", async (req, res) => {
await syncLibraryFolders();
await syncLibraryItems();
await syncShowItems();
await syncItemInfo();
res.send();
@@ -463,4 +600,14 @@ router.get("/writeSeasonsAndEpisodes", async (req, res) => {
//////////////////////////////////////
//////////////////////////////////////////////////////writeMediaInfo
router.get("/writeMediaInfo", async (req, res) => {
await syncItemInfo();
res.send();
});
//////////////////////////////////////
module.exports = router;

View File

@@ -30,6 +30,15 @@ h1{
margin-bottom: 0.5em;
}
h2{
color: white;
font-weight: lighter !important;
margin: 0 !important;
margin-top: 0.5em;
margin-bottom: 0.5em;
}

View File

@@ -23,6 +23,7 @@ import Users from './pages/users';
import UserInfo from './pages/components/user-info';
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';
@@ -128,6 +129,7 @@ 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="/statistics" element={<Statistics />} />
<Route path="/activity" element={<Activity />} />
<Route path="/testing" element={<Testing />} />

View File

@@ -40,9 +40,7 @@ function Activity() {
},
})
.then((data) => {
console.log("data");
setData(data.data);
console.log(data);
})
.catch((error) => {
console.log(error);

View File

@@ -86,7 +86,7 @@ function ActivityTable(props) {
<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>{!item.SeriesName ? item.NowPlayingItemName : item.SeriesName+' - '+ item.NowPlayingItemName}</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>{item.results.length+1}</div>
</div>
@@ -95,7 +95,7 @@ function ActivityTable(props) {
<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>{!sub_item.SeriesName ? sub_item.NowPlayingItemName : sub_item.SeriesName+' - '+ sub_item.NowPlayingItemName}</div>
<div><Link to={`/item/${sub_item.EpisodeId || sub_item.Id}`}>{!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>

View File

@@ -1,7 +1,8 @@
import React, {useState} from "react";
import { Link } from "react-router-dom";
import { Blurhash } from 'react-blurhash';
import "../../../css/lastplayed.css";
import "../../css/lastplayed.css";
function formatTime(time) {
@@ -32,6 +33,7 @@ function LastWatchedCard(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<Link to={`/item/${props.data.Id}`}>
<div className="last-card-banner">
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'}/>}
<img
@@ -47,6 +49,7 @@ function LastWatchedCard(props) {
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
</div>
</Link>
<div className="last-item-details">
<div className="last-last-played">

View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { useParams } from 'react-router-dom';
import GlobalStats from './item-info/globalStats';
import ItemDetails from './item-info/item-details';
import MoreItems from "./item-info/more-items";
import Config from "../../lib/config";
import Loading from "./general/loading";
function ItemInfo() {
const { Id } = useParams();
const [data, setData] = useState();
const [config, setConfig] = useState();
const [refresh, setRefresh] = useState(true);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
if(config){
setRefresh(true);
try {
console.log(Id);
const itemData = await axios.post(`/api/getItemDetails`, {
Id: Id
}, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data[0]);
setRefresh(false);
} catch (error) {
console.log(error);
}
}
};
fetchData();
if (!config) {
fetchConfig();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, Id]);
console.log(data);
if(!data)
{
return <></>;
}
if(refresh)
{
return <Loading/>;
}
return (
<div>
<ItemDetails data={data} hostUrl={config.hostUrl}/>
<GlobalStats ItemId={Id}/>
{["Series","Season"].includes(data && data.Type)?
<MoreItems data={data}/>
:
<></>
}
</div>
);
}
export default ItemInfo;

View File

@@ -0,0 +1,85 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import "../../css/globalstats.css";
import WatchTimeStats from "./globalstats/watchtimestats";
function GlobalStats(props) {
const [dayStats, setDayStats] = useState({});
const [weekStats, setWeekStats] = useState({});
const [monthStats, setMonthStats] = useState({});
const [allStats, setAllStats] = useState({});
const token = localStorage.getItem('token');
useEffect(() => {
const fetchData = async () => {
try {
const dayData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*1),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setDayStats(dayData.data);
const weekData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*7),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setWeekStats(weekData.data);
const monthData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*30),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setMonthStats(monthData.data);
const allData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*999),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAllStats(allData.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [props.ItemId,token]);
// console.log(dayStats);
return (
<div>
<h1 className="py-3">Item Stats</h1>
<div className="global-stats-container">
<WatchTimeStats data={dayStats} heading={"Last 24 Hours"} />
<WatchTimeStats data={weekStats} heading={"Last 7 Days"} />
<WatchTimeStats data={monthStats} heading={"Last 30 Days"} />
<WatchTimeStats data={allStats} heading={"All Time"} />
</div>
</div>
);
}
export default GlobalStats;

View File

@@ -0,0 +1,65 @@
import React from "react";
import "../../../css/globalstats.css";
function WatchTimeStats(props) {
function formatTime(totalSeconds, numberClassName, labelClassName) {
const units = [
{ label: 'Day', seconds: 86400 },
{ label: 'Hour', seconds: 3600 },
{ label: 'Minute', seconds: 60 },
];
const parts = units.reduce((result, { label, seconds }) => {
const value = Math.floor(totalSeconds / seconds);
if (value) {
const formattedValue = <p className={numberClassName}>{value}</p>;
const formattedLabel = (
<span className={labelClassName}>
{label}
{value === 1 ? '' : 's'}
</span>
);
result.push(
<span key={label} className="time-part">
{formattedValue} {formattedLabel}
</span>
);
totalSeconds -= value * seconds;
}
return result;
}, []);
if (parts.length === 0) {
return (
<>
<p className={numberClassName}>0</p>{' '}
<p className={labelClassName}>Minutes</p>
</>
);
}
return parts;
}
return (
<div className="global-stats">
<div className="stats-header">
<div>{props.heading}</div>
</div>
<div className="play-duration-stats" key={props.data.ItemId}>
<p className="stat-value"> {props.data.Plays || 0}</p>
<p className="stat-unit" >Plays /</p>
<>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')}</>
</div>
</div>
);
}
export default WatchTimeStats;

View File

@@ -0,0 +1,99 @@
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { Blurhash } from 'react-blurhash';
import { Row, Col } from "react-bootstrap";
import "../../css/items/item-details.css";
function ItemDetails(props) {
const [loaded, setLoaded] = useState(false);
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) {
// Convert ticks to seconds
const seconds = Math.floor(ticks / 10000000);
// Calculate hours, minutes, and remaining seconds
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
// Format the time string as hh:MM:ss
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
return timeString;
}
return (
<div className="item-detail-container">
<Row className="justify-content-center justify-content-md-start">
<Col className="col-auto my-4 my-md-0">
{props.data.PrimaryImageHash && !loaded ? <Blurhash hash={props.data.PrimaryImageHash} width={'200px'} height={'300px'}/> : null}
<img
className="item-image"
src={
props.hostUrl +
"/Items/" +
(props.data.Type==="Episode"? props.data.SeriesId : props.data.Id) +
"/Images/Primary?fillWidth=200&quality=90"
}
alt=""
style={{
display: loaded ? "block" :"none"
}}
onLoad={() => setLoaded(true)}
/>
</Col>
<Col >
<div className="item-details">
<h1 className="">
{props.data.SeriesId?
<Link to={`/item/${props.data.SeriesId}`}>{props.data.SeriesName || props.data.Name}</Link>
:
props.data.SeriesName || props.data.Name
}
</h1>
<div className="my-3">
{props.data.CommunityRating ? <p style={{color:"lightgrey", fontSize:"0.8em", fontStyle:"italic"}}>Community Rating: {props.data.CommunityRating}</p> :<></>}
{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==="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> :<></>}
{props.data.RunTimeTicks ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">{props.data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(props.data.RunTimeTicks)}</p> :<></>}
{props.data.Size ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Size: {formatFileSize(props.data.Size)}</p> :<></>}
</div>
</div>
</Col>
</Row>
</div>
);
}
export default ItemDetails;

View File

@@ -0,0 +1,82 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import MoreItemCards from "./more-items/more-items-card";
import Config from "../../../lib/config";
import "../../css/users/user-details.css";
function MoreItems(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
if(config)
{
try {
let url=`/api/getSeasons`;
if(props.data.Type==='Season')
{
url=`/api/getEpisodes`;
}
const itemData = await axios.post(url, {
Id: props.data.EpisodeId||props.data.Id
},{
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);
} catch (error) {
console.log(error);
}
}
};
fetchData();
if (!config) {
fetchConfig();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, props]);
if (!data || data.lenght===0) {
return <></>;
}
return (
<div className="last-played">
<h1 className="my-3">{props.data.Type==="Season" ? "Episodes" : "Seasons"}</h1>
<div className="last-played-container">
{data.map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id}/>
))}
</div>
</div>
);
}
export default MoreItems;

View File

@@ -0,0 +1,67 @@
import React, {useState} from "react";
import { Blurhash } from 'react-blurhash';
import { Link } from "react-router-dom";
import { useParams } from 'react-router-dom';
import "../../../css/lastplayed.css";
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-banner episode" : "last-card-banner"}>
{props.data.ImageBlurHashes && !loaded ? <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/> : null}
{fallback ?
<img
src={
`${
props.base_url +
"/Items/" +
Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
:
<img
src={
`${
props.base_url +
"/Items/" +
(props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
}
</div>
</Link>
<div className="last-item-details">
<div className="last-item-name"> {props.data.Name}</div>
{props.data.Type==="Episode"?
<div className="last-item-name"> S{props.data.ParentIndexNumber || 0} - E{props.data.IndexNumber || 0}</div>
:
<></>
}
</div>
</div>
);
}
export default MoreItemCards;

View File

@@ -1,5 +1,6 @@
import React, {useState} from "react";
import { Blurhash } from 'react-blurhash';
import { Link } from "react-router-dom";
import "../../../css/lastplayed.css";
@@ -9,6 +10,7 @@ function RecentlyAddedCard(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<Link to={`/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%'}/>}
<img
@@ -24,6 +26,7 @@ function RecentlyAddedCard(props) {
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
</div>
</Link>
<div className="last-item-details">
<div className="last-item-name"> {props.data.Name}</div>

View File

@@ -1,7 +1,10 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import ItemCardInfo from "./LastWatched/last-watched-card";
// import ItemCardInfo from "./LastWatched/last-watched-card";
import LastWatchedCard from "../general/last-watched-card";
import Config from "../../../lib/config";
import "../../css/users/user-details.css";
@@ -61,7 +64,7 @@ function LibraryLastWatched(props) {
<h1 className="my-3">Last Watched</h1>
<div className="last-played-container">
{data.map((item) => (
<ItemCardInfo data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
<LastWatchedCard data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
))}
</div>

View File

@@ -8,6 +8,33 @@ import Col from 'react-bootstrap/Col';
function LibraryCard(props) {
function formatFileSize(sizeInBytes) {
const sizeInKB = sizeInBytes / 1024; // 1 KB = 1024 bytes
if (sizeInKB < 1024) {
return `${sizeInKB.toFixed(2)} KB`;
} else {
const sizeInMB = sizeInKB / 1024; // 1 MB = 1024 KB
if (sizeInMB < 1024) {
return `${sizeInMB.toFixed(2)} MB`;
} else {
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
if (sizeInGB < 1024) {
return `${sizeInGB.toFixed(2)} GB`;
} else {
const sizeInTB = sizeInGB / 1024; // 1 TB = 1024 GB
if (sizeInTB < 1024) {
return `${sizeInTB.toFixed(2)} TB`;
} else {
const sizeInPB = sizeInTB / 1024; // 1 PB = 1024 TB
return `${sizeInPB.toFixed(2)} PB`;
}
}
}
}
}
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
@@ -83,6 +110,16 @@ function LibraryCard(props) {
<Col className="text-end">{props.data.CollectionType==='tvshows' ? 'Series' : "Movies"}</Col>
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">Total Files</Col>
<Col className="text-end">{props.metadata.files}</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>
</Row>
<Row className="space-between-end card-row">
<Col className="card-label">Total Plays</Col>
<Col className="text-end">{props.data.Plays}</Col>
@@ -117,6 +154,7 @@ function LibraryCard(props) {
<Col className="card-label">Episodes</Col>
<Col className="text-end">{props.data.CollectionType==='tvshows' ? props.data.Episode_Count : ''}</Col>
</Row>
</Card.Body>
</Card>

View File

@@ -91,7 +91,9 @@ function sessionCard(props) {
<Row className="justify-content-between">
<Col>
<Card.Text>
{props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)}
<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>

View File

@@ -44,7 +44,7 @@ export default function LibrarySync() {
return (
<div className="settings-form">
<h1 className="my-2">Tasks</h1>
<Row className="mb-3" controlId="form_task_sync">
<Row className="mb-3">
<Form.Label column sm="2">
Syncronize with Jellyfin

View File

@@ -122,7 +122,7 @@ export default function SettingsConfig() {
<div className="general-settings-page">
<h1>General Settings</h1>
<Form onSubmit={handleFormSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3" controlId="form_jellyfin_url">
<Form.Group as={Row} className="mb-3" >
<Form.Label column className="fs-4">
Jellyfin Url
</Form.Label>
@@ -131,7 +131,7 @@ export default function SettingsConfig() {
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="form_api_key">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="fs-4">
API Key
</Form.Label>

View File

@@ -17,7 +17,7 @@ function ItemStatComponent(props) {
const cardStyle = {
backgroundImage: `url(${props.base_url}/Items/${props.data[0].Id}/Images/Backdrop/?fillWidth=300&quality=10), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
backgroundSize: 'contain',
};
const cardBgStyle = {
@@ -33,9 +33,9 @@ function ItemStatComponent(props) {
return (
<Card className="stat-card" style={cardStyle}>
<div style={cardBgStyle}>
<Row className="h-100">
<Card className="stat-card rounded-2" style={cardStyle}>
<div style={cardBgStyle} className="rounded-2">
<Row className="h-100 rounded-2">
<Col className="d-none d-lg-block stat-card-banner">
{props.icon ?
<div className="stat-card-icon">
@@ -49,8 +49,7 @@ function ItemStatComponent(props) {
</div>
)}
<Card.Img
variant="top"
className="stat-card-image rounded-0"
className="stat-card-image"
src={props.base_url + "/Items/" + props.data[0].Id + "/Images/Primary?fillWidth=400&quality=90"}
style={{ display: loaded ? 'block' : 'none' }}
onLoad={handleImageLoad}
@@ -81,7 +80,17 @@ function ItemStatComponent(props) {
<Card.Text>{item.Name}</Card.Text>
</Link>
:
<Card.Text>{item.Name || item.Client}</Card.Text>
!item.Client && !props.icon ?
<Link to={`/item/${item.Id}`}>
<Card.Text>{item.Name}</Card.Text>
</Link>
:
!item.Client && props.icon ?
<Link to={`/libraries/${item.Id}`}>
<Card.Text>{item.Name}</Card.Text>
</Link>
:
<Card.Text>{item.Client}</Card.Text>
}
</div>

View File

@@ -9,7 +9,7 @@ function DailyPlayStats(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(15);
const [days, setDays] = useState(20);
const token = localStorage.getItem("token");
@@ -66,7 +66,7 @@ function DailyPlayStats(props) {
}
return (
<div className="main-widget">
<h1>Daily Play Count Per Library - Last {days} Days</h1>
<h2 className="text-start my-2">Daily Play Count Per Library - Last {days} Days</h2>
<div className="graph">
<Chart libraries={libraries} stats={stats} />

View File

@@ -7,7 +7,7 @@ import "../../css/stats.css";
function PlayStatsByDay(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(60);
const [days, setDays] = useState(20);
const token = localStorage.getItem("token");
useEffect(() => {
@@ -62,7 +62,7 @@ function PlayStatsByDay(props) {
return (
<div className="statistics-widget">
<h1>Play Count By Day - Last {days} Days</h1>
<h2 className="text-start my-2">Play Count By Day - Last {days} Days</h2>
<div className="graph small">
<Chart libraries={libraries} stats={stats} />
</div>

View File

@@ -6,7 +6,7 @@ import "../../css/stats.css";
function PlayStatsByHour(props) {
const [stats, setStats] = useState();
const [libraries, setLibraries] = useState();
const [days, setDays] = useState(60);
const [days, setDays] = useState(20);
const token = localStorage.getItem("token");
useEffect(() => {
@@ -62,7 +62,7 @@ function PlayStatsByHour(props) {
return (
<div className="statistics-widget">
<h1>Play Count By Hour - Last {days} Days</h1>
<h2 className="text-start my-2">Play Count By Hour - Last {days} Days</h2>
<div className="graph small">
<Chart libraries={libraries} stats={stats} />
</div>

View File

@@ -69,7 +69,7 @@ function GlobalStats(props) {
return (
<div>
<h1>User Stats</h1>
<h1 className="py-3">User Stats</h1>
<div className="global-stats-container">
<WatchTimeStats data={dayStats} heading={"Last 24 Hours"} />
<WatchTimeStats data={weekStats} heading={"Last 7 Days"} />

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import LastPlayedItem from "./lastplayed/last-played-item";
import LastWatchedCard from "../general/last-watched-card";
import Config from "../../../lib/config";
import "../../css/users/user-details.css";
@@ -23,6 +23,8 @@ function LastPlayed(props) {
};
const fetchData = async () => {
if(config)
{
try {
const itemData = await axios.post(`/stats/getUserLastPlayed`, {
userid: props.UserId,
@@ -35,7 +37,8 @@ function LastPlayed(props) {
setData(itemData.data);
} catch (error) {
console.log(error);
}
}
}
};
if (!data) {
@@ -57,10 +60,10 @@ function LastPlayed(props) {
return (
<div className="last-played">
<h1>Last Watched</h1>
<h1 className="my-3">Last Watched</h1>
<div className="last-played-container">
{data.map((item) => (
<LastPlayedItem data={item} base_url={config.hostUrl} key={item.Id+item.EpisodeNumber}/>
<LastWatchedCard data={item} base_url={config.hostUrl} key={item.Id+item.EpisodeNumber}/>
))}
</div>

View File

@@ -1,67 +0,0 @@
import React, {useState} from "react";
import { Blurhash } from 'react-blurhash';
import "../../../css/lastplayed.css";
function formatTime(time) {
const units = {
days: ['Day', 'Days'],
hours: ['Hour', 'Hours'],
minutes: ['Minute', 'Minutes'],
seconds: ['Second', 'Seconds']
};
let formattedTime = '';
if (time.days) {
formattedTime = `${time.days} ${units.days[time.days > 1 ? 1 : 0]}`;
} else if (time.hours) {
formattedTime = `${time.hours} ${units.hours[time.hours > 1 ? 1 : 0]}`;
} else if (time.minutes) {
formattedTime = `${time.minutes} ${units.minutes[time.minutes > 1 ? 1 : 0]}`;
} else {
formattedTime = `${time.seconds} ${units.seconds[time.seconds > 1 ? 1 : 0]}`;
}
return `${formattedTime} ago`;
}
function LastPlayedItem(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<div className="last-card-banner">
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'}/>}
<img
src={
`${
props.base_url +
"/Items/" +
props.data.Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
}
onLoad={() => setLoaded(true)}
alt=""
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
</div>
<div className="last-item-details">
<div className="last-last-played">
{formatTime(props.data.LastPlayed)}
</div>
<div className="last-item-name"> {props.data.Name}</div>
<div className="last-item-episode"> {props.data.EpisodeName}</div>
</div>
{props.data.SeasonNumber ?
<div className="last-item-episode number"> S{props.data.SeasonNumber} - E{props.data.EpisodeNumber}</div>:
<></>
}
</div>
);
}
export default LastPlayedItem;

View File

@@ -22,6 +22,7 @@ function UserDetails(props) {
};
const fetchData = async () => {
if(config){
try {
const userData = await axios.post(`/stats/getUserDetails`, {
userid: props.UserId,
@@ -35,6 +36,8 @@ function UserDetails(props) {
} catch (error) {
console.log(error);
}
}
};
if (!data) {

View File

@@ -9,7 +9,7 @@ div a
background-color: rgba(0, 0, 0, 0.4);
}
.table-rows-content:hover a
.table-rows-content div:hover a
{
color: #00A4DC;
}

View File

@@ -0,0 +1,22 @@
.item-detail-container
{
color:white;
background-color: rgb(100, 100, 100,0.2);
padding: 20px;
margin: 20px 0;
border-radius: 4px;
}
.item-name
{
font-size: 2.5em;
font-weight: 500;
margin: 0;
}
.item-image
{
max-width: 200px;
border-radius: 4px;
object-fit: cover;
}

View File

@@ -5,7 +5,6 @@
background-color: rgb(100, 100, 100,0.2);
padding: 20px;
border-radius: 4px;
margin-right: 20px;
color: white;
margin-bottom: 20px;
min-height: 300px;
@@ -46,13 +45,27 @@
}
.episode{
width: 220px !important;
height: 128px !important;
}
.last-card-banner {
width: 150px;
height: 224px;
transition: opacity 0.2s ease-in-out;
}
.last-card-banner:hover {
opacity: 0.5;
}
.last-card-banner img {
width: 100%;
height: 100%;

View File

@@ -8,6 +8,7 @@
justify-content: center;
align-items: center;
z-index: 9999;
background-color: #1e1c22;
}
.component-loading {

View File

@@ -20,13 +20,14 @@
.small
{
height: 500px;
}
.statistics-graphs
{
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-gap: 20px;
margin-bottom: 20px;
@@ -42,12 +43,6 @@
margin-bottom: 10px !important;
}
.statistics-widget
{
flex: 1;
max-width: 49.5%;
/* margin-right: 20px; */
}
.statistics-widget h1{
margin-bottom: 10px !important;
@@ -57,13 +52,13 @@
width: 100%;
height: 400px;
}
@media (min-width: 768px) {
.chart-canvas {
height: 500px;
}
}
@media (min-width: 992px) {
.chart-canvas {
height: 600px;

View File

@@ -1,9 +1,9 @@
.Users
{
color: white;
padding-right: 20px;
padding-bottom: 50px;
margin-top: 10px;
/* padding-right: 20px; */
padding-bottom: 20px;
/* margin-top: 10px; */
}
.user-activity-table {

View File

@@ -13,6 +13,7 @@ import Row from "react-bootstrap/Row";
function Libraries() {
const [data, setData] = useState();
const [metadata, setMetaData] = useState();
const [config, setConfig] = useState(null);
useEffect(() => {
@@ -39,9 +40,23 @@ function Libraries() {
},
})
.then((data) => {
console.log("data");
setData(data.data);
console.log(data);
})
.catch((error) => {
console.log(error);
});
const metadataurl = `/stats/getLibraryMetadata`;
axios
.get(metadataurl, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
setMetaData(data.data);
})
.catch((error) => {
console.log(error);
@@ -54,15 +69,12 @@ function Libraries() {
fetchConfig();
}
if (!data) {
fetchLibraries();
}
fetchLibraries();
const intervalId = setInterval(fetchLibraries, 60000 * 60);
return () => clearInterval(intervalId);
}, [data, config]);
}, [ config]);
if (!data || !config) {
if (!data) {
return <Loading />;
}
@@ -74,7 +86,7 @@ function Libraries() {
{data &&
data.map((item) => (
<LibraryCard key={item.Id} data={item} base_url={config.hostUrl}/>
<LibraryCard key={item.Id} data={item} metadata={metadata.find(data => data.Id === item.Id)} base_url={config.hostUrl}/>
))}

View File

@@ -8,8 +8,8 @@ import PlayStatsByDay from "./components/statistics/play-stats-by-day";
import PlayStatsByHour from "./components/statistics/play-stats-by-hour";
function Statistics() {
const [days, setDays] = useState(60);
const [input, setInput] = useState(60);
const [days, setDays] = useState(20);
const [input, setInput] = useState(20);
const handleKeyDown = (event) => {
if (event.key === "Enter") {

View File

@@ -99,7 +99,6 @@ function Users() {
},
})
.then((data) => {
console.log(data);
setData(data.data);
})
.catch((error) => {
@@ -140,7 +139,7 @@ function Users() {
return (
<div className="Users">
<div className="Heading">
<div className="Heading py-2">
<h1 >All Users</h1>
<div className="pagination-range">
<div className="header">Items</div>