mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
169
backend/classes/db-helper.js
Normal file
169
backend/classes/db-helper.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
43
backend/migrations/080_js_latest_playback_activity.js
Normal file
43
backend/migrations/080_js_latest_playback_activity.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
114
backend/migrations/087_optimize_js_latest_playback_activity.js
Normal file
114
backend/migrations/087_optimize_js_latest_playback_activity.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
`);
|
||||
};
|
||||
177
backend/migrations/091_fix_function_fs_get_user_activity.js
Normal file
177
backend/migrations/091_fix_function_fs_get_user_activity.js
Normal 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;
|
||||
`);
|
||||
};
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
7
backend/utils/sanitizer.js
Normal file
7
backend/utils/sanitizer.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const sanitizer = require("sanitize-filename");
|
||||
|
||||
const sanitizeFilename = (filename) => {
|
||||
return sanitizer(filename);
|
||||
};
|
||||
|
||||
module.export = { sanitizeFilename };
|
||||
@@ -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
913
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
305
public/locales/it-IT/translation.json
Normal file
305
public/locales/it-IT/translation.json
Normal 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"
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -151,3 +151,7 @@ h2 {
|
||||
.hide-tab-titles {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: #a7a7a7 !important; /* Replace with your desired color */
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ export const languages = [
|
||||
id: "zh-CN",
|
||||
description: "简体中文",
|
||||
},
|
||||
{
|
||||
id: "it-IT",
|
||||
description: "Italiano",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
205
src/pages/activity_time_line.jsx
Normal file
205
src/pages/activity_time_line.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
80
src/pages/components/activity-timeline/activity-timeline.jsx
Normal file
80
src/pages/components/activity-timeline/activity-timeline.jsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
57
src/pages/components/activity-timeline/helpers.jsx
Normal file
57
src/pages/components/activity-timeline/helpers.jsx
Normal 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();
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
src/pages/components/general/busyLoader.jsx
Normal file
12
src/pages/components/general/busyLoader.jsx
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.Activity {
|
||||
/* margin-top: 10px; */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
5
src/pages/css/items/item-stat-component.css
Normal file
5
src/pages/css/items/item-stat-component.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.overflow-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
28
src/pages/css/timeline/activity-timeline.css
Normal file
28
src/pages/css/timeline/activity-timeline.css
Normal 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;
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user