bck/frnt end changes to accomade sync and stats

This commit is contained in:
Thegan Govender
2023-03-12 20:48:34 +02:00
parent 6dd98cb07a
commit 28ed76d6c4
24 changed files with 1820 additions and 1291 deletions

11
.vscode/launch.json vendored
View File

@@ -1,16 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://10.0.0.20:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: start",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -0,0 +1,48 @@
const WebSocket = require('ws');
function createWebSocketServer(port) {
const wss = new WebSocket.Server({ port });
let connected = false;
// function to handle WebSocket connections
function handleConnection(ws) {
if (!connected) {
console.log('Client connected');
connected = true;
// listen for messages from the client
ws.on('message', (message) => {
console.log(`Received message: ${message}`);
});
// listen for close events
ws.on('close', () => {
console.log('Client disconnected');
connected = false;
});}
else
{
console.log('WebSocket connection already established');
}
}
// call the handleConnection function for each new WebSocket connection
wss.on('connection', handleConnection);
// define a separate method that sends a message to all connected clients
function sendMessageToClients(message) {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
let parsedMessage = JSON.stringify(message);
console.log(parsedMessage);
client.send(parsedMessage);
}
});
}
return sendMessageToClients;
}
module.exports = createWebSocketServer;

View File

@@ -1,42 +1,40 @@
// api.js
const express = require('express');
const pgp = require('pg-promise')();
const db = require('./db');
const express = require("express");
// const pgp = require("pg-promise")();
const db = require("./db");
const router = express.Router();
router.get('/test', async (req, res) => {
router.get("/test", async (req, res) => {
console.log(`ENDPOINT CALLED: /test`);
res.send('Backend Responded Succesfully');
res.send("Backend Responded Succesfully");
});
router.get('/getconfig', async (req, res) => {
router.get("/getconfig", async (req, res) => {
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
console.log(`ENDPOINT CALLED: /getconfig: ` + rows);
// console.log(`ENDPOINT CALLED: /setconfig: `+rows.length);
res.send(rows);
});
router.post('/setconfig', async (req, res) => {
router.post("/setconfig", async (req, res) => {
const { JF_HOST, JF_API_KEY } = req.body;
const { rows } = await db.query('UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1', [JF_HOST, JF_API_KEY]);
const { rows } = await db.query(
'UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1',
[JF_HOST, JF_API_KEY]
);
console.log({ JF_HOST: JF_HOST, JF_API_KEY: JF_API_KEY });
res.send(rows);
console.log(`ENDPOINT CALLED: /setconfig: `);
});
router.get('/getAllFromJellyfin', async (req, res) => {
const sync = require('./sync');
router.get("/getAllFromJellyfin", async (req, res) => {
const sync = require("./sync");
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: 'Config Details Not Found' });
res.send({ error: "Config Details Not Found" });
return;
}
@@ -45,410 +43,7 @@ router.get('/getAllFromJellyfin', async (req, res) => {
res.send(results);
console.log(`ENDPOINT CALLED: /getAllFromJellyfin: `);
});
router.get('/getShows', async (req, res) => {
const sync = require('./sync');
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: 'Config Details Not Found' });
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const results = await _sync.getShows();
res.send(results);
console.log(`ENDPOINT CALLED: /getShows: `);
});
router.get('/writeAllShows', async (req, res) => {
const sync = require('./sync');
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: 'Config Details Not Found' });
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getShows();
const existingIds = await db.query('SELECT "Id" FROM jf_library_shows').then(res => res.rows.map(row => row.Id));
const columns = ['Id', 'Name', 'ServerId', 'PremiereDate', 'EndDate', 'CommunityRating', 'RunTimeTicks', 'ProductionYear', 'IsFolder', 'Type', 'Status', 'ImageTagsPrimary', 'ImageTagsBanner', 'ImageTagsLogo', 'ImageTagsThumb', 'BackdropImageTags', 'ParentId']; // specify the columns to insert into
const dataToInsert = data.filter(row => !existingIds.includes(row.Id)).map(item => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
EndDate: item.EndDate,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IsFolder: item.IsFolder,
Type: item.Type,
Status: item.Status,
ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
ImageTagsBanner: item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null,
ImageTagsLogo: item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null,
ImageTagsThumb: item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
BackdropImageTags: item.BackdropImageTags[0],
ParentId: item.ParentId
}));
if(!dataToInsert || dataToInsert.length==0)
{
res.send(('No new shows to insert'));
return;
}
(async () => {
// const client = await pool.connect();
try {
await db.query('BEGIN');
const query = pgp.helpers.insert(dataToInsert, columns, 'jf_library_shows');
await db.query(query);
await db.query('COMMIT');
console.log('Bulk insert successful');
res.send('Bulk insert successful');
} catch (error) {
await db.query('ROLLBACK');
console.error('Error performing bulk insert:', error);
res.send(('Error performing bulk insert:', error));
}
})();
console.log(`ENDPOINT CALLED: /getShows: `);
});
router.get('/getSeasonsAndEpisodes', async (req, res) => {
const sync = require('./sync');
const results=[];
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 { rows:shows } = await db.query('SELECT * FROM jf_library_shows');
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
if (shows && shows.length > 0) {
for (const show of shows) {
const data = await _sync.getSeasonsAndEpisodes(show.Id);
results.push(data);
}
} else {
console.log("No shows found.");
results.push({Status:'Error',Message:'No shows found.'});
}
res.send(results);
console.log(`ENDPOINT CALLED: /writeSeasonsAndEpisodes: `);
});
router.get('/writeSeasonsAndEpisodes', async (req, res) => {
const sync = require('./sync');
const results=[];
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 { rows:shows } = await db.query('SELECT * FROM jf_library_shows');
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
if (shows && shows.length > 0) {
for (const show of shows) {
const data = await _sync.getSeasonsAndEpisodes(show.Id);
const columnSeasons = ['Id', 'Name', 'ServerId', 'IndexNumber', 'Type', 'ParentLogoItemId', 'ParentBackdropItemId', 'ParentBackdropImageTags', 'SeriesName', 'SeriesId', 'SeriesPrimaryImageTag']; // specify the columns to insert into
const columnEpisodes = ['Id', 'EpisodeId', 'Name', 'ServerId', 'PremiereDate', 'OfficialRating', 'CommunityRating', 'RunTimeTicks', 'ProductionYear', 'IndexNumber', 'ParentIndexNumber', 'Type', 'ParentLogoItemId', 'ParentBackdropItemId', 'ParentBackdropImageTags', 'SeriesId', 'SeasonId', 'SeasonName', 'SeriesName']; // specify the columns to insert into
const existingSeasons = await db.query('SELECT "Id" FROM jf_library_seasons').then(res => res.rows.map(row => row.Id));
const existingEpisodes = await db.query('SELECT "Id" FROM jf_library_episodes').then(res => res.rows.map(row => row.Id));
const seasonsToInsert = await data.allSeasons.filter(row => !existingSeasons.includes(row.Id)).map(item => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
IndexNumber: item.IndexNumber,
Type: item.Type,
ParentLogoItemId: item.ParentLogoItemId,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags: item.ParentBackdropImageTags!==undefined ? item.ParentBackdropImageTags[0] : null,
SeriesName: item.SeriesName,
SeriesId: item.ParentId,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag
}));
const episodesToInsert =await data.allEpisodes.filter(row => !existingEpisodes.includes((row.Id+row.ParentId))).map(item => ({
Id: (item.Id+item.ParentId),
EpisodeId: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
OfficialRating: item.OfficialRating,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
Type: item.Type,
ParentLogoItemId: item.ParentLogoItemId,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags:item.ParentBackdropImageTags!==undefined ? item.ParentBackdropImageTags[0] : null,
SeriesId: item.SeriesId,
SeasonId: item.ParentId,
SeasonName: item.SeasonName,
SeriesName: item.SeriesName
}));
if(seasonsToInsert.length>0)
{
await (async () => {
// const client = await pool.connect();
try {
await db.query('BEGIN');
//insert seasons
const queryseasons = pgp.helpers.insert(seasonsToInsert, columnSeasons, 'jf_library_seasons');
await db.query(queryseasons);
//
await db.query('COMMIT');
console.log('Bulk insert successful');
results.push({Status:'Success',Message:('Season insert successful for '+ show.Name)});
} catch (error) {
await db.query('ROLLBACK');
console.error('Error performing bulk insert:', error);
results.push({Status:'Error',Message:('Error performing bulk insert:', error)});
}
})();
}else{
results.push({Status:'Information',Message:'No new seasons to insert for '+ show.Name});
}
if(episodesToInsert.length>0)
{
await (async () => {
// const client = await pool.connect();
try {
await db.query('BEGIN');
//insert episodes
const queryepisodes = pgp.helpers.insert(episodesToInsert, columnEpisodes, 'jf_library_episodes');
await db.query(queryepisodes);
//
await db.query('COMMIT');
console.log('Bulk insert successful');
results.push({Status:'Success',Message:('Episode insert successful for '+ show.Name)});
} catch (error) {
await db.query('ROLLBACK');
console.error('Error performing bulk insert:', error);
results.push({Status:'Error',Message:('Error performing bulk insert:', error)});
}
})();
}else{
results.push({Status:'Information',Message:'No new episodes to insert for '+ show.Name});
}
}
} else {
console.log("No shows found.");
results.push({Status:'Error',Message:'No shows found.'});
}
res.send(results);
console.log(`ENDPOINT CALLED: /writeSeasonsAndEpisodes: `);
});
router.get('/writeLibraries', async (req, res) => {
const sync = require('./sync');
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: 'Config Details Not Found' });
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getLibraries();
// res.send(data);
// return;
const columns = ['Id', 'Name', 'ServerId', 'IsFolder', 'Type', 'CollectionType', 'ImageTagsPrimary']; // specify the columns to insert into
const existingIds = await db.query('SELECT "Id" FROM jf_libraries').then(res => res.rows.map(row => row.Id));
const dataToInsert = data.filter(row => !existingIds.includes(row.Id)).map(item => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
IsFolder: item.IsFolder,
Type: item.Type,
CollectionType: item.CollectionType,
ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null
}));
if(dataToInsert.length===0)
{
res.send('No new libraries to insert');
return;
}
(async () => {
// const client = await pool.connect();
try {
await db.query('BEGIN');
const query = pgp.helpers.insert(dataToInsert, columns, 'jf_libraries');
await db.query(query);
await db.query('COMMIT');
console.log('Bulk insert successful');
res.send('Bulk insert successful');
} catch (error) {
await db.query('ROLLBACK');
console.error('Error performing bulk insert:', error);
res.send(('Error performing bulk insert:', error));
}
})();
// res.send(results);
console.log(`ENDPOINT CALLED: /writeLibraries: `);
});
router.get('/writeLibraryItems', async (req, res) => {
const sync = require('./sync');
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: 'Config Details Not Found' });
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getAllItems();
const existingIds = await db.query('SELECT "Id" FROM jf_library_items').then(res => res.rows.map(row => row.Id));
// res.send(data);
// return;
const columns = ['Id', 'Name', 'ServerId', 'PremiereDate', 'EndDate', 'CommunityRating', 'RunTimeTicks', 'ProductionYear', 'IsFolder', 'Type', 'Status', 'ImageTagsPrimary', 'ImageTagsBanner', 'ImageTagsLogo', 'ImageTagsThumb', 'BackdropImageTags', 'ParentId']; // specify the columns to insert into
const dataToInsert = data.filter(row => !existingIds.includes(row.Id)).map(item => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
EndDate: item.EndDate,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IsFolder: item.IsFolder,
Type: item.Type,
Status: item.Status,
ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
ImageTagsBanner: item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null,
ImageTagsLogo: item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null,
ImageTagsThumb: item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
BackdropImageTags: item.BackdropImageTags[0],
ParentId: item.ParentId
}));
if(dataToInsert.length===0)
{
res.send('No new library Items to insert');
return;
}
(async () => {
// const client = await pool.connect();
try {
await db.query('BEGIN');
await db.query('TRUNCATE TABLE jf_library_items');
const query = pgp.helpers.insert(dataToInsert, columns, 'jf_library_items');
await db.query(query);
await db.query('COMMIT');
console.log('Bulk insert successful');
res.send('Bulk insert successful');
} catch (error) {
await db.query('ROLLBACK');
console.error('Error performing bulk insert:', error);
res.send(('Error performing bulk insert:', error));
}
})();
// res.send(results);
console.log(`ENDPOINT CALLED: /writeLibraryItems: `);
});
module.exports = router;

View File

@@ -2,6 +2,8 @@
const express = require('express');
const cors = require('cors');
const apiRouter = require('./api');
const syncRouter = require('./sync');
const statsRouter = require('./stats');
const app = express();
const PORT = process.env.PORT || 3003;
@@ -10,6 +12,8 @@ const LISTEN_IP = '127.0.0.1';
app.use(express.json()); // middleware to parse JSON request bodies
app.use(cors());
app.use('/api', apiRouter); // mount the API router at /api
app.use('/sync', syncRouter); // mount the API router at /sync
app.use('/stats', statsRouter); // mount the API router at /stats
app.listen(PORT, () => {
console.log(`Server listening on http://${LISTEN_IP}:${PORT}`);

18
backend/stats.js Normal file
View File

@@ -0,0 +1,18 @@
// api.js
const express = require("express");
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) => {
const { rows } = await db.query('SELECT * FROM jf_library_count_view');
res.send(rows);
console.log(`ENDPOINT CALLED: /getLibraryOverview`);
});
module.exports = router;

View File

@@ -1,25 +1,34 @@
// import { Component } from 'react';
const axios = require('axios');
// import Config from '../lib/config';
const express = require("express");
const pgp = require("pg-promise")();
const db = require("./db");
const axios = require("axios");
class sync {
const ws = require("./WebsocketHandler");
const sendMessageToClients = ws(8080);
const router = express.Router();
/////////////////////////////////////////Functions
class sync {
constructor(hostUrl, apiKey) {
this.hostUrl = hostUrl;
this.apiKey = apiKey;
}
async getAdminUser() {
try {
const url = `${this.hostUrl}/Users`;
console.log('getAdminUser: ',url);
console.log("getAdminUser: ", url);
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': this.apiKey,
"X-MediaBrowser-Token": this.apiKey,
},
});
const adminUser = response.data.filter(user => user.Policy.IsAdministrator === true);
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
return adminUser || null;
} catch (error) {
console.log(error);
@@ -27,106 +36,697 @@ class sync {
}
}
async getAllItems() {
if( this.hostUrl ===undefined || this.apiKey ===undefined)
{
return('Error: Method Not Initialized');
}
const libraries=await this.getLibraries();
const allitems=[];
for (let i = 0; i < libraries.length; i++) {
const item = libraries[i];
let libraryItems=await this.getItem(item.Id);
// allitems.push({library:item, data:libraryItems});
const libraryItemsWithParent = libraryItems.map(items => ({ ...items, ...{ParentId: item.Id} }));
// libraryItems["ParentId"]=item.Id;
allitems.push(...libraryItemsWithParent);
}
return allitems;
}
async getLibraries() {
try {
const admins=await this.getAdminUser()
const userid=admins[0].Id;
const url = `${this.hostUrl}/users/${userid}/Items`;
console.log('getLibraries: ',url);
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': this.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 admins=await this.getAdminUser()
const userid=admins[0].Id;
const url = `${this.hostUrl}/users/${userid}/Items?ParentID=${itemID}`;
const admins = await this.getAdminUser();
const userid = admins[0].Id;
let url = `${this.hostUrl}/users/${userid}/Items`;
if (itemID !== undefined) {
url += `?ParentID=${itemID}`;
}
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': this.apiKey,
"X-MediaBrowser-Token": this.apiKey,
},
});
return response.data.Items;
const results = response.data.Items;
if (itemID === undefined) {
return results.filter((type) =>
["tvshows", "movies"].includes(type.CollectionType)
);
} else {
return results;
}
} catch (error) {
console.log(error);
return [];
}
}
async getSeasonsAndEpisodes(showId) {
const allSeasons = [];
const allEpisodes = [];
async getShows() {
let libraries=await this.getLibraries();
libraries=libraries.filter(type => type.CollectionType==='tvshows');
const allShows=[];
for (let i = 0; i < libraries.length; i++) {
const item = libraries[i];
const showItems=await this.getItem(item.Id);
const showsWithParent = showItems.map(items => ({ ...items, ...{ParentId: item.Id} }));
allShows.push(...showsWithParent);
}
return allShows;
let seasonItems = await this.getItem(showId);
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);
const episodeWithParent = episodeItems.map((items) => ({
...items,
...{ ParentId: season.Id },
}));
allEpisodes.push(...episodeWithParent);
}
return { allSeasons: allSeasons, allEpisodes: allEpisodes };
}
}
////////////////////////////////////////API Methods
///////////////////////////////////////writeLibraries
router.get("/writeLibraries", async (req, res) => {
let message = [];
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
sendMessageToClients({Message:"Error: Config details not found!" });
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getItem(); //getting all root folders aka libraries
async getSeasonsAndEpisodes(showId) {
const allSeasons=[];
const allEpisodes=[];
const columns = [
"Id",
"Name",
"ServerId",
"IsFolder",
"Type",
"CollectionType",
"ImageTagsPrimary",
]; // specify the columns to insert into
const existingIds = await db
.query('SELECT "Id" FROM jf_libraries')
.then((res) => res.rows.map((row) => row.Id)); // get existing library Ids from the db
//data mapping
const mapping = (item) => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
IsFolder: item.IsFolder,
Type: item.Type,
CollectionType: item.CollectionType,
ImageTagsPrimary:
item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
});
let dataToInsert = [];
//filter fix if jf_libraries is empty
if (existingIds.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
dataToInsert = await data.map(mapping);
} else {
// otherwise, filter only new data to insert
dataToInsert = await data
.filter((row) => !existingIds.includes(row.Id))
.map(mapping);
}
//Bulkinsert new data not on db
if (dataToInsert.length !== 0) {
//insert new
await (async () => {
try {
await db.query("BEGIN");
const query = pgp.helpers.insert(dataToInsert, columns, "jf_libraries");
await db.query(query);
await db.query("COMMIT");
message.push({
Type: "Success",
Message: dataToInsert.length + " Rows Inserted.",
});
sendMessageToClients(dataToInsert.length + " Rows Inserted.");
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + error,
});
sendMessageToClients({Message:"Error performing bulk insert:" + error});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk insert" });
sendMessageToClients({Message:"No new data to bulk insert"});
}
//Bulk delete from db thats no longer on api
if (existingIds.length > data.length) {
await (async () => {
try {
await db.query("BEGIN");
const AllIds = data.map((row) => row.Id);
const deleteQuery = {
text: `DELETE FROM jf_libraries WHERE "Id" NOT IN (${pgp.as.csv(
AllIds
)})`,
};
const queries = [deleteQuery];
for (let query of queries) {
await db.query(query);
}
await db.query("COMMIT");
message.push({
Type: "Success",
Message: existingIds.length - data.length + " Rows Removed.",
});
sendMessageToClients(existingIds.length - data.length + " Rows Removed.");
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk removal:" + error,
});
sendMessageToClients({Message:"Error performing bulk removal:" + error});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk delete" });
sendMessageToClients({Message:"No new data to bulk delete"});
}
//Sent logs
let seasonItems=await this.getItem(showId);
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);
const episodeWithParent = episodeItems.map(items => ({ ...items, ...{ParentId: season.Id} }));
allEpisodes.push(...episodeWithParent);
res.send(message);
}
console.log(`ENDPOINT CALLED: /writeLibraries: `);
});
return {allSeasons:allSeasons,allEpisodes:allEpisodes};
}
//////////////////////////////////////////////////////writeLibraryItems
router.get("/writeLibraryItems", async (req, res) => {
let message = [];
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);
//Get all Library items
//gets all libraries
const libraries = await _sync.getItem();
const data = [];
//for each item in library run get item using that id as the ParentId (This gets the children of the parent id)
for (let i = 0; i < libraries.length; i++) {
const item = libraries[i];
let libraryItems = await _sync.getItem(item.Id);
const libraryItemsWithParent = libraryItems.map((items) => ({
...items,
...{ ParentId: item.Id },
}));
data.push(...libraryItemsWithParent);
}
/////////////////////
module.exports= sync;
const existingIds = await db
.query('SELECT "Id" FROM jf_library_items')
.then((res) => res.rows.map((row) => row.Id));
//Mappings to store data in DB
const columns = [
"Id",
"Name",
"ServerId",
"PremiereDate",
"EndDate",
"CommunityRating",
"RunTimeTicks",
"ProductionYear",
"IsFolder",
"Type",
"Status",
"ImageTagsPrimary",
"ImageTagsBanner",
"ImageTagsLogo",
"ImageTagsThumb",
"BackdropImageTags",
"ParentId",
]; // specify the columns to insert into
//data mapping
const mapping = (item) => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
EndDate: item.EndDate,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IsFolder: item.IsFolder,
Type: item.Type,
Status: item.Status,
ImageTagsPrimary:
item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
ImageTagsBanner:
item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null,
ImageTagsLogo:
item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null,
ImageTagsThumb:
item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
BackdropImageTags: item.BackdropImageTags[0],
ParentId: item.ParentId,
});
let dataToInsert = [];
//filter fix if jf_libraries is empty
if (existingIds.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
dataToInsert = await data.map(mapping);
} else {
// otherwise, filter only new data to insert
dataToInsert = await data
.filter((row) => !existingIds.includes(row.Id))
.map(mapping);
}
//Bulkinsert new data not on db
if (dataToInsert.length !== 0) {
//insert new
await (async () => {
try {
await db.query("BEGIN");
const query = pgp.helpers.insert(
dataToInsert,
columns,
"jf_library_items"
);
await db.query(query);
await db.query("COMMIT");
message.push({
Type: "Success",
Message: dataToInsert.length + " Rows Inserted.",
});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + error,
});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk insert" });
}
//Bulk delete from db thats no longer on api
if (existingIds.length > data.length) {
await (async () => {
try {
await db.query("BEGIN");
const AllIds = data.map((row) => row.Id);
const deleteQuery = {
text: `DELETE FROM jf_library_items WHERE "Id" NOT IN (${pgp.as.csv(
AllIds
)})`,
};
const queries = [deleteQuery];
for (let query of queries) {
await db.query(query);
}
await db.query("COMMIT");
message.push({
Type: "Success",
Message: existingIds.length - data.length + " Rows Removed.",
});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk removal:" + error,
});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk delete" });
}
//Sent logs
res.send(message);
console.log(`ENDPOINT CALLED: /writeLibraryItems: `);
});
//////////////////////////////////////////////////////writeSeasonsAndEpisodes
router.get("/writeSeasonsAndEpisodes", async (req, res) => {
sendMessageToClients({color:'yellow',Message:"Beginning Seasons and Episode sync"});
const message = [];
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: shows } = await db.query(
`SELECT * FROM public.jf_library_items where "Type"='Series'`
);
//loop for each show
for (const show of shows) {
const data = await _sync.getSeasonsAndEpisodes(show.Id);
//
//get existing seasons and episodes
console.log(show.Id);
const existingIdsSeasons = await db
.query(
`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`
)
.then((res) => res.rows.map((row) => row.Id));
let existingIdsEpisodes = [];
if (existingIdsSeasons.length > 0) {
existingIdsEpisodes = await db
.query(
`SELECT * FROM public.jf_library_episodes WHERE "SeasonId" IN (${existingIdsSeasons
.filter((seasons) => seasons !== "")
.map((seasons) => pgp.as.value(seasons))
.map((value) => "'" + value + "'")
.join(", ")})`
)
.then((res) => res.rows.map((row) => row.Id));
}
//Mappings to store data in DB
const columnSeasons = [
"Id",
"Name",
"ServerId",
"IndexNumber",
"Type",
"ParentLogoItemId",
"ParentBackdropItemId",
"ParentBackdropImageTags",
"SeriesName",
"SeriesId",
"SeriesPrimaryImageTag",
]; // specify the columns to insert into
const columnEpisodes = [
"Id",
"EpisodeId",
"Name",
"ServerId",
"PremiereDate",
"OfficialRating",
"CommunityRating",
"RunTimeTicks",
"ProductionYear",
"IndexNumber",
"ParentIndexNumber",
"Type",
"ParentLogoItemId",
"ParentBackdropItemId",
"ParentBackdropImageTags",
"SeriesId",
"SeasonId",
"SeasonName",
"SeriesName",
]; // specify the columns to insert into
//data mapping
const seasonsmapping = (item) => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
IndexNumber: item.IndexNumber,
Type: item.Type,
ParentLogoItemId: item.ParentLogoItemId,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags:
item.ParentBackdropImageTags !== undefined
? item.ParentBackdropImageTags[0]
: null,
SeriesName: item.SeriesName,
SeriesId: item.ParentId,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
});
const episodemapping = (item) => ({
Id: item.Id + item.ParentId,
EpisodeId: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
OfficialRating: item.OfficialRating,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
Type: item.Type,
ParentLogoItemId: item.ParentLogoItemId,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags:
item.ParentBackdropImageTags !== undefined
? item.ParentBackdropImageTags[0]
: null,
SeriesId: item.SeriesId,
SeasonId: item.ParentId,
SeasonName: item.SeasonName,
SeriesName: item.SeriesName,
});
//
let seasonsToInsert = [];
let episodesToInsert = [];
//filter fix if jf_libraries is empty
if (existingIdsSeasons.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
seasonsToInsert = await data.allSeasons.map(seasonsmapping);
} else {
// otherwise, filter only new data to insert
seasonsToInsert = await data.allSeasons
.filter((row) => !existingIdsSeasons.includes(row.Id))
.map(seasonsmapping);
}
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(episodemapping);
} else {
// otherwise, filter only new data to insert
episodesToInsert = await data.allEpisodes
.filter((row) => !existingIdsEpisodes.includes(row.Id + row.ParentId))
.map(episodemapping);
}
///insert delete seasons
//Bulkinsert new data not on db
if (seasonsToInsert.length !== 0) {
//insert new
await (async () => {
try {
await db.query("BEGIN");
const query = pgp.helpers.insert(
seasonsToInsert,
columnSeasons,
"jf_library_seasons"
);
await db.query(query);
await db.query("COMMIT");
message.push({
Type: "Success",
Message: seasonsToInsert.length + " Rows Inserted for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({color:'cornflowerblue',Message:seasonsToInsert.length + " Rows Inserted for " + show.Name});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + error,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({color:'red',Message:"Error performing bulk insert:" + error});
}
})();
} else {
message.push({
Type: "Success",
Message: "No new data to bulk insert for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({Message:"No new data to bulk insert for " + show.Name});
}
//Bulk delete from db thats no longer on api
if (existingIdsSeasons.length > data.allSeasons.length) {
await (async () => {
try {
await db.query("BEGIN");
const AllIds = data.allSeasons.map((row) => row.Id);
const deleteQuery = {
text: `DELETE FROM jf_library_seasons WHERE "Id" NOT IN (${pgp.as.csv(
AllIds
)})`,
};
const queries = [deleteQuery];
for (let query of queries) {
await db.query(query);
}
await db.query("COMMIT");
message.push({
Type: "Success",
Message:
existingIdsSeasons.length -
data.allSeasons.length +
" Rows Removed for " +
show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({color:'orange',Message:existingIdsSeasons.length -data.allSeasons.length +" Rows Removed for " +show.Name});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk removal:" + error,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({color:'red',Message:"Error performing bulk removal:" + error});
}
})();
} else {
message.push({
Type: "Success",
Message: "No new data to bulk delete for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({Message:"No new data to bulk delete for " + show.Name});
}
//insert delete episodes
//Bulkinsert new data not on db
if (episodesToInsert.length !== 0) {
//insert new
await (async () => {
try {
await db.query("BEGIN");
const query = pgp.helpers.insert(
episodesToInsert,
columnEpisodes,
"jf_library_episodes"
);
await db.query(query);
await db.query("COMMIT");
message.push({
Type: "Success",
Message:
episodesToInsert.length + " Rows Inserted for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({color:'cornflowerblue',Message:episodesToInsert.length + " Rows Inserted for " + show.Name});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + error,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({color:'red',Message:"Error performing bulk insert:" + error});
}
})();
} else {
message.push({
Type: "Success",
Message: "No new data to bulk insert for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({Message:"No new data to bulk insert for " + show.Name});
}
//Bulk delete from db thats no longer on api
if (existingIdsEpisodes.length > data.allEpisodes.length) {
await (async () => {
try {
await db.query("BEGIN");
const AllIds = data.allEpisodes.map((row) => row.Id + row.ParentId);
const deleteQuery = {
text: `DELETE FROM jf_library_episodes WHERE "Id" NOT IN (${pgp.as.csv(
AllIds
)})`,
};
const queries = [deleteQuery];
for (let query of queries) {
await db.query(query);
}
await db.query("COMMIT");
message.push({
Type: "Success",
Message:
existingIdsEpisodes.length -
data.allEpisodes.length +
" Rows Removed for " +
show.Name,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({color:'orange',Message: existingIdsEpisodes.length - data.allEpisodes.length + " Rows Removed for " + show.Name});
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk removal:" + error,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({color:'red',Message:"Error performing bulk removal:" + error});
}
})();
} else {
message.push({
Type: "Success",
Message: "No new data to bulk delete for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_episodes",
});
sendMessageToClients({Message:"No new data to bulk delete for " + show.Name});
}
}
sendMessageToClients({color:'lightgreen',Message:"Sync Complete"});
res.send(message);
console.log(`ENDPOINT CALLED: /writeSeasonsAndEpisodes: `);
});
//////////////////////////////////////
module.exports = router;

73
package-lock.json generated
View File

@@ -27,7 +27,8 @@
"remixicon-react": "^1.0.0",
"sequelize": "^6.29.0",
"sqlite3": "^5.1.4",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"ws": "^8.13.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -12885,6 +12886,26 @@
}
}
},
"node_modules/jsdom/node_modules/ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
@@ -19263,26 +19284,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz",
"integrity": "sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/webpack-manifest-plugin": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz",
@@ -19857,15 +19858,15 @@
}
},
"node_modules/ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"engines": {
"node": ">=8.3.0"
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
@@ -29181,6 +29182,14 @@
"whatwg-url": "^8.5.0",
"ws": "^7.4.6",
"xml-name-validator": "^3.0.0"
},
"dependencies": {
"ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"requires": {}
}
}
},
"jsesc": {
@@ -33647,12 +33656,6 @@
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.0.0"
}
},
"ws": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz",
"integrity": "sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==",
"requires": {}
}
}
},
@@ -34124,9 +34127,9 @@
}
},
"ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"requires": {}
},
"xml-name-validator": {

View File

@@ -22,7 +22,8 @@
"remixicon-react": "^1.0.0",
"sequelize": "^6.29.0",
"sqlite3": "^5.1.4",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"ws": "^8.13.0"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -1,6 +1,6 @@
import { Component } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import { Component } from "react";
import axios from "axios";
import Config from "../lib/config";
class API extends Component {
constructor(props) {
@@ -10,14 +10,13 @@ class API extends Component {
};
}
async getSessions() {
try {
const config = await Config();
const url = `${config.hostUrl}/Sessions`;
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data;
@@ -28,16 +27,15 @@ class API extends Component {
}
async getActivityData(limit) {
if(limit===undefined || limit<1)
{
return[];
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,
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data;
@@ -53,10 +51,12 @@ class API extends Component {
const url = `${config.hostUrl}/Users`;
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
"X-MediaBrowser-Token": config.apiKey,
},
});
const adminUser = response.data.filter(user => user.Policy.IsAdministrator === true);
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
return adminUser || null;
} catch (error) {
console.log(error);
@@ -65,35 +65,35 @@ class API extends Component {
}
async getLibraries() {
try {
const config = await Config();
const admins=await this.getAdminUser()
const userid=admins[0].Id;
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,
"X-MediaBrowser-Token": config.apiKey,
},
});
const mediafolders = response.data.Items.filter(type => ['tvshows','movies'].includes(type.CollectionType));
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 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,
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data.Items;
@@ -103,14 +103,13 @@ class API extends Component {
}
}
async getRecentlyPlayed(userid,limit) {
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,
"X-MediaBrowser-Token": config.apiKey,
},
});
return response.data.Items;
@@ -119,8 +118,6 @@ class API extends Component {
return [];
}
}
}
export default API;

View File

@@ -1,50 +1,44 @@
import React, { useState, useEffect } from 'react';
import API from '../classes/jellyfin-api';
import '../App.css'
import Loading from './components/loading';
import React, { useState, useEffect } from "react";
import API from "../classes/jellyfin-api";
import "../App.css";
import Loading from "./components/loading";
function Activity() {
const [data, setData] = useState([]);
useEffect(() => {
let _api= new API()
let _api = new API();
const fetchData = () => {
_api.getActivityData(30).then((ActivityData) => {
if (data && data.length > 0)
{
const newDataOnly = ActivityData.Items.filter(item => {
return !data.some(existingItem => existingItem.Id === item.Id);
if (data && data.length > 0) {
const newDataOnly = ActivityData.Items.filter((item) => {
return !data.some((existingItem) => existingItem.Id === item.Id);
});
setData([...newDataOnly, ...data.slice(0, data.length - newDataOnly.length)]);
} else
{
setData([
...newDataOnly,
...data.slice(0, data.length - newDataOnly.length),
]);
} else {
setData(ActivityData.Items);
}
});
};
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, [data]);
const options = {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
};
if (!data || data.length === 0) {
@@ -52,16 +46,25 @@ function Activity() {
}
return (
<div className='Activity'>
<div className="Activity">
<h1>Activity Log</h1>
<ul>
{data &&
data.map(item => (
<li key={item.Id} className={data.findIndex(items => items.Id === item.Id) <= 30 ? 'new' : 'old'}>
<div className='ActivityDetail'> {item.Name}</div>
<div className='ActivityTime'>{new Date(item.Date).toLocaleString('en-GB', options).replace(',', '')}</div>
data.map((item) => (
<li
key={item.Id}
className={
data.findIndex((items) => items.Id === item.Id) <= 30
? "new"
: "old"
}
>
<div className="ActivityDetail"> {item.Name}</div>
<div className="ActivityTime">
{new Date(item.Date)
.toLocaleString("en-GB", options)
.replace(",", "")}
</div>
</li>
))}
</ul>
@@ -70,4 +73,3 @@ function Activity() {
}
export default Activity;

View File

@@ -1,60 +1,76 @@
import "../css/libraryOverview.css"
import React, { useState, useEffect } from 'react';
import axios from "axios";
import "../css/libraryOverview.css";
import Config from "../../lib/config";
import React, { useState, useEffect } from "react";
import axios from "axios";
import Loading from "./loading";
export default function LibraryOverView() {
const [data, setData] = useState([]);
const [data, setData] = useState([]);
const [base_url, setURL] = useState("");
useEffect(() => {
if (base_url === "") {
Config()
.then((config) => {
setURL(config.hostUrl);
})
.catch((error) => {
console.log(error);
});
}
const fetchData = () => {
const url = `http://localhost:3003/stats/getLibraryOverview`;
axios
.get(url)
.then((response) => setData(response.data))
.catch((error) => console.log(error));
};
useEffect(() => {
Config().then(config => {
const url = `${config.hostUrl}/Items/counts`;
const fetchData = () => {
axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
},
})
.then(response => setData(response.data))
.catch(error => console.log(error));
};
fetchData();
const intervalId = setInterval(fetchData, 60000);
return () => clearInterval(intervalId);
});
}, []);
if (data.length === 0) {
return <Loading />;
if (!data || data.length === 0) {
fetchData();
}
}, [data,base_url]);
return (
<div className="overview">
<div className="card">
<div className="item-card-count">
{data.MovieCount + data.EpisodeCount} Media Files
</div>
if (data.length === 0) {
return <Loading />;
}
return (
<div className="overview">
{data &&
data.map((stats) => (
<div className="card" style={{
backgroundImage: `url(${
base_url +
"/Items/" +
(stats.Isd) +
"/Images/Primary?quality=50"
})`,
}}
key={stats.Id}
>
<div className="item-count">
<div>
<p>Items in Library</p><p>{stats.Library_Count}</p>
</div>
{stats.CollectionType === "tvshows" ? (
<div>
<div className="item-count">
<p>{data.MovieCount}</p> <p>Movies</p>
</div>
<div className="item-count">
<p>{data.BoxSetCount}</p> <p>Box Sets</p>
</div>
<div className="item-count">
<p>{data.SeriesCount}</p> <p>Shows</p>
</div>
<div className="item-count">
<p>{data.EpisodeCount}</p> <p>Episodes</p>
</div>
<p>Seasons</p><p>{stats.Season_Count}</p>
</div>
) : (
<></>
)}
{stats.CollectionType === "tvshows" ? (
<div>
<p>Episodes</p><p>{stats.Episode_Count}</p>
</div>
) : (
<></>
)}
</div>
</div>
)
}
</div>
))}
</div>
);
}

View File

@@ -1,69 +1,76 @@
import React from 'react';
import React from "react";
function getLastPlayedTimeString(datetime) {
const now = new Date();
const lastPlayed = new Date(datetime);
const timeDifference = Math.abs(now.getTime() - lastPlayed.getTime());
const yearsDifference = Math.floor(timeDifference / (1000 * 3600 * 24 * 365));
const weeksDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24 * 365)) / (1000 * 3600 * 24 * 7)
);
const daysDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24 * 7)) / (1000 * 3600 * 24)
);
const hoursDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24)) / (1000 * 3600)
);
const minutesDifference = Math.floor(
(timeDifference % (1000 * 3600)) / (1000 * 60)
);
const timeUnits = [
{ label: "year", pluralLabel: "years", value: yearsDifference },
{ label: "week", pluralLabel: "weeks", value: weeksDifference },
{ label: "day", pluralLabel: "days", value: daysDifference },
{ label: "hour", pluralLabel: "hours", value: hoursDifference },
{ label: "minute", pluralLabel: "minutes", value: minutesDifference },
];
const timeString = timeUnits
.filter((unit) => unit.value > 0)
.map((unit, index, array) => {
const label = unit.value === 1 ? unit.label : unit.pluralLabel;
if (index === array.length - 1 && array.length > 1) {
// Special case for last time unit
return `and ${unit.value} ${label}`;
} else {
return `${unit.value} ${label}`;
}
})
.join(" ");
return `Watched ${timeString} ago`;
}
const now = new Date();
const lastPlayed = new Date(datetime);
const timeDifference = Math.abs(now.getTime() - lastPlayed.getTime());
const yearsDifference = Math.floor(timeDifference / (1000 * 3600 * 24 * 365));
const weeksDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24 * 365)) / (1000 * 3600 * 24 * 7)
);
const daysDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24 * 7)) / (1000 * 3600 * 24)
);
const hoursDifference = Math.floor(
(timeDifference % (1000 * 3600 * 24)) / (1000 * 3600)
);
const minutesDifference = Math.floor(
(timeDifference % (1000 * 3600)) / (1000 * 60)
);
const timeUnits = [
{ label: "year", pluralLabel: "years", value: yearsDifference },
{ label: "week", pluralLabel: "weeks", value: weeksDifference },
{ label: "day", pluralLabel: "days", value: daysDifference },
{ label: "hour", pluralLabel: "hours", value: hoursDifference },
{ label: "minute", pluralLabel: "minutes", value: minutesDifference },
];
const timeString = timeUnits
.filter((unit) => unit.value > 0)
.map((unit, index, array) => {
const label = unit.value === 1 ? unit.label : unit.pluralLabel;
if (index === array.length - 1 && array.length > 1) {
// Special case for last time unit
return `and ${unit.value} ${label}`;
} else {
return `${unit.value} ${label}`;
}
})
.join(" ");
return `Watched ${timeString} ago`;
}
function RecentCard(props) {
return (
<div key={props.data.recent.Id} className='recent-card' >
<div key={props.data.recent.Id} className="recent-card">
<div
className="recent-card-banner"
style={{
backgroundImage: `url(${
props.data.base_url +
"/Items/" +
(props.data.recent.SeriesId
? props.data.recent.SeriesId
: props.data.recent.Id) +
"/Images/Primary?quality=50&tag=" +
props.data.recent.SeriesPrimaryImageTag ||
props.data.recent.ImageTags.Primary
})`,
}}
></div>
<div className='recent-card-banner'
style={{ backgroundImage: `url(${props.data.base_url + '/Items/' + (props.data.recent.SeriesId ? props.data.recent.SeriesId : props.data.recent.Id) + '/Images/Primary?quality=50&tag=' + props.data.recent.SeriesPrimaryImageTag || props.data.recent.ImageTags.Primary})` }}
>
<div className="recent-card-details">
<div className="recent-card-item-name"> {props.data.recent.Name}</div>
<div className="recent-card-last-played">
{" "}
{getLastPlayedTimeString(props.data.recent.UserData.LastPlayedDate)}
</div>
<div className='recent-card-details' >
<div className='recent-card-item-name'> {props.data.recent.Name}</div>
<div className='recent-card-last-played'> {getLastPlayedTimeString(props.data.recent.UserData.LastPlayedDate)}</div>
</div>
</div>
);
}
export default RecentCard;
export default RecentCard;

View File

@@ -1,68 +1,58 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
// import axios from 'axios';
import Config from '../../lib/config';
import API from '../../classes/jellyfin-api';
import Config from "../../lib/config";
import API from "../../classes/jellyfin-api";
import "../css/recent.css"
import "../css/recent.css";
// import "../../App.css"
import RecentCard from "./recent-card";
import RecentCard from './recent-card';
import Loading from './loading';
import Loading from "./loading";
function RecentlyPlayed() {
const [data, setData] = useState([]);
const [base_url, setURL] = useState('');
const [base_url, setURL] = useState("");
// const [errorHandler, seterrorHandler] = useState({ error_count: 0, error_message: '' })
useEffect(() => {
const _api = new API();
const fetchData = () => {
_api.getRecentlyPlayed('5f63950a2339462196eb8cead70cae7e',10).then((recentData) => {
setData(recentData);
});
_api
.getRecentlyPlayed("5f63950a2339462196eb8cead70cae7e", 10)
.then((recentData) => {
setData(recentData);
});
};
Config().then(config => {
setURL(config.hostUrl);
}).catch(error => {
console.log(error);
}
);
Config()
.then((config) => {
setURL(config.hostUrl);
})
.catch((error) => {
console.log(error);
});
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, []);
if (!data || data.length === 0) {
return <Loading />;
}
return (
<div className='recent'>
<div className="recent">
{data &&
data.sort((a, b) => b.UserData.LastPlayedDate.localeCompare(a.UserData.LastPlayedDate)).map(recent => (
<RecentCard data={{ recent: recent, base_url: base_url }} />
))}
data
.sort((a, b) =>
b.UserData.LastPlayedDate.localeCompare(a.UserData.LastPlayedDate)
)
.map((recent) => (
<RecentCard data={{ recent: recent, base_url: base_url }} />
))}
</div>
);
}
export default RecentlyPlayed;

View File

@@ -1,14 +1,8 @@
import React from 'react';
import React from "react";
import AccountCircleFillIcon from 'remixicon-react/AccountCircleFillIcon';
// import InformationFillIcon from 'remixicon-react/InformationFillIcon';
import PlayFillIcon from 'remixicon-react/PlayFillIcon';
import PauseFillIcon from 'remixicon-react/PauseFillIcon';
// import { Tooltip } from 'antd';
// import { Tooltip } from "@mui/material";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import PlayFillIcon from "remixicon-react/PlayFillIcon";
import PauseFillIcon from "remixicon-react/PauseFillIcon";
function ticksToTimeString(ticks) {
// Convert ticks to seconds
@@ -20,7 +14,9 @@ function ticksToTimeString(ticks) {
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')}`;
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
return timeString;
}
@@ -29,82 +25,200 @@ function sessionCard(props) {
// Access data passed in as a prop using `props.data`
if (props.data.session.NowPlayingItem === undefined) {
return(
<div key={props.data.session.Id} className='session-card' >
return (
<div key={props.data.session.Id} className="session-card">
<div className="card-banner"></div>
<div className='card-banner'>
</div>
<div className='card-details' >
<div className='card-device'>
<img className='card-device-image' src={props.data.base_url + '/web/assets/img/devices/' + ((props.data.session.DeviceName.toLowerCase().includes('ios') || props.data.session.Client.toLowerCase().includes('ios')) ? 'ios' : (props.data.session.DeviceName.toLowerCase().includes('android') || props.data.session.Client.toLowerCase().includes('android')) ? 'android' : props.data.session.DeviceName.replace(' ', '').toLowerCase()) + '.svg'} alt=''></img>
<div className='card-device-name'> {props.data.session.DeviceName}</div>
<div className='card-client'>{props.data.session.Client + ' ' + props.data.session.ApplicationVersion}</div>
<div className="card-details">
<div className="card-device">
<img
className="card-device-image"
src={
props.data.base_url +
"/web/assets/img/devices/" +
(props.data.session.DeviceName.toLowerCase().includes("ios") ||
props.data.session.Client.toLowerCase().includes("ios")
? "ios"
: props.data.session.DeviceName.toLowerCase().includes(
"android"
) ||
props.data.session.Client.toLowerCase().includes("android")
? "android"
: props.data.session.DeviceName.replace(
" ",
""
).toLowerCase()) +
".svg"
}
alt=""
></img>
<div className="card-device-name">
{" "}
{props.data.session.DeviceName}
</div>
<div className="card-client">
{props.data.session.Client +
" " +
props.data.session.ApplicationVersion}
</div>
</div>
<div className='card-user'>
{(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 />}
<div className='card-username'> {props.data.session.UserName}</div>
{/* <div className='card-ip'>{props.data.session.RemoteEndPoint}</div> */}
<div className="card-user">
{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 />
)}
<div className="card-username"> {props.data.session.UserName}</div>
</div>
<div className='card-play-state'>
</div>
<div className='card-item-name'> </div>
<div className='card-playback-position'> </div>
<div className="card-play-state"></div>
<div className="card-item-name"> </div>
<div className="card-playback-position"> </div>
</div>
<div className="progress-bar">
<div className="progress" style={{ width: `0%` }}></div>
<div className="progress" style={{ width: `0%` }}></div>
</div>
</div>
);
}
// console.log(props.data);
return (
<div key={props.data.session.Id} className='session-card' style={{ backgroundImage: `url(${(props.data.base_url + '/Items/' + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + '/Images/Backdrop/0?maxWidth=1000&tag=' + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.ParentBackdropImageTags[0] : props.data.session.NowPlayingItem.BackdropImageTags[0]) + '&quality=50')})` }}>
<div className='card-banner'>
<img className='card-banner-image' src={props.data.base_url + '/Items/' + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + '/Images/Primary?quality=50&tag=' + props.data.session.NowPlayingItem.SeriesPrimaryImageTag || props.data.session.NowPlayingItem.ImageTags.Primary} alt=''></img>
<div
key={props.data.session.Id}
className="session-card"
style={{
backgroundImage: `url(${
props.data.base_url +
"/Items/" +
(props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id) +
"/Images/Backdrop/0?maxWidth=1000&tag=" +
(props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.ParentBackdropImageTags[0]
: props.data.session.NowPlayingItem.BackdropImageTags[0]) +
"&quality=50"
})`,
}}
>
<div className="card-banner">
<img
className="card-banner-image"
src={
props.data.base_url +
"/Items/" +
(props.data.session.NowPlayingItem.SeriesId
? props.data.session.NowPlayingItem.SeriesId
: props.data.session.NowPlayingItem.Id) +
"/Images/Primary?quality=50&tag=" +
props.data.session.NowPlayingItem.SeriesPrimaryImageTag ||
props.data.session.NowPlayingItem.ImageTags.Primary
}
alt=""
></img>
</div>
<div className='card-details' >
<div className='card-device'>
<img className='card-device-image' src={props.data.base_url + '/web/assets/img/devices/' + ((props.data.session.DeviceName.toLowerCase().includes('ios') || props.data.session.Client.toLowerCase().includes('ios')) ? 'ios' : (props.data.session.DeviceName.toLowerCase().includes('android') || props.data.session.Client.toLowerCase().includes('android')) ? 'android' : props.data.session.DeviceName.replace(' ', '').toLowerCase()) + '.svg'} alt=''></img>
<div className='card-device-name'> {props.data.session.DeviceName}</div>
<div className='card-client'>{props.data.session.Client + ' ' + props.data.session.ApplicationVersion}</div>
<div className="card-details">
<div className="card-device">
<img
className="card-device-image"
src={
props.data.base_url +
"/web/assets/img/devices/" +
(props.data.session.DeviceName.toLowerCase().includes("ios") ||
props.data.session.Client.toLowerCase().includes("ios")
? "ios"
: props.data.session.DeviceName.toLowerCase().includes(
"android"
) ||
props.data.session.Client.toLowerCase().includes("android")
? "android"
: props.data.session.DeviceName.replace(
" ",
""
).toLowerCase()) +
".svg"
}
alt=""
></img>
<div className="card-device-name">
{" "}
{props.data.session.DeviceName}
</div>
<div className="card-client">
{props.data.session.Client +
" " +
props.data.session.ApplicationVersion}
</div>
</div>
<div className='card-user'>
{(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 />}
<div className='card-username'> {props.data.session.UserName}</div>
{/* <div className='card-ip'>{props.data.session.RemoteEndPoint}</div> */}
<div className="card-user">
{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 />
)}
<div className="card-username"> {props.data.session.UserName}</div>
</div>
<div className='card-play-state'>{(props.data.session.PlayState.IsPaused) ? <PauseFillIcon /> : <PlayFillIcon />}
{/* <Tooltip title={props.data.session.PlayState.PlayMethod}><InformationFillIcon color="#fff" /></Tooltip> */}
<div className="card-play-state">
{props.data.session.PlayState.IsPaused ? (
<PauseFillIcon />
) : (
<PlayFillIcon />
)}
</div>
<div className="card-item-name">
{" "}
{props.data.session.NowPlayingItem.Name}
</div>
<div className='card-item-name'> {props.data.session.NowPlayingItem.Name}</div>
<div className='card-playback-position'> {ticksToTimeString(props.data.session.PlayState.PositionTicks)} / {ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)}</div>
<div className="card-playback-position">
{" "}
{ticksToTimeString(props.data.session.PlayState.PositionTicks)} /{" "}
{ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)}
</div>
</div>
<div className="progress-bar">
<div className="progress" style={{ width: `${(props.data.session.PlayState.PositionTicks / props.data.session.NowPlayingItem.RunTimeTicks) * 100}%` }}></div>
<div
className="progress"
style={{
width: `${
(props.data.session.PlayState.PositionTicks /
props.data.session.NowPlayingItem.RunTimeTicks) *
100
}%`,
}}
></div>
</div>
</div>
);
}
export default sessionCard;
export default sessionCard;

View File

@@ -1,27 +1,20 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
// import axios from 'axios';
import Config from '../../lib/config';
import API from '../../classes/jellyfin-api';
import Config from "../../lib/config";
import API from "../../classes/jellyfin-api";
import "../css/sessions.css"
import "../css/sessions.css";
// import "../../App.css"
import SessionCard from "./session-card";
import SessionCard from './session-card';
import Loading from './loading';
import Loading from "./loading";
function Sessions() {
const [data, setData] = useState([]);
const [base_url, setURL] = useState('');
const [base_url, setURL] = useState("");
// const [errorHandler, seterrorHandler] = useState({ error_count: 0, error_message: '' })
useEffect(() => {
const _api = new API();
const fetchData = () => {
@@ -30,41 +23,36 @@ function Sessions() {
});
};
if(base_url==='')
{
Config().then(config => {
setURL(config.hostUrl);
}).catch(error => {
console.log(error);
}
);
if (base_url === "") {
Config()
.then((config) => {
setURL(config.hostUrl);
})
.catch((error) => {
console.log(error);
});
}
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, []);
if (!data || data.length === 0) {
return <Loading />;
}
return (
<div className='sessions'>
<div className="sessions">
{data &&
data.sort((a, b) => a.Id.padStart(12, '0').localeCompare(b.Id.padStart(12, '0'))).map(session => (
<SessionCard data={{ session: session, base_url: base_url }} />
))}
data
.sort((a, b) =>
a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))
)
.map((session) => (
<SessionCard data={{ session: session, base_url: base_url }} />
))}
</div>
);
}
export default Sessions;

View File

@@ -0,0 +1,49 @@
import React, { useEffect, useState, useRef } from 'react';
import '../../css/websocket/websocket.css';
const WebSocketComponent = () => {
const [messages, setMessages] = useState([]);
const messagesEndRef = useRef(null);
useEffect(() => {
// create a new WebSocket connection
const socket = new WebSocket('ws://localhost:8080');
// handle incoming messages
socket.addEventListener('message', (event) => {
let message = JSON.parse(event.data);
setMessages(prevMessages => [...prevMessages, message]);
});
// cleanup function to close the WebSocket connection when the component unmounts
return () => {
socket.close();
}
}, []);
// function to handle scrolling to the last message
const scrollToBottom = () => {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
// scroll to the last message whenever messages change
useEffect(() => {
scrollToBottom();
}, [messages]);
return (
<div>
{/* <h1>WebSocket Example</h1> */}
<div className="console-container">
{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>
);
};
export default WebSocketComponent;

View File

@@ -0,0 +1,40 @@
import React from "react";
import axios from "axios";
// import Config from "../../../lib/config";
// import Loading from "../loading";
import "../../css/settings.css";
export default function LibrarySync() {
async function writeSeasonsAndEpisodes() {
// Send a GET request to /system/configuration to test copnnection
let isValid = false;
let errorMessage = "";
await axios
.get("http://localhost:3003/sync/writeSeasonsAndEpisodes")
.then((response) => {
if (response.status === 200) {
isValid = true;
}
})
.catch((error) => {
console.log(error);
});
return { isValid: isValid, errorMessage: errorMessage };
}
const handleClick = () => {
writeSeasonsAndEpisodes();
console.log('Button clicked!');
}
return (
<div>
<button onClick={handleClick}>Run Sync</button>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import Loading from "../loading";
import "../../css/settings.css";
export default function SettingsConfig() {
// const [config, setConfig] = useState({});
const [formValues, setFormValues] = useState({});
const [isSubmitted, setisSubmitted] = useState("");
const [loadSate, setloadSate] = useState("Loading");
const [submissionMessage, setsubmissionMessage] = useState("");
useEffect(() => {
Config()
.then((config) => {
setFormValues({ JF_HOST: config.hostUrl, JF_API_KEY: config.apiKey });
setloadSate("Loaded");
})
.catch((error) => {
console.log("Error updating config:", error);
setloadSate("Critical");
setsubmissionMessage(
"Error Retrieving Configuration. Unable to contact Backend Server"
);
});
}, []);
async function validateSettings(_url, _apikey) {
let isValid = false;
let errorMessage = "";
await axios
.get(_url + "/system/configuration", {
headers: {
"X-MediaBrowser-Token": _apikey,
},
})
.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}`;
}
});
return { isValid: isValid, errorMessage: errorMessage };
}
async function handleFormSubmit(event) {
event.preventDefault();
let validation = await validateSettings(
formValues.JF_HOST,
formValues.JF_API_KEY
);
console.log(validation);
if (!validation.isValid) {
setisSubmitted("Failed");
setsubmissionMessage(validation.errorMessage);
return;
}
setisSubmitted("");
// Send a POST request to /api/setconfig/ with the updated configuration
axios
.post("http://localhost:3003/api/setconfig/", formValues, {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => {
console.log("Config updated successfully:", response.data);
setisSubmitted("Success");
setsubmissionMessage("Success Updated Configuration");
})
.catch((error) => {
console.log("Error updating config:", error);
setisSubmitted("Failed");
setsubmissionMessage("Error Updating Configuration: ", error);
});
}
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
if (loadSate === "Loading") {
return <Loading />;
}
if (loadSate === "Critical") {
return <div className="submit critical">{submissionMessage}</div>;
}
return (
<form onSubmit={handleFormSubmit} className="settings-form">
<div>
<label htmlFor="JF_HOST">Jellyfin Server</label>
<input
type="text"
id="JF_HOST"
name="JF_HOST"
value={formValues.JF_HOST || ""}
onChange={handleFormChange}
/>
</div>
<div>
<label htmlFor="JF_API_KEY">API Key</label>
<input
type="text"
id="JF_API_KEY"
name="JF_API_KEY"
value={formValues.JF_API_KEY || ""}
onChange={handleFormChange}
/>
</div>
{isSubmitted !== "" ? (
<div
className={
isSubmitted === "Failed" ? "submit error" : "submit success"
}
>
{submissionMessage}
</div>
) : (
<></>
)}
<button type="submit">Save</button>
</form>
);
}

View File

@@ -1,39 +1,62 @@
.overview{
color: black;
.overview {
color: white;
margin-top: 20px;
font-family: 'Railway', sans-serif;
font-weight: bold;
}
.card
{
/* width: 400px; */
display: flex;
flex-direction: row;
}
.card {
width: 100%;
height: 200px;
margin-right: 20px;
height: 170px;
background: linear-gradient(60deg, #AA5CC3 , #00A4DC);
border-radius: 4px;
text-align: center;
background-size: cover;
background-position: center;
margin-bottom: 5px;
background-color: black;
}
.item-card-count {
}
.item-card-count
{
font-size: 40px;
padding-top: 20px;
padding-bottom: 10px;
}
}
.item-count
{
font-size: 32px;
display: inline-block;
.item-count {
backdrop-filter: blur(2px);
font-size: 20px;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
padding-left: 30px;
padding-right: 30px;
}
p
{
}
.item-count > div {
display: inline-block;
margin-right: 10px;
text-align: center;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.item-count > div p {
margin: 0;
}
}
.item-count > div:not(:last-child) {
margin-right: 10px;
}
p {
margin: 0;
}

View File

@@ -0,0 +1,19 @@
.console-container {
background-color: black;
color: white;
height: 500px;
overflow-y: auto;
margin-top: 20px;
padding: 10px;
margin-right: 10px;
border-radius: 4px;
}
.console-message {
margin-bottom: 10px;
}
.console-text {
margin: 0;
font-family: monospace;
}

View File

@@ -1,95 +1,94 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import './css/libraries.css'
import Loading from './components/loading';
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../lib/config";
import "./css/libraries.css";
import Loading from "./components/loading";
function Libraries() {
const [data, setData] = useState([]);
const [config, setConfig] = useState(null);
const [data, setData] = useState([]);
const [config, setConfig] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === 'ERR_NETWORK') {
console.log(error);
}
}
};
const fetchData = () => {
if (config) {
const url = `${config.hostUrl}/Library/MediaFolders`;
const apiKey = config.apiKey;
axios.get(url, {
headers: {
'X-MediaBrowser-Token': apiKey,
},
})
.then(data => {
console.log('data');
setData(data.data.Items);
console.log(data);
})
.catch(error => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
if (data.length === 0) {
fetchData();
}
const fetchData = () => {
if (config) {
const url = `${config.hostUrl}/Library/MediaFolders`;
const apiKey = config.apiKey;
axios
.get(url, {
headers: {
"X-MediaBrowser-Token": apiKey,
},
})
.then((data) => {
console.log("data");
setData(data.data.Items);
console.log(data);
})
.catch((error) => {
console.log(error);
});
}
};
const intervalId = setInterval(fetchData, (60000*60));
return () => clearInterval(intervalId);
}, [data, config]);
if (!data || data.length === 0) {
return <Loading />;
if (!config) {
fetchConfig();
}
return (
<div className='Activity'>
<h1>Libraries</h1>
<ul>
{data &&
data.filter(collection => ['tvshows', 'movies'].includes(collection.CollectionType)).map(item => (
<li key={item.Id}>
if (data.length === 0) {
fetchData();
}
{/* <div className='ActivityDetail'> {item.Name}</div> */}
<div className='library-banner'>
<img className='library-banner-image' src={config.hostUrl + '/Items/' + item.Id + '/Images/Primary?quality=50'} alt=''></img>
</div>
const intervalId = setInterval(fetchData, 60000 * 60);
return () => clearInterval(intervalId);
}, [data, config]);
if (!data || data.length === 0) {
return <Loading />;
}
</li>
))}
</ul>
</div>
);
return (
<div className="Activity">
<h1>Libraries</h1>
<ul>
{data &&
data
.filter((collection) =>
["tvshows", "movies"].includes(collection.CollectionType)
)
.map((item) => (
<li key={item.Id}>
{/* <div className='ActivityDetail'> {item.Name}</div> */}
<div className="library-banner">
<img
className="library-banner-image"
src={
config.hostUrl +
"/Items/" +
item.Id +
"/Images/Primary?quality=50"
}
alt=""
></img>
</div>
</li>
))}
</ul>
</div>
);
}
export default Libraries;

View File

@@ -1,143 +1,20 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import Loading from './components/loading';
import React from "react";
import './css/settings.css'
import SettingsConfig from "./components/settings/settingsConfig";
import LibrarySync from "./components/settings/librarySync";
import WebSocketComponent from "./components/settings/WebSocketComponent ";
import "./css/settings.css";
export default function Settings() {
// const [config, setConfig] = useState({});
const [formValues, setFormValues] = useState({});
const [isSubmitted, setisSubmitted] = useState('');
const [loadSate, setloadSate] = useState('Loading');
const [submissionMessage, setsubmissionMessage] = useState('');
useEffect(() => {
Config().then(config => {
setFormValues({ JF_HOST: config.hostUrl, JF_API_KEY: config.apiKey });
setloadSate('Loaded');
}).catch(error => {
console.log('Error updating config:', error);
setloadSate('Critical');
setsubmissionMessage('Error Retrieving Configuration. Unable to contact Backend Server');
});
}, []);
async function validateSettings(_url, _apikey) {
// Send a GET request to /system/configuration to test copnnection
let isValid = false;
let errorMessage = '';
await axios.get(_url + '/system/configuration', {
headers: {
'X-MediaBrowser-Token': _apikey,
},
})
.then(response => {
// console.log('HTTP status code:', response.status); // logs the HTTP status code
//console.log('Response data:', response.data); // logs the response data
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}`;
}
});
return ({ isValid: isValid, errorMessage: errorMessage });
}
async function handleFormSubmit(event) {
event.preventDefault();
let validation = await validateSettings(formValues.JF_HOST, formValues.JF_API_KEY);
console.log(validation);
if (!validation.isValid) {
setisSubmitted('Failed');
setsubmissionMessage(validation.errorMessage);
return;
}
setisSubmitted('');
// Send a POST request to /api/setconfig/ with the updated configuration
axios.post('http://localhost:3003/api/setconfig/', formValues, {
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
console.log('Config updated successfully:', response.data);
setisSubmitted('Success');
setsubmissionMessage('Success Updated Configuration');
})
.catch(error => {
console.log('Error updating config:', error);
setisSubmitted('Failed');
setsubmissionMessage('Error Updating Configuration: ', error);
});
}
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
if (loadSate === 'Loading') {
return (
<Loading />
)
}
if (loadSate === 'Critical') {
return (
<div className='submit critical'>{submissionMessage}</div>
)
}
return (
<form onSubmit={handleFormSubmit} className="settings-form">
<div>
<label htmlFor="JF_HOST">Jellyfin Server</label>
<input type="text" id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ''} onChange={handleFormChange} />
</div>
<div>
<label htmlFor="JF_API_KEY">API Key</label>
<input type="text" id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ''} onChange={handleFormChange} />
</div>
{isSubmitted !== '' ? <div className={isSubmitted === 'Failed' ? 'submit error' : 'submit success'}>{submissionMessage}</div> :
<></>
}
<button type="submit">Save</button>
</form>
)
}
<div>
<SettingsConfig/>
<LibrarySync/>
<WebSocketComponent/>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../lib/config";
import './css/setup.css'
import "./css/setup.css";
// import Loading from './components/loading';
@@ -10,114 +10,91 @@ function Setup() {
const [config, setConfig] = useState(null);
const [formValues, setFormValues] = useState({});
const [processing, setProcessing] = useState(false);
const [submitButtonText, setsubmitButtonText] = useState('Save');
const [submitButtonText, setsubmitButtonText] = useState("Save");
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
async function validateSettings(_url, _apikey) {
// Send a GET request to /system/configuration to test copnnection
let isValid = false;
let errorMessage = '';
await axios.get(_url + '/system/configuration', {
headers: {
'X-MediaBrowser-Token': _apikey,
},
})
.then(response => {
// console.log('HTTP status code:', response.status); // logs the HTTP status code
//console.log('Response data:', response.data); // logs the response data
let errorMessage = "";
await axios
.get(_url + "/system/configuration", {
headers: {
"X-MediaBrowser-Token": _apikey,
},
})
.then((response) => {
if (response.status === 200) {
isValid = true;
}
})
.catch(error => {
.catch((error) => {
// console.log(error.code);
if (error.code === 'ERR_NETWORK') {
if (error.code === "ERR_NETWORK") {
isValid = false;
errorMessage = `Unable to connect to Jellyfin Server`;
} else
if (error.response.status === 401) {
isValid = false;
errorMessage = `Error ${error.response.status} Not Authorized`;
} 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}`;
}
} else if (error.response.status === 401) {
isValid = false;
errorMessage = `Error ${error.response.status} Not Authorized`;
} else if (error.response.status === 404) {
isValid = false;
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
} else {
isValid = false;
errorMessage = `Error : ${error.response.status}`;
}
});
return ({ isValid: isValid, errorMessage: errorMessage });
return { isValid: isValid, errorMessage: errorMessage };
}
async function handleFormSubmit(event) {
setProcessing(true);
event.preventDefault();
// if(formValues.JF_HOST=='' || formValues.JF_API_KEY=='')
// {
// setsubmitButtonText('Plea');
// return;
// }
let validation = await validateSettings(formValues.JF_HOST, formValues.JF_API_KEY);
event.preventDefault();
let validation = await validateSettings(
formValues.JF_HOST,
formValues.JF_API_KEY
);
if (!validation.isValid) {
setsubmitButtonText(validation.errorMessage);
setProcessing(false);
return;
}
// Send a POST request to /api/setconfig/ with the updated configuration
axios.post('http://localhost:3003/api/setconfig/', formValues, {
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
setsubmitButtonText('Settings Saved');
axios
.post("http://localhost:3003/api/setconfig/", formValues, {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => {
setsubmitButtonText("Settings Saved");
setProcessing(false);
setTimeout(() => {
window.location.href = '/';
window.location.href = "/";
}, 1000);
return;
})
.catch(error => {
setsubmitButtonText('Error Saving Settings');
.catch((error) => {
setsubmitButtonText("Error Saving Settings");
setProcessing(false);
});
}
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === 'ERR_NETWORK') {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
@@ -126,32 +103,40 @@ function Setup() {
if (!config) {
fetchConfig();
}
}, [config]);
return (
<section>
<div className="form-box">
<form onSubmit={handleFormSubmit}>
<h2>Setup</h2>
<div className="inputbox">
<input
type="text"
id="JF_HOST"
name="JF_HOST"
value={formValues.JF_HOST || ""}
onChange={handleFormChange}
required
/>
<label htmlFor="JF_HOST">Server URL</label>
</div>
<div className="inputbox">
<input
type="text"
id="JF_API_KEY"
name="JF_API_KEY"
value={formValues.JF_API_KEY || ""}
onChange={handleFormChange}
required
/>
<label htmlFor="JF_API_KEY">API Key</label>
</div>
<form onSubmit={handleFormSubmit} >
<h2>Setup</h2>
<div className='inputbox'>
<input type="text" id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ''} onChange={handleFormChange} required/>
<label htmlFor="JF_HOST">Server URL</label>
</div>
<div className='inputbox'>
<input type="text" id="JF_API_KEY" name="JF_API_KEY" value={formValues.JF_API_KEY || ''} onChange={handleFormChange} required/>
<label htmlFor="JF_API_KEY">API Key</label>
</div>
<button type="submit" className='setup-button'>{processing ? 'Validating...' : submitButtonText}</button>
</form>
<button type="submit" className="setup-button">
{processing ? "Validating..." : submitButtonText}
</button>
</form>
</div>
</section>
);

View File

@@ -1,140 +1,138 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../lib/config";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import AccountCircleFillIcon from 'remixicon-react/AccountCircleFillIcon';
import './css/usersactivity.css'
import Loading from './components/loading';
import "./css/usersactivity.css";
import Loading from "./components/loading";
function UserActivity() {
const [data, setData] = useState([]);
const [config, setConfig] = useState(null);
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const [data, setData] = useState([]);
const [config, setConfig] = useState(null);
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
function handleSort(key) {
const direction = sortConfig.key === key && sortConfig.direction === 'ascending' ? 'descending' : 'ascending';
setSortConfig({ key, direction });
}
function handleSort(key) {
const direction =
sortConfig.key === key && sortConfig.direction === "ascending"
? "descending"
: "ascending";
setSortConfig({ key, direction });
}
function sortData(data, { key, direction }) {
if (!key) return data;
function sortData(data, { key, direction }) {
if (!key) return data;
const sortedData = [...data];
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;
});
sortedData.sort((a, b) => {
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
return 0;
});
return sortedData;
}
return sortedData;
}
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === 'ERR_NETWORK') {
console.log(error);
}
}
};
const fetchData = () => {
if (config) {
const url = `${config.hostUrl}/user_usage_stats/user_activity?days=9999`;
const apiKey = config.apiKey;
axios.get(url, {
headers: {
'X-MediaBrowser-Token': apiKey,
},
})
.then(data => {
console.log('data');
setData(data.data);
console.log(data);
})
.catch(error => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
if (data.length === 0) {
fetchData();
}
const fetchData = () => {
if (config) {
const url = `${config.hostUrl}/user_usage_stats/user_activity?days=9999`;
const apiKey = config.apiKey;
axios
.get(url, {
headers: {
"X-MediaBrowser-Token": apiKey,
},
})
.then((data) => {
console.log("data");
setData(data.data);
console.log(data);
})
.catch((error) => {
console.log(error);
});
}
};
const intervalId = setInterval(fetchData, 60000);
return () => clearInterval(intervalId);
}, [data, config]);
// const options = {
// day: 'numeric',
// month: 'numeric',
// year: 'numeric',
// hour: 'numeric',
// minute: 'numeric',
// second: 'numeric',
// hour12: true
// };
if (!data || data.length === 0) {
return <Loading />;
if (!config) {
fetchConfig();
}
const sortedData = sortData(data, sortConfig);
return (
<div className='Users'>
<h1>Users</h1>
<table className='user-activity-table'>
<thead>
<tr>
{/* <th onClick={() => handleSort('user_id')}>User ID</th> */}
<th></th>
<th onClick={() => handleSort('user_name')}>User</th>
<th onClick={() => handleSort('item_name')}>Last Watched</th>
<th onClick={() => handleSort('client_name')}>Last Client</th>
<th onClick={() => handleSort('total_count')}>Total Plays</th>
<th onClick={() => handleSort('total_play_time')}>Total Watch Time</th>
<th onClick={() => handleSort('last_seen')}>Last Seen</th>
</tr>
</thead>
<tbody>
{
sortedData.map(item => (
<tr key={item.user_id}>
<td>{(item.has_image) ? <img className='card-user-image' src={config.hostUrl + '/Users/' + item.user_id + '/Images/Primary?quality=10'} alt='' /> : <AccountCircleFillIcon color="#fff" size={30}/> }</td>
<td>{item.user_name}</td>
<td>{item.item_name}</td>
<td>{item.client_name}</td>
<td>{item.total_count}</td>
<td>{item.total_play_time}</td>
<td>{item.last_seen} ago</td>
{/* <td>{new Date(item.latest_date).toLocaleString('en-GB', options).replace(',', '')}</td> */}
</tr>
))}
</tbody>
</table>
</div>
);
if (data.length === 0) {
fetchData();
}
const intervalId = setInterval(fetchData, 60000);
return () => clearInterval(intervalId);
}, [data, config]);
if (!data || data.length === 0) {
return <Loading />;
}
const sortedData = sortData(data, sortConfig);
return (
<div className="Users">
<h1>Users</h1>
<table className="user-activity-table">
<thead>
<tr>
<th></th>
<th onClick={() => handleSort("user_name")}>User</th>
<th onClick={() => handleSort("item_name")}>Last Watched</th>
<th onClick={() => handleSort("client_name")}>Last Client</th>
<th onClick={() => handleSort("total_count")}>Total Plays</th>
<th onClick={() => handleSort("total_play_time")}>
Total Watch Time
</th>
<th onClick={() => handleSort("last_seen")}>Last Seen</th>
</tr>
</thead>
<tbody>
{sortedData.map((item) => (
<tr key={item.user_id}>
<td>
{item.has_image ? (
<img
className="card-user-image"
src={
config.hostUrl +
"/Users/" +
item.user_id +
"/Images/Primary?quality=10"
}
alt=""
/>
) : (
<AccountCircleFillIcon color="#fff" size={30} />
)}
</td>
<td>{item.user_name}</td>
<td>{item.item_name}</td>
<td>{item.client_name}</td>
<td>{item.total_count}</td>
<td>{item.total_play_time}</td>
<td>{item.last_seen} ago</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default UserActivity;