Add recently played(WIP). centralized API function

This commit is contained in:
Thegan Govender
2023-03-09 20:47:52 +02:00
parent 504aaee682
commit dbe75bd82e
17 changed files with 795 additions and 98 deletions

2
.vscode/launch.json vendored
View File

@@ -9,7 +9,7 @@
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://10.0.0.99:3000",
"url": "http://10.0.0.20:3000",
"webRoot": "${workspaceFolder}"
}
]

View File

@@ -10,17 +10,35 @@ router.get('/test', async (req, res) => {
});
router.get('/getconfig', async (req, res) => {
const { rows } = await db.query('SELECT * FROM app_config');
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) => {
const { JF_HOST, JF_API_KEY } = req.body;
console.log(req.body);
const { rows } = await db.query('UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2', [JF_HOST, JF_API_KEY]);
console.log(`ENDPOINT CALLED: /setconfig: `+rows);
res.send(rows);
const { rows } = await db.query('UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1', [JF_HOST, JF_API_KEY]);
res.send(rows);
// const { existing } = await db.query('SELECT * FROM app_config where "ID"=1');
// console.log("Lenght: "+existing.rows[0].length );
// if(existing != undefined && existing.length != 0)
// {
// console.log("Update Config");
// const { rows } = await db.query('UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1', [JF_HOST, JF_API_KEY]);
// res.send(rows);
// }
// else{
// console.log("Insert Config");
// const { rows } = await db.query('INSERT into app_config VALUES ( $1, $2, null, null)', [JF_HOST, JF_API_KEY]);
// res.send(rows);
// }
console.log(`ENDPOINT CALLED: /setconfig: `);
});
module.exports = router;

View File

@@ -1,10 +1,17 @@
// import logo from './logo.svg';
import './App.css';
import React, { useState, useEffect } from 'react';
import {
Routes,
Route,
} from "react-router-dom";
import Config from './lib/config';
import Loading from './pages/components/loading';
import Setup from './pages/setup';
import SideNav from './pages/components/sidenav';
import Home from './pages/home';
@@ -13,9 +20,46 @@ import Activity from './pages/activity';
import UserActivity from './pages/useractivity';
import Libraries from './pages/libraries';
import RecentlyPlayed from './pages/components/recentlyplayed';
import UserData from './pages/userdata';
function App() {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
setLoading(false);
} catch (error) {
if (error.code === 'ERR_NETWORK') {
console.log(error);
}
}
};
if (!config) {
fetchConfig();
}
}, [config]);
if (loading) {
return <Loading />;
}
if (!config || config.apiKey ==null) {
return <Setup />;
}
return (
<div className="App">
<SideNav />
@@ -28,6 +72,7 @@ function App() {
<Route path="/libraries" element={<Libraries />} />
<Route path="/usersactivity" element={<UserActivity />} />
<Route path="/userdata" element={<UserData />} />
<Route path="/recent" element={<RecentlyPlayed />} />
</Routes>
</main>
</div>

126
src/classes/jellyfin-api.js Normal file
View File

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

View File

@@ -2,7 +2,7 @@ import { Component } from 'react';
import axios from 'axios';
import Config from '../lib/config';
class GetSeries extends Component {
class sync extends Component {
constructor(props) {
super(props);
this.state = {
@@ -11,10 +11,50 @@ class GetSeries extends Component {
}
async getData() {
async getAdminUser() {
try {
const config = await Config();
const url = `${config.hostUrl}/users/5f63950a2339462196eb8cead70cae7e/Items?ParentID=9d7ad6afe9afa2dab1a2f6e00ad28fa6`;
const url = `${config.hostUrl}/Users`;
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
},
});
const adminUser = response.data.filter(user => user.Policy.IsAdministrator === true);
return adminUser || null;
} catch (error) {
console.log(error);
return [];
}
}
async getLibraries() {
try {
const config = await Config();
const admins=await this.getAdminUser()
const userid=admins[0].Id;
const url = `${config.hostUrl}/users/${userid}/Items`;
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
},
});
const mediafolders = response.data.Items.filter(type => ['tvshows','movies'].includes(type.CollectionType));
return mediafolders || null;
} catch (error) {
console.log(error);
return [];
}
}
async getItem(itemID) {
try {
const config = await Config();
const admins=await this.getAdminUser()
const userid=admins[0].Id;
const url = `${config.hostUrl}/users/${userid}/Items?ParentID=${itemID}`;
const response = await axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
@@ -26,6 +66,8 @@ class GetSeries extends Component {
return [];
}
}
}
export default GetSeries;
export default sync;

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
async function Config() {
try {
const response = await axios.get('http://10.0.0.99:3003/api/getconfig');
const response = await axios.get('http://10.0.0.20:3003/api/getconfig');
const { JF_HOST, JF_API_KEY, APP_USER, APP_PASSWORD } = response.data[0];
return { hostUrl: JF_HOST, apiKey: JF_API_KEY, username: APP_USER, password: APP_PASSWORD };
} catch (error) {

View File

@@ -5,6 +5,9 @@ import FileListFillIcon from 'remixicon-react/FileListFillIcon';
import BarChartFillIcon from 'remixicon-react/BarChartFillIcon';
import SettingsFillIcon from 'remixicon-react/SettingsFillIcon';
import GalleryFillIcon from 'remixicon-react/GalleryFillIcon';
import UserFillIcon from 'remixicon-react/UserFillIcon';
import ReactjsFillIcon from 'remixicon-react/ReactjsFillIcon';
export const navData = [
{
@@ -24,23 +27,30 @@ export const navData = [
icon: <GalleryFillIcon />,
text: "Libraries",
link: "libraries"
},
} ,
{
id: 3,
icon: <UserFillIcon />,
text: "Recently Played",
link: "recent"
},
{
id: 4,
icon: <BarChartFillIcon />,
text: "User Activity",
link: "usersactivity"
},
{
id: 4,
icon: <BarChartFillIcon />,
text: "User Data",
id: 5,
icon: <ReactjsFillIcon />,
text: "Library Data",
link: "userdata"
},
{
id: 5,
id: 6,
icon: <SettingsFillIcon />,
text: "Settings",
link: "settings"
}
]

View File

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

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import API from '../classes/jellyfin-api';
import '../App.css'
@@ -10,55 +9,30 @@ import Loading from './components/loading';
function Activity() {
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);
}
}
};
let _api= new API()
const fetchData = () => {
if (config) {
const url = `${config.hostUrl}/System/ActivityLog/Entries?limit=30`;
const apiKey = config.apiKey;
axios.get(url, {
headers: {
'X-MediaBrowser-Token': apiKey,
},
})
.then(newData => {
if (data && data.length > 0) {
const newDataOnly = newData.data.Items.filter(item => {
return !data.some(existingItem => existingItem.Id === item.Id);
});
setData([...newDataOnly, ...data.slice(0, data.length - newDataOnly.length)]);
} else {
setData(newData.data.Items);
}
})
.catch(error => {
console.log(error);
_api.getActivityData(30).then((ActivityData) => {
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(ActivityData.Items);
}
});
};
if (!config) {
fetchConfig();
}
const intervalId = setInterval(fetchData, 2000);
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, [data,config]);
}, [data]);
@@ -70,7 +44,7 @@ function Activity() {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: true
hour12: false
};
if (!data || data.length === 0) {

View File

@@ -0,0 +1,69 @@
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`;
}
function RecentCard(props) {
return (
<div key={props.data.recent.Id} className='recent-card' >
<div className='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-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;

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
// import axios from 'axios';
import Config from '../../lib/config';
import API from '../../classes/jellyfin-api';
import "../css/recent.css"
// import "../../App.css"
import RecentCard from './recent-card';
import Loading from './loading';
function RecentlyPlayed() {
const [data, setData] = 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);
});
};
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'>
{data &&
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,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
// import axios from 'axios';
import Config from '../../lib/config';
import API from '../../classes/jellyfin-api';
import "../css/sessions.css"
// import "../../App.css"
@@ -22,45 +23,23 @@ function Sessions() {
useEffect(() => {
const _api = new API();
const fetchData = () => {
_api.getSessions().then((SessionData) => {
setData(SessionData);
});
};
Config().then(config => {
console.log('hit api');
// let error_counter=0;
setURL(config.hostUrl);
const url = `${config.hostUrl}/sessions`;
const fetchData = () => {
axios.get(url, {
headers: {
'X-MediaBrowser-Token': config.apiKey,
},
})
.then(response => setData(response.data))
.catch(error => {
console.log(error);
// error_counter++;
// console.log(error_counter);
// if(error_counter>9)
// {
// console.log('Terminating');
// return () => clearInterval(intervalId);
// }
}
);
};
// fetchData();
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}).catch(error => {
// if (error.code === 'ERR_NETWORK') {
// console.log(errorHandler.error_count);
// let _error_count = 1;
// let _error_message = '';
// seterrorHandler({ error_count: _error_count, error_message: _error_message });
// }
console.log(error);
}
);
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, []);
@@ -76,8 +55,8 @@ function 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 }}/>
<SessionCard data={{ session: session, base_url: base_url }} />
))}
</div>

75
src/pages/css/recent.css Normal file
View File

@@ -0,0 +1,75 @@
.recent {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(185px, 200px));
grid-auto-rows: 340px;/* max-width+offset so 215 + 20*/
background-color: rgba(0,0,0,0.5);
padding: 20px;
border-radius: 4px;
margin-right: 20px;
color: white;
}
.recent-card
{
display: flex;
flex-direction: column;
/* background-color: grey; */
box-shadow: 0 0 20px rgba(255, 255, 255, 0.05);
height: 320px;
width: 185px;
border-radius: 4px;
/* Add a third row that takes up remaining space */
}
.card-banner {
width: 100%;
height: 70%;
background-size: cover;
background-repeat: no-repeat;
background-position: center top;
border-radius: 4px 4px 0px 0px;
}
.recent-card-details {
width: 100%;
height: 30%;
position: relative;
/* margin: 4px; */
/* background-color: #f71b1b; */
}
.recent-card-item-name {
width: 185px;
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
margin: 0;
/*
position: absolute; */
}
.recent-card-last-played{
width: 185px;
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
bottom: 0;
margin: 0;
}

View File

@@ -15,6 +15,7 @@
color: white;
background-color: grey;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.05);
max-height: 215px;
max-width: 500px;

117
src/pages/css/setup.css Normal file
View File

@@ -0,0 +1,117 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500&display=swap');
/* *{
margin: 0;
padding: 0;
font-family: 'poppins',sans-serif;
} */
section{
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 100%;
/* background: url('background6.jpg')no-repeat; */
background-position: center;
background-size: cover;
}
.form-box{
position: relative;
width: 400px;
height: 450px;
background: transparent;
border: 2px solid rgba(255,255,255,0.5);
border-radius: 20px;
backdrop-filter: blur(15px);
display: flex;
justify-content: center;
align-items: center;
}
h2{
font-size: 2em;
color: #fff;
text-align: center;
}
.inputbox{
position: relative;
margin: 30px 0;
width: 310px;
border-bottom: 2px solid #fff;
}
.inputbox label{
position: absolute;
top: 50%;
left: 5px;
transform: translateY(-50%);
color: #fff;
font-size: 1em;
pointer-events: none;
transition: .2s;
}
input:focus ~ label,
input:valid ~ label{
top: -15px;
}
.inputbox input {
width: 100%;
height: 50px;
background: transparent;
border: none;
outline: none;
font-size: 1em;
color: #fff;
}
.inputbox ion-icon{
position: absolute;
right: 8px;
color: #fff;
font-size: 1.2em;
top: 20px;
}
.forget{
margin: -15px 0 15px ;
font-size: .9em;
color: #fff;
display: flex;
justify-content: space-between;
}
.forget label input{
margin-right: 3px;
}
.forget label a{
color: #fff;
text-decoration: none;
}
.forget label a:hover{
text-decoration: underline;
}
.setup-button{
width: 100%;
height: 40px;
border-radius: 40px;
background: #fff;
border: none;
outline: none;
cursor: pointer;
font-size: 1em;
font-weight: 600;
}
.register{
font-size: .9em;
color: #fff;
text-align: center;
margin: 25px 0 10px;
}
.register p a{
text-decoration: none;
color: #fff;
font-weight: 600;
}
.register p a:hover{
text-decoration: underline;
}

160
src/pages/setup.js Normal file
View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Config from '../lib/config';
import './css/setup.css'
// import Loading from './components/loading';
function Setup() {
const [config, setConfig] = useState(null);
const [formValues, setFormValues] = useState({});
const [processing, setProcessing] = useState(false);
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
if (response.status === 200) {
isValid = true;
}
})
.catch(error => {
// console.log(error.code);
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}`;
}
});
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);
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');
setProcessing(false);
setTimeout(() => {
window.location.href = '/';
}, 1000);
return;
})
.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') {
console.log(error);
}
}
};
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>
<button type="submit" className='setup-button'>{processing ? 'Validating...' : submitButtonText}</button>
</form>
</div>
</section>
);
}
export default Setup;

View File

@@ -1,16 +1,22 @@
import React, { useState, useEffect } from 'react';
import GetSeries from '../classes/sync';
// import sync from '../classes/sync';
import './css/libraries.css';
import Loading from './components/loading';
import API from '../classes/jellyfin-api';
function UserData() {
const [data, setData] = useState([]);
useEffect(() => {
const seriesInstance = new GetSeries(); // create an instance of the GetSeries class
seriesInstance.getData().then((seriesData) => {
const seriesInstance = new API(); // create an instance of the GetSeries class
seriesInstance.getLibraries().then((seriesData) => {
setData(seriesData);
});
}, []); // run this effect only once, when the component mounts
if (!data || data.length === 0) {