UI Changes, stat fixes

Changed around nav bar
Added users stats view
renamed views for better understanding(still more to do).
CREATED SCRIPT TO SPINUP POSTGRESS DB WITH TABLES AND FUNCTIONS
This commit is contained in:
Thegan Govender
2023-03-21 21:52:41 +02:00
parent 582a39918e
commit 4c4fcd04d5
67 changed files with 1861 additions and 1472 deletions

View File

@@ -1,13 +0,0 @@
-- Database: jfstat
-- DROP DATABASE IF EXISTS jfstat;
CREATE DATABASE jfstat
WITH
OWNER = jfstat
ENCODING = 'UTF8'
LC_COLLATE = 'en_US.utf8'
LC_CTYPE = 'en_US.utf8'
TABLESPACE = pg_default
CONNECTION LIMIT = -1
IS_TEMPLATE = False;

View File

@@ -1,27 +0,0 @@
-- Table: public.jf_activity_watchdog
-- DROP TABLE IF EXISTS public.jf_activity_watchdog;
CREATE TABLE IF NOT EXISTS public.jf_activity_watchdog
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"IsPaused" boolean DEFAULT false,
"UserId" text COLLATE pg_catalog."default",
"UserName" text COLLATE pg_catalog."default",
"Client" text COLLATE pg_catalog."default",
"DeviceName" text COLLATE pg_catalog."default",
"DeviceId" text COLLATE pg_catalog."default",
"ApplicationVersion" text COLLATE pg_catalog."default",
"NowPlayingItemId" text COLLATE pg_catalog."default",
"NowPlayingItemName" text COLLATE pg_catalog."default",
"SeasonId" text COLLATE pg_catalog."default",
"SeriesName" text COLLATE pg_catalog."default",
"EpisodeId" text COLLATE pg_catalog."default",
"PlaybackDuration" bigint,
"ActivityDateInserted" timestamp with time zone
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_activity_watchdog
OWNER to postgres;

View File

@@ -1,32 +0,0 @@
-- Table: public.jf_library_episodes
-- DROP TABLE IF EXISTS public.jf_library_episodes;
CREATE TABLE IF NOT EXISTS public.jf_library_episodes
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"EpisodeId" text COLLATE pg_catalog."default" NOT NULL,
"Name" text COLLATE pg_catalog."default",
"ServerId" text COLLATE pg_catalog."default",
"PremiereDate" timestamp with time zone,
"OfficialRating" text COLLATE pg_catalog."default",
"CommunityRating" double precision,
"RunTimeTicks" bigint,
"ProductionYear" integer,
"IndexNumber" integer,
"ParentIndexNumber" integer,
"Type" text COLLATE pg_catalog."default",
"ParentLogoItemId" text COLLATE pg_catalog."default",
"ParentBackdropItemId" text COLLATE pg_catalog."default",
"ParentBackdropImageTags" text COLLATE pg_catalog."default",
"SeriesId" text COLLATE pg_catalog."default",
"SeasonId" text COLLATE pg_catalog."default",
"SeasonName" text COLLATE pg_catalog."default",
"SeriesName" text COLLATE pg_catalog."default",
CONSTRAINT jf_library_episodes_pkey PRIMARY KEY ("Id")
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_library_episodes
OWNER to postgres;

View File

@@ -1,18 +0,0 @@
-- Table: public.app_config
-- DROP TABLE IF EXISTS public.app_config;
CREATE TABLE IF NOT EXISTS public.app_config
(
"ID" integer NOT NULL GENERATED ALWAYS AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
"JF_HOST" text COLLATE pg_catalog."default",
"JF_API_KEY" text COLLATE pg_catalog."default",
"APP_USER" text COLLATE pg_catalog."default",
"APP_PASSWORD" text COLLATE pg_catalog."default",
CONSTRAINT app_config_pkey PRIMARY KEY ("ID")
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.app_config
OWNER to postgres;

View File

@@ -1,20 +0,0 @@
-- Table: public.jf_libraries
-- DROP TABLE IF EXISTS public.jf_libraries;
CREATE TABLE IF NOT EXISTS public.jf_libraries
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"Name" text COLLATE pg_catalog."default" NOT NULL,
"ServerId" text COLLATE pg_catalog."default",
"IsFolder" boolean NOT NULL DEFAULT true,
"Type" text COLLATE pg_catalog."default" NOT NULL DEFAULT 'CollectionFolder'::text,
"CollectionType" text COLLATE pg_catalog."default" NOT NULL,
"ImageTagsPrimary" text COLLATE pg_catalog."default",
CONSTRAINT jf_libraries_pkey PRIMARY KEY ("Id")
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_libraries
OWNER to postgres;

View File

@@ -1,38 +0,0 @@
-- Table: public.jf_library_items
-- DROP TABLE IF EXISTS public.jf_library_items;
CREATE TABLE IF NOT EXISTS public.jf_library_items
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"Name" text COLLATE pg_catalog."default" NOT NULL,
"ServerId" text COLLATE pg_catalog."default",
"PremiereDate" timestamp with time zone,
"EndDate" timestamp with time zone,
"CommunityRating" double precision,
"RunTimeTicks" bigint,
"ProductionYear" integer,
"IsFolder" boolean,
"Type" text COLLATE pg_catalog."default",
"Status" text COLLATE pg_catalog."default",
"ImageTagsPrimary" text COLLATE pg_catalog."default",
"ImageTagsBanner" text COLLATE pg_catalog."default",
"ImageTagsLogo" text COLLATE pg_catalog."default",
"ImageTagsThumb" text COLLATE pg_catalog."default",
"BackdropImageTags" text COLLATE pg_catalog."default",
"ParentId" text COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT jf_library_items_pkey PRIMARY KEY ("Id"),
CONSTRAINT jf_library_items_fkey FOREIGN KEY ("ParentId")
REFERENCES public.jf_libraries ("Id") MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE SET NULL
NOT VALID
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_library_items
OWNER to postgres;
COMMENT ON CONSTRAINT jf_library_items_fkey ON public.jf_library_items
IS 'jf_library';

View File

@@ -1,24 +0,0 @@
-- Table: public.jf_library_seasons
-- DROP TABLE IF EXISTS public.jf_library_seasons;
CREATE TABLE IF NOT EXISTS public.jf_library_seasons
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"Name" text COLLATE pg_catalog."default",
"ServerId" text COLLATE pg_catalog."default",
"IndexNumber" integer,
"Type" text COLLATE pg_catalog."default",
"ParentLogoItemId" text COLLATE pg_catalog."default",
"ParentBackdropItemId" text COLLATE pg_catalog."default",
"ParentBackdropImageTags" text COLLATE pg_catalog."default",
"SeriesName" text COLLATE pg_catalog."default",
"SeriesId" text COLLATE pg_catalog."default",
"SeriesPrimaryImageTag" text COLLATE pg_catalog."default",
CONSTRAINT jf_library_seasons_pkey PRIMARY KEY ("Id")
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_library_seasons
OWNER to postgres;

View File

@@ -1,27 +0,0 @@
-- Table: public.jf_playback_activity
-- DROP TABLE IF EXISTS public.jf_playback_activity;
CREATE TABLE IF NOT EXISTS public.jf_playback_activity
(
"Id" text COLLATE pg_catalog."default" NOT NULL,
"IsPaused" boolean DEFAULT false,
"UserId" text COLLATE pg_catalog."default",
"UserName" text COLLATE pg_catalog."default",
"Client" text COLLATE pg_catalog."default",
"DeviceName" text COLLATE pg_catalog."default",
"DeviceId" text COLLATE pg_catalog."default",
"ApplicationVersion" text COLLATE pg_catalog."default",
"NowPlayingItemId" text COLLATE pg_catalog."default",
"NowPlayingItemName" text COLLATE pg_catalog."default",
"SeasonId" text COLLATE pg_catalog."default",
"SeriesName" text COLLATE pg_catalog."default",
"EpisodeId" text COLLATE pg_catalog."default",
"PlaybackDuration" bigint,
"ActivityDateInserted" timestamp with time zone
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.jf_playback_activity
OWNER to postgres;

View File

@@ -1,45 +0,0 @@
-- FUNCTION: public.fs_most_played_items(integer, text)
-- DROP FUNCTION IF EXISTS public.fs_most_played_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_played_items(
days integer,
itemtype text)
RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "Name" text, "Id" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.plays,
t.total_playback_duration,
i."Name",
i."Id"
FROM (
SELECT
count(*) AS plays,
sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration,
jf_playback_activity."NowPlayingItemId"
FROM
jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId"
ORDER BY
count(*) DESC
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemType
ORDER BY
t.plays DESC;
END;
$BODY$;
ALTER FUNCTION public.fs_most_played_items(integer, text)
OWNER TO postgres;

View File

@@ -1,52 +0,0 @@
-- FUNCTION: public.fs_most_popular_items(integer, text)
-- DROP FUNCTION IF EXISTS public.fs_most_popular_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_popular_items(
days integer,
itemtype text)
RETURNS TABLE(unique_viewers bigint, latest_activity_date timestamp with time zone, "Name" text, "Id" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.unique_viewers,
t.latest_activity_date,
i."Name",
i."Id"
FROM (
SELECT
jf_playback_activity."NowPlayingItemId",
count(DISTINCT jf_playback_activity."UserId") AS unique_viewers,
latest_activity_date.latest_date AS latest_activity_date
FROM
jf_playback_activity
JOIN (
SELECT
jf_playback_activity_1."NowPlayingItemId",
max(jf_playback_activity_1."ActivityDateInserted") AS latest_date
FROM
jf_playback_activity jf_playback_activity_1
GROUP BY jf_playback_activity_1."NowPlayingItemId"
) latest_activity_date
ON jf_playback_activity."NowPlayingItemId" = latest_activity_date."NowPlayingItemId"
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId", latest_activity_date.latest_date
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemType
ORDER BY
t.unique_viewers DESC, t.latest_activity_date DESC;
END;
$BODY$;
ALTER FUNCTION public.fs_most_popular_items(integer, text)
OWNER TO postgres;

View File

@@ -1,48 +0,0 @@
-- FUNCTION: public.fs_most_viewed_libraries(integer)
-- DROP FUNCTION IF EXISTS public.fs_most_viewed_libraries(integer);
CREATE OR REPLACE FUNCTION public.fs_most_viewed_libraries(
days integer)
RETURNS TABLE("Plays" numeric, "Id" text, "Name" text, "ServerId" text, "IsFolder" boolean, "Type" text, "CollectionType" text, "ImageTagsPrimary" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
sum(t."Plays"),
l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary"
FROM (
SELECT count(*) AS "Plays",
sum(jf_playback_activity."PlaybackDuration") AS "TotalPlaybackDuration",
jf_playback_activity."NowPlayingItemId"
FROM jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY jf_playback_activity."NowPlayingItemId"
ORDER BY "Plays" DESC
) t
JOIN jf_library_items i
ON i."Id" = t."NowPlayingItemId"
JOIN jf_libraries l
ON l."Id" = i."ParentId"
GROUP BY
l."Id"
ORDER BY
(sum( t."Plays")) DESC;
END;
$BODY$;
ALTER FUNCTION public.fs_most_viewed_libraries(integer)
OWNER TO postgres;

View File

@@ -1,11 +0,0 @@
-- Role: jfstat
-- DROP ROLE IF EXISTS jfstat;
CREATE ROLE jfstat WITH
LOGIN
SUPERUSER
INHERIT
CREATEDB
CREATEROLE
NOREPLICATION
ENCRYPTED PASSWORD 'SCRAM-SHA-256$4096:Cf1EY3ozsXG1sR/TWv/Xcw==$Om2f07jurCEEyaOGV/Fju9AGtUVj67Q1JFm0AZSiK4M=:lFaFNHdvtEHzC8l5qUf/uAWENJHa1T9jM3Bv5WDz66E=';

View File

@@ -1,22 +0,0 @@
-- View: public.jf_library_count_view
-- DROP VIEW public.jf_library_count_view;
CREATE OR REPLACE VIEW public.jf_library_count_view
AS
SELECT l."Id",
l."Name",
l."CollectionType",
count(DISTINCT i."Id") AS "Library_Count",
count(DISTINCT s."Id") AS "Season_Count",
count(DISTINCT e."Id") AS "Episode_Count"
FROM jf_libraries l
JOIN jf_library_items i ON i."ParentId" = l."Id"
LEFT JOIN jf_library_seasons s ON s."SeriesId" = i."Id"
LEFT JOIN jf_library_episodes e ON e."SeasonId" = s."Id"
GROUP BY l."Id", l."Name"
ORDER BY (count(DISTINCT i."Id")) DESC;
ALTER TABLE public.jf_library_count_view
OWNER TO postgres;

View File

@@ -1,16 +0,0 @@
-- View: public.js_most_active_user
-- DROP VIEW public.js_most_active_user;
CREATE OR REPLACE VIEW public.js_most_active_user
AS
SELECT count(*) AS "Plays",
jf_playback_activity."UserId",
jf_playback_activity."UserName"
FROM jf_playback_activity
GROUP BY jf_playback_activity."UserId", jf_playback_activity."UserName"
ORDER BY (count(*)) DESC;
ALTER TABLE public.js_most_active_user
OWNER TO postgres;

View File

@@ -1,15 +0,0 @@
-- View: public.js_most_used_clients
-- DROP VIEW public.js_most_used_clients;
CREATE OR REPLACE VIEW public.js_most_used_clients
AS
SELECT count(*) AS "Plays",
jf_playback_activity."Client"
FROM jf_playback_activity
GROUP BY jf_playback_activity."Client"
ORDER BY (count(*)) DESC;
ALTER TABLE public.js_most_used_clients
OWNER TO postgres;

View File

@@ -20,8 +20,17 @@ router.get("/getconfig", async (req, res) => {
router.post("/setconfig", async (req, res) => {
const { JF_HOST, JF_API_KEY } = req.body;
const { rows:getConfig } = await db.query('SELECT * FROM app_config where "ID"=1');
let query='UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1';
if(getConfig.length===0)
{
query='INSERT INTO app_config ("JF_HOST","JF_API_KEY","APP_USER","APP_PASSWORD") VALUES ($1,$2,null,null)';
}
const { rows } = await db.query(
'UPDATE app_config SET "JF_HOST"=$1, "JF_API_KEY"=$2 where "ID"=1',
query,
[JF_HOST, JF_API_KEY]
);
console.log({ JF_HOST: JF_HOST, JF_API_KEY: JF_API_KEY });

View File

@@ -13,6 +13,7 @@ const jf_activity_watchdog_columns = [
"SeasonId",
"SeriesName",
"PlaybackDuration",
"PlayMethod",
"ActivityDateInserted",
];
@@ -32,6 +33,7 @@ const jf_activity_watchdog_columns = [
SeasonId: item.NowPlayingItem.SeasonId || null,
SeriesName: item.NowPlayingItem.SeriesName || null,
PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0,
PlayMethod:item.PlayState.PlayMethod,
ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: new Date().toISOString(),
});

View File

@@ -14,6 +14,7 @@
"SeasonId",
"SeriesName",
"PlaybackDuration",
"PlayMethod",
"ActivityDateInserted",
];
@@ -33,6 +34,7 @@
SeasonId: item.NowPlayingItem.SeasonId || null,
SeriesName: item.NowPlayingItem.SeriesName || null,
PlaybackDuration: item.PlaybackDuration !== undefined ? item.PlaybackDuration: 0,
PlayMethod: item.PlayState.PlayMethod !== undefined ? item.PlayState.PlayMethod : item.PlayMethod ,
ActivityDateInserted: item.ActivityDateInserted !== undefined ? item.ActivityDateInserted: new Date().toISOString(),
});

View File

@@ -0,0 +1,23 @@
////////////////////////// pn delete move to playback
const jf_users_columns = [
"Id",
"Name",
"PrimaryImageTag",
"LastLoginDate",
"LastActivityDate",
"IsAdministrator"
];
const jf_users_mapping = (item) => ({
Id: item.Id,
Name: item.Name,
PrimaryImageTag: item.PrimaryImageTag,
LastLoginDate: item.LastLoginDate,
LastActivityDate: item.LastActivityDate,
IsAdministrator: item.Policy.IsAdministrator,
});
module.exports = {
jf_users_columns,
jf_users_mapping,
};

View File

@@ -45,6 +45,20 @@ router.post("/getMostViewedMovies", async (req, res) => {
});
router.post("/getMostViewedMusic", async (req, res) => {
const {days} = req.body;
let _days=days;
if(days===undefined)
{
_days=30;
}
const { rows } = await db.query(
`select * from fs_most_played_items(${_days},'Audio') limit 5`
);
res.send(rows);
});
router.post("/getMostViewedLibraries", async (req, res) => {
@@ -61,17 +75,38 @@ router.post("/getMostViewedLibraries", async (req, res) => {
});
router.get("/getMostUsedClient", async (req, res) => {
const { rows } = await db.query('SELECT * FROM js_most_used_clients limit 5');
router.post("/getMostUsedClient", async (req, res) => {
const {days} = req.body;
let _days=days;
if(days===undefined)
{
_days=30;
}
const { rows } = await db.query(
`select * from fs_most_used_clients(${_days}) limit 5`
);
res.send(rows);
});
router.get("/getMostActiveUsers", async (req, res) => {
const { rows } = await db.query('SELECT * FROM js_most_active_user limit 5');
router.post("/getMostActiveUsers", async (req, res) => {
const {days} = req.body;
let _days=days;
if(days===undefined)
{
_days=30;
}
const { rows } = await db.query(
`select * from fs_most_active_user(${_days}) limit 5`
);
res.send(rows);
});
router.post("/getMostPopularMovies", async (req, res) => {
const {days} = req.body;
let _days=days;
@@ -95,7 +130,7 @@ router.post("/getMostPopularSeries", async (req, res) => {
{
_days=30;
}
console.log(`select * from fs_most_popular_items(${_days},'Series') limit 5`);
const { rows } = await db.query(
`select * from fs_most_popular_items(${_days},'Series') limit 5`
);
@@ -103,6 +138,21 @@ router.post("/getMostPopularSeries", async (req, res) => {
});
router.post("/getMostPopularMusic", async (req, res) => {
const {days} = req.body;
let _days=days;
if(days===undefined)
{
_days=30;
}
const { rows } = await db.query(
`select * from fs_most_popular_items(${_days},'Audio') limit 5`
);
res.send(rows);
});
router.get("/getPlaybackActivity", async (req, res) => {
const { rows } = await db.query('SELECT * FROM jf_playback_activity');
@@ -110,4 +160,10 @@ router.get("/getPlaybackActivity", async (req, res) => {
// console.log(`ENDPOINT CALLED: /getPlaybackActivity`);
});
router.get("/getAllUserActivity", async (req, res) => {
const { rows } = await db.query('SELECT * FROM jf_all_user_activity');
res.send(rows);
// console.log(`ENDPOINT CALLED: /getPlaybackActivity`);
});
module.exports = router;

View File

@@ -8,22 +8,12 @@ const sendMessageToClients = ws(8080);
const router = express.Router();
const {
jf_libraries_columns,
jf_libraries_mapping,
} = require("./models/jf_libraries");
const {
jf_library_items_columns,
jf_library_items_mapping,
} = require("./models/jf_library_items");
const {
jf_library_seasons_columns,
jf_library_seasons_mapping,
} = require("./models/jf_library_seasons");
const {
jf_library_episodes_columns,
jf_library_episodes_mapping,
} = require("./models/jf_library_episodes");
const {jf_libraries_columns,jf_libraries_mapping,} = require("./models/jf_libraries");
const {jf_library_items_columns,jf_library_items_mapping,} = require("./models/jf_library_items");
const {jf_library_seasons_columns,jf_library_seasons_mapping,} = require("./models/jf_library_seasons");
const {jf_library_episodes_columns,jf_library_episodes_mapping,} = require("./models/jf_library_episodes");
const {jf_users_columns,jf_users_mapping,} = require("./models/jf_users");
/////////////////////////////////////////Functions
class sync {
@@ -32,6 +22,22 @@ class sync {
this.apiKey = apiKey;
}
async getUsers() {
try {
const url = `${this.hostUrl}/Users`;
console.log("getAdminUser: ", url);
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
return response.data;
} catch (error) {
console.log(error);
return [];
}
}
async getAdminUser() {
try {
const url = `${this.hostUrl}/Users`;
@@ -51,10 +57,9 @@ class sync {
}
}
async getItem(itemID) {
async getItem(itemID,userid) {
try {
const admins = await this.getAdminUser();
const userid = admins[0].Id;
let url = `${this.hostUrl}/users/${userid}/Items`;
if (itemID !== undefined) {
url += `?ParentID=${itemID}`;
@@ -68,7 +73,7 @@ class sync {
const results = response.data.Items;
if (itemID === undefined) {
return results.filter((type) =>
["tvshows", "movies"].includes(type.CollectionType)
["tvshows", "movies","music"].includes(type.CollectionType)
);
} else {
return results;
@@ -78,11 +83,10 @@ class sync {
return [];
}
}
async getSeasonsAndEpisodes(showId) {
async getSeasonsAndEpisodes(showId,userid) {
const allSeasons = [];
const allEpisodes = [];
let seasonItems = await this.getItem(showId);
let seasonItems = await this.getItem(showId,userid);
const seasonWithParent = seasonItems.map((items) => ({
...items,
...{ ParentId: showId },
@@ -90,7 +94,7 @@ class sync {
allSeasons.push(...seasonWithParent);
for (let e = 0; e < seasonItems.length; e++) {
const season = seasonItems[e];
let episodeItems = await this.getItem(season.Id);
let episodeItems = await this.getItem(season.Id,userid);
const episodeWithParent = episodeItems.map((items) => ({
...items,
...{ ParentId: season.Id },
@@ -103,9 +107,8 @@ class sync {
}
////////////////////////////////////////API Methods
///////////////////////////////////////writeLibraries
router.get("/writeLibraries", async (req, res) => {
let message = [];
///////////////////////////////////////Write Users
router.get("/writeUsers", async (req, res) => {
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
@@ -115,120 +118,113 @@ router.get("/writeLibraries", async (req, res) => {
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getItem(); //getting all root folders aka libraries
// specify the columns to insert into
const data = await _sync.getUsers();
const existingIds = await db
.query('SELECT "Id" FROM jf_libraries')
.query('SELECT "Id" FROM jf_users')
.then((res) => res.rows.map((row) => row.Id)); // get existing library Ids from the db
//data mapping
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(jf_libraries_mapping);
dataToInsert = await data.map(jf_users_mapping);
} else {
// otherwise, filter only new data to insert
dataToInsert = await data
.filter((row) => !existingIds.includes(row.Id))
.map(jf_libraries_mapping);
.map(jf_users_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,
jf_libraries_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" });
let result = await db.insertBulk("jf_users",dataToInsert,jf_users_columns);
if (result.Result === "SUCCESS") {
sendMessageToClients(dataToInsert.length + " Rows Inserted.");
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
//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" });
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_users",toDeleteIds);
if (result.Result === "SUCCESS") {
sendMessageToClients(toDeleteIds.length + " Rows Removed.");
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
//Sent logs
res.send(message);
res.send();
});
///////////////////////////////////////writeLibraries
router.get("/writeLibraries", async (req, res) => {
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 admins = await _sync.getAdminUser();
const userid = admins[0].Id;
const data = await _sync.getItem(undefined,userid); //getting all root folders aka libraries
const existingIds = await db
.query('SELECT "Id" FROM jf_libraries')
.then((res) => res.rows.map((row) => row.Id));
let dataToInsert = [];
//filter fix if jf_libraries is empty
if (existingIds.length === 0) {
dataToInsert = await data.map(jf_libraries_mapping);
} else {
dataToInsert = await data.filter((row) => !existingIds.includes(row.Id)).map(jf_libraries_mapping);
}
if (dataToInsert.length !== 0) {
let result = await db.insertBulk("jf_libraries",dataToInsert,jf_libraries_columns);
if (result.Result === "SUCCESS") {
sendMessageToClients(dataToInsert.length + " Rows Inserted.");
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_libraries",toDeleteIds);
if (result.Result === "SUCCESS") {
sendMessageToClients(toDeleteIds.length + " Rows Removed.");
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
res.send();
console.log(`ENDPOINT CALLED: /writeLibraries: `);
});
//////////////////////////////////////////////////////writeLibraryItems
router.get("/writeLibraryItems", async (req, res) => {
let message = [];
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1' );
const { rows: cleanup } = await db.query('DELETE FROM jf_playback_activity where "NowPlayingItemId" not in (select "Id" from jf_library_items)' );
sendMessageToClients({ color: "orange", Message: cleanup.length+" orphaned activity logs removed" });
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
@@ -237,20 +233,18 @@ router.get("/writeLibraryItems", async (req, res) => {
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 1/2" });
sendMessageToClients({
color: "yellow",
Message: "Beginning Library Item Sync",
});
//Get all Library items
//gets all libraries
const libraries = await _sync.getItem();
sendMessageToClients({color: "yellow",Message: "Beginning Library Item Sync",});
const admins = await _sync.getAdminUser();
const userid = admins[0].Id;
const libraries = await _sync.getItem(undefined,userid);
const data = [];
let insertCounter = 0;
let deleteCounter = 0;
//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);
let libraryItems = await _sync.getItem(item.Id,userid);
const libraryItemsWithParent = libraryItems.map((items) => ({
...items,
...{ ParentId: item.Id },
@@ -258,153 +252,79 @@ router.get("/writeLibraryItems", async (req, res) => {
data.push(...libraryItemsWithParent);
}
/////////////////////
const existingIds = await db
.query('SELECT "Id" FROM jf_library_items')
.then((res) => res.rows.map((row) => row.Id));
//data mapping
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(jf_library_items_mapping);
} else {
// otherwise, filter only new data to insert
dataToInsert = await data
.filter((row) => !existingIds.includes(row.Id))
.map(jf_library_items_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,
jf_library_items_columns,
"jf_library_items"
);
await db.query(query);
await db.query("COMMIT");
message.push({
Type: "Success",
Message: dataToInsert.length + " Rows Inserted.",
});
insertCounter += dataToInsert.length;
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + error,
});
sendMessageToClients({
color: "red",
Message: "Error performing Item insert:" + error,
});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk insert" });
let result = await db.insertBulk("jf_library_items",dataToInsert,jf_library_items_columns);
if (result.Result === "SUCCESS") {
insertCounter += dataToInsert.length;
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
//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.",
});
deleteCounter += existingIds.length - data.length;
} catch (error) {
await db.query("ROLLBACK");
message.push({
Type: "Error",
Message: "Error performing bulk removal:" + error,
});
sendMessageToClients({
color: "red",
Message: "Error performing Item removal:" + error,
});
}
})();
} else {
message.push({ Type: "Success", Message: "No new data to bulk delete" });
// sendMessageToClients({Message:"No new Library items to bulk delete"});
}
//Sent logs
sendMessageToClients({
color: "dodgerblue",
Message: insertCounter + " Library Items Inserted.",
});
sendMessageToClients({
color: "orange",
Message: deleteCounter + " Library Items Removed.",
});
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_library_items",toDeleteIds);
if (result.Result === "SUCCESS") {
deleteCounter +=toDeleteIds.length;
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
sendMessageToClients({color: "dodgerblue",Message: insertCounter + " Library Items Inserted.",});
sendMessageToClients({color: "orange",Message: deleteCounter + " Library Items Removed.",});
sendMessageToClients({ color: "yellow", Message: "Item Sync Complete" });
res.send(message);
res.send();
console.log(`ENDPOINT CALLED: /writeLibraryItems: `);
});
//////////////////////////////////////////////////////writeSeasonsAndEpisodes
router.get("/writeSeasonsAndEpisodes", async (req, res) => {
sendMessageToClients({ color: "lawngreen", Message: "Syncing... 2/2" });
sendMessageToClients({
color: "yellow",
Message: "Beginning Seasons and Episode sync",
});
const message = [];
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
sendMessageToClients({color: "yellow", Message: "Beginning Seasons and Episode sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
const { rows: shows } = await db.query(
`SELECT * FROM public.jf_library_items where "Type"='Series'`
);
const { rows: shows } = await db.query(`SELECT * FROM public.jf_library_items where "Type"='Series'`);
let insertSeasonsCount = 0;
let insertEpisodeCount = 0;
let deleteSeasonsCount = 0;
let deleteEpisodeCount = 0;
const admins = await _sync.getAdminUser();
const userid = admins[0].Id;
//loop for each show
for (const show of shows) {
const data = await _sync.getSeasonsAndEpisodes(show.Id);
const data = await _sync.getSeasonsAndEpisodes(show.Id,userid);
//
//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 = [];
@@ -448,202 +368,63 @@ router.get("/writeSeasonsAndEpisodes", async (req, res) => {
if (seasonsToInsert.length !== 0) {
let result = await db.insertBulk("jf_library_seasons",seasonsToInsert,jf_library_seasons_columns);
if (result.Result === "SUCCESS") {
message.push({
Type: "Success",
Message: seasonsToInsert.length + " Rows Inserted for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
insertSeasonsCount += seasonsToInsert.length;
} else {
message.push({
Type: "Error",
Message: "Error performing bulk insert:" + result.message,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
} else {
message.push({
Type: "Success",
Message: "No new data to bulk insert for " + show.Name,
ItemId: show.Id,
TableName: "jf_library_seasons",
});
}
}
const toDeleteIds = existingIdsSeasons.filter((id) =>!data.allSeasons.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteIds.length > 0) {
let table="jf_library_seasons";
let result = await db.deleteBulk(table,toDeleteIds);
let result = await db.deleteBulk("jf_library_seasons",toDeleteIds);
if (result.Result === "SUCCESS") {
message.push({
Type: "Success",
Message: toDeleteIds.length + " Rows Removed for " + show.Name,
ItemId: show.Id,
TableName: table,
});
deleteSeasonsCount +=toDeleteIds.length;
} else {
message.push({
Type: "Error",
Message: result.message,
ItemId: show.Id,
TableName: table,
});
sendMessageToClients({
color: "red",
Message: result.message,
});
sendMessageToClients({color: "red",Message: result.message,});
}
} 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");
let result = await db.insertBulk("jf_library_episodes",episodesToInsert,jf_library_episodes_columns);
if (result.Result === "SUCCESS") {
insertEpisodeCount += episodesToInsert.length;
} else {
sendMessageToClients({
color: "red",
Message: "Error performing bulk insert:" + result.message,
});
}
}
const query = pgp.helpers.insert(
episodesToInsert,
jf_library_episodes_columns,
"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",
});
insertEpisodeCount += episodesToInsert.length;
// sendMessageToClients({color:'dodgerblue',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});
}
const toDeleteEpisodeIds = existingIdsEpisodes.filter((id) =>!data.allEpisodes.some((row) => (row.Id + row.ParentId) === id ));
//Bulk delete from db thats no longer on api
if (existingIdsEpisodes.length > data.allEpisodes.length) {
await (async () => {
try {
await db.query("BEGIN");
if (toDeleteEpisodeIds.length > 0) {
let result = await db.deleteBulk("jf_library_episodes",toDeleteEpisodeIds);
if (result.Result === "SUCCESS") {
deleteEpisodeCount +=toDeleteEpisodeIds.length;
} else {
sendMessageToClients({color: "red",Message: result.message,});
}
}
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",
});
deleteEpisodeCount +=
existingIdsEpisodes.length - data.allEpisodes.length;
// 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({ Message: "Sync complete for " + show.Name });
}
sendMessageToClients({
color: "dodgerblue",
Message: insertSeasonsCount + " Seasons inserted.",
});
sendMessageToClients({
color: "orange",
Message: deleteSeasonsCount + " Seasons Removed.",
});
sendMessageToClients({
color: "dodgerblue",
Message: insertEpisodeCount + " Episodes inserted.",
});
sendMessageToClients({
color: "orange",
Message: deleteEpisodeCount + " Episodes Removed.",
});
sendMessageToClients({color: "dodgerblue",Message: insertSeasonsCount + " Seasons inserted.",});
sendMessageToClients({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
sendMessageToClients({color: "dodgerblue",Message: insertEpisodeCount + " Episodes inserted.",});
sendMessageToClients({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
sendMessageToClients({ color: "lawngreen", Message: "Sync Complete" });
res.send(message);
res.send();
console.log(`ENDPOINT CALLED: /writeSeasonsAndEpisodes: `);
});
//////////////////////////////////////

View File

@@ -10,6 +10,10 @@ async function ActivityMonitor(interval) {
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
if(config.length===0)
{
return;
}
const base_url = config[0].JF_HOST;
const apiKey = config[0].JF_API_KEY;

View File

@@ -0,0 +1,2 @@
FROM postgres
COPY init.sql /docker-entrypoint-initdb.d/

View File

@@ -0,0 +1,3 @@
docker run --name jellystat-postgres -e POSTGRES_PASSWORD=fy1W0POt5$ -p 25432:5432 -d jellystat-postgres
docker build -t jellystat-postgres .

591
postgres-image/init.sql Normal file
View File

@@ -0,0 +1,591 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 15.2 (Debian 15.2-1.pgdg110+1)
-- Dumped by pg_dump version 15.1
-- Started on 2023-03-21 19:12:22 UTC
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- TOC entry 3389 (class 1262 OID 16387)
-- Name: jfstat; Type: DATABASE; Schema: -; Owner: jfstat
--
CREATE DATABASE jfstat WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'en_US.utf8';
-- ALTER DATABASE jfstat OWNER TO jfstat;
\connect jfstat
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- TOC entry 232 (class 1255 OID 41783)
-- Name: fs_most_active_user(integer); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.fs_most_active_user(days integer) RETURNS TABLE("Plays" bigint, "UserId" text, "Name" text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT count(*) AS "Plays",
jf_playback_activity."UserId",
jf_playback_activity."UserName" AS "Name"
FROM jf_playback_activity
WHERE jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) AND NOW()
GROUP BY jf_playback_activity."UserId", jf_playback_activity."UserName"
ORDER BY (count(*)) DESC;
END;
$$;
ALTER FUNCTION public.fs_most_active_user(days integer) OWNER TO postgres;
--
-- TOC entry 246 (class 1255 OID 41695)
-- Name: fs_most_played_items(integer, text); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.fs_most_played_items(days integer, itemtype text) RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "Name" text, "Id" text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
t.plays,
t.total_playback_duration,
i."Name",
i."Id"
FROM (
SELECT
count(*) AS plays,
sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration,
jf_playback_activity."NowPlayingItemId"
FROM
jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId"
ORDER BY
count(*) DESC
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemType
ORDER BY
t.plays DESC;
END;
$$;
ALTER FUNCTION public.fs_most_played_items(days integer, itemtype text) OWNER TO postgres;
--
-- TOC entry 245 (class 1255 OID 41690)
-- Name: fs_most_popular_items(integer, text); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.fs_most_popular_items(days integer, itemtype text) RETURNS TABLE(unique_viewers bigint, latest_activity_date timestamp with time zone, "Name" text, "Id" text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
t.unique_viewers,
t.latest_activity_date,
i."Name",
i."Id"
FROM (
SELECT
jf_playback_activity."NowPlayingItemId",
count(DISTINCT jf_playback_activity."UserId") AS unique_viewers,
latest_activity_date.latest_date AS latest_activity_date
FROM
jf_playback_activity
JOIN (
SELECT
jf_playback_activity_1."NowPlayingItemId",
max(jf_playback_activity_1."ActivityDateInserted") AS latest_date
FROM
jf_playback_activity jf_playback_activity_1
GROUP BY jf_playback_activity_1."NowPlayingItemId"
) latest_activity_date
ON jf_playback_activity."NowPlayingItemId" = latest_activity_date."NowPlayingItemId"
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId", latest_activity_date.latest_date
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemType
ORDER BY
t.unique_viewers DESC, t.latest_activity_date DESC;
END;
$$;
ALTER FUNCTION public.fs_most_popular_items(days integer, itemtype text) OWNER TO postgres;
--
-- TOC entry 231 (class 1255 OID 41730)
-- Name: fs_most_used_clients(integer); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.fs_most_used_clients(days integer) RETURNS TABLE("Plays" bigint, "Client" text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT count(*) AS "Plays",
jf_playback_activity."Client"
FROM jf_playback_activity
WHERE jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) AND NOW()
GROUP BY jf_playback_activity."Client"
ORDER BY (count(*)) DESC;
END;
$$;
ALTER FUNCTION public.fs_most_used_clients(days integer) OWNER TO postgres;
--
-- TOC entry 244 (class 1255 OID 41701)
-- Name: fs_most_viewed_libraries(integer); Type: FUNCTION; Schema: public; Owner: postgres
--
CREATE FUNCTION public.fs_most_viewed_libraries(days integer) RETURNS TABLE("Plays" numeric, "Id" text, "Name" text, "ServerId" text, "IsFolder" boolean, "Type" text, "CollectionType" text, "ImageTagsPrimary" text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
sum(t."Plays"),
l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary"
FROM (
SELECT count(*) AS "Plays",
sum(jf_playback_activity."PlaybackDuration") AS "TotalPlaybackDuration",
jf_playback_activity."NowPlayingItemId"
FROM jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY jf_playback_activity."NowPlayingItemId"
ORDER BY "Plays" DESC
) t
JOIN jf_library_items i
ON i."Id" = t."NowPlayingItemId"
JOIN jf_libraries l
ON l."Id" = i."ParentId"
GROUP BY
l."Id"
ORDER BY
(sum( t."Plays")) DESC;
END;
$$;
ALTER FUNCTION public.fs_most_viewed_libraries(days integer) OWNER TO postgres;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- TOC entry 220 (class 1259 OID 16395)
-- Name: app_config; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.app_config (
"ID" integer NOT NULL,
"JF_HOST" text,
"JF_API_KEY" text,
"APP_USER" text,
"APP_PASSWORD" text
);
ALTER TABLE public.app_config OWNER TO postgres;
--
-- TOC entry 221 (class 1259 OID 16402)
-- Name: app_config_ID_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
ALTER TABLE public.app_config ALTER COLUMN "ID" ADD GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME public."app_config_ID_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
--
-- TOC entry 228 (class 1259 OID 41300)
-- Name: jf_activity_watchdog; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_activity_watchdog (
"Id" text NOT NULL,
"IsPaused" boolean DEFAULT false,
"UserId" text,
"UserName" text,
"Client" text,
"DeviceName" text,
"DeviceId" text,
"ApplicationVersion" text,
"NowPlayingItemId" text,
"NowPlayingItemName" text,
"SeasonId" text,
"SeriesName" text,
"EpisodeId" text,
"PlaybackDuration" bigint,
"ActivityDateInserted" timestamp with time zone,
"PlayMethod" text
);
ALTER TABLE public.jf_activity_watchdog OWNER TO postgres;
--
-- TOC entry 227 (class 1259 OID 41294)
-- Name: jf_playback_activity; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_playback_activity (
"Id" text NOT NULL,
"IsPaused" boolean DEFAULT false,
"UserId" text,
"UserName" text,
"Client" text,
"DeviceName" text,
"DeviceId" text,
"ApplicationVersion" text,
"NowPlayingItemId" text,
"NowPlayingItemName" text,
"SeasonId" text,
"SeriesName" text,
"EpisodeId" text,
"PlaybackDuration" bigint,
"ActivityDateInserted" timestamp with time zone,
"PlayMethod" text
);
ALTER TABLE public.jf_playback_activity OWNER TO postgres;
--
-- TOC entry 229 (class 1259 OID 41731)
-- Name: jf_users; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_users (
"Id" text NOT NULL,
"Name" text,
"PrimaryImageTag" text,
"LastLoginDate" timestamp with time zone,
"LastActivityDate" timestamp with time zone,
"IsAdministrator" boolean
);
ALTER TABLE public.jf_users OWNER TO postgres;
--
-- TOC entry 230 (class 1259 OID 41771)
-- Name: jf_all_user_activity; Type: VIEW; Schema: public; Owner: postgres
--
CREATE VIEW public.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",
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 public.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 public.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 public.jf_playback_activity
WHERE (jf_playback_activity."UserId" = u."Id")) plays ON (true))
ORDER BY j."ActivityDateInserted";
ALTER TABLE public.jf_all_user_activity OWNER TO postgres;
--
-- TOC entry 222 (class 1259 OID 16411)
-- Name: jf_libraries; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_libraries (
"Id" text NOT NULL,
"Name" text NOT NULL,
"ServerId" text,
"IsFolder" boolean DEFAULT true NOT NULL,
"Type" text DEFAULT 'CollectionFolder'::text NOT NULL,
"CollectionType" text NOT NULL,
"ImageTagsPrimary" text
);
ALTER TABLE public.jf_libraries OWNER TO postgres;
--
-- TOC entry 226 (class 1259 OID 25160)
-- Name: jf_library_count_view; Type: VIEW; Schema: public; Owner: postgres
--
CREATE VIEW public.jf_library_count_view AS
SELECT
NULL::text AS "Id",
NULL::text AS "Name",
NULL::text AS "CollectionType",
NULL::bigint AS "Library_Count",
NULL::bigint AS "Season_Count",
NULL::bigint AS "Episode_Count";
ALTER TABLE public.jf_library_count_view OWNER TO postgres;
--
-- TOC entry 225 (class 1259 OID 24906)
-- Name: jf_library_episodes; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_library_episodes (
"Id" text NOT NULL,
"EpisodeId" text NOT NULL,
"Name" text,
"ServerId" text,
"PremiereDate" timestamp with time zone,
"OfficialRating" text,
"CommunityRating" double precision,
"RunTimeTicks" bigint,
"ProductionYear" integer,
"IndexNumber" integer,
"ParentIndexNumber" integer,
"Type" text,
"ParentLogoItemId" text,
"ParentBackdropItemId" text,
"ParentBackdropImageTags" text,
"SeriesId" text,
"SeasonId" text,
"SeasonName" text,
"SeriesName" text
);
ALTER TABLE public.jf_library_episodes OWNER TO postgres;
--
-- TOC entry 223 (class 1259 OID 24599)
-- Name: jf_library_items; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_library_items (
"Id" text NOT NULL,
"Name" text NOT NULL,
"ServerId" text,
"PremiereDate" timestamp with time zone,
"EndDate" timestamp with time zone,
"CommunityRating" double precision,
"RunTimeTicks" bigint,
"ProductionYear" integer,
"IsFolder" boolean,
"Type" text,
"Status" text,
"ImageTagsPrimary" text,
"ImageTagsBanner" text,
"ImageTagsLogo" text,
"ImageTagsThumb" text,
"BackdropImageTags" text,
"ParentId" text NOT NULL
);
ALTER TABLE public.jf_library_items OWNER TO postgres;
--
-- TOC entry 224 (class 1259 OID 24731)
-- Name: jf_library_seasons; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.jf_library_seasons (
"Id" text NOT NULL,
"Name" text,
"ServerId" text,
"IndexNumber" integer,
"Type" text,
"ParentLogoItemId" text,
"ParentBackdropItemId" text,
"ParentBackdropImageTags" text,
"SeriesName" text,
"SeriesId" text,
"SeriesPrimaryImageTag" text
);
ALTER TABLE public.jf_library_seasons OWNER TO postgres;
--
-- TOC entry 3228 (class 2606 OID 16401)
-- Name: app_config app_config_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.app_config
ADD CONSTRAINT app_config_pkey PRIMARY KEY ("ID");
--
-- TOC entry 3230 (class 2606 OID 16419)
-- Name: jf_libraries jf_libraries_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_libraries
ADD CONSTRAINT jf_libraries_pkey PRIMARY KEY ("Id");
--
-- TOC entry 3236 (class 2606 OID 24912)
-- Name: jf_library_episodes jf_library_episodes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_library_episodes
ADD CONSTRAINT jf_library_episodes_pkey PRIMARY KEY ("Id");
--
-- TOC entry 3232 (class 2606 OID 24605)
-- Name: jf_library_items jf_library_items_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_library_items
ADD CONSTRAINT jf_library_items_pkey PRIMARY KEY ("Id");
--
-- TOC entry 3234 (class 2606 OID 24737)
-- Name: jf_library_seasons jf_library_seasons_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_library_seasons
ADD CONSTRAINT jf_library_seasons_pkey PRIMARY KEY ("Id");
--
-- TOC entry 3238 (class 2606 OID 41737)
-- Name: jf_users jf_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_users
ADD CONSTRAINT jf_users_pkey PRIMARY KEY ("Id");
--
-- TOC entry 3382 (class 2618 OID 25163)
-- Name: jf_library_count_view _RETURN; Type: RULE; Schema: public; Owner: postgres
--
CREATE OR REPLACE VIEW public.jf_library_count_view AS
SELECT l."Id",
l."Name",
l."CollectionType",
count(DISTINCT i."Id") AS "Library_Count",
count(DISTINCT s."Id") AS "Season_Count",
count(DISTINCT e."Id") AS "Episode_Count"
FROM (((public.jf_libraries l
JOIN public.jf_library_items i ON ((i."ParentId" = l."Id")))
LEFT JOIN public.jf_library_seasons s ON ((s."SeriesId" = i."Id")))
LEFT JOIN public.jf_library_episodes e ON ((e."SeasonId" = s."Id")))
GROUP BY l."Id", l."Name"
ORDER BY (count(DISTINCT i."Id")) DESC;
--
-- TOC entry 3239 (class 2606 OID 24617)
-- Name: jf_library_items jf_library_items_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.jf_library_items
ADD CONSTRAINT jf_library_items_fkey FOREIGN KEY ("ParentId") REFERENCES public.jf_libraries("Id") ON DELETE SET NULL NOT VALID;
--
-- TOC entry 3390 (class 0 OID 0)
-- Dependencies: 3239
-- Name: CONSTRAINT jf_library_items_fkey ON jf_library_items; Type: COMMENT; Schema: public; Owner: postgres
--
COMMENT ON CONSTRAINT jf_library_items_fkey ON public.jf_library_items IS 'jf_library';
-- Completed on 2023-03-21 19:12:22 UTC
--
-- PostgreSQL database dump complete
--

View File

@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>JellyStat</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "JellyStat",
"name": "Statistics for Jellyfin",
"icons": [
{
"src": "favicon.ico",

View File

@@ -1,11 +1,7 @@
.App {
/* text-align: center; */
/* padding-bottom: 5%; */
display: grid;
gap: 1rem;
/* grid-template-areas:
"sidenav main"; */
grid-template-columns: auto 1fr;
main{
padding-inline: 10px;
}
.App-logo {

View File

@@ -13,21 +13,23 @@ import Loading from './pages/components/loading';
import Setup from './pages/setup';
import SideNav from './pages/components/sidenav';
import Navbar from './pages/components/navbar';
import Home from './pages/home';
import Settings from './pages/settings';
import Activity from './pages/activity';
import UserActivity from './pages/useractivity';
import Users from './pages/users';
import UserInfo from './pages/user-info';
import Libraries from './pages/libraries';
import ErrorPage from './pages/components/error';
import RecentlyPlayed from './pages/components/recentlyplayed';
import UserData from './pages/userdata';
import Testing from './pages/testing';
function App() {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [errorFlag, seterrorFlag] = useState(false);
useEffect(() => {
@@ -37,8 +39,10 @@ function App() {
const newConfig = await Config();
if(newConfig !== 'ERR_NETWORK'){
setConfig(newConfig);
setLoading(false);
}else{
seterrorFlag(true);
}
setLoading(false);
} catch (error) {
@@ -55,6 +59,10 @@ if (loading) {
return <Loading />;
}
if (errorFlag) {
return <ErrorPage message={"Error: Unable to connect to Jellystat Backend"} />;
}
if (!config || config.apiKey ==null) {
return <Setup />;
}
@@ -63,16 +71,16 @@ if (!config || config.apiKey ==null) {
return (
<div className="App">
<SideNav />
<Navbar />
<div>
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
<Route path="/activity" element={<Activity />} />
<Route path="/users" element={<Users />} />
<Route path="/user-info/:UserId" element={<UserInfo />} />
<Route path="/libraries" element={<Libraries />} />
<Route path="/usersactivity" element={<UserActivity />} />
<Route path="/userdata" element={<UserData />} />
<Route path="/testing" element={<Testing />} />
<Route path="/recent" element={<RecentlyPlayed />} />
</Routes>
</main>

View File

@@ -17,3 +17,25 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html {
overflow: auto; /* show scrollbar when needed */
}
html::-webkit-scrollbar {
width: 10px; /* set scrollbar width */
}
html::-webkit-scrollbar-track {
background-color: transparent; /* set track color */
}
html::-webkit-scrollbar-thumb {
background-color: #8888884d; /* set thumb color */
border-radius: 5px; /* round corners */
}
html::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}

View File

@@ -18,36 +18,24 @@ export const navData = [
},
{
id: 1,
icon: <FileListFillIcon />,
text: "Activity",
link: "activity"
icon: <UserFillIcon />,
text: "Users",
link: "users"
},
{
id: 2,
icon: <GalleryFillIcon />,
text: "Libraries",
link: "libraries"
} ,
{
id: 3,
icon: <UserFillIcon />,
text: "Recently Played",
link: "recent"
},
{
id: 4,
icon: <BarChartFillIcon />,
text: "User Activity",
link: "usersactivity"
icon: <ReactjsFillIcon />,
text: "Component Testing Playground",
link: "testing"
},
{
id: 5,
icon: <ReactjsFillIcon />,
text: "Library Data",
link: "userdata"
},
{
id: 6,
icon: <SettingsFillIcon />,
text: "Settings",
link: "settings"

View File

@@ -7,9 +7,12 @@ import MostUsedClient from "./statCards/most_used_client";
import MostActiveUsers from "./statCards/most_active_users";
import MPSeries from "./statCards/mp_series";
import MPMovies from "./statCards/mp_movies";
import MVMusic from "./statCards/mv_music";
import MPMusic from "./statCards/mp_music";
import "../css/statCard.css";
function StatCards() {
function WatchStatistics() {
const [days, setDays] = useState(30);
const [input, setInput] = useState(30);
@@ -50,12 +53,15 @@ function StatCards() {
<MPMovies days={days} />
<MVSeries days={days} />
<MPSeries days={days} />
<MVMusic days={days}/>
<MPMusic days={days}/>
<MVLibraries days={days} />
<MostUsedClient days={days} />
<MostActiveUsers days={days} />
</div>
</div>
);
}
export default StatCards;
export default WatchStatistics;

View File

@@ -0,0 +1,12 @@
import React from "react";
import "../css/error.css";
function ErrorPage(props) {
return (
<div className="error">
<div className="message">{props.message}</div>
</div>
);
}
export default ErrorPage;

View File

@@ -1,27 +1,21 @@
import "../css/libraryOverview.css";
import Config from "../../lib/config";
import React, { useState, useEffect } from "react";
import axios from "axios";
import Loading from "./loading";
import LibraryStatComponent from "./libraryStatCard/library-stat-component";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
export default function LibraryOverView() {
const SeriesIcon=<TvLineIcon size={"80%"} /> ;
const MovieIcon=<FilmLineIcon size={"80%"} /> ;
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 = `/stats/getLibraryOverview`;
axios
@@ -33,7 +27,7 @@ export default function LibraryOverView() {
if (!data || data.length === 0) {
fetchData();
}
}, [data, base_url]);
}, [data]);
if (data.length === 0) {
return <Loading />;
@@ -43,67 +37,12 @@ export default function LibraryOverView() {
<div>
<h1>Library Statistics</h1>
<div className="overview-container">
<div className="library-card">
<div className="library-image">
<div className="library-icons">
<TvLineIcon size={"80%"} />
</div>
</div>
<div className="library">
<div className="library-header">
<div>MOVIE LIBRARIES</div>
<div className="library-header-count">MOVIES</div>
</div>
<div className="stats-list">
{data &&
data.filter((stat) => stat.CollectionType === "movies")
.map((item, index) => (
<div className="library-item" key={item.Id}>
<p className="library-item-index">{index + 1}</p>
<p className="library-item-name">{item.Name}</p>
<p className="library-item-count">{item.Library_Count}</p>
</div>
))}
</div>
</div>
</div>
<div className="library-card">
<div className="library-image">
<div className="library-icons">
<FilmLineIcon size={"80%"} />
</div>
</div>
<div className="library">
<div className="library-header">
<div>SHOW LIBRARIES</div>
<div className="library-header-count">
SERIES / SEASONS / EPISODES
</div>
</div>
<div className="stats-list">
{data &&
data.filter((stat) => stat.CollectionType === "tvshows")
.map((item, index) => (
<div className="library-item" key={item.Id}>
<p className="library-item-index">{index + 1}</p>
<p className="library-item-name">{item.Name}</p>
<p className="library-item-count">{item.Library_Count} / {item.Season_Count} / {item.Episode_Count}</p>
</div>
))}
</div>
</div>
</div>
</div>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "movies")} heading={"MOVIE LIBRARIES"} units={"MOVIES"} icon={MovieIcon}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "tvshows")} heading={"SHOW LIBRARIES"} units={"SERIES / SEASONS / EPISODES"} icon={SeriesIcon}/>
<LibraryStatComponent data={data.filter((stat) => stat.CollectionType === "music")} heading={"MUSIC LIBRARIES"} units={"SONGS"} icon={SeriesIcon}/>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import React from "react";
function LibraryStatComponent(props) {
if (props.data.length === 0) {
return <></>;
}
return (
<div className="library-card">
<div className="library-image">
<div className="library-icons">
{props.icon}
</div>
</div>
<div className="library">
<div className="library-header">
<div>{props.heading}</div>
<div className="library-header-count">{props.units}</div>
</div>
<div className="stats-list">
{props.data
.map((item, index) => (
<div className="library-item" key={item.Id}>
<p className="library-item-index">{index + 1}</p>
<p className="library-item-name">{item.Name}</p>
<p className="library-item-count">{item.CollectionType =='tvshows'? (item.Library_Count+' / '+item.Season_Count+' / '+item.Episode_Count): item.Library_Count}</p>
</div>
))}
</div>
</div>
</div>
);
}
export default LibraryStatComponent;

View File

@@ -0,0 +1,19 @@
import { NavLink } from "react-router-dom";
import { navData } from "../../lib/navdata";
import "../css/navbar.css"
export default function Navbar() {
return (
<div className={'navbar'}>
{navData.map(item =>{
return <NavLink key={item.id} className={'navitem'} to={item.link}>
{item.icon}
<span className={'nav-text'}>{item.text}</span>
</NavLink>
})}
</div>
)
}

View File

@@ -6,7 +6,7 @@ import API from "../../classes/jellyfin-api";
import "../css/sessions.css";
// import "../../App.css"
import SessionCard from "./session-card";
import SessionCard from "./sessions/session-card";
import Loading from "./loading";
@@ -44,7 +44,8 @@ function Sessions() {
}
if (data.length === 0) {
return(<div>
return(
<div className="sessions">
<h1>Sessions</h1>
<div style={{color:"grey", fontSize:"0.8em", fontStyle:"italic"}}>
No Active Sessions Found
@@ -53,9 +54,9 @@ function Sessions() {
}
return (
<div>
<div className="sessions">
<h1>Sessions</h1>
<div className="sessions">
<div className="sessions-container">
{data &&
data
.sort((a, b) =>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import '../../css/websocket/websocket.css';
const WebSocketComponent = () => {
const TerminalComponent = () => {
const [messages, setMessages] = useState([]);
const messagesEndRef = useRef(null);
@@ -46,4 +46,4 @@ const WebSocketComponent = () => {
);
};
export default WebSocketComponent;
export default TerminalComponent;

View File

@@ -1,17 +1,27 @@
import React, { useState } from "react";
import axios from "axios";
// import Config from "../../../lib/config";
// import Loading from "../loading";
import "../../css/settings.css";
export default function LibrarySync() {
const [processing, setProcessing] = useState(false);
async function writeSeasonsAndEpisodes() {
async function beginSync() {
setProcessing(true);
await axios
.get("/sync/writeLibraries")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
await axios
.get("/sync/writeLibraryItems")
.then((response) => {
@@ -34,13 +44,25 @@ export default function LibrarySync() {
.catch((error) => {
console.log(error);
});
await axios
.get("/sync/writeUsers")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
const handleClick = () => {
writeSeasonsAndEpisodes();
beginSync();
console.log('Button clicked!');
}

View File

@@ -1,28 +0,0 @@
import "../css/sidenav.css"
import { NavLink } from "react-router-dom";
import MenuUnfoldFillIcon from 'remixicon-react/MenuUnfoldFillIcon';
import MenuFoldFillIcon from 'remixicon-react/MenuFoldFillIcon';
import { navData } from "../../lib/navdata";
import { useState } from "react";
export default function Sidenav() {
const [open, setopen] = useState(false)
const toggleOpen = () => {
setopen(!open)
}
return (
<div className={open? 'sidenav':'sidenav Closed'}>
<button className={open? 'menuBtn menuBtn-open':'menuBtn' } onClick={toggleOpen}>
{open? <MenuFoldFillIcon color="#fff" />:<MenuUnfoldFillIcon color="#fff" />}
</button>
{navData.map(item =>{
return <NavLink key={item.id} className={'sideitem'} to={item.link}>
{item.icon}
<span className={open? 'text-open' :'text-closed'}>{open? item.text : ''}</span>
</NavLink>
})}
</div>
)
}

View File

@@ -1,14 +1,16 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import StatComponent from "./statsComponent";
import ComponentLoading from "../ComponentLoading";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
function MostActiveUsers() {
function MostActiveUsers(props) {
const [data, setData] = useState([]);
const [days, setDays] = useState(30);
const [imgError, setImgError] = useState(false);
const [config, setConfig] = useState(null);
@@ -31,7 +33,11 @@ function MostActiveUsers() {
const url = `/stats/getMostActiveUsers`;
axios
.get(url)
.post(url, {days:props.days}, {
headers: {
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
@@ -50,9 +56,15 @@ function MostActiveUsers() {
fetchLibraries();
}
if (days !== props.days) {
setDays(props.days);
fetchLibraries();
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config]);
}, [data, config, days,props.days]);
@@ -95,30 +107,7 @@ function MostActiveUsers() {
></img>
}
</div>
<div className="stats">
<div className="stats-header">
<div>MOST ACTIVE USERS</div>
<div className="stats-header-plays">Plays</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.UserId}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.UserName}</p>
<p className="stat-item-count"> {item.Plays}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST ACTIVE USERS"} units={"Plays"}/>
</div>
);

View File

@@ -1,58 +1,49 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import StatComponent from "./statsComponent";
import ComponentLoading from "../ComponentLoading";
import ComputerLineIcon from "remixicon-react/ComputerLineIcon";
function MostUsedClient() {
function MostUsedClient(props) {
const [data, setData] = useState([]);
// const [base_url, setURL] = useState("");
const [config, setConfig] = useState(null);
const [days, setDays] = useState(30);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
const fetchLibraries = () => {
if (config) {
const url = `/stats/getMostUsedClient`;
axios
.get(url)
.post(url, {days:props.days}, {
headers: {
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
if (!data || data.length===0) {
fetchLibraries();
}
if (days !== props.days) {
setDays(props.days);
fetchLibraries();
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config]);
}, [data, days,props.days]);
if (!data) {
return(
@@ -75,30 +66,7 @@ function MostUsedClient() {
<ComputerLineIcon size={'80%'}/>
</div>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST USED CLIENTS</div>
<div className="stats-header-plays">Plays</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Client}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Client}</p>
<p className="stat-item-count"> {item.Plays}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST USED CLIENTS"} units={"Plays"}/>
</div>
);

View File

@@ -4,7 +4,7 @@ import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
// import PlaybackActivity from "./components/playbackactivity";
import StatComponent from "./statsComponent";
function MPMovies(props) {
const [data, setData] = useState([]);
@@ -98,30 +98,7 @@ function MPMovies(props) {
></img>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST POPULAR MOVIES</div>
<div className="stats-header-plays">Users</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Id}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Name}</p>
<p className="stat-item-count"> {item.unique_viewers}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST POPULAR MOVIES"} units={"Users"}/>
</div>
);

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
// import PlaybackActivity from "./components/playbackactivity";
function MPMusic(props) {
const [data, setData] = useState([]);
const [days, setDays] = useState(30);
// const [base_url, setURL] = 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 fetchLibraries = () => {
if (config) {
const url = `/stats/getMostPopularMusic`;
axios
.post(url, { days: props.days }, {
headers: {
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
if (!data || data.length === 0) {
fetchLibraries();
}
if (days !== props.days) {
setDays(props.days);
fetchLibraries();
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config, days,props.days]);
if (!data) {
return(
<div className="stats-card">
<ComponentLoading />
</div>
);
}
if (data.length === 0) {
return <></>;
}
return (
<div className="stats-card"
style={{
backgroundImage: `url(${
config.hostUrl +
"/Items/" +
(data[0].Id) +
"/Images/Backdrop/0?maxWidth=1000&quality=50"
})`}}
>
<div className="popular-image">
<img
className="popular-banner-image"
src={
config.hostUrl +
"/Items/" +
(data[0].Id) +
"/Images/Primary?quality=50"
}
alt=""
></img>
</div>
<StatComponent data={data} heading={"MOST POPULAR MUSIC"} units={"Users"}/>
</div>
);
}
export default MPMusic;

View File

@@ -3,6 +3,7 @@ import axios from "axios";
import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
// import PlaybackActivity from "./components/playbackactivity";
@@ -13,7 +14,6 @@ function MPSeries(props) {
const [config, setConfig] = useState(null);
console.log('PROPS: '+ days);
useEffect(() => {
const fetchConfig = async () => {
try {
@@ -99,29 +99,7 @@ function MPSeries(props) {
></img>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST POPULAR SERIES</div>
<div className="stats-header-plays">Users</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Id}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Name}</p>
<p className="stat-item-count"> {item.unique_viewers}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST POPULAR SERIES"} units={"Users"}/>
</div>

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
@@ -11,23 +11,12 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
function MVLibraries(props) {
const [data, setData] = useState([]);
const [days, setDays] = useState(30);
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 fetchLibraries = () => {
if (config) {
const url = `/stats/getMostViewedLibraries`;
axios
@@ -42,13 +31,9 @@ function MVLibraries(props) {
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
if (!data || data.length===0) {
fetchLibraries();
@@ -60,7 +45,7 @@ function MVLibraries(props) {
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config, days,props.days]);
}, [data, days,props.days]);
if (!data) {
return(
@@ -89,29 +74,7 @@ function MVLibraries(props) {
</div>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST VIEWED LIBRARIES</div>
<div className="stats-header-plays">Plays</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Id}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Name}</p>
<p className="stat-item-count"> {item.Plays}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST VIEWED LIBRARIES"} units={"Plays"}/>
</div>

View File

@@ -3,9 +3,10 @@ import axios from "axios";
import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
function MVMovies(props) {
function MVMusic(props) {
const [data, setData] = useState([]);
const [days, setDays] = useState(30);
@@ -98,33 +99,11 @@ function MVMovies(props) {
></img>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST VIEWED MOVIES</div>
<div className="stats-header-plays">Plays</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Id}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Name}</p>
<p className="stat-item-count"> {item.Plays}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST VIEWED MOVIES"} units={"Plays"}/>
</div>
);
}
export default MVMovies;
export default MVMusic;

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
function MVMovies(props) {
const [data, setData] = useState([]);
const [days, setDays] = useState(30);
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 fetchLibraries = () => {
if (config) {
const url = `/stats/getMostViewedMusic`;
axios
.post(url, {days:props.days}, {
headers: {
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
if (!data || data.length===0) {
fetchLibraries();
}
if (days !== props.days) {
setDays(props.days);
fetchLibraries();
}
const intervalId = setInterval(fetchLibraries, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config, days,props.days]);
if (!data) {
return(
<div className="stats-card">
<ComponentLoading />
</div>
);
}
if (data.length === 0) {
return <></>;
}
return (
<div className="stats-card"
style={{
backgroundImage: `url(${
config.hostUrl +
"/Items/" +
(data[0].Id) +
"/Images/Backdrop/0?maxWidth=1000&quality=50"
})`}}
>
<div className="popular-image">
<img
className="popular-banner-image"
src={
config.hostUrl +
"/Items/" +
(data[0].Id) +
"/Images/Primary?quality=50"
}
alt=""
></img>
</div>
<StatComponent data={data} heading={"MOST PLAYED MUSIC"} units={"Plays"}/>
</div>
);
}
export default MVMovies;

View File

@@ -4,6 +4,8 @@ import Config from "../../../lib/config";
import ComponentLoading from "../ComponentLoading";
import StatComponent from "./statsComponent";
// import PlaybackActivity from "./components/playbackactivity";
function MVSeries(props) {
@@ -98,29 +100,7 @@ function MVSeries(props) {
></img>
</div>
<div className="stats">
<div className="stats-header">
<div>MOST VIEWED SERIES</div>
<div className="stats-header-plays">Plays</div>
</div>
<div className = "stats-list">
{data &&
data
.map((item,index) => (
<div className='stat-item' key={item.Id}>
<p className="stat-item-index">{(index+1)}</p>
<p className="stat-item-name">{item.Name}</p>
<p className="stat-item-count"> {item.Plays}</p>
</div>
))}
</div>
</div>
<StatComponent data={data} heading={"MOST VIEWED SERIES"} units={"Plays"}/>
</div>

View File

@@ -0,0 +1,25 @@
import React from "react";
function StatComponent(props) {
return (
<div className="stats">
<div className="stats-header">
<div>{props.heading}</div>
<div className="stats-header-plays">{props.units}</div>
</div>
<div className="stats-list">
{props.data &&
props.data.map((item, index) => (
<div className="stat-item" key={item.Id}>
<p className="stat-item-index">{index + 1}</p>
<p className="stat-item-name">{item.Name || item.Client}</p>
<p className="stat-item-count"> {item.Plays || item.unique_viewers}</p>
</div>
))}
</div>
</div>
);
}
export default StatComponent;

18
src/pages/css/error.css Normal file
View File

@@ -0,0 +1,18 @@
.error
{
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.error .message
{
color:crimson;
font-size: 1.5em;
font-weight: 500;
}

36
src/pages/css/navbar.css Normal file
View File

@@ -0,0 +1,36 @@
.navbar {
display: flex;
justify-content: flex-end;
align-items: center;
background-color: rgb(10,25,41);
height: 50px;
}
.navitem {
display: flex;
align-items: center;
height: 100%;
color: white;
font-size: 16px;
text-decoration: none;
padding: 0 20px;
transition: background-color 0.2s ease-in-out;
}
.nav-text {
margin-left: 10px;
}
.active
{
background-color: #308df046 !important;
transition: background-color 0.2s ease-in-out;
}
.navitem:hover {
background-color: #326aa541;
}

View File

@@ -1,4 +1,9 @@
.sessions {
margin-bottom: 10px;
}
.sessions-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(520px, 520px));
grid-auto-rows: 235px;/* max-width+offset so 215 + 20*/
@@ -9,7 +14,6 @@
}
.session-card {
display: flex;
color: white;

View File

@@ -1,75 +0,0 @@
.sidenav {
width: 250px;
transition: width 0.3s ease-in-out;
height: 100vh;
/* z-index: 100; */
top:0;
background-color: rgb(10,25,41);
/* padding-top: 28px; */
position: sticky;
}
.active
{
background-color: #308df046 !important;
transition: background-color 0.2s ease-in-out;
}
.Closed {
/* composes: sidenav; */
transition: width 0.3s ease-in-out;
width: 60px;
/* padding-top: 28px; */
/* position: absolute; */
}
.sideitem {
display: flex;
align-items: center;
padding: 10px 20px;
cursor: pointer;
color: #B2BAC2;
text-decoration: none;
overflow: hidden;
}
.text-open {
padding-left: 16px;
opacity: 1;
transition: opacity 0.3s ease-in;
}
.text-closed {
/* padding-left: 16px; */
opacity: 0;
transition: opacity 0.3s ease-in;
}
.sideitem:hover {
background-color: #244f7d1c;
}
.menuBtn {
align-self: center;
align-self: flex-start;
justify-self: flex-end;
color: #B2BAC2;
background-color: transparent;
border: none;
cursor: pointer;
padding-left: 10px;
width: inherit;
transition: width 0.3s ease-in;
}
.menuBtn:hover {
background-color: #244f7d1c;
}
.menuBtn-open {
width: 100%;
transition: width 0.3s ease-in;
}

View File

@@ -1,11 +1,13 @@
.Heading
{
display: flex;
margin-bottom: 10px;
}
.Heading h1
{
padding-right: 10px;
margin: 0;
}
.stat-cards-container
@@ -127,10 +129,11 @@
height: 35px;
color: white;
display: flex;
background-color: rgb(0, 99, 248,0.6);
background-color: rgb(0, 0, 0,0.2);
border-radius: 4px;
font-size: 1.2em;
align-self: center;
align-self: flex-end;
justify-content: space-evenly;
}
@@ -165,6 +168,6 @@ input[type=number] {
.date-range .header,
.date-range .trailer
{
padding: 5px;
text-align: center;
padding-inline: 10px;
align-self: center;
}

160
src/pages/css/users.css Normal file
View File

@@ -0,0 +1,160 @@
.Users
{
color: white;
padding-right: 20px;
padding-bottom: 50px;
}
.user-activity-table {
border-collapse: collapse;
border-radius: 5px;
/* margin: 25px 0; */
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
/* box-shadow: 0 0 20px rgba(255, 255, 255, 0.15); */
background-color: rgba(0, 0, 0, 0.2);
color: white;
width: 100%;
}
th,
td
{
padding: 10px 15px;
/* text-align: left; */
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
border-left:1px solid rgba(255, 255, 255, 0.05);
}
td a{
text-decoration: none;
color: white;
}
td:hover a{
color: rgb(0, 164, 219);
}
th {
background-color: rgba(0, 0, 0, 0.8);
cursor: pointer;
}
th:hover {
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
}
/* tbody tr:last-of-type {
border-bottom: 2px solid #009879;
} */
.card-user-image
{
border-radius: 50%;
width: 30px;
height: 30px;
object-fit: cover;
}
tbody tr:hover
{
background-color: rgba(0, 0, 0, 0.4);
}
td:first-child {
border-left: none;
}
td:last-child {
border-right: none;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 1rem;
}
.page-btn {
padding: 5px 10px;
border: none;
border-radius: 5px;
margin-inline: 5px;
background-color: rgb(10,25,41);
color: white;
cursor: pointer;
}
.page-btn:hover {
background-color: #333;
color: #fff;
}
.page-btn:disabled {
opacity: 0.5;
cursor: default;
}
.page-number {
margin-inline: 10px;
font-weight: bold;
}
.pagination-range
{
width: 130px;
height: 35px;
color: white;
display: flex;
background-color: rgb(0, 0, 0,0.2);
border-radius: 4px;
font-size: 1.2em;
align-self: flex-end;
justify-content: space-between;
}
.pagination-range select
{
height: 35px;
outline: none;
border: none;
border-radius: 4px;
background-color: rgb(255, 255, 255, 0.1);
color:white;
font-size: 1em;
}
.pagination-range .header
{
padding-inline: 10px;
align-self: center;
}
select option {
background-color: #4a4a4a;
outline: unset;
width: 100%;
border: none;
}
.pagination-range .items
{
background-color: rgb(255, 255, 255, 0.1);
padding-inline: 10px;
}

View File

@@ -1,67 +0,0 @@
.Users
{
color: white;
padding-right: 20px;
padding-bottom: 50px;
}
.user-activity-table {
border-collapse: collapse;
border-radius: 5px;
/* margin: 25px 0; */
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.15);
color: white;
width: 100%;
}
th,
td {
padding: 12px 15px;
/* text-align: left; */
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
border-left:1px solid rgba(255, 255, 255, 0.05);
}
th {
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
}
th:hover {
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
}
/* tbody tr:last-of-type {
border-bottom: 2px solid #009879;
} */
.card-user-image
{
border-radius: 50%;
width: 30px;
height: 30px;
object-fit: cover;
}
tr:hover
{
background-color: rgba(0, 0, 0, 0.2);
}
td:first-child {
border-left: none;
}
td:last-child {
border-right: none;
}

View File

@@ -17,3 +17,25 @@
margin: 0;
font-family: monospace;
}
.console-container {
overflow: auto; /* show scrollbar when needed */
}
.console-container::-webkit-scrollbar {
width: 10px; /* set scrollbar width */
}
.console-container::-webkit-scrollbar-track {
background-color: transparent; /* set track color */
}
.console-container::-webkit-scrollbar-thumb {
background-color: #8888884d; /* set thumb color */
border-radius: 5px; /* round corners */
}
.console-container::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}

View File

@@ -3,7 +3,7 @@ import React from 'react'
import './css/home.css'
import Sessions from './components/sessions'
import StatCards from './components/StatsCards'
import WatchStatistics from './components/WatchStatistics'
import LibraryOverView from './components/libraryOverview'
@@ -12,7 +12,7 @@ export default function Home() {
<div>
<Sessions />
<StatCards/>
<WatchStatistics/>
<LibraryOverView/>
</div>

View File

@@ -3,7 +3,7 @@ import axios from "axios";
import Config from "../lib/config";
import "./css/libraries.css";
import "./css/usersactivity.css";
import "./css/users.css";
import Loading from "./components/loading";

View File

@@ -2,7 +2,8 @@ import React from "react";
import SettingsConfig from "./components/settings/settingsConfig";
import LibrarySync from "./components/settings/librarySync";
import WebSocketComponent from "./components/settings/WebSocketComponent ";
import TerminalComponent from "./components/settings/TerminalComponent";
import "./css/settings.css";
@@ -13,7 +14,7 @@ export default function Settings() {
<div>
<SettingsConfig/>
<LibrarySync/>
<WebSocketComponent/>
<TerminalComponent/>
</div>
);

View File

@@ -15,6 +15,59 @@ function Setup() {
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
async function beginSync() {
setProcessing(true);
await axios
.get("/sync/writeLibraries")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
await axios
.get("/sync/writeLibraryItems")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
await axios
.get("/sync/writeSeasonsAndEpisodes")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
await axios
.get("/sync/writeUsers")
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
async function validateSettings(_url, _apikey) {
// Send a GET request to /system/configuration to test copnnection
@@ -73,12 +126,13 @@ function Setup() {
"Content-Type": "application/json",
},
})
.then((response) => {
.then(async (response) => {
setsubmitButtonText("Settings Saved");
setProcessing(false);
setTimeout(() => {
window.location.href = "/";
}, 1000);
await beginSync();
return;
})

View File

@@ -4,7 +4,7 @@ import './css/libraries.css';
import Loading from './components/loading';
// import PlaybackActivity from './components/playbackactivity';
import Activity from './activity';
// import StatCards from './components/StatsCards';
import LibraryOverView from './components/libraryOverview';
@@ -12,7 +12,7 @@ import LibraryOverView from './components/libraryOverview';
import API from '../classes/jellyfin-api';
function UserData() {
function Testing() {
const [data, setData] = useState([]);
useEffect(() => {
@@ -49,4 +49,4 @@ function UserData() {
);
}
export default UserData;
export default Testing;

14
src/pages/user-info.js Normal file
View File

@@ -0,0 +1,14 @@
import { useParams } from 'react-router-dom';
function UserInfo() {
const { UserId } = useParams();
// Fetch data for the user with the specified userId
return (
<div>
<p>{UserId}</p>
</div>
);
}
export default UserInfo;

View File

@@ -1,138 +0,0 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../lib/config";
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
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 });
function handleSort(key) {
const direction =
sortConfig.key === key && sortConfig.direction === "ascending"
? "descending"
: "ascending";
setSortConfig({ key, direction });
}
function sortData(data, { key, direction }) {
if (!key) return data;
const sortedData = [...data];
sortedData.sort((a, b) => {
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
return 0;
});
return sortedData;
}
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();
}
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;

190
src/pages/users.js Normal file
View File

@@ -0,0 +1,190 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import Config from "../lib/config";
import { Link } from 'react-router-dom';
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
import "./css/users.css";
import Loading from "./components/loading";
function Users() {
const [data, setData] = useState([]);
const [config, setConfig] = useState(null);
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const [currentPage, setCurrentPage] = useState(1);
const [itemCount,setItemCount] = useState(10);
function handleSort(key) {
const direction =
sortConfig.key === key && sortConfig.direction === "ascending"
? "descending"
: "ascending";
setSortConfig({ key, direction });
}
function sortData(data, { key, direction }) {
if (!key) return data;
const sortedData = [...data];
sortedData.sort((a, b) => {
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
return 0;
});
return sortedData;
}
function formatTime(time) {
const units = {
days: ['Day', 'Days'],
hours: ['Hour', 'Hours'],
minutes: ['Minute', 'Minutes'],
seconds: ['Second', 'Seconds']
};
let formattedTime = '';
for (const unit in units) {
if (time[unit]) {
const unitName = units[unit][time[unit] > 1 ? 1 : 0];
formattedTime += `${time[unit]} ${unitName} `;
}
}
return `${formattedTime}ago`;
}
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 = `/stats/getAllUserActivity`;
axios
.get(url)
.then((data) => {
console.log(data);
setData(data.data);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
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);
const indexOfLastUser = currentPage * itemCount;
const indexOfFirstUser = indexOfLastUser - itemCount;
const currentUsers = sortedData.slice(indexOfFirstUser, indexOfLastUser);
const pageNumbers = [];
for (let i = 1; i <= Math.ceil(sortedData.length / itemCount); i++) {
pageNumbers.push(i);
}
return (
<div className="Users">
<div className="Heading">
<h1>All Users</h1>
<div className="pagination-range">
<div className="header">Items</div>
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value); setCurrentPage(1);}}>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<table className="user-activity-table">
<thead>
<tr>
<th></th>
<th onClick={() => handleSort("UserName")}>User</th>
<th onClick={() => handleSort("LastWatched")}>Last Watched</th>
<th onClick={() => handleSort("LastClient")}>Last Client</th>
<th onClick={() => handleSort("TotalPlays")}>Total Plays</th>
<th onClick={() => handleSort("TotalWatchTime")}>Total Watch Time</th>
<th onClick={() => handleSort("LastSeen")}>Last Seen</th>
</tr>
</thead>
<tbody>
{currentUsers.map((item) => (
<tr key={item.UserId}>
<td>
{item.PrimaryImageTag ? (
<img
className="card-user-image"
src={
config.hostUrl +
"/Users/" +
item.UserId +
"/Images/Primary?quality=10"
}
alt=""
/>
) : (
<AccountCircleFillIcon color="#fff" size={30} />
)}
</td>
<td> <Link to={`/user-info/${item.UserId}`}>{item.UserName}</Link></td>
<td>{item.LastWatched || 'never'}</td>
<td>{item.LastClient || 'n/a'}</td>
<td>{item.TotalPlays}</td>
<td>{item.TotalWatchTime || 0}</td>
<td>{item.LastSeen ? formatTime(item.LastSeen) : 'never'}</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<button className="page-btn" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
First
</button>
<button className="page-btn" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
Previous
</button>
<div className="page-number">{`Page ${currentPage} of ${pageNumbers.length}`}</div>
<button className="page-btn" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === pageNumbers.length}>
Next
</button>
<button className="page-btn" onClick={() => setCurrentPage(pageNumbers.length)} disabled={currentPage === pageNumbers.length}>
Last
</button>
</div>
</div>
);
}
export default Users;