Merge pull request #303 from CyferShepard/unstable

Release V1.1.3
This commit is contained in:
Thegan Govender
2025-02-02 17:14:56 +02:00
committed by GitHub
56 changed files with 4686 additions and 913 deletions

View File

@@ -0,0 +1,169 @@
const { pool } = require("../db.js");
const pgp = require("pg-promise")();
function wrapField(field) {
if (field === "*") {
return field;
}
if (
field.includes("COALESCE") ||
field.includes("SUM") ||
field.includes("COUNT") ||
field.includes("MAX") ||
field.includes("MIN") ||
field.includes("AVG") ||
field.includes("DISTINCT") ||
field.includes("json_agg") ||
field.includes("CASE")
) {
return field;
}
if (field.includes(" as ")) {
const [column, alias] = field.split(" as ");
return `${column
.split(".")
.map((part) => (part == "*" ? part : `"${part}"`))
.join(".")} as "${alias}"`;
}
return field
.split(".")
.map((part) => (part == "*" ? part : `"${part}"`))
.join(".");
}
function buildWhereClause(conditions) {
if (!Array.isArray(conditions)) {
return "";
}
return conditions
.map((condition, index) => {
if (Array.isArray(condition)) {
return `${index > 0 ? "AND" : ""} (${buildWhereClause(condition)})`;
} else if (typeof condition === "object") {
const { column, field, operator, value, type } = condition;
const conjunction = index === 0 ? "" : type ? type.toUpperCase() : "AND";
if (operator == "LIKE") {
return `${conjunction} ${column ? wrapField(column) : field} ${operator} ${value}`;
}
return `${conjunction} ${column ? wrapField(column) : field} ${operator} ${value}`;
}
return "";
})
.join(" ")
.trim();
}
function buildCTE(cte) {
if (!cte) {
return "";
}
const { select, table, cteAlias, alias, joins = [], where = [], group_by = [], order_by, sort_order = "desc" } = cte;
let query = `WITH ${cteAlias} AS (SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`;
// Add joins
joins.forEach((join) => {
const joinConditions = join.conditions
.map((condition, index) => {
const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND";
return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${
condition.second ? wrapField(condition.second) : `${condition.value}`
}`;
})
.join(" ");
const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`;
query += joinQuery;
});
// Add where conditions
const whereClause = buildWhereClause(where);
if (whereClause) {
query += ` WHERE ${whereClause}`;
}
// Add group by
if (group_by.length > 0) {
query += ` GROUP BY ${group_by.map(wrapField).join(", ")}`;
}
// Add order by
if (order_by) {
query += ` ORDER BY ${wrapField(order_by)} ${sort_order}`;
}
query += ")";
return query;
}
async function query({
cte,
select = ["*"],
table,
alias,
joins = [],
where = [],
values = [],
order_by = "Id",
sort_order = "desc",
pageNumber = 1,
pageSize = 50,
}) {
const client = await pool.connect();
try {
// Build the base query
let countQuery = `${buildCTE(cte)} SELECT COUNT(*) FROM ${wrapField(table)} AS ${wrapField(alias)}`;
let query = `${buildCTE(cte)} SELECT ${select.map(wrapField).join(", ")} FROM ${wrapField(table)} AS ${wrapField(alias)}`;
// Add joins
joins.forEach((join) => {
const joinConditions = join.conditions
.map((condition, index) => {
const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND";
return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${
condition.second ? wrapField(condition.second) : `${condition.value}`
}`;
})
.join(" ");
const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`;
query += joinQuery;
countQuery += joinQuery;
});
// Add where conditions
const whereClause = buildWhereClause(where);
if (whereClause) {
query += ` WHERE ${whereClause}`;
countQuery += ` WHERE ${whereClause}`;
}
// Add order by and pagination
query += ` ORDER BY ${wrapField(order_by)} ${sort_order}`;
query += ` LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}`;
// Execute the query
const result = await client.query(query, values);
// Count total rows
const countResult = await client.query(countQuery, values);
const totalRows = parseInt(countResult.rows[0].count, 10);
// Return the structured response
return {
pages: Math.ceil(totalRows / pageSize),
results: result.rows,
};
} catch (error) {
// console.timeEnd("queryWithPagingAndJoins");
console.error("Error occurred while executing query:", error.message);
return {
pages: 0,
results: [],
};
} finally {
client.release();
}
}
module.exports = {
query,
};

View File

@@ -1,7 +1,6 @@
const { Pool } = require("pg");
const pgp = require("pg-promise")();
const { update_query: update_query_map } = require("./models/bulk_insert_update_handler");
const moment = require("moment");
const _POSTGRES_USER = process.env.POSTGRES_USER;
const _POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD;
@@ -20,6 +19,9 @@ const pool = new Pool({
database: _POSTGRES_DATABASE,
password: _POSTGRES_PASSWORD,
port: _POSTGRES_PORT,
max: 20, // Maximum number of connections in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
});
pool.on("error", (err, client) => {
@@ -45,6 +47,12 @@ async function deleteBulk(table_name, data, pkName) {
await client.query("COMMIT");
message = data.length + " Rows removed.";
if (table_name === "jf_playback_activity") {
for (const view of materializedViews) {
refreshMaterializedView(view);
}
}
} catch (error) {
await client.query("ROLLBACK");
message = "Bulk delete error: " + error;
@@ -85,6 +93,32 @@ async function updateSingleFieldBulk(table_name, data, field_name, new_value, wh
return { Result: result, message: "" + message };
}
const materializedViews = ["js_latest_playback_activity", "js_library_stats_overview"];
async function refreshMaterializedView(view_name) {
const client = await pool.connect();
let result = "SUCCESS";
let message = "";
try {
await client.query("BEGIN");
const refreshQuery = {
text: `REFRESH MATERIALIZED VIEW ${view_name}`,
};
await client.query(refreshQuery);
await client.query("COMMIT");
message = view_name + " refreshed.";
} catch (error) {
await client.query("ROLLBACK");
message = "Refresh materialized view error: " + error;
result = "ERROR";
} finally {
client.release();
}
return { Result: result, message: "" + message };
}
async function insertBulk(table_name, data, columns) {
//dedupe data
@@ -113,6 +147,12 @@ async function insertBulk(table_name, data, columns) {
const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly
await client.query(query);
await client.query("COMMIT");
if (table_name === "jf_playback_activity") {
for (const view of materializedViews) {
refreshMaterializedView(view);
}
}
} catch (error) {
await client.query("ROLLBACK");
message = "" + error;
@@ -126,9 +166,15 @@ async function insertBulk(table_name, data, columns) {
};
}
async function query(text, params) {
async function query(text, params, refreshViews = false) {
try {
const result = await pool.query(text, params);
if (refreshViews) {
for (const view of materializedViews) {
refreshMaterializedView(view);
}
}
return result;
} catch (error) {
if (error?.routine === "auth_failed") {
@@ -163,6 +209,7 @@ async function querySingle(sql, params) {
}
module.exports = {
pool: pool,
query: query,
deleteBulk: deleteBulk,
insertBulk: insertBulk,

View File

@@ -0,0 +1,32 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata;
CREATE VIEW jf_playback_activity_with_metadata AS
select a.*,e."IndexNumber" as "EpisodeNumber",e."ParentIndexNumber" as "SeasonNumber",i."ParentId"
FROM "jf_playback_activity" AS "a"
LEFT JOIN jf_library_episodes AS e
ON "a"."EpisodeId" = "e"."EpisodeId"
AND "a"."SeasonId" = "e"."SeasonId"
LEFT JOIN jf_library_items AS i
ON "i"."Id" = "a"."NowPlayingItemId" OR "e"."SeriesId" = "i"."Id"
order by a."ActivityDateInserted" desc;
ALTER VIEW public.jf_playback_activity_with_metadata
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP VIEW IF EXISTS public.jf_playback_activity_with_metadata;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,43 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;
CREATE MATERIALIZED VIEW js_latest_playback_activity AS
WITH latest_activity AS (
SELECT
"NowPlayingItemId",
"EpisodeId",
"UserId",
MAX("ActivityDateInserted") AS max_date
FROM public.jf_playback_activity
GROUP BY "NowPlayingItemId", "EpisodeId", "UserId"
order by max_date desc
)
SELECT
a.*
FROM public.jf_playback_activity_with_metadata a
JOIN latest_activity u
ON a."NowPlayingItemId" = u."NowPlayingItemId"
AND COALESCE(a."EpisodeId", '1') = COALESCE(u."EpisodeId", '1')
AND a."UserId" = u."UserId"
AND a."ActivityDateInserted" = u.max_date
order by a."ActivityDateInserted" desc;
ALTER MATERIALIZED VIEW public.js_latest_playback_activity
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,28 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
CREATE OR REPLACE FUNCTION refresh_js_latest_playback_activity()
RETURNS TRIGGER AS $$
BEGIN
REFRESH MATERIALIZED VIEW js_latest_playback_activity;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
ALTER MATERIALIZED VIEW public.js_latest_playback_activity
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.refresh_js_latest_playback_activity;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,29 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_latest_playback_activity') THEN
CREATE TRIGGER refresh_js_latest_playback_activity_trigger
AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_js_latest_playback_activity();
END IF;
END
$$ LANGUAGE plpgsql;
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,137 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP VIEW IF EXISTS public.js_library_stats_overview;
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
CREATE MATERIALIZED VIEW public.js_library_stats_overview
AS
SELECT l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN (
SELECT
jso."Id",
jso."NowPlayingItemId",
jso."SeasonId",
jso."EpisodeId",
jso."ParentId",
jso."ActivityDateInserted"
FROM
jf_playback_activity_with_metadata jso
INNER JOIN (
SELECT
"ParentId",
MAX("ActivityDateInserted") AS max_date
FROM
jf_playback_activity_with_metadata
GROUP BY
"ParentId"
) latest ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date
) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
DROP VIEW IF EXISTS public.js_library_stats_overview;
CREATE OR REPLACE VIEW public.js_library_stats_overview
AS
SELECT DISTINCT ON (l."Id") l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity a
JOIN jf_library_items i_1 ON a."NowPlayingItemId" = i_1."Id"
WHERE i_1."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN ( SELECT DISTINCT ON (i_1."ParentId") 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",
jf_playback_activity."PlayMethod",
i_1."ParentId"
FROM jf_playback_activity
JOIN jf_library_items i_1 ON i_1."Id" = jf_playback_activity."NowPlayingItemId"
ORDER BY i_1."ParentId", jf_playback_activity."ActivityDateInserted" DESC) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,28 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
CREATE OR REPLACE FUNCTION refresh_js_library_stats_overview()
RETURNS TRIGGER AS $$
BEGIN
REFRESH MATERIALIZED VIEW js_library_stats_overview;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.refresh_js_library_stats_overview;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,29 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'refresh_js_library_stats_overview') THEN
CREATE TRIGGER refresh_js_library_stats_overview_trigger
AFTER INSERT OR UPDATE OR DELETE ON public.jf_playback_activity
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_js_library_stats_overview();
END IF;
END
$$ LANGUAGE plpgsql;
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,21 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;
DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP TRIGGER IF EXISTS refresh_js_library_stats_overview_trigger ON public.jf_playback_activity;
DROP TRIGGER IF EXISTS refresh_js_latest_playback_activity_trigger ON public.jf_playback_activity;
`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,114 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;
CREATE MATERIALIZED VIEW js_latest_playback_activity AS
WITH ranked_activity AS (
SELECT
"Id",
"IsPaused",
"UserId",
"UserName",
"Client",
"DeviceName",
"DeviceId",
"ApplicationVersion",
"NowPlayingItemId",
"NowPlayingItemName",
"SeasonId",
"SeriesName",
"EpisodeId",
"PlaybackDuration",
"ActivityDateInserted",
"PlayMethod",
"MediaStreams",
"TranscodingInfo",
"PlayState",
"OriginalContainer",
"RemoteEndPoint",
"ServerId",
imported,
"EpisodeNumber",
"SeasonNumber",
"ParentId",
ROW_NUMBER() OVER (
PARTITION BY "NowPlayingItemId", COALESCE("EpisodeId", '1'), "UserId"
ORDER BY "ActivityDateInserted" DESC
) AS rn
FROM jf_playback_activity_with_metadata
)
SELECT
"Id",
"IsPaused",
"UserId",
"UserName",
"Client",
"DeviceName",
"DeviceId",
"ApplicationVersion",
"NowPlayingItemId",
"NowPlayingItemName",
"SeasonId",
"SeriesName",
"EpisodeId",
"PlaybackDuration",
"ActivityDateInserted",
"PlayMethod",
"MediaStreams",
"TranscodingInfo",
"PlayState",
"OriginalContainer",
"RemoteEndPoint",
"ServerId",
imported,
"EpisodeNumber",
"SeasonNumber",
"ParentId"
FROM ranked_activity
WHERE rn = 1
ORDER BY "ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_latest_playback_activity
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_latest_playback_activity;
CREATE MATERIALIZED VIEW js_latest_playback_activity AS
WITH latest_activity AS (
SELECT
"NowPlayingItemId",
"EpisodeId",
"UserId",
MAX("ActivityDateInserted") AS max_date
FROM public.jf_playback_activity
GROUP BY "NowPlayingItemId", "EpisodeId", "UserId"
order by max_date desc
)
SELECT
a.*
FROM public.jf_playback_activity_with_metadata a
JOIN latest_activity u
ON a."NowPlayingItemId" = u."NowPlayingItemId"
AND COALESCE(a."EpisodeId", '1') = COALESCE(u."EpisodeId", '1')
AND a."UserId" = u."UserId"
AND a."ActivityDateInserted" = u.max_date
order by a."ActivityDateInserted" desc;
ALTER MATERIALIZED VIEW public.js_latest_playback_activity
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,130 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
CREATE MATERIALIZED VIEW public.js_library_stats_overview
AS
SELECT l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN ( SELECT jso."Id",
jso."NowPlayingItemId",
jso."SeasonId",
jso."EpisodeId",
jso."ParentId",
jso."ActivityDateInserted"
FROM js_latest_playback_activity jso
JOIN ( SELECT js_latest_playback_activity."ParentId",
max(js_latest_playback_activity."ActivityDateInserted") AS max_date
FROM js_latest_playback_activity
GROUP BY js_latest_playback_activity."ParentId") latest
ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date )
latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
CREATE MATERIALIZED VIEW public.js_library_stats_overview
AS
SELECT l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN (
SELECT
jso."Id",
jso."NowPlayingItemId",
jso."SeasonId",
jso."EpisodeId",
jso."ParentId",
jso."ActivityDateInserted"
FROM
jf_playback_activity_with_metadata jso
INNER JOIN (
SELECT
"ParentId",
MAX("ActivityDateInserted") AS max_date
FROM
jf_playback_activity_with_metadata
GROUP BY
"ParentId"
) latest ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date
) latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,122 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
CREATE MATERIALIZED VIEW public.js_library_stats_overview
AS
SELECT l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
latest_activity."ActivityDateInserted"
FROM jf_libraries l
LEFT JOIN ( SELECT jso."Id",
jso."NowPlayingItemId",
jso."SeasonId",
jso."EpisodeId",
jso."ParentId",
jso."ActivityDateInserted"
FROM js_latest_playback_activity jso
JOIN ( SELECT js_latest_playback_activity."ParentId",
max(js_latest_playback_activity."ActivityDateInserted") AS max_date
FROM js_latest_playback_activity
GROUP BY js_latest_playback_activity."ParentId") latest
ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date )
latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
}
};
exports.down = async function (knex) {
try {
await knex.schema.raw(`
DROP MATERIALIZED VIEW IF EXISTS public.js_library_stats_overview;
CREATE MATERIALIZED VIEW public.js_library_stats_overview
AS
SELECT l."Id",
l."Name",
l."ServerId",
l."IsFolder",
l."Type",
l."CollectionType",
l."ImageTagsPrimary",
i."Id" AS "ItemId",
i."Name" AS "ItemName",
i."Type" AS "ItemType",
i."PrimaryImageHash",
s."IndexNumber" AS "SeasonNumber",
e."IndexNumber" AS "EpisodeNumber",
e."Name" AS "EpisodeName",
( SELECT count(*) AS count
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS "Plays",
( SELECT sum(a."PlaybackDuration") AS sum
FROM jf_playback_activity_with_metadata a
WHERE a."ParentId" = l."Id") AS total_playback_duration,
l.total_play_time::numeric AS total_play_time,
l.item_count AS "Library_Count",
l.season_count AS "Season_Count",
l.episode_count AS "Episode_Count",
l.archived,
now() - latest_activity."ActivityDateInserted" AS "LastActivity"
FROM jf_libraries l
LEFT JOIN ( SELECT jso."Id",
jso."NowPlayingItemId",
jso."SeasonId",
jso."EpisodeId",
jso."ParentId",
jso."ActivityDateInserted"
FROM js_latest_playback_activity jso
JOIN ( SELECT js_latest_playback_activity."ParentId",
max(js_latest_playback_activity."ActivityDateInserted") AS max_date
FROM js_latest_playback_activity
GROUP BY js_latest_playback_activity."ParentId") latest
ON jso."ParentId" = latest."ParentId" AND jso."ActivityDateInserted" = latest.max_date )
latest_activity ON l."Id" = latest_activity."ParentId"
LEFT JOIN jf_library_items i ON i."Id" = latest_activity."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = latest_activity."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = latest_activity."EpisodeId"
ORDER BY l."Id", latest_activity."ActivityDateInserted" DESC;
ALTER MATERIALIZED VIEW public.js_library_stats_overview
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,73 @@
exports.up = function (knex) {
return knex.schema.raw(`
create or replace
function fs_get_user_activity(
user_id text,
library_ids text[]
)
returns table (
"UserName" text,
"Title" text,
"EpisodeCount" bigint,
"FirstActivityDate" timestamptz,
"LastActivityDate" timestamptz,
"TotalPlaybackDuration" bigint,
"SeasonName" text,
"MediaType" text,
"NowPlayingItemId" text
) as $$
begin
return QUERY
select
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end as "Title",
COUNT(distinct jp."EpisodeId") as "EpisodeCount",
MIN(jp."ActivityDateInserted") as "FirstActivityDate",
MAX(jp."ActivityDateInserted") as "LastActivityDate",
SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration",
ls."Name" as "SeasonName",
MAX(jl."CollectionType") as "MediaType",
jp."NowPlayingItemId"
from
public.jf_playback_activity as jp
join
public.jf_library_items as jli on
jp."NowPlayingItemId" = jli."Id"
join
public.jf_libraries as jl on
jli."ParentId" = jl."Id"
left join
public.jf_library_seasons as ls on
jp."SeasonId" = ls."Id"
where
jp."UserId" = user_id
and jl."Id" = any(library_ids)
group by
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end,
jp."SeriesName",
jp."SeasonId",
ls."Name",
jp."NowPlayingItemId"
having
not (MAX(jl."Name") = 'Shows'
and ls."Name" is null)
and SUM(jp."PlaybackDuration") >= 30
order by
MAX(jp."ActivityDateInserted") desc;
end;
$$ language plpgsql;
`);
};
exports.down = function (knex) {
return knex.schema.raw(`
DROP FUNCTION IF EXISTS fs_get_user_activity;
`);
};

View File

@@ -0,0 +1,177 @@
exports.up = function (knex) {
return knex.schema.raw(`
DROP FUNCTION IF EXISTS fs_get_user_activity;
CREATE OR REPLACE FUNCTION fs_get_user_activity(
user_id TEXT,
library_ids TEXT[]
)
RETURNS TABLE (
"UserName" TEXT,
"Title" TEXT,
"EpisodeCount" BIGINT,
"FirstActivityDate" TIMESTAMPTZ,
"LastActivityDate" TIMESTAMPTZ,
"TotalPlaybackDuration" BIGINT,
"SeasonName" TEXT,
"MediaType" TEXT,
"NowPlayingItemId" TEXT
) AS $$
BEGIN
RETURN QUERY
WITH DateDifferences AS (
SELECT
jp."UserName" AS "UserNameCol",
CASE
WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName"
ELSE jp."NowPlayingItemName"
END AS "TitleCol",
jp."EpisodeId" AS "EpisodeIdCol",
jp."ActivityDateInserted" AS "ActivityDateInsertedCol",
jp."PlaybackDuration" AS "PlaybackDurationCol",
ls."Name" AS "SeasonNameCol",
jl."CollectionType" AS "MediaTypeCol",
jp."NowPlayingItemId" AS "NowPlayingItemIdCol",
LAG(jp."ActivityDateInserted") OVER (PARTITION BY jp."UserName", CASE WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" ELSE jp."NowPlayingItemName" END ORDER BY jp."ActivityDateInserted") AS prev_date
FROM
public.jf_playback_activity AS jp
JOIN
public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id"
JOIN
public.jf_libraries AS jl ON jli."ParentId" = jl."Id"
LEFT JOIN
public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id"
WHERE
jp."UserId" = user_id
AND jl."Id" = ANY(library_ids)
),
GroupedEntries AS (
SELECT
"UserNameCol",
"TitleCol",
"EpisodeIdCol",
"ActivityDateInsertedCol",
"PlaybackDurationCol",
"SeasonNameCol",
"MediaTypeCol",
"NowPlayingItemIdCol",
prev_date,
CASE
WHEN prev_date IS NULL OR "ActivityDateInsertedCol" > prev_date + INTERVAL '1 month' THEN 1 -- Pick whatever interval you want here, I'm biased as I don't monitor music / never intended this feature to be used for music
ELSE 0
END AS new_group
FROM
DateDifferences
),
FinalGroups AS (
SELECT
"UserNameCol",
"TitleCol",
"EpisodeIdCol",
"ActivityDateInsertedCol",
"PlaybackDurationCol",
"SeasonNameCol",
"MediaTypeCol",
"NowPlayingItemIdCol",
SUM(new_group) OVER (PARTITION BY "UserNameCol", "TitleCol" ORDER BY "ActivityDateInsertedCol") AS grp
FROM
GroupedEntries
)
SELECT
"UserNameCol" AS "UserName",
"TitleCol" AS "Title",
COUNT(DISTINCT "EpisodeIdCol") AS "EpisodeCount",
MIN("ActivityDateInsertedCol") AS "FirstActivityDate",
MAX("ActivityDateInsertedCol") AS "LastActivityDate",
SUM("PlaybackDurationCol")::bigint AS "TotalPlaybackDuration",
"SeasonNameCol" AS "SeasonName",
MAX("MediaTypeCol") AS "MediaType",
"NowPlayingItemIdCol" AS "NowPlayingItemId"
FROM
FinalGroups
GROUP BY
"UserNameCol",
"TitleCol",
"SeasonNameCol",
"NowPlayingItemIdCol",
grp
HAVING
NOT (MAX("MediaTypeCol") = 'Shows' AND "SeasonNameCol" IS NULL)
AND SUM("PlaybackDurationCol") >= 20
ORDER BY
MAX("ActivityDateInsertedCol") DESC;
END;
$$ LANGUAGE plpgsql;
`);
};
exports.down = function (knex) {
return knex.schema.raw(`
DROP FUNCTION IF EXISTS fs_get_user_activity;
create or replace
function fs_get_user_activity(
user_id text,
library_ids text[]
)
returns table (
"UserName" text,
"Title" text,
"EpisodeCount" bigint,
"FirstActivityDate" timestamptz,
"LastActivityDate" timestamptz,
"TotalPlaybackDuration" bigint,
"SeasonName" text,
"MediaType" text,
"NowPlayingItemId" text
) as $$
begin
return QUERY
select
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end as "Title",
COUNT(distinct jp."EpisodeId") as "EpisodeCount",
MIN(jp."ActivityDateInserted") as "FirstActivityDate",
MAX(jp."ActivityDateInserted") as "LastActivityDate",
SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration",
ls."Name" as "SeasonName",
MAX(jl."CollectionType") as "MediaType",
jp."NowPlayingItemId"
from
public.jf_playback_activity as jp
join
public.jf_library_items as jli on
jp."NowPlayingItemId" = jli."Id"
join
public.jf_libraries as jl on
jli."ParentId" = jl."Id"
left join
public.jf_library_seasons as ls on
jp."SeasonId" = ls."Id"
where
jp."UserId" = user_id
and jl."Id" = any(library_ids)
group by
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end,
jp."SeriesName",
jp."SeasonId",
ls."Name",
jp."NowPlayingItemId"
having
not (MAX(jl."Name") = 'Shows'
and ls."Name" is null)
and SUM(jp."PlaybackDuration") >= 30
order by
MAX(jp."ActivityDateInserted") desc;
end;
$$ language plpgsql;
`);
};

View File

@@ -2,6 +2,8 @@
const express = require("express");
const db = require("../db");
const dbHelper = require("../classes/db-helper");
const pgp = require("pg-promise")();
const { randomUUID } = require("crypto");
@@ -15,38 +17,48 @@ const { tables } = require("../global/backup_tables");
const router = express.Router();
//consts
const groupedSortMap = [
{ field: "UserName", column: "a.UserName" },
{ field: "RemoteEndPoint", column: "a.RemoteEndPoint" },
{ field: "NowPlayingItemName", column: "FullName" },
{ field: "Client", column: "a.Client" },
{ field: "DeviceName", column: "a.DeviceName" },
{ field: "ActivityDateInserted", column: "a.ActivityDateInserted" },
{ field: "PlaybackDuration", column: `COALESCE(ar."TotalDuration", a."PlaybackDuration")` },
{ field: "TotalPlays", column: `COALESCE("TotalPlays",1)` },
];
const unGroupedSortMap = [
{ field: "UserName", column: "a.UserName" },
{ field: "RemoteEndPoint", column: "a.RemoteEndPoint" },
{ field: "NowPlayingItemName", column: "FullName" },
{ field: "Client", column: "a.Client" },
{ field: "DeviceName", column: "a.DeviceName" },
{ field: "ActivityDateInserted", column: "a.ActivityDateInserted" },
{ field: "PlaybackDuration", column: "a.PlaybackDuration" },
];
const filterFields = [
{ field: "UserName", column: `LOWER(a."UserName")` },
{ field: "RemoteEndPoint", column: `LOWER(a."RemoteEndPoint")` },
{
field: "NowPlayingItemName",
column: `LOWER(
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END
)`,
},
{ field: "Client", column: `LOWER(a."Client")` },
{ field: "DeviceName", column: `LOWER(a."DeviceName")` },
{ field: "ActivityDateInserted", column: "a.ActivityDateInserted", isColumn: true },
{ field: "PlaybackDuration", column: `a.PlaybackDuration`, isColumn: true, applyToCTE: true },
{ field: "TotalPlays", column: `COALESCE("TotalPlays",1)` },
];
//Functions
function groupActivity(rows) {
const groupedResults = {};
rows.forEach((row) => {
const key = row.NowPlayingItemId + row.EpisodeId + row.UserId;
if (groupedResults[key]) {
if (row.ActivityDateInserted > groupedResults[key].ActivityDateInserted) {
groupedResults[key] = {
...row,
results: groupedResults[key].results,
};
}
groupedResults[key].results.push(row);
} else {
groupedResults[key] = {
...row,
results: [],
};
groupedResults[key].results.push(row);
}
});
// Update GroupedResults with playbackDurationSum
Object.values(groupedResults).forEach((row) => {
if (row.results && row.results.length > 0) {
row.PlaybackDuration = row.results.reduce((acc, item) => acc + parseInt(item.PlaybackDuration), 0);
row.TotalPlays = row.results.length;
}
});
return groupedResults;
}
function groupRecentlyAdded(rows) {
const groupedResults = {};
rows.forEach((row) => {
@@ -122,11 +134,102 @@ async function purgeLibraryItems(id, withActivity, purgeAll = false) {
text: `DELETE FROM jf_playback_activity WHERE${
episodeIds.length > 0 ? ` "EpisodeId" IN (${pgp.as.csv(episodeIds)}) OR` : ""
}${seasonIds.length > 0 ? ` "SeasonId" IN (${pgp.as.csv(seasonIds)}) OR` : ""} "NowPlayingItemId"='${id}'`,
refreshViews: true,
};
await db.query(deleteQuery);
}
}
function buildFilterList(query, filtersArray) {
if (filtersArray.length > 0) {
query.where = query.where || [];
filtersArray.forEach((filter) => {
const findField = filterFields.find((item) => item.field === filter.field);
const column = findField?.column || "a.ActivityDateInserted";
const isColumn = findField?.isColumn || false;
const applyToCTE = findField?.applyToCTE || false;
if (filter.min) {
query.where.push({
column: column,
operator: ">=",
value: `$${query.values.length + 1}`,
});
query.values.push(filter.min);
if (applyToCTE) {
if (query.cte) {
if (!query.cte.where) {
query.cte.where = [];
}
query.cte.where.push({
column: column,
operator: ">=",
value: `$${query.values.length + 1}`,
});
query.values.push(filter.min);
}
}
}
if (filter.max) {
query.where.push({
column: column,
operator: "<=",
value: `$${query.values.length + 1}`,
});
query.values.push(filter.max);
if (applyToCTE) {
if (query.cte) {
if (!query.cte.where) {
query.cte.where = [];
}
query.cte.where.push({
column: column,
operator: "<=",
value: `$${query.values.length + 1}`,
});
query.values.push(filter.max);
}
}
}
if (filter.value) {
const whereClause = {
operator: "LIKE",
value: `$${query.values.length + 1}`,
};
query.values.push(`%${filter.value.toLowerCase()}%`);
if (isColumn) {
whereClause.column = column;
} else {
whereClause.field = column;
}
query.where.push(whereClause);
if (applyToCTE) {
if (query.cte) {
if (!query.cte.where) {
query.cte.where = [];
}
whereClause.value = `$${query.values.length + 1}`;
query.cte.where.push(whereClause);
query.values.push(`%${filter.value.toLowerCase()}%`);
}
}
}
});
}
}
//////////////////////////////
router.get("/getconfig", async (req, res) => {
try {
const config = await new configClass().getConfig();
@@ -952,6 +1055,7 @@ router.delete("/item/purge", async (req, res) => {
}${
seasons.length > 0 ? ` "SeasonId" IN (${pgp.as.csv(seasons.map((item) => item.SeasonId))}) OR` : ""
} "NowPlayingItemId"='${id}'`,
refreshViews: true,
};
await db.query(deleteQuery);
}
@@ -1085,20 +1189,150 @@ router.post("/setExcludedBackupTable", async (req, res) => {
//DB Queries - History
router.get("/getHistory", async (req, res) => {
const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query;
let filtersArray = [];
if (filters) {
try {
filtersArray = JSON.parse(filters);
} catch (error) {
return res.status(400).json({
error: "Invalid filters parameter",
example: [
{
field: "ActivityDateInserted",
min: "2024-12-31T22:00:00.000Z",
max: "2024-12-31T22:00:00.000Z",
},
{
field: "PlaybackDuration",
min: "1",
max: "10",
},
{
field: "TotalPlays",
min: "1",
max: "10",
},
{
field: "DeviceName",
value: "test",
},
{
field: "Client",
value: "test",
},
{
field: "NowPlayingItemName",
value: "test",
},
{
field: "RemoteEndPoint",
value: "127.0.0.1",
},
{
field: "UserName",
value: "test",
},
],
});
}
}
const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted";
const values = [];
try {
const { rows } = await db.query(`
SELECT a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" , i."ParentId"
FROM jf_playback_activity a
left join jf_library_episodes e
on a."EpisodeId"=e."EpisodeId"
and a."SeasonId"=e."SeasonId"
left join jf_library_items i
on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id"
order by a."ActivityDateInserted" desc`);
const cte = {
cteAlias: "activity_results",
select: [
"a.NowPlayingItemId",
`COALESCE(a."EpisodeId", '1') as "EpisodeId"`,
"a.UserId",
`json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`,
`COUNT(a.*) as "TotalPlays"`,
`SUM(a."PlaybackDuration") as "TotalDuration"`,
],
table: "jf_playback_activity_with_metadata",
alias: "a",
group_by: ["a.NowPlayingItemId", `COALESCE(a."EpisodeId", '1')`, "a.UserId"],
};
const groupedResults = groupActivity(rows);
const query = {
cte: cte,
select: [
"a.*",
"a.EpisodeNumber",
"a.SeasonNumber",
"a.ParentId",
"ar.results",
"ar.TotalPlays",
"ar.TotalDuration",
`
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END AS "FullName"
`,
],
table: "js_latest_playback_activity",
alias: "a",
joins: [
{
type: "left",
table: "activity_results",
alias: "ar",
conditions: [
{ first: "a.NowPlayingItemId", operator: "=", second: "ar.NowPlayingItemId" },
{ first: "a.EpisodeId", operator: "=", second: "ar.EpisodeId", type: "and" },
{ first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" },
],
},
],
res.send(Object.values(groupedResults));
order_by: sortField,
sort_order: desc ? "desc" : "asc",
pageNumber: page,
pageSize: size,
};
if (search && search.length > 0) {
query.where = [
{
field: `LOWER(
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END
)`,
operator: "LIKE",
value: `$${values.length + 1}`,
},
];
values.push(`%${search.toLowerCase()}%`);
}
query.values = values;
buildFilterList(query, filtersArray);
const result = await dbHelper.query(query);
result.results = result.results.map((item) => ({
...item,
PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration,
}));
const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results };
if (search && search.length > 0) {
response.search = search;
}
if (filtersArray.length > 0) {
response.filters = filtersArray;
}
res.send(response);
} catch (error) {
console.log(error);
}
@@ -1106,6 +1340,55 @@ router.get("/getHistory", async (req, res) => {
router.post("/getLibraryHistory", async (req, res) => {
try {
const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query;
let filtersArray = [];
if (filters) {
try {
filtersArray = JSON.parse(filters);
} catch (error) {
return res.status(400).json({
error: "Invalid filters parameter",
example: [
{
field: "ActivityDateInserted",
min: "2024-12-31T22:00:00.000Z",
max: "2024-12-31T22:00:00.000Z",
},
{
field: "PlaybackDuration",
min: "1",
max: "10",
},
{
field: "TotalPlays",
min: "1",
max: "10",
},
{
field: "DeviceName",
value: "test",
},
{
field: "Client",
value: "test",
},
{
field: "NowPlayingItemName",
value: "test",
},
{
field: "RemoteEndPoint",
value: "127.0.0.1",
},
{
field: "UserName",
value: "test",
},
],
});
}
}
const { libraryid } = req.body;
if (libraryid === undefined) {
@@ -1114,20 +1397,109 @@ router.post("/getLibraryHistory", async (req, res) => {
return;
}
const { rows } = await db.query(
`select a.* , e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber"
from jf_playback_activity a
join jf_library_items i
on i."Id"=a."NowPlayingItemId"
left join jf_library_episodes e
on a."EpisodeId"=e."EpisodeId"
and a."SeasonId"=e."SeasonId"
where i."ParentId"=$1
order by a."ActivityDateInserted" desc`,
[libraryid]
);
const groupedResults = groupActivity(rows);
res.send(Object.values(groupedResults));
const sortField = groupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted";
const values = [];
const cte = {
cteAlias: "activity_results",
select: [
"a.NowPlayingItemId",
`COALESCE(a."EpisodeId", '1') as "EpisodeId"`,
"a.UserId",
`json_agg(row_to_json(a) ORDER BY "ActivityDateInserted" DESC) as results`,
`COUNT(a.*) as "TotalPlays"`,
`SUM(a."PlaybackDuration") as "TotalDuration"`,
],
table: "jf_playback_activity_with_metadata",
alias: "a",
group_by: ["a.NowPlayingItemId", `COALESCE(a."EpisodeId", '1')`, "a.UserId"],
};
const query = {
cte: cte,
select: [
"a.*",
"a.EpisodeNumber",
"a.SeasonNumber",
"a.ParentId",
"ar.results",
"ar.TotalPlays",
"ar.TotalDuration",
`
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END AS "FullName"
`,
],
table: "js_latest_playback_activity",
alias: "a",
joins: [
{
type: "inner",
table: "jf_library_items",
alias: "i",
conditions: [
{ first: "i.Id", operator: "=", second: "a.NowPlayingItemId" },
{ first: "i.ParentId", operator: "=", value: `$${values.length + 1}` },
],
},
{
type: "left",
table: "activity_results",
alias: "ar",
conditions: [
{ first: "a.NowPlayingItemId", operator: "=", second: "ar.NowPlayingItemId" },
{ first: "a.EpisodeId", operator: "=", second: "ar.EpisodeId", type: "and" },
{ first: "a.UserId", operator: "=", second: "ar.UserId", type: "and" },
],
},
],
order_by: sortField,
sort_order: desc ? "desc" : "asc",
pageNumber: page,
pageSize: size,
};
values.push(libraryid);
if (search && search.length > 0) {
query.where = [
{
field: `LOWER(
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END
)`,
operator: "LIKE",
value: `$${values.length + 1}`,
},
];
values.push(`%${search.toLowerCase()}%`);
}
query.values = values;
buildFilterList(query, filtersArray);
const result = await dbHelper.query(query);
result.results = result.results.map((item) => ({
...item,
PlaybackDuration: item.TotalDuration ? item.TotalDuration : item.PlaybackDuration,
}));
const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results };
if (search && search.length > 0) {
response.search = search;
}
if (filtersArray.length > 0) {
response.filters = filtersArray;
}
res.send(response);
} catch (error) {
console.log(error);
res.status(503);
@@ -1137,6 +1509,7 @@ router.post("/getLibraryHistory", async (req, res) => {
router.post("/getItemHistory", async (req, res) => {
try {
const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query;
const { itemid } = req.body;
if (itemid === undefined) {
@@ -1145,23 +1518,119 @@ router.post("/getItemHistory", async (req, res) => {
return;
}
const { rows } = await db.query(
`select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber"
from jf_playback_activity a
left join jf_library_episodes e
on a."EpisodeId"=e."EpisodeId"
and a."SeasonId"=e."SeasonId"
where
(a."EpisodeId"=$1 OR a."SeasonId"=$1 OR a."NowPlayingItemId"=$1);`,
[itemid]
);
let filtersArray = [];
if (filters) {
try {
filtersArray = JSON.parse(filters);
filtersArray = filtersArray.filter((filter) => filter.field !== "TotalPlays");
} catch (error) {
return res.status(400).json({
error: "Invalid filters parameter",
example: [
{
field: "ActivityDateInserted",
min: "2024-12-31T22:00:00.000Z",
max: "2024-12-31T22:00:00.000Z",
},
{
field: "PlaybackDuration",
min: "1",
max: "10",
},
{
field: "TotalPlays",
min: "1",
max: "10",
},
{
field: "DeviceName",
value: "test",
},
{
field: "Client",
value: "test",
},
{
field: "NowPlayingItemName",
value: "test",
},
{
field: "RemoteEndPoint",
value: "127.0.0.1",
},
{
field: "UserName",
value: "test",
},
],
});
}
}
const groupedResults = rows.map((item) => ({
...item,
results: [],
}));
const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted";
const values = [];
const query = {
select: [
"a.*",
"a.EpisodeNumber",
"a.SeasonNumber",
"a.ParentId",
`
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END AS "FullName"
`,
],
table: "jf_playback_activity_with_metadata",
alias: "a",
where: [
[
{ column: "a.EpisodeId", operator: "=", value: `$${values.length + 1}` },
{ column: "a.SeasonId", operator: "=", value: `$${values.length + 2}`, type: "or" },
{ column: "a.NowPlayingItemId", operator: "=", value: `$${values.length + 3}`, type: "or" },
],
],
order_by: sortField,
sort_order: desc ? "desc" : "asc",
pageNumber: page,
pageSize: size,
};
res.send(groupedResults);
values.push(itemid);
values.push(itemid);
values.push(itemid);
if (search && search.length > 0) {
query.where = [
{
field: `LOWER(
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END
)`,
operator: "LIKE",
value: `$${values.length + 1}`,
},
];
values.push(`%${search.toLowerCase()}%`);
}
query.values = values;
buildFilterList(query, filtersArray);
const result = await dbHelper.query(query);
const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results };
if (search && search.length > 0) {
response.search = search;
}
if (filters) {
response.filters = JSON.parse(filters);
}
res.send(response);
} catch (error) {
console.log(error);
res.status(503);
@@ -1171,6 +1640,56 @@ router.post("/getItemHistory", async (req, res) => {
router.post("/getUserHistory", async (req, res) => {
try {
const { size = 50, page = 1, search, sort = "ActivityDateInserted", desc = true, filters } = req.query;
let filtersArray = [];
if (filters) {
try {
filtersArray = JSON.parse(filters);
filtersArray = filtersArray.filter((filter) => filter.field !== "TotalPlays");
} catch (error) {
return res.status(400).json({
error: "Invalid filters parameter",
example: [
{
field: "ActivityDateInserted",
min: "2024-12-31T22:00:00.000Z",
max: "2024-12-31T22:00:00.000Z",
},
{
field: "PlaybackDuration",
min: "1",
max: "10",
},
{
field: "TotalPlays",
min: "1",
max: "10",
},
{
field: "DeviceName",
value: "test",
},
{
field: "Client",
value: "test",
},
{
field: "NowPlayingItemName",
value: "test",
},
{
field: "RemoteEndPoint",
value: "127.0.0.1",
},
{
field: "UserName",
value: "test",
},
],
});
}
}
const { userid } = req.body;
if (userid === undefined) {
@@ -1179,21 +1698,66 @@ router.post("/getUserHistory", async (req, res) => {
return;
}
const { rows } = await db.query(
`select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber" , i."ParentId"
from jf_playback_activity a
left join jf_library_episodes e
on a."EpisodeId"=e."EpisodeId"
and a."SeasonId"=e."SeasonId"
left join jf_library_items i
on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id"
where a."UserId"=$1;`,
[userid]
);
const sortField = unGroupedSortMap.find((item) => item.field === sort)?.column || "a.ActivityDateInserted";
const groupedResults = groupActivity(rows);
const values = [];
const query = {
select: [
"a.*",
"a.EpisodeNumber",
"a.SeasonNumber",
"a.ParentId",
`
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END AS "FullName"
`,
],
table: "jf_playback_activity_with_metadata",
alias: "a",
where: [[{ column: "a.UserId", operator: "=", value: `$${values.length + 1}` }]],
order_by: sortField,
sort_order: desc ? "desc" : "asc",
pageNumber: page,
pageSize: size,
};
res.send(Object.values(groupedResults));
values.push(userid);
if (search && search.length > 0) {
query.where = [
{
field: `LOWER(
CASE
WHEN a."SeriesName" is null THEN a."NowPlayingItemName"
ELSE CONCAT(a."SeriesName" , ' : S' , a."SeasonNumber" , 'E' , a."EpisodeNumber" , ' - ' , a."NowPlayingItemName")
END
)`,
operator: "LIKE",
value: `$${values.length + 1}`,
},
];
values.push(`%${search.toLowerCase()}%`);
}
query.values = values;
buildFilterList(query, filtersArray);
const result = await dbHelper.query(query);
const response = { current_page: page, pages: result.pages, size: size, sort: sort, desc: desc, results: result.results };
if (search && search.length > 0) {
response.search = search;
}
if (filters) {
response.filters = JSON.parse(filters);
}
res.send(response);
} catch (error) {
console.log(error);
res.status(503);
@@ -1211,8 +1775,7 @@ router.post("/deletePlaybackActivity", async (req, res) => {
return;
}
await db.query(`DELETE from jf_playback_activity where "Id" = ANY($1)`, [ids]);
// const groupedResults = groupActivity(rows);
await db.query(`DELETE from jf_playback_activity where "Id" = ANY($1)`, [ids], true);
res.send(`${ids.length} Records Deleted`);
} catch (error) {
console.log(error);
@@ -1221,6 +1784,31 @@ router.post("/deletePlaybackActivity", async (req, res) => {
}
});
router.post("/getActivityTimeLine", async (req, res) => {
try {
const { userId, libraries } = req.body;
if (libraries === undefined || !Array.isArray(libraries)) {
res.status(400);
res.send("A list of IDs is required. EG: [1,2,3]");
return;
}
if (userId === undefined) {
res.status(400);
res.send("A userId is required.");
return;
}
const { rows } = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]);
res.send(rows);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });

View File

@@ -1,18 +1,19 @@
const express = require("express");
const { Pool } = require("pg");
const fs = require("fs");
const path = require("path");
const { randomUUID } = require("crypto");
const multer = require("multer");
const express = require('express');
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
const multer = require('multer');
const Logging = require("../classes/logging");
const backup = require("../classes/backup");
const triggertype = require("../logging/triggertype");
const taskstate = require("../logging/taskstate");
const taskName = require("../logging/taskName");
const Logging = require('../classes/logging');
const backup = require('../classes/backup');
const triggertype = require('../logging/triggertype');
const taskstate = require('../logging/taskstate');
const taskName = require('../logging/taskName');
const sanitizeFilename = require('../utils/sanitizer');
const { sendUpdate } = require("../ws");
const db = require("../db");
const { sendUpdate } = require('../ws');
const db = require('../db');
const router = express.Router();
@@ -21,14 +22,14 @@ const postgresUser = process.env.POSTGRES_USER;
const postgresPassword = process.env.POSTGRES_PASSWORD;
const postgresIp = process.env.POSTGRES_IP;
const postgresPort = process.env.POSTGRES_PORT;
const postgresDatabase = process.env.POSTGRES_DB || "jfstat";
const backupfolder = "backup-data";
const postgresDatabase = process.env.POSTGRES_DB || 'jfstat';
const backupfolder = 'backup-data';
// Restore function
function readFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
reject(err);
return;
@@ -40,8 +41,11 @@ function readFile(path) {
}
async function restore(file, refLog) {
refLog.logData.push({ color: "lawngreen", Message: "Starting Restore" });
refLog.logData.push({ color: "yellow", Message: "Restoring from Backup: " + file });
refLog.logData.push({ color: 'lawngreen', Message: 'Starting Restore' });
refLog.logData.push({
color: 'yellow',
Message: 'Restoring from Backup: ' + file,
});
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
@@ -58,49 +62,57 @@ async function restore(file, refLog) {
// Use await to wait for the Promise to resolve
jsonData = await readFile(backupPath);
} catch (err) {
refLog.logData.push({ color: "red", key: tableName, Message: `Failed to read backup file` });
refLog.logData.push({
color: 'red',
key: tableName,
Message: `Failed to read backup file`,
});
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
console.error(err);
}
// console.log(jsonData);
if (!jsonData) {
console.log("No Data");
console.log('No Data');
return;
}
for (let table of jsonData) {
const data = Object.values(table)[0];
const tableName = Object.keys(table)[0];
refLog.logData.push({ color: "dodgerblue", key: tableName, Message: `Restoring ${tableName}` });
refLog.logData.push({
color: 'dodgerblue',
key: tableName,
Message: `Restoring ${tableName}`,
});
for (let index in data) {
const keysWithQuotes = Object.keys(data[index]).map((key) => `"${key}"`);
const keyString = keysWithQuotes.join(", ");
const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`);
const keyString = keysWithQuotes.join(', ');
const valuesWithQuotes = Object.values(data[index]).map((col) => {
const valuesWithQuotes = Object.values(data[index]).map(col => {
if (col === null) {
return "NULL";
} else if (typeof col === "string") {
return 'NULL';
} else if (typeof col === 'string') {
return `'${col.replace(/'/g, "''")}'`;
} else if (typeof col === "object") {
} else if (typeof col === 'object') {
return `'${JSON.stringify(col).replace(/'/g, "''")}'`;
} else {
return `'${col}'`;
}
});
const valueString = valuesWithQuotes.join(", ");
const valueString = valuesWithQuotes.join(', ');
const query = `INSERT INTO ${tableName} (${keyString}) VALUES(${valueString}) ON CONFLICT DO NOTHING`;
const { rows } = await pool.query(query);
}
}
await pool.end();
refLog.logData.push({ color: "lawngreen", Message: "Restore Complete" });
refLog.logData.push({ color: 'lawngreen', Message: 'Restore Complete' });
}
// Route handler for backup endpoint
router.get("/beginBackup", async (req, res) => {
router.get('/beginBackup', async (req, res) => {
try {
const last_execution = await db
.query(
@@ -110,11 +122,11 @@ router.get("/beginBackup", async (req, res) => {
ORDER BY "TimeRun" DESC
LIMIT 1`
)
.then((res) => res.rows);
.then(res => res.rows);
if (last_execution.length !== 0) {
if (last_execution[0].Result === taskstate.RUNNING) {
sendUpdate("TaskError", "Error: Backup is already running");
sendUpdate('TaskError', 'Error: Backup is already running');
res.send();
return;
}
@@ -125,43 +137,50 @@ router.get("/beginBackup", async (req, res) => {
await Logging.insertLog(uuid, triggertype.Manual, taskName.backup);
await backup(refLog);
Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS);
res.send("Backup completed successfully");
sendUpdate("TaskComplete", { message: triggertype + " Backup Completed" });
res.send('Backup completed successfully');
sendUpdate('TaskComplete', { message: triggertype + ' Backup Completed' });
} catch (error) {
console.error(error);
res.status(500).send("Backup failed");
res.status(500).send('Backup failed');
}
});
router.get("/restore/:filename", async (req, res) => {
router.get('/restore/:filename', async (req, res) => {
try {
const uuid = randomUUID();
let refLog = { logData: [], uuid: uuid };
Logging.insertLog(uuid, triggertype.Manual, taskName.restore);
const filePath = path.join(__dirname, "..", backupfolder, req.params.filename);
const filename = sanitizeFilename(req.params.filename);
const filePath = path.join(
process.cwd(),
__dirname,
'..',
backupfolder,
filename
);
await restore(filePath, refLog);
Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS);
res.send("Restore completed successfully");
sendUpdate("TaskComplete", { message: "Restore completed successfully" });
res.send('Restore completed successfully');
sendUpdate('TaskComplete', { message: 'Restore completed successfully' });
} catch (error) {
console.error(error);
res.status(500).send("Restore failed");
res.status(500).send('Restore failed');
}
});
router.get("/files", (req, res) => {
router.get('/files', (req, res) => {
try {
const directoryPath = path.join(__dirname, "..", backupfolder);
const directoryPath = path.join(__dirname, '..', backupfolder);
fs.readdir(directoryPath, (err, files) => {
if (err) {
res.status(500).send("Unable to read directory");
res.status(500).send('Unable to read directory');
} else {
const fileData = files
.filter((file) => file.endsWith(".json"))
.map((file) => {
.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
return {
@@ -179,20 +198,34 @@ router.get("/files", (req, res) => {
});
//download backup file
router.get("/files/:filename", (req, res) => {
const filePath = path.join(__dirname, "..", backupfolder, req.params.filename);
router.get('/files/:filename', (req, res) => {
const filename = sanitizeFilename(req.params.filename);
const filePath = path.join(
process.cwd(),
__dirname,
'..',
backupfolder,
filename
);
res.download(filePath);
});
//delete backup
router.delete("/files/:filename", (req, res) => {
router.delete('/files/:filename', (req, res) => {
try {
const filePath = path.join(__dirname, "..", backupfolder, req.params.filename);
const filename = sanitizeFilename(req.params.filename);
const filePath = path.join(
process.cwd(),
__dirname,
'..',
backupfolder,
filename
);
fs.unlink(filePath, (err) => {
fs.unlink(filePath, err => {
if (err) {
console.error(err);
res.status(500).send("An error occurred while deleting the file.");
res.status(500).send('An error occurred while deleting the file.');
return;
}
@@ -200,13 +233,13 @@ router.delete("/files/:filename", (req, res) => {
res.status(200).send(`${filePath} has been deleted.`);
});
} catch (error) {
res.status(500).send("An error occurred while deleting the file.");
res.status(500).send('An error occurred while deleting the file.');
}
});
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, "..", backupfolder)); // Set the destination folder for uploaded files
cb(null, path.join(__dirname, '..', backupfolder)); // Set the destination folder for uploaded files
},
filename: function (req, file, cb) {
cb(null, file.originalname); // Set the file name
@@ -215,7 +248,7 @@ const storage = multer.diskStorage({
const upload = multer({ storage: storage });
router.post("/upload", upload.single("file"), (req, res) => {
router.post('/upload', upload.single('file'), (req, res) => {
// Handle the uploaded file here
res.json({
fileName: req.file.originalname,
@@ -225,7 +258,7 @@ router.post("/upload", upload.single("file"), (req, res) => {
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
res.status(404).send({ error: 'Not Found' });
});
module.exports = router;

View File

@@ -236,7 +236,9 @@ router.post("/getGlobalLibraryStats", async (req, res) => {
router.get("/getLibraryCardStats", async (req, res) => {
try {
const { rows } = await db.query("select * from js_library_stats_overview");
const { rows } = await db.query(
`select *, now() - js_library_stats_overview."ActivityDateInserted" AS "LastActivity" from js_library_stats_overview`
);
res.send(rows);
} catch (error) {
res.status(503);
@@ -252,7 +254,10 @@ router.post("/getLibraryCardStats", async (req, res) => {
return res.send("Invalid Library Id");
}
const { rows } = await db.query(`select * from js_library_stats_overview where "Id"=$1`, [libraryid]);
const { rows } = await db.query(
`select *, now() - js_library_stats_overview."ActivityDateInserted" AS "LastActivity" from js_library_stats_overview where "Id"=$1`,
[libraryid]
);
res.send(rows[0]);
} catch (error) {
console.log(error);

View File

@@ -1,4 +1,5 @@
const swaggerAutogen = require("swagger-autogen")();
const fs = require("fs");
const outputFile = "./swagger.json";
const endpointsFiles = ["./server.js"];
@@ -55,4 +56,42 @@ const config = {
module.exports = config;
swaggerAutogen(outputFile, endpointsFiles, config);
const modifySwaggerFile = (filePath) => {
const swaggerData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const endpointsToModify = ["/api/getHistory", "/api/getLibraryHistory", "/api/getUserHistory", "/api/getItemHistory"]; // Add more endpoints as needed
endpointsToModify.forEach((endpoint) => {
if (swaggerData.paths[endpoint]) {
const methods = Object.keys(swaggerData.paths[endpoint]);
methods.forEach((method) => {
const parameters = swaggerData.paths[endpoint][method].parameters;
if (parameters) {
parameters.forEach((param) => {
if (param.name === "sort") {
param.enum = [
"UserName",
"RemoteEndPoint",
"NowPlayingItemName",
"Client",
"DeviceName",
"ActivityDateInserted",
"PlaybackDuration",
];
if (endpoint.includes("getHistory") || endpoint.includes("getLibraryHistory")) {
param.enum.push("TotalPlays");
}
}
});
}
});
}
});
fs.writeFileSync(filePath, JSON.stringify(swaggerData, null, 2));
};
swaggerAutogen(outputFile, endpointsFiles, config).then(() => {
modifySwaggerFile(outputFile);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
const sanitizer = require("sanitize-filename");
const sanitizeFilename = (filename) => {
return sanitizer(filename);
};
module.export = { sanitizeFilename };

View File

@@ -1,43 +1,64 @@
const GitHub = require('github-api');
const packageJson = require('../package.json');
const {compareVersions} =require('compare-versions');
const GitHub = require("github-api");
const packageJson = require("../package.json");
const { compareVersions } = require("compare-versions");
const memoizee = require("memoizee");
async function checkForUpdates() {
const currentVersion = packageJson.version;
const repoOwner = 'cyfershepard';
const repoName = 'Jellystat';
const repoOwner = "cyfershepard";
const repoName = "Jellystat";
const gh = new GitHub();
let result={current_version: packageJson.version, latest_version:'', message:'', update_available:false};
let result = { current_version: packageJson.version, latest_version: "", message: "", update_available: false };
let latestVersion;
try {
const path = 'package.json';
const path = "package.json";
const response = await gh.getRepo(repoOwner, repoName).getContents('main', path);
const response = await gh.getRepo(repoOwner, repoName).getContents("main", path);
const content = response.data.content;
const decodedContent = Buffer.from(content, 'base64').toString();
const decodedContent = Buffer.from(content, "base64").toString();
latestVersion = JSON.parse(decodedContent).version;
if (compareVersions(latestVersion,currentVersion) > 0) {
if (compareVersions(latestVersion, currentVersion) > 0) {
// console.log(`A new version V.${latestVersion} of ${repoName} is available.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} has an update ${latestVersion}`, update_available:true };
} else if (compareVersions(latestVersion,currentVersion) < 0) {
result = {
current_version: packageJson.version,
latest_version: latestVersion,
message: `${repoName} has an update ${latestVersion}`,
update_available: true,
};
} else if (compareVersions(latestVersion, currentVersion) < 0) {
// console.log(`${repoName} is using a beta version.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is using a beta version`, update_available:false };
result = {
current_version: packageJson.version,
latest_version: latestVersion,
message: `${repoName} is using a beta version`,
update_available: false,
};
} else {
// console.log(`${repoName} is up to date.`);
result = { current_version: packageJson.version, latest_version: latestVersion, message: `${repoName} is up to date`, update_available:false };
result = {
current_version: packageJson.version,
latest_version: latestVersion,
message: `${repoName} is up to date`,
update_available: false,
};
}
} catch (error) {
console.error(`Failed to fetch releases for ${repoName}: ${error.message}`);
result = { current_version: packageJson.version, latest_version: 'N/A', message: `Failed to fetch releases for ${repoName}: ${error.message}`, update_available:false };
result = {
current_version: packageJson.version,
latest_version: "N/A",
message: `Failed to fetch releases for ${repoName}: ${error.message}`,
update_available: false,
};
}
return result;
}
const memoizedCheckForUpdates = memoizee(checkForUpdates, { maxAge: 300000, promise: true });
module.exports = { checkForUpdates };
module.exports = { checkForUpdates: memoizedCheckForUpdates };

913
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.2",
"version": "1.1.3",
"private": true,
"main": "src/index.jsx",
"scripts": {
@@ -15,12 +15,13 @@
"start": "cd backend && node server.js"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.14",
"@mui/material": "^5.15.14",
"@mui/x-data-grid": "^6.2.1",
"@mui/x-date-pickers": "^7.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.0",
"@mui/lab": "^6.0.0-beta.22",
"@mui/material": "^6.3.0",
"@mui/x-data-grid": "^7.23.3",
"@mui/x-date-pickers": "^7.23.3",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -48,7 +49,8 @@
"i18next-fs-backend": "^2.3.1",
"i18next-http-backend": "^2.4.3",
"knex": "^2.4.2",
"material-react-table": "^2.12.1",
"material-react-table": "^3.1.0",
"memoizee": "^0.4.17",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"passport": "^0.6.0",
@@ -67,10 +69,11 @@
"react-toastify": "^9.1.3",
"recharts": "^2.5.0",
"remixicon-react": "^1.0.0",
"sanitize-filename": "^1.6.3",
"semver": "^7.5.3",
"sequelize": "^6.29.0",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"socket.io-client": "^4.8.1",
"swagger-autogen": "^2.23.5",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",

View File

@@ -8,7 +8,8 @@
"STATISTICS": "Statistics",
"SETTINGS": "Settings",
"ABOUT": "About",
"LOGOUT": "Logout"
"LOGOUT": "Logout",
"TIMELINE": "Timeline"
},
"HOME_PAGE": {
"SESSIONS": "Sessions",
@@ -17,7 +18,9 @@
"LIBRARY_OVERVIEW": "Library Overview"
},
"SESSIONS": {
"NO_SESSIONS": "No Active Sessions Found"
"NO_SESSIONS": "No Active Sessions Found",
"DIRECT_PLAY": "Direct Play",
"TRANSCODE": "Transcode"
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "MOST VIEWED MOVIES",
@@ -75,7 +78,8 @@
"TAB_CONTROLS": {
"OVERVIEW": "Overview",
"ACTIVITY": "Activity",
"OPTIONS": "Options"
"OPTIONS": "Options",
"TIMELINE": "Timeline"
},
"ITEM_ACTIVITY": "Item Activity",
"ACTIVITY_TABLE": {
@@ -228,6 +232,10 @@
"GITHUB": "Github",
"Backup": "Backup Jellystat"
},
"TIMELINE_PAGE": {
"TIMELINE": "Timeline",
"EPISODES":"Episodes"
},
"SEARCH": "Search",
"TOTAL": "Total",
"LAST": "Last",
@@ -301,5 +309,6 @@
"LONGITUDE": "Longitude",
"TIMEZONE": "Timezone",
"POSTCODE": "Postcode",
"X_ROWS_SELECTED": "{ROWS} Rows Selected"
}
"X_ROWS_SELECTED": "{ROWS} Rows Selected",
"SUBTITLES": "Subtitles"
}

View File

@@ -17,7 +17,9 @@
"LIBRARY_OVERVIEW": "Vue d'ensemble des médiathèques"
},
"SESSIONS": {
"NO_SESSIONS": "Aucune lecture en cours n'a été trouvée"
"NO_SESSIONS": "Aucune lecture en cours n'a été trouvée",
"DIRECT_PLAY": "",
"TRANSCODE": ""
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "FILMS LES PLUS VUS",
@@ -177,7 +179,7 @@
"SIZE": "Taille",
"JELLYFIN_URL": "URL du serveur Jellyfin",
"EMBY_URL": "URL du serveur Emby",
"EXTERNAL_URL": "External URL",
"EXTERNAL_URL": "URL externe",
"API_KEY": "Clé API",
"API_KEYS": "Clés API",
"KEY_NAME": "Nom de la clé",
@@ -301,5 +303,6 @@
"LONGITUDE": "Longitude",
"TIMEZONE": "Fuseau horaire",
"POSTCODE": "Code postal",
"X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)"
"X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)",
"SUBTITLES": ""
}

View File

@@ -0,0 +1,305 @@
{
"JELLYSTAT": "Jellystat",
"MENU_TABS": {
"HOME": "Home",
"LIBRARIES": "Librerie",
"USERS": "Utenti",
"ACTIVITY": "Attività",
"STATISTICS": "Statistiche",
"SETTINGS": "Impostazioni",
"ABOUT": "Informazioni",
"LOGOUT": "Disconnetti"
},
"HOME_PAGE": {
"SESSIONS": "Sessioni",
"RECENTLY_ADDED": "Aggiunti di recente",
"WATCH_STATISTIC": "Statistiche di visualizzazione",
"LIBRARY_OVERVIEW": "Panoramica della libreria"
},
"SESSIONS": {
"NO_SESSIONS": "Nessuna sessione attiva trovata"
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "FILM PIÙ VISTI",
"MOST_POPULAR_MOVIES": "FILM PIÙ POPOLARI",
"MOST_VIEWED_SERIES": "SERIE PIÙ VISTE",
"MOST_POPULAR_SERIES": "SERIE PIÙ POPOLARI",
"MOST_LISTENED_MUSIC": "MUSICA PIÙ ASCOLTATA",
"MOST_POPULAR_MUSIC": "MUSICA PIÙ POPOLARE",
"MOST_VIEWED_LIBRARIES": "LIBRERIE PIÙ VISTE",
"MOST_USED_CLIENTS": "CLIENTS PIÙ UTILIZZATI",
"MOST_ACTIVE_USERS": "UTENTI PIÙ ATTIVI"
"CONCURRENT_STREAMS": "FLUSSI SIMULTANEI"
},
"LIBRARY_OVERVIEW": {
"MOVIE_LIBRARIES": "LIBRERIE FILM",
"SHOW_LIBRARIES": "LIBRERIE SERIE",
"MUSIC_LIBRARIES": "LIBRERIE MUSICALI",
"MIXED_LIBRARIES": "LIBRERIE MISTE"
},
"LIBRARY_CARD": {
"LIBRARY": "Libreria",
"TOTAL_TIME": "Tempo totale",
"TOTAL_FILES": "File totali",
"LIBRARY_SIZE": "Dimensione libreria",
"TOTAL_PLAYBACK": "Riproduzione totale",
"LAST_PLAYED": "Ultima riproduzione",
"LAST_ACTIVITY": "Ultima attività",
"TRACKED": "Tracciato"
},
"GLOBAL_STATS": {
"LAST_24_HRS": "Ultime 24 ore",
"LAST_7_DAYS": "Ultimi 7 giorni",
"LAST_30_DAYS": "Ultimi 30 giorni",
"LAST_180_DAYS": "Ultimi 180 giorni",
"LAST_365_DAYS": "Ultimi 365 giorni",
"ALL_TIME": "Totale",
"ITEM_STATS": "Statistiche elemento"
},
"ITEM_INFO": {
"FILE_PATH": "Percorso file",
"FILE_SIZE": "Dimensione file",
"RUNTIME": "Durata",
"AVERAGE_RUNTIME": "Durata media",
"OPEN_IN_JELLYFIN": "Apri in Jellyfin",
"ARCHIVED_DATA_OPTIONS": "Opzioni dati archiviati",
"PURGE": "Elimina",
"CONFIRM_ACTION": "Conferma azione",
"CONFIRM_ACTION_MESSAGE": "Sei sicuro di voler eliminare questo elemento",
"CONFIRM_ACTION_MESSAGE_2": "e l'attività di riproduzione associata"
},
"LIBRARY_INFO": {
"LIBRARY_STATS": "Statistiche libreria",
"LIBRARY_ACTIVITY": "Attività della libreria"
},
"TAB_CONTROLS": {
"OVERVIEW": "Panoramica",
"ACTIVITY": "Attività",
"OPTIONS": "Opzioni"
},
"ITEM_ACTIVITY": "Attività elemento",
"ACTIVITY_TABLE": {
"MODAL": {
"HEADER": "Informazioni flusso"
},
"IP_ADDRESS": "Indirizzo IP",
"CLIENT": "Client",
"DEVICE": "Dispositivo",
"PLAYBACK_DURATION": "Durata riproduzione",
"TOTAL_PLAYBACK": "Riproduzione totale",
"EXPAND": "Espandi",
"COLLAPSE": "Comprimi",
"SORT_BY": "Ordina per",
"ASCENDING": "Ascendente",
"DESCENDING": "Discendente",
"CLEAR_SORT": "Cancella ordinamento",
"CLEAR_FILTER": "Cancella filtro",
"FILTER_BY": "Filtra per",
"COLUMN_ACTIONS": "Azioni colonna",
"TOGGLE_SELECT_ROW": "Seleziona/Deseleziona riga",
"TOGGLE_SELECT_ALL": "Seleziona/Deseleziona tutto",
"MIN": "Min",
"MAX": "Max"
},
"TABLE_NAV_BUTTONS": {
"FIRST": "Primo",
"LAST": "Ultimo",
"NEXT": "Successivo",
"PREVIOUS": "Precedente"
},
"PURGE_OPTIONS": {
"PURGE_CACHE": "Elimina elemento nella cache",
"PURGE_CACHE_WITH_ACTIVITY": "Elimina elemento nella cache e attività di riproduzione",
"PURGE_LIBRARY_CACHE": "Elimina libreria e elementi nella cache",
"PURGE_LIBRARY_CACHE_WITH_ACTIVITY": "Elimina libreria, elementi e attività nella cache",
"PURGE_LIBRARY_ITEMS_CACHE": "Elimina solo elementi nella libreria",
"PURGE_LIBRARY_ITEMS_CACHE_WITH_ACTIVITY": "Elimina elementi nella libreria e attività",
"PURGE_ACTIVITY": "Sei sicuro di voler eliminare l'attività di riproduzione selezionata?"
},
"ERROR_MESSAGES": {
"FETCH_THIS_ITEM": "Recupera questo elemento da Jellyfin",
"NO_ACTIVITY": "Nessuna attività trovata",
"NEVER": "Mai",
"N/A": "N/D",
"NO_STATS": "Nessuna statistica da visualizzare",
"NO_BACKUPS": "Nessun backup trovato",
"NO_LOGS": "Nessun log trovato",
"NO_API_KEYS": "Nessuna chiave trovata",
"NETWORK_ERROR": "Impossibile connettersi al server Jellyfin",
"INVALID_LOGIN": "Nome utente o password non validi",
"INVALID_URL": "Errore {STATUS}: L'URL richiesto non è stato trovato.",
"UNAUTHORIZED": "Errore {STATUS}: Non autorizzato",
"PASSWORD_LENGTH": "La password deve essere lunga almeno 6 caratteri",
"USERNAME_REQUIRED": "Il nome utente è obbligatorio"
},
"SHOW_ARCHIVED_LIBRARIES": "Mostra librerie archiviate",
"HIDE_ARCHIVED_LIBRARIES": "Nascondi librerie archiviate",
"UNITS": {
"YEAR": "Anno",
"YEARS": "Anni",
"MONTH": "Mese",
"MONTHS": "Mesi",
"DAY": "Giorno",
"DAYS": "Giorni",
"HOUR": "Ora",
"HOURS": "Ore",
"MINUTE": "Minuto",
"MINUTES": "Minuti",
"SECOND": "Secondo",
"SECONDS": "Secondi",
"PLAYS": "Riproduzioni",
"ITEMS": "Elementi"
"STREAMS": "Flussi"
},
"USERS_PAGE": {
"ALL_USERS": "Tutti gli utenti",
"LAST_CLIENT": "Ultimo client",
"LAST_SEEN": "Ultima visualizzazione",
"AGO": "Fa",
"AGO_ALT": "",
"USER_STATS": "Statistiche utente",
"USER_ACTIVITY": "Attività dell'utente"
},
"STAT_PAGE": {
"STATISTICS": "Statistiche",
"DAILY_PLAY_PER_LIBRARY": "Numero di riproduzioni giornaliere per libreria",
"PLAY_COUNT_BY": "Conteggio riproduzioni per"
},
"SETTINGS_PAGE": {
"SETTINGS": "Impostazioni",
"LANGUAGE": "Lingua",
"SELECT_AN_ADMIN": "Seleziona un amministratore preferito",
"LIBRARY_SETTINGS": "Impostazioni libreria",
"BACKUP": "Backup",
"BACKUPS": "Backup",
"CHOOSE_FILE": "Scegli file",
"LOGS": "Log",
"SIZE": "Dimensione",
"JELLYFIN_URL": "URL Jellyfin",
"EMBY_URL": "URL Emby",
"EXTERNAL_URL": "URL esterno",
"API_KEY": "Chiave API",
"API_KEYS": "Chiavi API",
"KEY_NAME": "Nome chiave",
"KEY": "Chiave",
"NAME": "Nome",
"ADD_KEY": "Aggiungi chiave",
"DURATION": "Durata",
"EXECUTION_TYPE": "Tipo di esecuzione",
"RESULTS": "Risultati",
"SELECT_ADMIN": "Seleziona account amministratore preferito",
"HOUR_FORMAT": "Formato orario",
"HOUR_FORMAT_12": "12 ore",
"HOUR_FORMAT_24": "24 ore",
"SECURITY": "Sicurezza",
"CURRENT_PASSWORD": "Password attuale",
"NEW_PASSWORD": "Nuova password",
"UPDATE": "Aggiorna",
"REQUIRE_LOGIN": "Richiedi accesso",
"TASK": "Compito",
"TASKS": "Compiti",
"INTERVAL": "Intervallo",
"INTERVALS": {
"15_MIN": "15 minuti",
"30_MIN": "30 minuti",
"1_HOUR": "1 ora",
"12_HOURS": "12 ore",
"1_DAY": "1 giorno",
"1_WEEK": "1 settimana"
},
"SELECT_LIBRARIES_TO_IMPORT": "Seleziona librerie da importare",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "L'attività per gli elementi in queste librerie viene ancora tracciata, anche se non importata.",
"DATE_ADDED": "Data aggiunta"
},
"TASK_TYPE": {
"JOB": "Attività",
"IMPORT": "Importa"
},
"TASK_DESCRIPTION": {
"PartialJellyfinSync": "Sincronizzazione elementi aggiunti di recente",
"JellyfinSync": "Sincronizzazione completa con Jellyfin",
"Jellyfin_Playback_Reporting_Plugin_Sync": "Importa dati plugin di riproduzione",
"Backup": "Backup Jellystat"
},
"ABOUT_PAGE": {
"ABOUT_JELLYSTAT": "Informazioni su Jellystat",
"VERSION": "Versione",
"UPDATE_AVAILABLE": "Aggiornamento disponibile",
"GITHUB": "Github",
"Backup": "Backup Jellystat"
},
"SEARCH": "Cerca",
"TOTAL": "Totale",
"LAST": "Ultimo",
"SERIES": "Serie",
"SEASON": "Stagione",
"SEASONS": "Stagioni",
"EPISODE": "Episodio",
"EPISODES": "Episodi",
"MOVIES": "Film",
"MUSIC": "Musica",
"SONGS": "Canzoni",
"FILES": "File",
"LIBRARIES": "Librerie",
"USER": "Utente",
"USERS": "Utenti",
"TYPE": "Tipo",
"NEW_VERSION_AVAILABLE": "Nuova versione disponibile",
"ARCHIVED": "Archiviato",
"NOT_ARCHIVED": "Non archiviato",
"ALL": "Tutti",
"CLOSE": "Chiudi",
"TOTAL_PLAYS": "Riproduzioni totali",
"TITLE": "Titolo",
"VIEWS": "Visualizzazioni",
"WATCH_TIME": "Tempo di visualizzazione",
"LAST_WATCHED": "Ultima visualizzazione",
"MEDIA": "Media",
"SAVE": "Salva",
"YES": "Sì",
"NO": "No",
"FILE_NAME": "Nome file",
"DATE": "Data",
"START": "Inizia",
"DOWNLOAD": "Scarica",
"RESTORE": "Ripristina",
"ACTIONS": "Azioni",
"DELETE": "Elimina",
"BITRATE": "Bitrate",
"CONTAINER": "Contenitore",
"VIDEO": "Video",
"CODEC": "Codec",
"WIDTH": "Larghezza",
"HEIGHT": "Altezza",
"FRAMERATE": "Frequenza fotogrammi",
"DYNAMIC_RANGE": "Gamma dinamica",
"ASPECT_RATIO": "Rapporto d'aspetto",
"AUDIO": "Audio",
"CHANNELS": "Canali",
"LANGUAGE": "Lingua",
"STREAM_DETAILS": "Dettagli flusso",
"SOURCE_DETAILS": "Dettagli sorgente",
"DIRECT": "Diretto",
"TRANSCODE": "Transcodifica",
"USERNAME": "Nome utente",
"PASSWORD": "Password",
"LOGIN": "Accedi",
"FT_SETUP_PROGRESS": "Impostazione iniziale Passo {STEP} di {TOTAL}",
"VALIDATING": "Convalida",
"SAVE_JELLYFIN_DETAILS": "Salva dettagli Jellyfin",
"SETTINGS_SAVED": "Impostazioni salvate",
"SUCCESS": "Successo",
"PASSWORD_UPDATE_SUCCESS": "Password aggiornata con successo",
"CREATE_USER": "Crea utente",
"GEOLOCATION_INFO_FOR": "Informazioni geolocalizzazione per",
"CITY": "Città",
"REGION": "Regione",
"COUNTRY": "Paese",
"ORGANIZATION": "Organizzazione",
"ISP": "ISP",
"LATITUDE": "Latitudine",
"LONGITUDE": "Longitudine",
"TIMEZONE": "Fuso orario",
"POSTCODE": "CAP",
"X_ROWS_SELECTED": "{ROWS} righe selezionate"
}

View File

@@ -17,7 +17,9 @@
"LIBRARY_OVERVIEW": "媒体库总览"
},
"SESSIONS": {
"NO_SESSIONS": "未找到活动会话"
"NO_SESSIONS": "未找到活动会话",
"DIRECT_PLAY": "",
"TRANSCODE": ""
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "最多观看电影",
@@ -301,5 +303,6 @@
"LONGITUDE": "经度",
"TIMEZONE": "时区",
"POSTCODE": "邮编",
"X_ROWS_SELECTED": "已选中 {ROWS} 行"
"X_ROWS_SELECTED": "已选中 {ROWS} 行",
"SUBTITLES": ""
}

View File

@@ -151,3 +151,7 @@ h2 {
.hide-tab-titles {
display: none !important;
}
::placeholder {
color: #a7a7a7 !important; /* Replace with your desired color */
}

View File

@@ -11,4 +11,8 @@ export const languages = [
id: "zh-CN",
description: "简体中文",
},
{
id: "it-IT",
description: "Italiano",
},
];

View File

@@ -17,6 +17,7 @@ function Activity() {
const [config, setConfig] = useState(null);
const [streamTypeFilter, setStreamTypeFilter] = useState(localStorage.getItem("PREF_ACTIVITY_StreamTypeFilter") ?? "All");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10"));
const [libraryFilters, setLibraryFilters] = useState(
localStorage.getItem("PREF_ACTIVITY_libraryFilters") != undefined
@@ -25,9 +26,25 @@ function Activity() {
);
const [libraries, setLibraries] = useState([]);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true });
const [filterParams, setFilterParams] = useState([]);
const [isBusy, setIsBusy] = useState(false);
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
const onSortChange = (sort) => {
setSorting({ column: sort.column, desc: sort.desc });
};
const onFilterChange = (filter) => {
setFilterParams(filter);
};
function setItemLimit(limit) {
setItemCount(limit);
setItemCount(parseInt(limit));
localStorage.setItem("PREF_ACTIVITY_ItemCount", limit);
}
@@ -51,6 +68,16 @@ function Activity() {
}
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300); // Adjust the delay as needed
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
useEffect(() => {
const fetchConfig = async () => {
try {
@@ -64,9 +91,22 @@ function Activity() {
};
const fetchHistory = () => {
setIsBusy(true);
const url = `/api/getHistory`;
if (filterParams) {
console.log(JSON.stringify(filterParams));
}
axios
.get(url, {
params: {
size: itemCount,
page: currentPage,
search: debouncedSearchQuery,
sort: sorting.column,
desc: sorting.desc,
filters: filterParams != undefined ? JSON.stringify(filterParams) : null,
},
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
@@ -74,9 +114,11 @@ function Activity() {
})
.then((data) => {
setData(data.data);
setIsBusy(false);
})
.catch((error) => {
console.log(error);
setIsBusy(false);
});
};
@@ -111,9 +153,19 @@ function Activity() {
});
};
if (!data && config) {
fetchHistory();
fetchLibraries();
if (config) {
if (
!data ||
(data.current_page && data.current_page !== currentPage) ||
(data.size && data.size !== itemCount) ||
(data?.search ?? "") !== debouncedSearchQuery.trim() ||
(data?.sort ?? "") !== sorting.column ||
(data?.desc ?? true) !== sorting.desc ||
JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? [])
) {
fetchHistory();
fetchLibraries();
}
}
if (!config) {
@@ -122,7 +174,7 @@ function Activity() {
const intervalId = setInterval(fetchHistory, 60000 * 60);
return () => clearInterval(intervalId);
}, [data, config]);
}, [data, config, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]);
if (!data) {
return <Loading />;
@@ -145,15 +197,15 @@ function Activity() {
);
}
let filteredData = data;
let filteredData = data.results;
if (searchQuery) {
filteredData = data.filter((item) =>
(!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}
// if (searchQuery) {
// filteredData = data.results.filter((item) =>
// (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
// .toLowerCase()
// .includes(searchQuery.toLowerCase())
// );
// }
filteredData = filteredData.filter(
(item) =>
(libraryFilters.includes(item.ParentId) || item.ParentId == null) &&
@@ -240,7 +292,15 @@ function Activity() {
</div>
</div>
<div className="Activity">
<ActivityTable data={filteredData} itemCount={itemCount} />
<ActivityTable
data={filteredData}
itemCount={itemCount}
onPageChange={handlePageChange}
onSortChange={onSortChange}
onFilterChange={onFilterChange}
pageCount={data.pages}
isBusy={isBusy}
/>
</div>
</div>
);

View File

@@ -0,0 +1,205 @@
import "./css/stats.css";
import { Trans } from "react-i18next";
import ActivityTimelineComponent from "./components/activity-timeline/activity-timeline";
import { useEffect, useState } from "react";
import axios from "../lib/axios_instance.jsx";
import Config from "../lib/config.jsx";
import "./css/timeline/activity-timeline.css";
import Loading from "./components/general/loading";
import { Button, FormSelect, Modal } from "react-bootstrap";
import LibraryFilterModal from "./components/library/library-filter-modal";
function ActivityTimeline(props) {
const { preselectedUser } = props;
const [users, setUsers] = useState();
const [selectedUser, setSelectedUser] = useState(
preselectedUser ??
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ??
""
);
const [libraries, setLibraries] = useState();
const [config, setConfig] = useState(null);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
const [selectedLibraries, setSelectedLibraries] = useState(
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") !=
undefined
? JSON.parse(
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries")
)
: []
);
const timelineReady =
(users?.length > 0 || !!preselectedUser) && libraries?.length > 0;
const handleLibraryFilter = (selectedOptions) => {
setSelectedLibraries(selectedOptions);
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify(selectedOptions)
);
};
const handleUserSelection = (selectedUser) => {
console.log(selectedUser);
setSelectedUser(selectedUser);
localStorage.setItem("PREF_ACTIVITY_TIMELINE_selectedUser", selectedUser);
};
const toggleSelectAll = () => {
if (selectedLibraries.length > 0) {
setSelectedLibraries([]);
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify([])
);
} else {
setSelectedLibraries(libraries.map((library) => library.Id));
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify(libraries.map((library) => library.Id))
);
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
if (!config) {
fetchConfig();
}
}, [config]);
useEffect(() => {
if (config && !preselectedUser) {
const url = `/stats/getAllUserActivity`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((users) => {
setUsers(users.data);
})
.catch((error) => {
console.log(error);
});
}
}, [config, preselectedUser]);
useEffect(() => {
if (config) {
const url = `/stats/getLibraryMetadata`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((libraries) => {
setLibraries(libraries.data);
})
.catch((error) => {
console.log(error);
});
}
}, [config]);
return timelineReady ? (
<div className="watch-stats">
<div className="Heading">
<h1>
<Trans i18nKey={"TIMELINE_PAGE.TIMELINE"} />
</h1>
<div
className="d-flex flex-column flex-md-row"
style={{ whiteSpace: "nowrap" }}
>
<div className="user-selection">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3">
{!preselectedUser && (
<>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="USER" />
</div>
<FormSelect
onChange={(e) => handleUserSelection(e.target.value)}
value={selectedUser}
className="w-md-75 rounded-0 rounded-end"
>
{users.map((user) => (
<option key={user.UserId} value={user.UserId}>
{user.UserName}
</option>
))}
</FormSelect>
</>
)}
</div>
</div>
<div className="library-selection">
<Button
onClick={() => setShowLibraryFilters(true)}
className="ms-md-3 mb-3 my-md-3"
>
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Button>
<Modal
show={showLibraryFilters}
onHide={() => setShowLibraryFilters(false)}
>
<Modal.Header>
<Modal.Title>
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Modal.Title>
</Modal.Header>
<LibraryFilterModal
libraries={libraries}
selectedLibraries={selectedLibraries}
onSelectionChange={handleLibraryFilter}
/>
<Modal.Footer>
<Button variant="outline-primary" onClick={toggleSelectAll}>
<Trans i18nKey="ACTIVITY_TABLE.TOGGLE_SELECT_ALL" />
</Button>
<Button
variant="outline-primary"
onClick={() => setShowLibraryFilters(false)}
>
<Trans i18nKey="CLOSE" />
</Button>
</Modal.Footer>
</Modal>
</div>
</div>
</div>
<div>
{selectedUser && selectedLibraries?.length > 0 && (
<ActivityTimelineComponent
userId={selectedUser}
libraries={selectedLibraries}
/>
)}
</div>
</div>
) : (
<Loading />
);
}
export default ActivityTimeline;

View File

@@ -0,0 +1,86 @@
/* eslint-disable react/prop-types */
import { useState } from "react";
import TimelineItem from "@mui/lab/TimelineItem";
import TimelineSeparator from "@mui/lab/TimelineSeparator";
import TimelineConnector from "@mui/lab/TimelineConnector";
import TimelineContent from "@mui/lab/TimelineContent";
import Typography from "@mui/material/Typography";
import Card from "react-bootstrap/Card";
import baseUrl from "../../../lib/baseurl";
import "../../css/timeline/activity-timeline.css";
import moment from "moment";
import TvLineIcon from "remixicon-react/TvLineIcon.js";
import FilmLineIcon from "remixicon-react/FilmLineIcon.js";
import { MEDIA_TYPES } from "./helpers";
import { Link } from "react-router-dom";
import { Trans } from "react-i18next";
function formatEntryDates(entry) {
const { FirstActivityDate, LastActivityDate, MediaType } = entry;
const startDate = moment(FirstActivityDate);
const endDate = moment(LastActivityDate);
if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) {
return startDate.format("L");
} else {
return `${startDate.format("L")} - ${endDate.format("L")}`;
}
}
const DefaultImage = (props) => {
const { MediaType } = props;
return (
<div className="default_library_image default_library_image_hover d-flex justify-content-center align-items-center">
{MediaType === MEDIA_TYPES.Shows ? SeriesIcon : MovieIcon}
</div>
);
};
const SeriesIcon = <TvLineIcon size={"50%"} color="white" />;
const MovieIcon = <FilmLineIcon size={"50%"} color="white" />;
export default function ActivityTimelineItem(entry) {
const { Title, SeasonName, NowPlayingItemId, EpisodeCount, MediaType } =
entry;
const [useDefaultImage, setUseDefaultImage] = useState(false);
return (
<TimelineItem>
<TimelineSeparator>
<TimelineConnector />
<div className="activity-card">
<Link to={`/libraries/item/${NowPlayingItemId}`}>
{!useDefaultImage ? (
<Card.Img
variant="top"
className="activity-card-img"
src={
baseUrl +
"/proxy/Items/Images/Primary?id=" +
NowPlayingItemId +
"&fillWidth=800&quality=50"
}
onError={() => setUseDefaultImage(true)}
/>
) : (
<DefaultImage {...entry} />
)}
</Link>
</div>
<TimelineConnector />
</TimelineSeparator>
<TimelineContent>
<Typography variant="h6" component="span">
{Title}
</Typography>
{SeasonName && <Typography>{SeasonName}</Typography>}
<Typography>{formatEntryDates(entry)}</Typography>
{MediaType === MEDIA_TYPES.Shows && EpisodeCount && (
<Typography>
{EpisodeCount} <Trans i18nKey="TIMELINE_PAGE.EPISODES" />
</Typography>
)}
</TimelineContent>
</TimelineItem>
);
}

View File

@@ -0,0 +1,80 @@
/* eslint-disable react/prop-types */
import { useEffect, useState } from "react";
import axios from "../../../lib/axios_instance";
import Timeline from "@mui/lab/Timeline";
import "../../css/timeline/activity-timeline.css";
import Config from "../../../lib/config.jsx";
import Loading from "../../../pages/components/general/loading.jsx";
import ActivityTimelineItem from "./activity-timeline-item.jsx";
import { groupAdjacentSeasons } from "./helpers.jsx";
export default function ActivityTimelineComponent(props) {
const { userId, libraries } = props;
const [timelineEntries, setTimelineEntries] = useState();
const [config, setConfig] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
const fetchLibraries = () => {
if (config) {
const url = `/api/getActivityTimeLine`;
axios
.post(
url,
{ userId: userId, libraries: libraries },
{
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
}
)
.then((timelineEntries) => {
const groupedAdjacentSeasons = groupAdjacentSeasons([
...timelineEntries.data,
]);
setTimelineEntries(groupedAdjacentSeasons);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
fetchLibraries();
}, [userId, libraries, config]);
return timelineEntries?.length > 0 ? (
<div>
<Timeline position="alternate">
{timelineEntries.map((entry) => (
<ActivityTimelineItem
key={`${entry.Title}-${entry.FirstActivityDate}-${entry.LastActivityDate}`}
{...entry}
/>
))}
</Timeline>
</div>
) : (
<Loading />
);
}

View File

@@ -0,0 +1,57 @@
export const MEDIA_TYPES = {
Movies: "movies",
Shows: "tvshows",
Music: "music",
};
/**
* groups subsequent seasons of shows into single entries with a combined label and timeframe
* @param {*} timelineEntries List of entries as returned by /api/getActivityTimeLine
* @returns Same list of entries, seasons of the same show that follow each other will be merged into one entry
*/
export function groupAdjacentSeasons(timelineEntries) {
return timelineEntries
.reverse()
.map((entry, index, entryArray) => {
if (entry?.MediaType === MEDIA_TYPES.Shows) {
let potentialNextSeasonIndex = index + 1;
//if the next entry is another season of the same show, merge them
if (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
let highestSeasonName = entry.SeasonName;
let lastSeasonInSession;
//merge all further seasons as well
while (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
const potentialNextSeason = entryArray[potentialNextSeasonIndex];
if (entry.Title === potentialNextSeason?.Title) {
lastSeasonInSession = potentialNextSeason;
//remove season from list after usage
entryArray[potentialNextSeasonIndex] = undefined;
//hack: in my db the seasons weren't always sorted correctly.
if (
highestSeasonName?.localeCompare(
lastSeasonInSession.SeasonName
) === -1
) {
highestSeasonName = lastSeasonInSession.SeasonName;
}
} else {
//all subsequent seasons have been merged into one entry and were removed from the list
break;
}
potentialNextSeasonIndex++;
}
const newSeasonName = `${entry.SeasonName} - ${highestSeasonName}`;
const newLastActivityDate = lastSeasonInSession.LastActivityDate;
return {
...entry,
SeasonName: newSeasonName,
LastActivityDate: newLastActivityDate,
};
}
}
return entry;
})
.filter((entry) => !!entry)
.reverse();
}

View File

@@ -14,7 +14,7 @@ import StreamInfo from "./stream_info";
import "../../css/activity/activity-table.css";
import i18next from "i18next";
import IpInfoModal from "../ip-info";
// import Loading from "../general/loading";
import BusyLoader from "../general/busyLoader.jsx";
import { MRT_TablePagination, MaterialReactTable, useMaterialReactTable } from "material-react-table";
import { Box, ThemeProvider, Typography, createTheme } from "@mui/material";
@@ -34,7 +34,9 @@ function formatTotalWatchTime(seconds) {
}
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase()} `;
timeString += `${minutes} ${
minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase()
} `;
}
if (remainingSeconds > 0) {
@@ -58,18 +60,32 @@ const token = localStorage.getItem("token");
export default function ActivityTable(props) {
const twelve_hr = JSON.parse(localStorage.getItem("12hr"));
const [data, setData] = React.useState(props.data ?? []);
const uniqueUserNames = [...new Set(data.map((item) => item.UserName))];
const uniqueClients = [...new Set(data.map((item) => item.Client))];
const pages = props.pageCount || 1;
const isBusy = props.isBusy;
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState({
pageSize: 10,
pageIndex: 0,
pageSize: 10, //customize the default page size
});
const [sorting, setSorting] = React.useState([{ id: "Date", desc: true }]);
const [columnFilters, setColumnFilters] = React.useState([]);
const [modalState, setModalState] = React.useState(false);
const [modalData, setModalData] = React.useState();
const handlePageChange = (updater) => {
setPagination((old) => {
const newPaginationState = typeof updater === "function" ? updater(old) : updater;
const newPage = newPaginationState.pageIndex; // MaterialReactTable uses 0-based index
if (props.onPageChange) {
props.onPageChange(newPage + 1);
}
return newPaginationState;
});
};
//IP MODAL
const ipv4Regex = new RegExp(
@@ -136,8 +152,6 @@ export default function ActivityTable(props) {
{
accessorKey: "UserName",
header: i18next.t("USER"),
filterVariant: "select",
filterSelectOptions: uniqueUserNames,
Cell: ({ row }) => {
row = row.original;
return (
@@ -174,6 +188,7 @@ export default function ActivityTable(props) {
? row.NowPlayingItemName
: row.SeriesName + " : S" + row.SeasonNumber + "E" + row.EpisodeNumber + " - " + row.NowPlayingItemName
}`,
field: "NowPlayingItemName",
header: i18next.t("TITLE"),
minSize: 300,
Cell: ({ row }) => {
@@ -190,8 +205,6 @@ export default function ActivityTable(props) {
{
accessorKey: "Client",
header: i18next.t("ACTIVITY_TABLE.CLIENT"),
filterVariant: "select",
filterSelectOptions: uniqueClients,
Cell: ({ row }) => {
row = row.original;
return (
@@ -207,6 +220,7 @@ export default function ActivityTable(props) {
},
{
accessorFn: (row) => new Date(row.ActivityDateInserted),
field: "ActivityDateInserted",
header: i18next.t("DATE"),
size: 110,
filterVariant: "date-range",
@@ -228,12 +242,13 @@ export default function ActivityTable(props) {
accessorKey: "PlaybackDuration",
header: i18next.t("ACTIVITY_TABLE.TOTAL_PLAYBACK"),
minSize: 200,
filterFn: (row, id, filterValue) => formatTotalWatchTime(row.getValue(id)).startsWith(filterValue),
// filterFn: (row, id, filterValue) => formatTotalWatchTime(row.getValue(id)).startsWith(filterValue),
filterVariant: "range",
Cell: ({ cell }) => <span>{formatTotalWatchTime(cell.getValue())}</span>,
},
{
accessorFn: (row) => Number(row.TotalPlays ?? 1),
field: "TotalPlays",
header: i18next.t("TOTAL_PLAYS"),
filterFn: "betweenInclusive",
@@ -241,6 +256,51 @@ export default function ActivityTable(props) {
},
];
const fieldMap = columns.map((column) => {
return { accessorKey: column.accessorKey ?? column.field, header: column.header };
});
const handleSortingChange = (updater) => {
setSorting((old) => {
const newSortingState = typeof updater === "function" ? updater(old) : updater;
const column = newSortingState.length > 0 ? newSortingState[0].id : "Date";
const desc = newSortingState.length > 0 ? newSortingState[0].desc : true;
if (props.onSortChange) {
props.onSortChange({ column: fieldMap.find((field) => field.header == column)?.accessorKey ?? column, desc: desc });
}
return newSortingState;
});
};
const handleFilteringChange = (updater) => {
setColumnFilters((old) => {
const newFilterState = typeof updater === "function" ? updater(old) : updater;
const modifiedFilterState = newFilterState.map((filter) => ({ ...filter }));
modifiedFilterState.map((filter) => {
filter.field = fieldMap.find((field) => field.header == filter.id)?.accessorKey ?? filter.id;
delete filter.id;
if (Array.isArray(filter.value)) {
filter.min = filter.value[0];
filter.max = filter.value[1];
delete filter.value;
} else {
const val = filter.value;
delete filter.value;
filter.value = val;
}
return filter;
});
if (props.onFilterChange) {
props.onFilterChange(modifiedFilterState);
}
return newFilterState;
});
};
useEffect(() => {
setData(props.data);
}, [props.data]);
@@ -265,11 +325,21 @@ export default function ActivityTable(props) {
enableExpandAll: false,
enableExpanding: true,
enableDensityToggle: false,
enableFilters: true,
manualFiltering: true,
onSortingChange: handleSortingChange,
onColumnFiltersChange: handleFilteringChange,
enableTopToolbar: Object.keys(rowSelection).length > 0,
manualPagination: true,
manualSorting: true,
autoResetPageIndex: false,
initialState: {
expanded: false,
showGlobalFilter: true,
pagination: { pageSize: 10, pageIndex: 0 },
pagination: {
pageSize: 10,
pageIndex: 0,
},
sorting: [
{
id: "Date",
@@ -277,6 +347,8 @@ export default function ActivityTable(props) {
},
],
},
pageCount: pages,
rowCount: pagination.pageSize, // fix for bug causing pagination index to reset when row count changes
showAlertBanner: false,
enableHiding: false,
enableFullScreenToggle: false,
@@ -333,7 +405,7 @@ export default function ActivityTable(props) {
},
},
},
state: { rowSelection, pagination },
state: { rowSelection, pagination, sorting, columnFilters },
filterFromLeafRows: true,
getSubRows: (row) => {
if (Array.isArray(row.results) && row.results.length == 1) {
@@ -342,8 +414,7 @@ export default function ActivityTable(props) {
return row.results;
},
paginateExpandedRows: false,
onPaginationChange: setPagination,
onPaginationChange: handlePageChange,
getRowId: (row) => row.Id,
muiExpandButtonProps: ({ row }) => ({
children: row.getIsExpanded() ? <IndeterminateCircleFillIcon /> : <AddCircleFillIcon />,
@@ -417,6 +488,8 @@ export default function ActivityTable(props) {
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
{isBusy && <BusyLoader />}
<IpInfoModal show={ipModalVisible} onHide={() => setIPModalVisible(false)} ipAddress={ipAddressLookup} />
<Modal
show={confirmDeleteShow}

View File

@@ -90,8 +90,8 @@ function Row(logs) {
<TableRow>
<TableCell className="py-0 pb-1" ><Trans i18nKey={"FRAMERATE"}/></TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'}</TableCell>
</TableRow>
<TableRow>

View File

@@ -0,0 +1,12 @@
import React from "react";
import "../../css/loading.css";
function BusyLoader() {
return (
<div className="loading busy">
<div className="loading__spinner"></div>
</div>
);
}
export default BusyLoader;

View File

@@ -27,6 +27,7 @@ import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import baseUrl from "../../lib/baseurl";
import GlobalStats from "./general/globalStats";
import ErrorBoundary from "./general/ErrorBoundary.jsx";
function ItemInfo() {
const { Id } = useParams();
@@ -257,7 +258,7 @@ function ItemInfo() {
<Link
className="px-2"
to={
(config.settings?.EXTERNAL_URL ?? config.hostUrl) +
(config.settings?.EXTERNAL_URL ?? config.hostUrl) +
`/web/index.html#!/${config.IS_JELLYFIN ? "details" : "item"}?id=` +
(data.EpisodeId || data.Id) +
(config.settings.ServerID ? "&serverId=" + config.settings.ServerID : "")
@@ -357,7 +358,9 @@ function ItemInfo() {
{["Series", "Season"].includes(data && data.Type) ? <MoreItems data={data} /> : <></>}
</Tab>
<Tab eventKey="tabActivity" title="Activity" className="bg-transparent">
<ItemActivity itemid={Id} />
<ErrorBoundary>
<ItemActivity itemid={Id} />
</ErrorBoundary>
</Tab>
<Tab eventKey="tabOptions" title="Options" className="bg-transparent">
<ItemOptions itemid={Id} />

View File

@@ -9,10 +9,42 @@ import Config from "../../../lib/config.jsx";
function ItemActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem("token");
const [itemCount, setItemCount] = useState(10);
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10"));
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
const [streamTypeFilter, setStreamTypeFilter] = useState("All");
const [config, setConfig] = useState();
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true });
const [filterParams, setFilterParams] = useState([]);
const [isBusy, setIsBusy] = useState(false);
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
const onSortChange = (sort) => {
setSorting({ column: sort.column, desc: sort.desc });
};
const onFilterChange = (filter) => {
setFilterParams(filter);
};
function setItemLimit(limit) {
setItemCount(parseInt(limit));
localStorage.setItem("PREF_ACTIVITY_ItemCount", limit);
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300); // Adjust the delay as needed
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
useEffect(() => {
const fetchConfig = async () => {
@@ -30,6 +62,7 @@ function ItemActivity(props) {
const fetchData = async () => {
try {
setIsBusy(true);
const itemData = await axios.post(
`/api/getItemHistory`,
{
@@ -40,36 +73,53 @@ function ItemActivity(props) {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
params: {
size: itemCount,
page: currentPage,
search: debouncedSearchQuery,
sort: sorting.column,
desc: sorting.desc,
filters: filterParams != undefined ? JSON.stringify(filterParams) : null,
},
}
);
setData(itemData.data);
setIsBusy(false);
} catch (error) {
console.log(error);
}
};
if (!data) {
if (
!data ||
(data.current_page && data.current_page !== currentPage) ||
(data.size && data.size !== itemCount) ||
(data?.search ?? "") !== debouncedSearchQuery.trim() ||
(data?.sort ?? "") !== sorting.column ||
(data?.desc ?? true) !== sorting.desc ||
JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? [])
) {
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, props.itemid, token]);
}, [data, props.itemid, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]);
if (!data) {
if (!data || !data.results) {
return <></>;
}
let filteredData = data;
let filteredData = data.results;
if (searchQuery) {
filteredData = data.filter(
(item) =>
(!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
.toLowerCase()
.includes(searchQuery.toLowerCase()) || item.UserName.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// if (searchQuery) {
// filteredData = data.results.filter(
// (item) =>
// (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
// .toLowerCase()
// .includes(searchQuery.toLowerCase()) || item.UserName.toLowerCase().includes(searchQuery.toLowerCase())
// );
// }
filteredData = filteredData.filter((item) =>
streamTypeFilter == "All"
@@ -114,7 +164,7 @@ function ItemActivity(props) {
</div>
<FormSelect
onChange={(event) => {
setItemCount(event.target.value);
setItemLimit(event.target.value);
}}
value={itemCount}
className="my-md-3 w-md-75 rounded-0 rounded-end"
@@ -136,7 +186,15 @@ function ItemActivity(props) {
</div>
<div className="Activity">
<ActivityTable data={filteredData} itemCount={itemCount} />
<ActivityTable
data={filteredData}
itemCount={itemCount}
onPageChange={handlePageChange}
onSortChange={onSortChange}
onFilterChange={onFilterChange}
pageCount={data.pages}
isBusy={isBusy}
/>
</div>
</div>
);

View File

@@ -12,13 +12,30 @@ function LibraryActivity(props) {
const token = localStorage.getItem("token");
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_LIBRARY_ACTIVITY_ItemCount") ?? "10"));
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
const [streamTypeFilter, setStreamTypeFilter] = useState(
localStorage.getItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter") ?? "All"
);
const [config, setConfig] = useState();
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true });
const [filterParams, setFilterParams] = useState([]);
const [isBusy, setIsBusy] = useState(false);
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
const onSortChange = (sort) => {
setSorting({ column: sort.column, desc: sort.desc });
};
const onFilterChange = (filter) => {
setFilterParams(filter);
};
function setItemLimit(limit) {
setItemCount(limit);
setItemCount(parseInt(limit));
localStorage.setItem("PREF_LIBRARY_ACTIVITY_ItemCount", limit);
}
@@ -27,6 +44,16 @@ function LibraryActivity(props) {
localStorage.setItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter", filter);
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300); // Adjust the delay as needed
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
useEffect(() => {
const fetchConfig = async () => {
try {
@@ -42,7 +69,8 @@ function LibraryActivity(props) {
}
const fetchData = async () => {
try {
const libraryrData = await axios.post(
setIsBusy(true);
const libraryData = await axios.post(
`/api/getLibraryHistory`,
{
libraryid: props.LibraryId,
@@ -52,35 +80,52 @@ function LibraryActivity(props) {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
params: {
size: itemCount,
page: currentPage,
search: debouncedSearchQuery,
sort: sorting.column,
desc: sorting.desc,
filters: filterParams != undefined ? JSON.stringify(filterParams) : null,
},
}
);
setData(libraryrData.data);
setData(libraryData.data);
setIsBusy(false);
} catch (error) {
console.log(error);
}
};
if (!data) {
if (
!data ||
(data.current_page && data.current_page !== currentPage) ||
(data.size && data.size !== itemCount) ||
(data?.search ?? "") !== debouncedSearchQuery.trim() ||
(data?.sort ?? "") !== sorting.column ||
(data?.desc ?? true) !== sorting.desc ||
JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? [])
) {
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, props.LibraryId, token]);
}, [data, props.LibraryId, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]);
if (!data) {
if (!data || !data.results) {
return <></>;
}
let filteredData = data;
let filteredData = data.results;
if (searchQuery) {
filteredData = data.filter((item) =>
(!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}
// if (searchQuery) {
// filteredData = data.results.filter((item) =>
// (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
// .toLowerCase()
// .includes(searchQuery.toLowerCase())
// );
// }
filteredData = filteredData.filter((item) =>
streamTypeFilter == "All"
@@ -147,7 +192,15 @@ function LibraryActivity(props) {
</div>
<div className="Activity">
<ActivityTable data={filteredData} itemCount={itemCount} />
<ActivityTable
data={filteredData}
itemCount={itemCount}
onPageChange={handlePageChange}
onSortChange={onSortChange}
onFilterChange={onFilterChange}
pageCount={data.pages}
isBusy={isBusy}
/>
</div>
</div>
);

View File

@@ -43,20 +43,6 @@ function getETAFromTicks(ticks) {
return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function convertBitrate(bitrate) {
if (!bitrate) {
return "N/A";
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps + " Mbps";
} else {
return kbps + " Kbps";
}
}
function SessionCard(props) {
const cardStyle = {
backgroundImage: `url(proxy/Items/Images/Backdrop?id=${
@@ -136,30 +122,22 @@ function SessionCard(props) {
</Col>
</Row>
<Row className="d-flex flex-column flex-md-row">
{props.data.session.NowPlayingItem.VideoStream !== "" &&
<Col className="col-auto ellipse">
<span>{props.data.session.NowPlayingItem.VideoStream}</span>
</Col>
}
<Col className="col-auto ellipse">
{props.data.session.PlayState.PlayMethod +
(props.data.session.NowPlayingItem.MediaStreams
? " ( " +
props.data.session.NowPlayingItem.MediaStreams.find(
(stream) => stream.Type === "Video"
)?.Codec.toUpperCase() +
(props.data.session.TranscodingInfo
? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase()
: "") +
" - " +
convertBitrate(
props.data.session.TranscodingInfo
? props.data.session.TranscodingInfo.Bitrate
: props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video")
?.BitRate
) +
" )"
: "")}
{props.data.session.NowPlayingItem.AudioStream !== "" &&
<span>{props.data.session.NowPlayingItem.AudioStream}</span>
}
</Col>
<Col className="col-auto ellipse">
<Tooltip title={props.data.session.NowPlayingItem.SubtitleStream}>
<span>{props.data.session.NowPlayingItem.SubtitleStream}</span>
</Tooltip>
{props.data.session.NowPlayingItem.SubtitleStream !== "" &&
<Tooltip title={props.data.session.NowPlayingItem.SubtitleStream}>
<span>{props.data.session.NowPlayingItem.SubtitleStream}</span>
</Tooltip>
}
</Col>
</Row>
<Row>
@@ -213,7 +191,14 @@ function SessionCard(props) {
</Link>
</Card.Text>
</Row>
) : (
) : (props.data.session.NowPlayingItem.Type === "Audio" && props.data.session.NowPlayingItem.Artists.length > 0) ? (
<Col className="col-auto p-0">
<Card.Text>
{props.data.session.NowPlayingItem.Artists[0]}
</Card.Text>
</Col>
) :
(
<></>
)}
<Row className="d-flex flex-row justify-content-between p-0 m-0">

View File

@@ -9,8 +9,23 @@ import SessionCard from "./session-card";
import Loading from "../general/loading";
import { Trans } from "react-i18next";
import i18next from "i18next";
import socket from "../../../socket";
function convertBitrate(bitrate) {
if (!bitrate) {
return "N/A";
}
const kbps = (bitrate / 1000).toFixed(1);
const mbps = (bitrate / 1000000).toFixed(1);
if (kbps >= 1000) {
return mbps + " Mbps";
} else {
return kbps + " Kbps";
}
}
function Sessions() {
const [data, setData] = useState();
const [config, setConfig] = useState();
@@ -21,6 +36,8 @@ function Sessions() {
let toSet = data.filter((row) => row.NowPlayingItem !== undefined);
toSet.forEach((s) => {
handleLiveTV(s);
s.NowPlayingItem.VideoStream = getVideoStream(s);
s.NowPlayingItem.AudioStream = getAudioStream(s);
s.NowPlayingItem.SubtitleStream = getSubtitleStream(s);
});
setData(toSet);
@@ -39,6 +56,65 @@ function Sessions() {
}
};
const getVideoStream = (row) => {
let videoStream = row.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video");
if (videoStream === undefined) {
return "";
}
let transcodeType = i18next.t("SESSIONS.DIRECT_PLAY");
let transcodeVideoCodec = "";
if (row.TranscodingInfo && !row.TranscodingInfo.IsVideoDirect){
transcodeType = i18next.t("SESSIONS.TRANSCODE");
transcodeVideoCodec = ` -> ${row.TranscodingInfo.VideoCodec.toUpperCase()}`;
}
let bitRate = convertBitrate(
row.TranscodingInfo
? row.TranscodingInfo.Bitrate
: videoStream.BitRate);
const originalVideoCodec = videoStream.Codec.toUpperCase();
return `${i18next.t("VIDEO")}: ${transcodeType} (${originalVideoCodec}${transcodeVideoCodec} - ${bitRate})`;
}
const getAudioStream = (row) => {
let mediaTypeAudio = row.NowPlayingItem.Type === 'Audio';
let streamIndex = row.PlayState.AudioStreamIndex;
if ((streamIndex === undefined || streamIndex === -1) && !mediaTypeAudio) {
return "";
}
let transcodeType = i18next.t("SESSIONS.DIRECT_PLAY");
let transcodeCodec = "";
if (row.TranscodingInfo && !row.TranscodingInfo.IsAudioDirect){
transcodeType = i18next.t("SESSIONS.TRANSCODE");
transcodeCodec = ` -> ${row.TranscodingInfo.AudioCodec.toUpperCase()}`;
}
let bitRate = "";
if (mediaTypeAudio) {
bitRate = " - " + convertBitrate(
row.TranscodingInfo
? row.TranscodingInfo.Bitrate
: row.NowPlayingItem.Bitrate);
}
let originalCodec = "";
if (mediaTypeAudio){
originalCodec = `${row.NowPlayingItem.Container.toUpperCase()}`;
}
else if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length && streamIndex < row.NowPlayingItem.MediaStreams.length) {
originalCodec = row.NowPlayingItem.MediaStreams[streamIndex].Codec.toUpperCase();
}
return originalCodec != "" ? `${i18next.t("AUDIO")}: ${transcodeType} (${originalCodec}${transcodeCodec}${bitRate})`
: `${i18next.t("AUDIO")}: ${transcodeType}`;
}
const getSubtitleStream = (row) => {
let result = "";
@@ -53,7 +129,7 @@ function Sessions() {
}
if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length) {
result = `Subtitles: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`;
result = `${i18next.t("SUBTITLES")}: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`;
}
return result;

View File

@@ -150,7 +150,7 @@ async function handleFormSubmit(event) {
<div>
<h1 className="my-2"><Trans i18nKey={"SETTINGS_PAGE.API_KEYS"}/></h1>
{showAlert && showAlert.visible && (
<Alert variant={showAlert.type} onClose={handleCloseAlert} dismissible>
<Alert bg="dark" data-bs-theme="dark" variant={showAlert.type} onClose={handleCloseAlert} dismissible>
<Alert.Heading>{showAlert.title}</Alert.Heading>
<p>
{showAlert.message}

View File

@@ -133,14 +133,14 @@ function Row(file) {
<div className="d-flex justify-content-center">
<DropdownButton title={i18next.t("ACTIONS")} variant="outline-primary" disabled={disabled}>
<Dropdown.Item as="button" variant="primary" onClick={() => downloadBackup(data.name)}>
<Trans i18nKey={"DOWNLOAD"}/>
<Trans i18nKey={"DOWNLOAD"} />
</Dropdown.Item>
<Dropdown.Item as="button" variant="warning" onClick={() => restoreBackup(data.name)}>
<Trans i18nKey={"RESTORE"}/>
<Trans i18nKey={"RESTORE"} />
</Dropdown.Item>
<Dropdown.Divider></Dropdown.Divider>
<Dropdown.Item as="button" variant="danger" onClick={() => deleteBackup(data.name)}>
<Trans i18nKey={"DELETE"}/>
<Trans i18nKey={"DELETE"} />
</Dropdown.Item>
</DropdownButton>
</div>
@@ -222,7 +222,7 @@ export default function BackupFiles() {
<Trans i18nKey={"SETTINGS_PAGE.BACKUPS"} />
</h1>
{showAlert && showAlert.visible && (
<Alert variant={showAlert.type} onClose={handleCloseAlert} dismissible>
<Alert variant={showAlert.type} bg="dark" data-bs-theme="dark" onClose={handleCloseAlert} dismissible>
<Alert.Heading>{showAlert.title}</Alert.Heading>
<p>{showAlert.message}</p>
</Alert>
@@ -233,10 +233,14 @@ export default function BackupFiles() {
<TableHead>
<TableRow>
<TableCell>
<Trans i18nKey={"FILE_NAME"}/>
<Trans i18nKey={"FILE_NAME"} />
</TableCell>
<TableCell>
<Trans i18nKey={"DATE"} />
</TableCell>
<TableCell>
<Trans i18nKey={"SETTINGS_PAGE.SIZE"} />
</TableCell>
<TableCell><Trans i18nKey={"DATE"}/></TableCell>
<TableCell><Trans i18nKey={"SETTINGS_PAGE.SIZE"}/></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
@@ -249,7 +253,7 @@ export default function BackupFiles() {
{files.length === 0 ? (
<tr>
<td colSpan="5" style={{ textAlign: "center", fontStyle: "italic", color: "grey" }} className="py-2">
<Trans i18nKey={"ERROR_MESSAGES.NO_BACKUPS"}/>
<Trans i18nKey={"ERROR_MESSAGES.NO_BACKUPS"} />
</td>
</tr>
) : (
@@ -275,11 +279,11 @@ export default function BackupFiles() {
<div className="d-flex justify-content-end my-2">
<ButtonGroup className="pagination-buttons">
<Button className="page-btn" onClick={() => setPage(0)} disabled={page === 0}>
<Trans i18nKey={"TABLE_NAV_BUTTONS.FIRST"}/>
<Trans i18nKey={"TABLE_NAV_BUTTONS.FIRST"} />
</Button>
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
<Trans i18nKey={"TABLE_NAV_BUTTONS.PREVIOUS"}/>
<Trans i18nKey={"TABLE_NAV_BUTTONS.PREVIOUS"} />
</Button>
<div className="page-number d-flex align-items-center justify-content-center">{`${page * rowsPerPage + 1}-${Math.min(
@@ -288,7 +292,7 @@ export default function BackupFiles() {
)} of ${files.length}`}</div>
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}>
<Trans i18nKey={"TABLE_NAV_BUTTONS.NEXT"}/>
<Trans i18nKey={"TABLE_NAV_BUTTONS.NEXT"} />
</Button>
<Button
@@ -296,7 +300,7 @@ export default function BackupFiles() {
onClick={() => setPage(Math.ceil(files.length / rowsPerPage) - 1)}
disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}
>
<Trans i18nKey={"TABLE_NAV_BUTTONS.LAST"}/>
<Trans i18nKey={"TABLE_NAV_BUTTONS.LAST"} />
</Button>
</ButtonGroup>
</div>

View File

@@ -95,11 +95,6 @@ export default function SettingsConfig() {
async function handleFormSubmit(event) {
event.preventDefault();
setisSubmitted("");
if (formValues.JS_C_PASSWORD) {
setisSubmitted("Failed");
setsubmissionMessage(i18next.t("ERROR_MESSAGES.PASSWORD_LENGTH"));
return;
}
if (
(formValues.JS_C_PASSWORD && !formValues.JS_PASSWORD) ||
@@ -213,9 +208,9 @@ export default function SettingsConfig() {
{isSubmitted !== "" ? (
isSubmitted === "Failed" ? (
<Alert variant="danger">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessage}</Alert>
) : (
<Alert variant="success">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessage}</Alert>
)
) : (
<></>

View File

@@ -231,9 +231,9 @@ export default function SettingsConfig() {
</Form.Group>
{isSubmitted !== "" ? (
isSubmitted === "Failed" ? (
<Alert variant="danger">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessage}</Alert>
) : (
<Alert variant="success">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessage}</Alert>
)
) : (
<></>
@@ -263,9 +263,9 @@ export default function SettingsConfig() {
{isSubmittedExternal !== "" ? (
isSubmittedExternal === "Failed" ? (
<Alert variant="danger">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessageExternal}</Alert>
) : (
<Alert variant="success">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessageExternal}</Alert>
)
) : (
<></>

View File

@@ -7,6 +7,7 @@ import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import Tooltip from "@mui/material/Tooltip";
import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon";
import "../../css/items/item-stat-component.css";
function ItemStatComponent(props) {
const [loaded, setLoaded] = useState(false);
@@ -36,8 +37,8 @@ function ItemStatComponent(props) {
return (
<Card className="stat-card rounded-2" style={cardStyle}>
<div style={cardBgStyle} className="rounded-2">
<Row className="h-100 rounded-2">
<Col className="d-none d-lg-block stat-card-banner">
<Row className="row-max-witdh rounded-2 no-gutters">
<Col className="d-none d-lg-block stat-card-banner px-0">
{props.icon ? (
<div className="stat-card-icon">{props.icon}</div>
) : (
@@ -80,7 +81,7 @@ function ItemStatComponent(props) {
</>
)}
</Col>
<Col className="w-100">
<Col className="stat-card-details px-0">
<Card.Body className="w-100">
<Card.Header className="d-flex justify-content-between border-0 p-0 bg-transparent">
<div>
@@ -98,30 +99,30 @@ function ItemStatComponent(props) {
{item.UserId ? (
<Link to={`/users/${item.UserId}`} className="item-name">
<Tooltip title={item.Name}>
<Card.Text>{item.Name}</Card.Text>
<Card.Text className="item-text">{item.Name}</Card.Text>
</Tooltip>
</Link>
) : !item.Client && !props.icon ? (
<Link to={`/libraries/item/${item.Id}`} className="item-name">
<Tooltip title={item.Name}>
<Card.Text>{item.Name}</Card.Text>
<Card.Text className="item-text">{item.Name}</Card.Text>
</Tooltip>
</Link>
) : !item.Client && props.icon ? (
item.Id ? (
<Link to={`/libraries/${item.Id}`} className="item-name">
<Tooltip title={item.Name}>
<Card.Text>{item.Name}</Card.Text>
<Card.Text className="item-text">{item.Name}</Card.Text>
</Tooltip>
</Link>
) : (
<Tooltip title={item.Name}>
<Card.Text>{item.Name}</Card.Text>
<Card.Text className="item-text">{item.Name}</Card.Text>
</Tooltip>
)
) : (
<Tooltip title={item.Client}>
<Card.Text>{item.Client}</Card.Text>
<Card.Text className="item-text">{item.Client}</Card.Text>
</Tooltip>
)}
</div>

View File

@@ -11,6 +11,7 @@ import "../css/users/user-details.css";
import { Trans } from "react-i18next";
import baseUrl from "../../lib/baseurl";
import GlobalStats from "./general/globalStats";
import ActivityTimeline from "../activity_time_line";
function UserInfo() {
const { UserId } = useParams();
@@ -77,7 +78,12 @@ function UserInfo() {
) : (
<img
className="user-image"
src={baseUrl + "/proxy/Users/Images/Primary?id=" + UserId + "&quality=100"}
src={
baseUrl +
"/proxy/Users/Images/Primary?id=" +
UserId +
"&quality=100"
}
onError={handleImageError}
alt=""
></img>
@@ -103,11 +109,23 @@ function UserInfo() {
>
<Trans i18nKey="TAB_CONTROLS.ACTIVITY" />
</Button>
<Button
onClick={() => setActiveTab("tabTimeline")}
active={activeTab === "tabTimeline"}
variant="outline-primary"
type="button"
>
<Trans i18nKey="TAB_CONTROLS.TIMELINE" />
</Button>
</ButtonGroup>
</div>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant="pills">
<Tabs
defaultActiveKey="tabOverview"
activeKey={activeTab}
variant="pills"
>
<Tab eventKey="tabOverview" className="bg-transparent">
<GlobalStats
id={UserId}
@@ -120,6 +138,9 @@ function UserInfo() {
<Tab eventKey="tabActivity" className="bg-transparent">
<UserActivity UserId={UserId} />
</Tab>
<Tab eventKey="tabTimeline" className="bg-transparent">
<ActivityTimeline preselectedUser={UserId} />
</Tab>
</Tabs>
</div>
);

View File

@@ -13,13 +13,33 @@ import Config from "../../../lib/config.jsx";
function UserActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem("token");
const [itemCount, setItemCount] = useState(10);
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10"));
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
const [streamTypeFilter, setStreamTypeFilter] = useState("All");
const [libraryFilters, setLibraryFilters] = useState([]);
const [libraries, setLibraries] = useState([]);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
const [config, setConfig] = useState();
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState({ column: "ActivityDateInserted", desc: true });
const [filterParams, setFilterParams] = useState([]);
const [isBusy, setIsBusy] = useState(false);
function setItemLimit(limit) {
setItemCount(parseInt(limit));
localStorage.setItem("PREF_ACTIVITY_ItemCount", limit);
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300); // Adjust the delay as needed
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
useEffect(() => {
const fetchConfig = async () => {
@@ -51,9 +71,22 @@ function UserActivity(props) {
}
};
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
const onSortChange = (sort) => {
setSorting({ column: sort.column, desc: sort.desc });
};
const onFilterChange = (filter) => {
setFilterParams(filter);
};
useEffect(() => {
const fetchHistory = async () => {
try {
setIsBusy(true);
const itemData = await axios.post(
`/api/getUserHistory`,
{
@@ -64,9 +97,18 @@ function UserActivity(props) {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
params: {
size: itemCount,
page: currentPage,
search: debouncedSearchQuery,
sort: sorting.column,
desc: sorting.desc,
filters: filterParams != undefined ? JSON.stringify(filterParams) : null,
},
}
);
setData(itemData.data);
setIsBusy(false);
} catch (error) {
console.log(error);
}
@@ -97,26 +139,37 @@ function UserActivity(props) {
});
};
fetchHistory();
if (
!data ||
(data.current_page && data.current_page !== currentPage) ||
(data.size && data.size !== itemCount) ||
(data?.search ?? "") !== debouncedSearchQuery.trim() ||
(data?.sort ?? "") !== sorting.column ||
(data?.desc ?? true) !== sorting.desc ||
JSON.stringify(data?.filters ?? []) !== JSON.stringify(filterParams ?? [])
) {
fetchHistory();
}
fetchLibraries();
const intervalId = setInterval(fetchHistory, 60000 * 5);
return () => clearInterval(intervalId);
}, [props.UserId, token]);
}, [props.UserId, token, itemCount, currentPage, debouncedSearchQuery, sorting, filterParams]);
if (!data) {
if (!data || !data.results) {
return <></>;
}
let filteredData = data;
let filteredData = data.results;
if (searchQuery) {
filteredData = data.filter((item) =>
(!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}
// if (searchQuery) {
// filteredData = data.results.filter((item) =>
// (!item.SeriesName ? item.NowPlayingItemName : item.SeriesName + " - " + item.NowPlayingItemName)
// .toLowerCase()
// .includes(searchQuery.toLowerCase())
// );
// }
filteredData = filteredData.filter(
(item) =>
@@ -182,7 +235,7 @@ function UserActivity(props) {
</div>
<FormSelect
onChange={(event) => {
setItemCount(event.target.value);
setItemLimit(event.target.value);
}}
value={itemCount}
className="my-md-3 w-md-75 rounded-0 rounded-end"
@@ -204,7 +257,15 @@ function UserActivity(props) {
</div>
<div className="Activity">
<ActivityTable data={filteredData} itemCount={itemCount} />
<ActivityTable
data={filteredData}
itemCount={itemCount}
onPageChange={handlePageChange}
onSortChange={onSortChange}
onFilterChange={onFilterChange}
pageCount={data.pages}
isBusy={isBusy}
/>
</div>
</div>
);

View File

@@ -1,3 +1,4 @@
.Activity {
/* margin-top: 10px; */
position: relative;
}

View File

@@ -0,0 +1,5 @@
.overflow-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,38 +1,48 @@
@import './variables.module.css';
@import "./variables.module.css";
.loading {
margin: 0px;
height: calc(100vh - 100px);
margin: 0px;
height: calc(100vh - 100px);
display: flex;
justify-content: center;
align-items: center;
background-color: var(--background-color);
transition: opacity 800ms ease-in;
opacity: 1;
}
.loading::before
{
display: flex;
justify-content: center;
align-items: center;
background-color: var(--background-color);
transition: opacity 800ms ease-in;
opacity: 1;
}
.busy {
height: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 55px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */
z-index: 9999; /* High z-index to be above other elements */
}
.loading::before {
opacity: 0;
}
.loading__spinner {
width: 50px;
height: 50px;
border: 5px solid #ccc;
border-top-color: #333;
border-radius: 50%;
animation: spin 1s ease-in-out infinite;
.loading__spinner {
width: 50px;
height: 50px;
border: 5px solid #ccc;
border-top-color: #333;
border-radius: 50%;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,52 +1,66 @@
@import './variables.module.css';
.grid-stat-cards
{
@import "./variables.module.css";
.grid-stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(auto, 520px));
grid-auto-rows: 200px;/* max-width+offset so 215 + 20*/
grid-auto-rows: 200px; /* max-width+offset so 215 + 20*/
margin-top: 8px;
background-color: var(--secondary-background-color);
border-radius: 8px;
padding: 20px;
}
.stat-card{
.stat-card {
border: 0 !important;
background-color: var(--background-color)!important;
background-color: var(--background-color) !important;
color: white;
max-width: 500px;
max-height: 180px;
}
.stat-card-banner
{
.no-gutters {
--bs-gutter-x: 0 !important; /* Remove horizontal gutter */
--bs-gutter-y: 0 !important; /* Remove vertical gutter if needed */
}
.row-max-witdh {
max-width: 500px;
}
.stat-card-banner {
max-width: 120px !important;
}
.stat-card-details {
max-width: 380px !important;
}
.stat-card-image-audio {
width: 120px !important;
top: 15%;
position: relative;
}
.item-text {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-card-image {
width: 120px !important;
height: 180px;
}
.stat-card-icon
{
.stat-card-icon {
width: 120px !important;
position: relative;
top: 50%;
left: 65%;
transform: translate(-50%, -50%);
}
.stat-items
{
.stat-items {
color: white;
}
@@ -56,85 +70,71 @@
}
.stat-item-count {
text-align: right;
color: var(--secondary-color);
color: var(--secondary-color);
font-weight: 500;
font-size: 1.1em;
}
.Heading
{
.Heading {
display: flex;
flex-direction: row;
}
.Heading h1
{
.Heading h1 {
padding-right: 10px;
}
.date-range
{
.date-range {
width: 220px;
height: 35px;
color: white;
display: flex;
background-color: var(--secondary-background-color);
background-color: var(--secondary-background-color);
border-radius: 8px;
font-size: 1.2em;
align-self: flex-end;
justify-content: space-evenly;
}
.date-range .days input
{
.date-range .days input {
height: 35px;
outline: none;
border: none;
background-color:transparent;
color:white;
background-color: transparent;
color: white;
font-size: 1em;
width: 40px;
}
.date-range .days
{
.date-range .days {
background-color: rgb(255, 255, 255, 0.1);
padding-inline: 10px;
padding-inline: 10px;
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
input[type="number"] {
-moz-appearance: textfield;
}
.date-range .header,
.date-range .trailer
{
.date-range .trailer {
padding-inline: 10px;
align-self: center;
}
.stat-items div a{
.stat-items div a {
text-decoration: none !important;
color: white !important;
}
.stat-items div a:hover{
color: var(--secondary-color) !important;
.stat-items div a:hover {
color: var(--secondary-color) !important;
}
.item-name :hover{
color: var(--secondary-color) !important;
.item-name :hover {
color: var(--secondary-color) !important;
}

View File

@@ -0,0 +1,28 @@
@import "../variables.module.css";
.Heading {
justify-content: space-between;
}
.activity-card {
display: flex;
width: 10rem;
* {
flex-grow: 1;
border-radius: var(--bs-border-radius-lg) !important;
}
.activity-card-img {
object-fit: cover;
background-color: black;
background-repeat: no-repeat;
background-size: cover;
transition: all 0.2s ease-in-out;
}
}
.MuiTimelineItem-root {
height: 20rem;
}
.MuiTimelineContent-root {
align-self: center;
}

View File

@@ -11,6 +11,7 @@ import About from "./pages/about";
import TestingRoutes from "./pages/testing";
import Activity from "./pages/activity";
import Statistics from "./pages/statistics";
import ActivityTimeline from "./pages/activity_time_line";
const routes = [
{
@@ -58,6 +59,11 @@ const routes = [
element: <Activity />,
exact: true,
},
{
path: "/timeline",
element: <ActivityTimeline />,
exact: true,
},
{
path: "/about",
element: <About />,