Fix for Proxy with SSL support

Added SSL proxying on the server side so that HTTP content can be served with HTTPS content

Changed css colors from hardcoded values to use variables

Changes to websockets to be able to work with SSL/Proxies

WIP support of 12/24Hr time format in activities tables  (Not working yet)
WIP added version checker to be able to check for available updates from GitHub (Not working yet)
This commit is contained in:
Thegan Govender
2023-05-09 09:53:39 +02:00
parent d37ebb160e
commit 4391c2ede8
53 changed files with 838 additions and 381 deletions

View File

@@ -3,6 +3,17 @@ const express = require("express");
const axios = require("axios");
const ActivityMonitor=require('./watchdog/ActivityMonitor');
const db = require("./db");
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
const router = express.Router();
@@ -154,7 +165,14 @@ router.post("/getItemDetails", async (req, res) => {
query
);
res.send(episodes);
if(episodes.length!==0)
{
res.send(episodes);
}else
{
res.status(404).send('Item not found');
}
}else{
@@ -250,20 +268,16 @@ router.post("/getItemHistory", async (req, res) => {
("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}');`
);
const groupedResults = {};
rows.forEach(row => {
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
} else {
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
...row,
results: []
};
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
}
});
const groupedResults = rows.map(item => ({
...item,
results: []
}));
res.send(Object.values(groupedResults));
res.send(groupedResults);
} catch (error) {
console.log(error);
res.send(error);
@@ -307,7 +321,7 @@ router.get("/getAdminUsers", async (req, res) => {
try {
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
const url = `${config[0].JF_HOST}/Users`;
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY,
},
@@ -339,6 +353,87 @@ router.get("/runWatchdog", async (req, res) => {
}
});
router.get("/getSessions", async (req, res) => {
try {
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
let url=`${config[0].JF_HOST}/sessions`;
const response_data = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
res.send(response_data.data);
} catch (error) {
console.log(error);
res.send(error);
}
});
router.post("/validateSettings", async (req, res) => {
const { url,apikey } = req.body;
let isValid = false;
let errorMessage = "";
try
{
await axios_instance
.get(url + "/system/configuration", {
headers: {
"X-MediaBrowser-Token": apikey,
},
})
.then((response) => {
if (response.status === 200) {
isValid = true;
}
})
.catch((error) => {
if (error.code === "ERR_NETWORK") {
isValid = false;
errorMessage = `Error : Unable to connect to Jellyfin Server`;
} else if (error.code === "ECONNREFUSED") {
isValid = false;
errorMessage = `Error : Unable to connect to Jellyfin Server`;
}
else if (error.response && error.response.status === 401) {
isValid = false;
errorMessage = `Error: ${error.response.status} Not Authorized. Please check API key`;
} else if (error.response && error.response.status === 404) {
isValid = false;
errorMessage = `Error ${error.response.status}: The requested URL was not found.`;
} else {
isValid = false;
errorMessage = `${error}`;
}
});
}catch(error)
{
isValid = false;
errorMessage = `Error: ${error}`;
}
res.send({isValid:isValid,errorMessage:errorMessage });
});

View File

@@ -187,24 +187,32 @@ router.get('/restore/:filename', async (req, res) => {
router.get('/files', (req, res) => {
const directoryPath = path.join(__dirname, backupfolder);
fs.readdir(directoryPath, (err, files) => {
if (err) {
res.status(500).send('Unable to read directory');
} else {
const fileData = files.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime
};
});
res.json(fileData);
}
});
try
{
const directoryPath = path.join(__dirname, backupfolder);
fs.readdir(directoryPath, (err, files) => {
if (err) {
res.status(500).send('Unable to read directory');
} else {
const fileData = files.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime
};
});
res.json(fileData);
}
});
}catch(error)
{
console.log(error);
}
});
@@ -216,9 +224,10 @@ router.get('/restore/:filename', async (req, res) => {
//delete backup
router.delete('/files/:filename', (req, res) => {
const filePath = path.join(__dirname, backupfolder, req.params.filename);
try{
const filePath = path.join(__dirname, backupfolder, req.params.filename);
fs.unlink(filePath, (err) => {
if (err) {
console.error(err);

View File

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

114
backend/proxy.js Normal file
View File

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

View File

@@ -7,11 +7,15 @@ const knexConfig = require('./migrations');
const authRouter= require('./auth');
const apiRouter = require('./api');
const proxyRouter = require('./proxy');
const syncRouter = require('./sync');
const statsRouter = require('./stats');
const backupRouter = require('./backup');
const ActivityMonitor = require('./watchdog/ActivityMonitor');
const { checkForUpdates } = require('./version-control');
const app = express();
const db = knex(knexConfig.development);
@@ -49,6 +53,7 @@ function verifyToken(req, res, next) {
app.use('/auth', authRouter); // mount the API router at /api, with JWT middleware
app.use('/api', verifyToken, apiRouter); // mount the API router at /api, with JWT middleware
app.use('/proxy', proxyRouter); // mount the API router at /api, with JWT middleware
app.use('/sync', verifyToken, syncRouter); // mount the API router at /sync, with JWT middleware
app.use('/stats', verifyToken, statsRouter); // mount the API router at /stats, with JWT middleware
app.use('/data', verifyToken, backupRouter); // mount the API router at /stats, with JWT middleware
@@ -62,6 +67,7 @@ try{
db.migrate.latest().then(() => {
app.listen(PORT, async () => {
console.log(`Server listening on http://${LISTEN_IP}:${PORT}`);
checkForUpdates();
ActivityMonitor.ActivityMonitor(1000);
});
});

View File

@@ -1,6 +1,7 @@
// api.js
const express = require("express");
const db = require("./db");
const axios=require("axios");
const router = express.Router();
@@ -283,6 +284,44 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
}
});
router.get("/getRecentlyAdded", async (req, res) => {
try {
const { libraryid } = req.query;
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
const adminurl = `${config[0].JF_HOST}/Users`;
const response = await axios.get(adminurl, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
let url=`${config[0].JF_HOST}/users/${adminUser[0].Id}/Items/latest?parentId=${libraryid}`;
const response_data = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY ,
},
});
res.send(response_data.data);
} catch (error) {
console.log(error);
res.send(error);
}
});

View File

@@ -2,6 +2,17 @@ const express = require("express");
const pgp = require("pg-promise")();
const db = require("./db");
const axios = require("axios");
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
const wss = require("./WebsocketHandler");
const socket=wss;
@@ -29,7 +40,7 @@ class sync {
try {
const url = `${this.hostUrl}/Users`;
console.log("getAdminUser: ", url);
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
@@ -45,7 +56,7 @@ class sync {
try {
const url = `${this.hostUrl}/Users`;
console.log("getAdminUser: ", url);
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
@@ -67,7 +78,7 @@ class sync {
if (itemID !== undefined) {
url += `?ParentID=${itemID}`;
}
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
@@ -94,7 +105,7 @@ class sync {
if (itemID !== undefined) {
url += `?ParentID=${itemID}`;
}
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
@@ -114,7 +125,7 @@ class sync {
let url = `${this.hostUrl}/Items/${itemID}/playbackinfo?userId=${userid}`;
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
@@ -337,10 +348,12 @@ async function syncShowItems()
let deleteEpisodeCount = 0;
//loop for each show
let show_counter=0;
for (const show of shows) {
const allSeasons = await _sync.getSeasonsAndEpisodes(show.Id,'Seasons');
const allEpisodes =await _sync.getSeasonsAndEpisodes(show.Id,'Episodes');
show_counter++;
socket.sendMessageToClients({ Message: "Syncing shows " + (show_counter/shows.length*100).toFixed(2) +"%" ,key:'show_sync'});
const existingIdsSeasons = await db.query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`).then((res) => res.rows.map((row) => row.Id));
@@ -431,7 +444,7 @@ async function syncShowItems()
}
socket.sendMessageToClients({ Message: "Sync complete for " + show.Name });
}
socket.sendMessageToClients({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
@@ -595,7 +608,7 @@ async function syncPlaybackPluginData()
const url = `${base_url}/user_usage_stats/submit_custom_query`;
const response = await axios.post(url, {
const response = await axios_instance.post(url, {
CustomQueryString: query,
}, {
headers: {

View File

@@ -0,0 +1,33 @@
const GitHub = require('github-api');
const packageJson = require('../package.json');
async function checkForUpdates() {
const currentVersion = packageJson.version;
const repoOwner = 'cyfershepard';
const repoName = 'jellystat';
const gh = new GitHub();
const repo = gh.getRepo(repoOwner, repoName);
let latestVersion;
try {
const releases = await repo.listReleases();
if (releases.data.length > 0) {
latestVersion = releases.data[0].tag_name;
console.log(releases.data);
}
} catch (error) {
console.error(`Failed to fetch releases for ${repoName}: ${error.message}`);
}
if (latestVersion && latestVersion !== currentVersion) {
console.log(`A new version (${latestVersion}) of ${repoName} is available.`);
} else if (latestVersion) {
console.log(`${repoName} is up to date.`);
}
else {
console.log(`Unable to retrieve latest version`);
}
}
module.exports = { checkForUpdates };

View File

@@ -1,10 +1,22 @@
const db = require("../db");
const pgp = require("pg-promise")();
const axios = require("axios");
const moment = require('moment');
const { columnsPlayback, mappingPlayback } = require('../models/jf_playback_activity');
const { jf_activity_watchdog_columns, jf_activity_watchdog_mapping } = require('../models/jf_activity_watchdog');
const { randomUUID } = require('crypto');
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: (process.env.REJECT_SELF_SIGNED_CERTIFICATES || 'true').toLowerCase() ==='true'
});
const axios_instance = axios.create({
httpsAgent: agent
});
async function ActivityMonitor(interval) {
console.log("Activity Interval: " + interval);
@@ -30,7 +42,7 @@ async function ActivityMonitor(interval) {
}
const url = `${base_url}/Sessions`;
const response = await axios.get(url, {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": apiKey,
},

71
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"concurrently": "^7.6.0",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"github-api": "^3.4.0",
"http-proxy-middleware": "^2.0.6",
"knex": "^2.4.2",
"moment": "^2.29.4",
@@ -38,6 +39,7 @@
"react-blurhash": "^0.3.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"recharts": "^2.5.0",
@@ -10034,6 +10036,38 @@
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA=="
},
"node_modules/github-api": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/github-api/-/github-api-3.4.0.tgz",
"integrity": "sha512-2yYqYS6Uy4br1nw0D3VrlYWxtGTkUhIZrumBrcBwKdBOzMT8roAe8IvI6kjIOkxqxapKR5GkEsHtz3Du/voOpA==",
"dependencies": {
"axios": "^0.21.1",
"debug": "^2.2.0",
"js-base64": "^2.1.9",
"utf8": "^2.1.1"
}
},
"node_modules/github-api/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/github-api/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/github-api/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/glob": {
"version": "7.2.3",
"license": "ISC",
@@ -12918,6 +12952,11 @@
"topo": "3.x.x"
}
},
"node_modules/js-base64": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz",
"integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ=="
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"license": "MIT",
@@ -16406,6 +16445,25 @@
"version": "6.0.11",
"license": "MIT"
},
"node_modules/react-fast-compare": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
"integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
},
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
"dependencies": {
"object-assign": "^4.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.1.1",
"react-side-effect": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.3.0"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"license": "MIT"
@@ -16533,6 +16591,14 @@
}
}
},
"node_modules/react-side-effect": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
"integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-smooth": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.2.tgz",
@@ -18461,6 +18527,11 @@
"requires-port": "^1.0.0"
}
},
"node_modules/utf8": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
"integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg=="
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"license": "MIT"

View File

@@ -20,6 +20,7 @@
"concurrently": "^7.6.0",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"github-api": "^3.4.0",
"http-proxy-middleware": "^2.0.6",
"knex": "^2.4.2",
"moment": "^2.29.4",
@@ -33,6 +34,7 @@
"react-blurhash": "^0.3.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"recharts": "^2.5.0",

View File

@@ -1,5 +1,4 @@
@import 'pages/css/variables.module.css';
main{
margin-inline: 20px;
}
@@ -17,7 +16,7 @@ main{
body
{
background-color: #1e1c22 !important;
background-color: var(--background-color) !important;
/* background-color: #17151a; */
color: white;
}
@@ -81,3 +80,28 @@ h2{
}
.btn-outline-primary
{
color: grey!important;
border-color: var(--primary-color) !important;
}
.btn-outline-primary:hover
{
color: white !important;
background-color: var(--primary-color) !important;
}
.btn-outline-primary.active
{
color: white !important;
background-color: var(--primary-color) !important;
}
.btn-outline-primary:focus
{
color: white !important;
background-color: var(--primary-color) !important;
}

View File

@@ -1,10 +1,8 @@
// import logo from './logo.svg';
import './App.css';
import React, { useState, useEffect } from 'react';
import {
Routes,
Route,
} from "react-router-dom";
import { Routes, Route } from "react-router-dom";
import { Helmet } from 'react-helmet';
import axios from 'axios';
import Config from './lib/config';
@@ -67,7 +65,7 @@ function App() {
axios
.get("/auth/isConfigured")
.then(async (response) => {
console.log(response);
// console.log(response);
if(response.status===200)
{
setisConfigured(true);
@@ -119,6 +117,9 @@ if (config && config.apiKey ===null) {
if (config && isConfigured && token!==null){
return (
<div className="App">
<Helmet>
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
</Helmet>
<Navbar />
<div>
<main>

View File

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

View File

@@ -17,9 +17,11 @@ import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon';
import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon';
import '../../css/activity/activity-table.css';
// localStorage.setItem('hour12',true);
let hour_format = Boolean(localStorage.getItem('hour12'));
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
let formattedTime='';
@@ -53,7 +55,7 @@ function Row(data) {
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
hour12: hour_format,
};
@@ -75,7 +77,7 @@ function Row(data) {
<TableCell>{row.Client}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 minutes'}</TableCell>
<TableCell>{row.results.length}</TableCell>
<TableCell>{row.results.length !==0 ? row.results.length : 1}</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
@@ -154,7 +156,7 @@ export default function ActivityTable(props) {
{props.data
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<Row key={row.Id} row={row} />
<Row key={row.Id+row.NowPlayingItemId+row.EpisodeId} row={row} />
))}
{props.data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Activity Found</td></tr> :''}

View File

@@ -39,10 +39,9 @@ function LastWatchedCard(props) {
<img
src={
`${
props.base_url +
"/Items/" +
"/Proxy/Items/Images/Primary?id=" +
props.data.Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -12,6 +12,7 @@ import "../css/items/item-details.css";
import MoreItems from "./item-info/more-items";
import ItemActivity from "./item-info/item-activity";
import ErrorPage from "./general/error";
import Config from "../../lib/config";
@@ -79,10 +80,12 @@ useEffect(() => {
setData(itemData.data[0]);
setRefresh(false);
} catch (error) {
setData({notfound:true, message:error.response.data});
console.log(error);
}
setRefresh(false);
}
};
@@ -101,18 +104,19 @@ useEffect(() => {
if(!data)
{
return <></>;
}
if(refresh)
if(!data || refresh)
{
return <Loading/>;
}
if(data && data.notfound)
{
return <ErrorPage message={data.message}/>;
}
return (
<div>
@@ -123,10 +127,9 @@ if(refresh)
<img
className="item-image"
src={
config.hostUrl +
"/Items/" +
"/Proxy/Items/Images/Primary?id=" +
(data.Type==="Episode"? data.SeriesId : data.Id) +
"/Images/Primary?fillWidth=200&quality=90"
"&fillWidth=200&quality=90"
}
alt=""
style={{

View File

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

View File

@@ -15,11 +15,9 @@ function RecentlyAddedCard(props) {
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/>}
<img
src={
`${
props.base_url +
"/Items/" +
`${"/Proxy/Items/Images/Primary?id=" +
props.data.Id +
"/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}`
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}

View File

@@ -80,24 +80,11 @@ function LibraryCard(props) {
<Card.Img
variant="top"
className="library-card-banner"
src={props.base_url + "/Items/" + props.data.Id + "/Images/Primary/?fillWidth=800&quality=50"}
src={"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
/>
</div>
</Link>
<Link to={`/libraries/${props.data.Id}`}>
<div
className="library-card-banner"
style={{
backgroundImage: `url(${
props.base_url +
"/Items/" +
props.data.Id +
"/Images/Primary/?fillWidth=400&quality=90"
})`,
}}
/>
</Link>
<Card.Body className="library-card-details">
<Row className="space-between-end card-row">

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import {FormControl } from 'react-bootstrap';
import MoreItemCards from "../item-info/more-items/more-items-card";
@@ -11,6 +13,7 @@ import "../../css/library/media-items.css";
function LibraryItems(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
@@ -35,7 +38,6 @@ function LibraryItems(props) {
},
});
setData(itemData.data);
console.log(itemData.data);
} catch (error) {
console.log(error);
}
@@ -53,6 +55,15 @@ function LibraryItems(props) {
return () => clearInterval(intervalId);
}, [config, props.LibraryId]);
let filteredData = data;
if(searchQuery)
{
filteredData = data.filter((item) =>
item.Name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (!data || !config) {
return <></>;
@@ -60,9 +71,13 @@ function LibraryItems(props) {
return (
<div className="last-played">
<div className="d-md-flex justify-content-between">
<h1 className="my-3">Media</h1>
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="my-3 w-25" />
</div>
<div className="media-items-container">
{data.map((item) => (
{filteredData.map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
))}

View File

@@ -22,29 +22,30 @@ function RecentlyPlayed(props) {
}
};
const fetchAdmin = async () => {
try {
let url=`/api/getAdminUsers`;
const adminData = await axios.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
return adminData.data[0].Id;
// setData(itemData.data);
} catch (error) {
console.log(error);
}
};
// const fetchAdmin = async () => {
// try {
// let url=`/api/getAdminUsers`;
// const adminData = await axios.get(url, {
// headers: {
// Authorization: `Bearer ${config.token}`,
// "Content-Type": "application/json",
// },
// });
// return adminData.data[0].Id;
// // setData(itemData.data);
// } catch (error) {
// console.log(error);
// }
// };
const fetchData = async () => {
try {
let adminId=await fetchAdmin();
let url=`${config.hostUrl}/users/${adminId}/Items/latest?parentId=${props.LibraryId}`;
// let adminId=await fetchAdmin();
let url=`/stats/getRecentlyAdded?libraryid=${props.LibraryId}`;
const itemData = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": config.apiKey,
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
setData(itemData.data);

View File

@@ -33,7 +33,7 @@ function sessionCard(props) {
// Access data passed in as a prop using `props.data`
const cardStyle = {
backgroundImage: `url(${props.data.base_url}/Items/${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}/Images/Backdrop?fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
backgroundImage: `url(Proxy/Items/Images/Backdrop?id=${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
};
@@ -53,7 +53,7 @@ function sessionCard(props) {
<Card.Img
variant="top"
className="stat-card-image rounded-0"
src={props.data.base_url + "/Items/" + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + "/Images/Primary?fillHeight=320&fillWidth=213&quality=50"}
src={"/Proxy/Items/Images/Primary?id=" + (props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id) + "&fillHeight=320&fillWidth=213&quality=50"}
/>
@@ -105,11 +105,8 @@ function sessionCard(props) {
<img
className="card-user-image"
src={
props.data.base_url +
"/Users/" +
"/Proxy/Users/Images/Primary?id=" +
props.data.session.UserId +
"/Images/Primary?tag=" +
props.data.session.UserPrimaryImageTag +
"&quality=50"
}
alt=""

View File

@@ -1,7 +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 API from "../../../classes/jellyfin-api";
import "../../css/sessions.css";
// import "../../App.css"
@@ -12,27 +12,45 @@ import Loading from "../general/loading";
function Sessions() {
const [data, setData] = useState();
const [base_url, setURL] = useState("");
const [config, setConfig] = useState();
// const [errorHandler, seterrorHandler] = useState({ error_count: 0, error_message: '' })
useEffect(() => {
const _api = new API();
const fetchData = () => {
_api.getSessions().then((SessionData) => {
let results=SessionData.filter((session) => session.NowPlayingItem);
setData(results);
});
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (base_url === "") {
Config()
.then((config) => {
setURL(config.hostUrl);
const fetchData = () => {
if (config) {
const url = `/api/getSessions`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.catch((error) => {
console.log(error);
});
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
if(!data)
{
@@ -41,7 +59,7 @@ function Sessions() {
const intervalId = setInterval(fetchData, 1000);
return () => clearInterval(intervalId);
}, [data,base_url]);
}, [data,config]);
if (!data) {
return <Loading />;
@@ -63,11 +81,12 @@ function Sessions() {
<div className="sessions-container">
{data &&
data
.filter(row => row.NowPlayingItem !== undefined)
.sort((a, b) =>
a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))
)
.map((session) => (
<SessionCard key={session.Id} data={{ session: session, base_url: base_url }} />
<SessionCard key={session.Id} data={{ session: session, base_url: config.base_url }} />
))}
</div>
</div>

View File

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

View File

@@ -128,14 +128,15 @@ function Row(file) {
<TableCell>{data.name}</TableCell>
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(data.datecreated))}</TableCell>
<TableCell>{formatFileSize(data.size)}</TableCell>
<TableCell className="d-flex justify-content-center">
<DropdownButton title="Actions" variant="outline-primary">
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(data.name)}>Download</Dropdown.Item>
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(data.name)}>Restore</Dropdown.Item>
<Dropdown.Divider ></Dropdown.Divider>
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(data.name)}>Delete</Dropdown.Item>
</DropdownButton>
<TableCell className="">
<div className="d-flex justify-content-center">
<DropdownButton title="Actions" variant="outline-primary">
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(data.name)}>Download</Dropdown.Item>
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(data.name)}>Restore</Dropdown.Item>
<Dropdown.Divider ></Dropdown.Divider>
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(data.name)}>Delete</Dropdown.Item>
</DropdownButton>
</div>
</TableCell>
@@ -229,7 +230,7 @@ const handlePreviousPageClick = () => {
</Alert>
)}
<TableContainer className='rounded-2 overflow-visible'>
<TableContainer className='rounded-2'>
<Table aria-label="collapsible table" >
<TableHead>
<TableRow>

View File

@@ -66,8 +66,8 @@ export default function LibrarySync() {
}
return (
<div className="settings-form">
<h1 className="my-2">Tasks</h1>
<div className="tasks">
<h1 >Tasks</h1>
<Row className="mb-3">
<Form.Label column sm="2">

View File

@@ -20,6 +20,7 @@ export default function SettingsConfig() {
const [isSubmitted, setisSubmitted] = useState("");
const [loadSate, setloadSate] = useState("Loading");
const [submissionMessage, setsubmissionMessage] = useState("");
const token = localStorage.getItem('token');
useEffect(() => {
Config()
@@ -38,37 +39,23 @@ export default function SettingsConfig() {
}, []);
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}`;
}
});
const result = await axios
.post("/api/validateSettings", {
url:_url,
apikey: _apikey
return { isValid: isValid, errorMessage: errorMessage };
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.catch((error) => {
let errorMessage= `Error : ${error}`;
});
let data=result.data;
return { isValid:data.isValid, errorMessage:data.errorMessage} ;
}
async function handleFormSubmit(event) {
@@ -77,7 +64,7 @@ export default function SettingsConfig() {
formValues.JF_HOST,
formValues.JF_API_KEY
);
console.log(validation);
if (!validation.isValid) {
setisSubmitted("Failed");
setsubmissionMessage(validation.errorMessage);
@@ -119,7 +106,7 @@ export default function SettingsConfig() {
return (
<div className="general-settings-page">
<div>
<h1>General Settings</h1>
<Form onSubmit={handleFormSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3" >

View File

@@ -12,10 +12,10 @@ function ItemStatComponent(props) {
setLoaded(true);
}
const backgroundImage=`/proxy/Items/Images/Backdrop?id=${props.data[0].Id}&fillWidth=300&quality=10`;
const cardStyle = {
backgroundImage: `url(${props.base_url}/Items/${props.data[0].Id}/Images/Backdrop/?fillWidth=300&quality=10), linear-gradient(to right, #00A4DC, #AA5CC3)`,
backgroundImage: `url(${backgroundImage}), linear-gradient(to right, #00A4DC, #AA5CC3)`,
height:'100%',
backgroundSize: 'cover',
};
@@ -50,7 +50,7 @@ function ItemStatComponent(props) {
)}
<Card.Img
className="stat-card-image"
src={props.base_url + "/Items/" + props.data[0].Id + "/Images/Primary?fillWidth=400&quality=90"}
src={"Proxy/Items/Images/Primary?id=" + props.data[0].Id + "& fillWidth=400&quality=90"}
style={{ display: loaded ? 'block' : 'none' }}
onLoad={handleImageLoad}
onError={() => setLoaded(false)}

View File

@@ -80,10 +80,9 @@ function UserInfo() {
<img
className="user-image"
src={
config.hostUrl +
"/Users/" +
data.Id +
"/Images/Primary?fillHeight=100&fillWidth=100&quality=90"
"/Proxy/Users/Images/Primary?id=" +
UserId+
"&quality=100"
}
onError={handleImageError}
alt=""

View File

@@ -1,7 +1,8 @@
@import '../variables.module.css';
.activity-table
{
background-color: rgba(100,100, 100, 0.2);
background-color: var(--secondary-background-color);
color: white !important;
}
@@ -9,7 +10,7 @@
td,th, td>button
{
color: white !important;
background-color: rgba(100,100, 100, 0.2);
background-color: var(--secondary-background-color);
}
@@ -38,7 +39,7 @@ td:hover > a
}
select option {
background-color: #4a4a4a;
background-color: var(--secondary-color);
outline: unset;
width: 100%;
border: none;
@@ -63,7 +64,7 @@ width: 130px;
height: 35px;
color: white;
display: flex;
background-color: rgb(100, 100, 100,0.3);
background-color: var(--secondary-background-color);
border-radius: 8px;
font-size: 1.2em;
align-self: flex-end;
@@ -86,6 +87,6 @@ font-size: 1em;
.page-btn
{
background-color: rgb(90 45 165) !important;
border-color: rgb(90 45 165) !important;
background-color: var(--primary-color) !important;
border-color: var(--primary-color)!important;
}

View File

@@ -1,13 +1,15 @@
.error
{
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: 0px;
height: calc(100vh - 100px);
display: flex;
justify-content: center;
align-items: center;
/* z-index: 9999; */
background-color: #1e1c22;
transition: opacity 800ms ease-in;
opacity: 1;
}
.error .message

View File

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

View File

@@ -1,7 +1,8 @@
@import '../variables.module.css';
.item-detail-container
{
color:white;
background-color: rgb(100, 100, 100,0.2);
background-color: var(--secondary-background-color);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
@@ -26,6 +27,6 @@
}
.item-details div a:hover{
color: #00A4DC !important;
color: var(--secondary-color) !important;
}

View File

@@ -1,8 +1,9 @@
@import './variables.module.css';
.last-played-container {
display: flex;
overflow-x: auto;
background-color: rgb(100, 100, 100,0.2);
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
@@ -40,7 +41,7 @@
width: 150px;
border-radius: 8px;
background-color: #1e1c22;
background-color: var(--background-color);
}
@@ -92,7 +93,7 @@
}
.last-item-details a:hover{
color: #00A4DC !important;
color: var(--secondary-color) !important;
}
@@ -120,6 +121,6 @@
.last-last-played{
font-size: 0.8em;
margin-bottom: 5px;
color: #00a4dc;
color: var(--secondary-color);
}

View File

@@ -6,9 +6,3 @@
}
/* .library-banner-image
{
border-radius: 5px;
max-width: 500px;
max-height: 500px;
} */

View File

@@ -1,3 +1,5 @@
@import '../variables.module.css';
.lib-card{
color: white;
max-width: 400px;
@@ -6,7 +8,7 @@
.card-label
{
color: #00A4DC;
color: var(--secondary-color);
}
.card-row .col
@@ -39,7 +41,7 @@
.library-card-details
{
background-color: rgb(100, 100, 100,0.2) !important;
background-color: var(--secondary-background-color) !important;
}

View File

@@ -1,10 +1,11 @@
@import '../variables.module.css';
.media-items-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 150px));
grid-gap: 20px;
background-color: rgb(100, 100, 100,0.2);
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
@@ -30,4 +31,18 @@
.media-items-container::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}
.form-control
{
color: white !important;
background-color: var(--secondary-background-color) !important;
border-color: var(--secondary-background-color) !important;
}
.form-control:focus
{
box-shadow: none !important;
border-color: var(--primary-color) !important;
}

View File

@@ -1,12 +1,7 @@
@import './variables.module.css';
.navbar {
/* display: flex;
justify-content: flex-end;
align-items: center; */
background-color: #5a2da5;
/* background: linear-gradient(to right, #AA5CC3,#00A4DC); */
/* height: 50px; */
/* position: sticky;
top: 0; */
background-color: var(--primary-color);
}

View File

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

View File

@@ -1,11 +1,9 @@
@import './variables.module.css';
.sessions-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
/* margin-right: 20px; */
}
.session-card {
@@ -13,13 +11,10 @@
color: white;
background-color: grey;
/* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */
max-height: 180px;
max-width: 500px;
/* margin-left: 20px; */
/* margin-bottom: 10px; */
background-size: cover;
border-radius: 8px 8px 0px 8px;
@@ -32,8 +27,6 @@
.progress-bar {
/* grid-row: 2 / 3;
grid-column: 1/3; */
height: 5px;
background-color: #101010 !important;
border-radius: 0px 0px 8px 8px;
@@ -43,7 +36,7 @@
.progress-custom {
height: 100%;
background-color: #00A4DC;
background-color: var(--secondary-color);
transition: width 0.2s ease-in-out;
border-radius: 0px 0px 0px 8px;
}
@@ -73,7 +66,6 @@
.card-banner-image {
border-radius: 8px 0px 0px 0px;
max-height: inherit;
/* box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.8); */
}
.card-user {
@@ -98,7 +90,6 @@
.card-user-image-default
{
/* width: 50px !important; */
font-size: large;
}
@@ -125,7 +116,6 @@
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
/* grid-column-gap: 10px; */
}
.card-device-name {
@@ -137,8 +127,6 @@
.card-device-image {
max-width: 35px;
width: 100%;
/* margin-right: 5px; */
/* grid-row: 1 / span 2; */
}
.card-client {
@@ -161,9 +149,6 @@
.card-playback-position {
bottom: 5px;
/* right: 5px; */
/* text-align: right; */
/* position: absolute; */
}
.device-info {
@@ -181,6 +166,6 @@ margin-bottom: 100%;
}
.card-text a:hover{
color: #00A4DC !important;
color: var(--secondary-color) !important;
}

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
@import './variables.module.css';
.grid-stat-cards
{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
/* margin-right: 20px; */
margin-top: 8px;
}
.stat-card{
border: 0 !important;
background-color: rgba(100 100 100 / 0.1) !important;
background-color: var(--background-color)!important;
color: white;
max-width: 500px;
max-height: 180px;
@@ -43,9 +43,8 @@
color: grey;
}
.stat-item-count {
/* width: 10%; */
text-align: right;
color: #00A4DC;
color: var(--secondary-color);
font-weight: 500;
font-size: 1.1em;
@@ -69,7 +68,7 @@
height: 35px;
color: white;
display: flex;
background-color: rgb(100, 100, 100,0.3);
background-color: var(--secondary-background-color);
border-radius: 8px;
font-size: 1.2em;
align-self: flex-end;
@@ -119,5 +118,5 @@ input[type=number] {
}
.stat-items div a:hover{
color: #00A4DC !important;
color: var(--secondary-color) !important;
}

View File

@@ -1,3 +1,4 @@
@import './variables.module.css';
.watch-stats
{
margin-top: 10px;
@@ -8,11 +9,9 @@
height: 700px;
color:black !important;
background-color:rgba(100,100,100,0.2);
background-color: var(--secondary-background-color);
padding:10px;
border-radius:8px;
/* text-align: center; */
}

View File

@@ -1,7 +1,8 @@
@import '../variables.module.css';
.user-detail-container
{
color:white;
background-color: rgb(100, 100, 100,0.2);
background-color: var(--secondary-background-color);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
@@ -23,7 +24,7 @@
height: 100px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 10px 5px rgba(100,100,100,0.2);
box-shadow: 0 0 10px 5px var(--secondary-background-color);
}
.user-image-container

View File

@@ -0,0 +1,6 @@
:root {
--primary-color: #5a2da5;
--secondary-color: #00A4DC;
--background-color: #1e1c22;
--secondary-background-color: rgba(100, 100, 100,0.2);
}

View File

@@ -15,12 +15,11 @@
.console-text {
margin: 0;
font-family: monospace;
text-overflow: ellipsis;
overflow-x: hidden;
}
.console-container {
overflow: auto; /* show scrollbar when needed */
}
.console-container::-webkit-scrollbar {
width: 10px; /* set scrollbar width */
@@ -37,4 +36,4 @@
.console-container::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}
}

View File

@@ -91,6 +91,7 @@ function Login() {
type="text"
id="username"
name="username"
autocomplete="on"
value={formValues.username || ""}
onChange={handleFormChange}
required
@@ -102,6 +103,7 @@ function Login() {
type="text"
id="password"
name="password"
autocomplete="on"
value={formValues.password || ""}
onChange={handleFormChange}
required

View File

@@ -1,4 +1,5 @@
import React from "react";
import {Tabs, Tab } from 'react-bootstrap';
import SettingsConfig from "./components/settings/settingsConfig";
import LibrarySync from "./components/settings/librarySync";
@@ -15,11 +16,20 @@ export default function Settings() {
return (
<div>
<SettingsConfig/>
<BackupFiles/>
<LibrarySync/>
<div className="settings my-2">
<Tabs defaultActiveKey="tabGeneral" variant='pills'>
<Tab eventKey="tabGeneral" className='bg-transparent my-2' title='General Settings' style={{minHeight:'500px'}}>
<SettingsConfig/>
</Tab>
<Tab eventKey="tabTasks" className='bg-transparent my-2' title='Tasks' style={{minHeight:'500px'}}>
<LibrarySync/>
</Tab>
<Tab eventKey="tabBackup" className='bg-transparent my-2' title='Backup' style={{minHeight:'500px'}}>
<BackupFiles/>
</Tab>
</Tabs>
<TerminalComponent/>
</div>

View File

@@ -3,6 +3,7 @@ import axios from "axios";
import Config from "../lib/config";
import "./css/setup.css";
const token = localStorage.getItem('token');
// import LibrarySync from "./components/settings/librarySync";
// import Loading from './components/loading';
@@ -39,38 +40,23 @@ function Setup() {
}
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) => {
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} Unauthorized`;
} 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}`;
}
});
const result = await axios
.post("/api/validateSettings", {
url:_url,
apikey: _apikey
return { isValid: isValid, errorMessage: errorMessage };
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.catch((error) => {
let errorMessage= `Error : ${error}`;
});
let data=result.data;
return { isValid:data.isValid, errorMessage:data.errorMessage} ;
}
async function handleFormSubmit(event) {

View File

@@ -66,10 +66,9 @@ function Row(row) {
<img
className="card-user-image"
src={
row.hostUrl +
"/Users/" +
"Proxy/Users/Images/Primary?id=" +
data.UserId +
"/Images/Primary?quality=10"
"&quality=10"
}
alt=""
/>
@@ -78,7 +77,7 @@ function Row(row) {
)}
</TableCell>
<TableCell><Link to={`/users/${data.UserId}`} className="text-decoration-none">{data.UserName}</Link></TableCell>
<TableCell>{data.LastWatched || 'never'}</TableCell>
<TableCell><Link to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">{data.LastWatched || 'never'}</Link></TableCell>
<TableCell>{data.LastClient || 'n/a'}</TableCell>
<TableCell>{data.TotalPlays}</TableCell>
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || 0}</TableCell>
@@ -189,9 +188,9 @@ function Users() {
<TableBody>
{data && data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<Row key={row.id} data={row} hostUrl={config.hostUrl}/>
<Row key={row.UserId} data={row} hostUrl={config.hostUrl}/>
))}
{data.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}}>No Backups Found</td></tr> :''}
{data.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}}>No Users Found</td></tr> :''}
</TableBody>
</Table>

View File

@@ -8,6 +8,13 @@ module.exports = function(app) {
changeOrigin: true,
})
);
app.use(
`/proxy`,
createProxyMiddleware({
target: `http://127.0.0.1:${process.env.PORT || 3003}`,
changeOrigin: true,
})
);
app.use(
`/stats`,
createProxyMiddleware({
@@ -40,8 +47,9 @@ module.exports = function(app) {
`/ws`,
createProxyMiddleware({
target: `ws://127.0.0.1:${process.env.WS_PORT || 3004}`,
changeOrigin: true,
ws: true,
changeOrigin: true,
secure: false,
})
);