diff --git a/backend/classes/db-helper.js b/backend/classes/db-helper.js new file mode 100644 index 0000000..b5f9976 --- /dev/null +++ b/backend/classes/db-helper.js @@ -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, +}; diff --git a/backend/db.js b/backend/db.js index 9eaa16c..748eb27 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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, diff --git a/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js b/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js new file mode 100644 index 0000000..31b539f --- /dev/null +++ b/backend/migrations/079_create_view_jf_playback_activity_with_metadata.js @@ -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); + } +}; diff --git a/backend/migrations/080_js_latest_playback_activity.js b/backend/migrations/080_js_latest_playback_activity.js new file mode 100644 index 0000000..cc7e031 --- /dev/null +++ b/backend/migrations/080_js_latest_playback_activity.js @@ -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); + } +}; diff --git a/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js b/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js new file mode 100644 index 0000000..d0b4e10 --- /dev/null +++ b/backend/migrations/081_create_trigger_refresh_function_js_latest_playback_activity.js @@ -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); + } +}; diff --git a/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js b/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js new file mode 100644 index 0000000..767d4be --- /dev/null +++ b/backend/migrations/082_create_trigger_refresh_js_latest_playback_activity.js @@ -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); + } +}; diff --git a/backend/migrations/083_create_materialized_view_js_library_stats_overview.js b/backend/migrations/083_create_materialized_view_js_library_stats_overview.js new file mode 100644 index 0000000..3b65269 --- /dev/null +++ b/backend/migrations/083_create_materialized_view_js_library_stats_overview.js @@ -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); + } +}; diff --git a/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js b/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js new file mode 100644 index 0000000..04035bf --- /dev/null +++ b/backend/migrations/084_create_trigger_refresh_function_js_library_stats_overview.js @@ -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); + } +}; diff --git a/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js b/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js new file mode 100644 index 0000000..c8a5f92 --- /dev/null +++ b/backend/migrations/085_create_trigger_refresh_js_library_stats_overview.js @@ -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); + } +}; diff --git a/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js b/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js new file mode 100644 index 0000000..a6df304 --- /dev/null +++ b/backend/migrations/086_drop_all_refresh_triggers_on_activity_table.js @@ -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); + } +}; diff --git a/backend/migrations/087_optimize_js_latest_playback_activity.js b/backend/migrations/087_optimize_js_latest_playback_activity.js new file mode 100644 index 0000000..82b3f4b --- /dev/null +++ b/backend/migrations/087_optimize_js_latest_playback_activity.js @@ -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); + } +}; diff --git a/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js b/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js new file mode 100644 index 0000000..7cdbbf2 --- /dev/null +++ b/backend/migrations/088_optimize_materialized_view_js_library_stats_overview.js @@ -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); + } +}; diff --git a/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js b/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js new file mode 100644 index 0000000..721b1d2 --- /dev/null +++ b/backend/migrations/089_optimize_materialized_view_js_library_stats_overview_last_activity.js @@ -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); + } +}; diff --git a/backend/migrations/090_create_function_fs_get_user_activity.js b/backend/migrations/090_create_function_fs_get_user_activity.js new file mode 100644 index 0000000..acf59c3 --- /dev/null +++ b/backend/migrations/090_create_function_fs_get_user_activity.js @@ -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; + `); +}; diff --git a/backend/migrations/091_fix_function_fs_get_user_activity.js b/backend/migrations/091_fix_function_fs_get_user_activity.js new file mode 100644 index 0000000..23d6f12 --- /dev/null +++ b/backend/migrations/091_fix_function_fs_get_user_activity.js @@ -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; + `); +}; diff --git a/backend/routes/api.js b/backend/routes/api.js index 3526802..f6d8adb 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -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" }); diff --git a/backend/routes/backup.js b/backend/routes/backup.js index d24ae44..9fb99ea 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -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; diff --git a/backend/routes/stats.js b/backend/routes/stats.js index f213bfc..0f8bd98 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -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); diff --git a/backend/swagautogen.js b/backend/swagautogen.js index 3520a8e..76b6ec4 100644 --- a/backend/swagautogen.js +++ b/backend/swagautogen.js @@ -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); +}); diff --git a/backend/swagger.json b/backend/swagger.json index f8ec8c5..28fda5c 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -37,7 +37,10 @@ "description": "Jellystat Log Endpoints" } ], - "schemes": ["http", "https"], + "schemes": [ + "http", + "https" + ], "securityDefinitions": { "apiKey": { "type": "apiKey", @@ -48,7 +51,9 @@ "paths": { "/auth/login": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -74,6 +79,9 @@ "401": { "description": "Unauthorized" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -82,12 +90,17 @@ }, "/auth/isConfigured": { "get": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "responses": { "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -96,7 +109,9 @@ }, "/auth/createuser": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -122,6 +137,9 @@ "403": { "description": "Forbidden" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -130,7 +148,9 @@ }, "/auth/configSetup": { "post": { - "tags": ["Auth"], + "tags": [ + "Auth" + ], "description": "", "parameters": [ { @@ -156,6 +176,9 @@ "400": { "description": "Bad Request" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -164,7 +187,9 @@ }, "/proxy/web/assets/img/devices/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -177,6 +202,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -185,7 +213,9 @@ }, "/proxy/Items/Images/Backdrop/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -213,6 +243,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -221,7 +254,9 @@ }, "/proxy/Items/Images/Primary/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -244,6 +279,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -252,7 +290,9 @@ }, "/proxy/Users/Images/Primary/": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -275,6 +315,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "500": { "description": "Internal Server Error" } @@ -283,12 +326,17 @@ }, "/proxy/getSessions": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "responses": { "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -297,12 +345,17 @@ }, "/proxy/getAdminUsers": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "responses": { "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -311,7 +364,9 @@ }, "/proxy/getRecentlyAdded": { "get": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -324,6 +379,9 @@ "200": { "description": "OK" }, + "404": { + "description": "Not Found" + }, "503": { "description": "Service Unavailable" } @@ -332,7 +390,9 @@ }, "/proxy/validateSettings": { "post": { - "tags": ["Proxy"], + "tags": [ + "Proxy" + ], "description": "", "parameters": [ { @@ -357,13 +417,18 @@ }, "400": { "description": "Bad Request" + }, + "404": { + "description": "Not Found" } } } }, "/api/getconfig": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -403,7 +468,9 @@ }, "/api/getLibraries": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -440,7 +507,9 @@ }, "/api/getRecentlyAdded": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -495,7 +564,9 @@ }, "/api/setconfig": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -548,9 +619,68 @@ } } }, + "/api/setExternalUrl": { + "post": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "ExternalUrl": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/setPreferredAdmin": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -605,7 +735,9 @@ }, "/api/setRequireLogin": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -657,7 +789,9 @@ }, "/api/updateCredentials": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -718,7 +852,9 @@ }, "/api/updatePassword": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -770,7 +906,9 @@ }, "/api/TrackedLibraries": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -810,7 +948,9 @@ }, "/api/setExcludedLibraries": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -862,7 +1002,9 @@ }, "/api/UntrackedUsers": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -902,7 +1044,9 @@ }, "/api/setUntrackedUsers": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -954,7 +1098,9 @@ }, "/api/keys": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -989,7 +1135,9 @@ } }, "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1039,7 +1187,9 @@ } }, "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1091,7 +1241,9 @@ }, "/api/getTaskSettings": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1131,7 +1283,9 @@ }, "/api/setTaskSettings": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1189,7 +1343,9 @@ }, "/api/CheckForUpdates": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1226,7 +1382,9 @@ }, "/api/getUserDetails": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1281,7 +1439,9 @@ }, "/api/getLibrary": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1336,7 +1496,9 @@ }, "/api/getLibraryItems": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1388,7 +1550,9 @@ }, "/api/getSeasons": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1440,7 +1604,9 @@ }, "/api/getEpisodes": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1492,7 +1658,9 @@ }, "/api/getItemDetails": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1544,7 +1712,9 @@ }, "/api/item/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1602,7 +1772,9 @@ }, "/api/library/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1660,7 +1832,9 @@ }, "/api/libraryItems/purge": { "delete": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1716,9 +1890,11 @@ } } }, - "/api/getHistory": { + "/api/getBackupTables": { "get": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1749,13 +1925,18 @@ }, "404": { "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" } } } }, - "/api/getLibraryHistory": { + "/api/setExcludedBackupTable": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1773,6 +1954,182 @@ "in": "query", "type": "string" }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "table": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/api/getHistory": { + "get": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration", + "TotalPlays" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/api/getLibraryHistory": { + "post": { + "tags": [ + "API" + ], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration", + "TotalPlays" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -1810,7 +2167,9 @@ }, "/api/getItemHistory": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1828,6 +2187,45 @@ "in": "query", "type": "string" }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -1865,7 +2263,9 @@ }, "/api/getUserHistory": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1883,6 +2283,45 @@ "in": "query", "type": "string" }, + { + "name": "size", + "in": "query", + "type": "string" + }, + { + "name": "page", + "in": "query", + "type": "string" + }, + { + "name": "search", + "in": "query", + "type": "string" + }, + { + "name": "sort", + "in": "query", + "type": "string", + "enum": [ + "UserName", + "RemoteEndPoint", + "NowPlayingItemName", + "Client", + "DeviceName", + "ActivityDateInserted", + "PlaybackDuration" + ] + }, + { + "name": "desc", + "in": "query", + "type": "string" + }, + { + "name": "filters", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -1920,7 +2359,9 @@ }, "/api/deletePlaybackActivity": { "post": { - "tags": ["API"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -1973,83 +2414,11 @@ } } }, - "/sync/beginSync": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/sync/beginPartialSync": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/sync/fetchItem": { + "/api/getActivityTimeLine": { "post": { - "tags": ["Sync"], + "tags": [ + "API" + ], "description": "", "parameters": [ { @@ -2073,7 +2442,10 @@ "schema": { "type": "object", "properties": { - "itemId": { + "userId": { + "example": "any" + }, + "libraries": { "example": "any" } } @@ -2096,55 +2468,17 @@ "404": { "description": "Not Found" }, - "500": { - "description": "Internal Server Error" - }, "503": { "description": "Service Unavailable" } } } }, - "/sync/syncPlaybackPluginData": { - "get": { - "tags": ["Sync"], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, "/stats/getLibraryOverview": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2184,7 +2518,9 @@ }, "/stats/getMostViewedByType": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2239,7 +2575,9 @@ }, "/stats/getMostPopularByType": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2294,7 +2632,9 @@ }, "/stats/getMostViewedLibraries": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2346,7 +2686,9 @@ }, "/stats/getMostUsedClient": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2398,7 +2740,9 @@ }, "/stats/getMostActiveUsers": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2450,7 +2794,9 @@ }, "/stats/getPlaybackActivity": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2490,7 +2836,9 @@ }, "/stats/getAllUserActivity": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2527,7 +2875,9 @@ }, "/stats/getUserLastPlayed": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2579,7 +2929,9 @@ }, "/stats/getGlobalUserStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2634,7 +2986,9 @@ }, "/stats/getGlobalItemStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2689,7 +3043,9 @@ }, "/stats/getGlobalLibraryStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2744,7 +3100,9 @@ }, "/stats/getLibraryCardStats": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2782,7 +3140,9 @@ } }, "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2834,7 +3194,9 @@ }, "/stats/getLibraryMetadata": { "get": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2874,7 +3236,9 @@ }, "/stats/getLibraryItemsWithStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2923,7 +3287,9 @@ }, "/stats/getLibraryItemsPlayMethodStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -2984,7 +3350,9 @@ }, "/stats/getPlaybackMethodStats": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3036,7 +3404,9 @@ }, "/stats/getLibraryLastPlayed": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3088,7 +3458,9 @@ }, "/stats/getViewsOverTime": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3140,7 +3512,9 @@ }, "/stats/getViewsByDays": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3192,7 +3566,9 @@ }, "/stats/getViewsByHour": { "post": { - "tags": ["Stats"], + "tags": [ + "Stats" + ], "description": "", "parameters": [ { @@ -3244,7 +3620,9 @@ }, "/backup/beginBackup": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3284,7 +3662,9 @@ }, "/backup/restore/{filename}": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3330,7 +3710,9 @@ }, "/backup/files": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3370,7 +3752,9 @@ }, "/backup/files/{filename}": { "get": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3396,6 +3780,9 @@ } ], "responses": { + "200": { + "description": "OK" + }, "401": { "description": "Unauthorized" }, @@ -3408,7 +3795,9 @@ } }, "delete": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3454,7 +3843,9 @@ }, "/backup/upload": { "post": { - "tags": ["Backup"], + "tags": [ + "Backup" + ], "description": "", "parameters": [ { @@ -3491,7 +3882,9 @@ }, "/logs/getLogs": { "get": { - "tags": ["Logs"], + "tags": [ + "Logs" + ], "description": "", "parameters": [ { @@ -3528,7 +3921,9 @@ }, "/utils/geolocateIp": { "post": { - "tags": ["Utils"], + "tags": [ + "Utils" + ], "description": "", "parameters": [ { @@ -3590,4 +3985,4 @@ "apiKey": [] } ] -} +} \ No newline at end of file diff --git a/backend/utils/sanitizer.js b/backend/utils/sanitizer.js new file mode 100644 index 0000000..169beaf --- /dev/null +++ b/backend/utils/sanitizer.js @@ -0,0 +1,7 @@ +const sanitizer = require("sanitize-filename"); + +const sanitizeFilename = (filename) => { + return sanitizer(filename); +}; + +module.export = { sanitizeFilename }; diff --git a/backend/version-control.js b/backend/version-control.js index 99a774e..1cf964a 100644 --- a/backend/version-control.js +++ b/backend/version-control.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json index 2d78d6c..cec26f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "jfstat", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jfstat", - "version": "1.1.2", + "version": "1.1.3", "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", @@ -41,7 +42,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", @@ -60,10 +62,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", @@ -2330,9 +2333,9 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", - "integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2699,15 +2702,15 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", @@ -2717,47 +2720,47 @@ } }, "node_modules/@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "dependencies": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" }, "node_modules/@emotion/react": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", - "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { @@ -2770,33 +2773,33 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", - "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "dependencies": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" }, "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -2809,27 +2812,27 @@ } }, "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", @@ -3257,28 +3260,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.1" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", - "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.6.1" + "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -3286,9 +3292,10 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", @@ -4185,29 +4192,30 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", - "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "version": "5.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz", + "integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4216,32 +4224,33 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.14.tgz", - "integrity": "sha512-on75VMd0XqZfaQW+9pGjSNiqW+ghc5E2ZSLRBXwcXl/C4YzjfyjrLPhrEpKnR9Uym9KXBvxrhoHfPcczYHweyA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz", + "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.14.tgz", - "integrity": "sha512-vj/51k7MdFmt+XVw94sl30SCvGx6+wJLsNYjZRgxhS6y3UtnWnypMOsm3Kmg8TN+P0dqwsjy4/fX7B1HufJIhw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.3.0.tgz", + "integrity": "sha512-3uWws6DveDn5KxCS34p+sUNMxehuclQY6OmoJeJJ+Sfg9L7LGBpksY/nX5ywKAqickTZnn+sQyVcp963ep9jvw==", "dependencies": { - "@babel/runtime": "^7.23.9" + "@babel/runtime": "^7.26.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^5.0.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@mui/material": "^6.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4249,26 +4258,22 @@ } } }, - "node_modules/@mui/material": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.14.tgz", - "integrity": "sha512-kEbRw6fASdQ1SQ7LVdWR5OlWV3y7Y54ZxkLzd6LV5tmz+NpO3MJKZXSfgR0LHMP7meKsPiMm4AuzV0pXDpk/BQ==", + "node_modules/@mui/lab": { + "version": "6.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.22.tgz", + "integrity": "sha512-9nwUfBj+UzoQJOCbqV+JcCSJ74T+gGWrM1FMlXzkahtYUcMN+5Zmh2ArlttW3zv2dZyCzp7K5askcnKF0WzFQg==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/base": "5.0.0-beta.40", - "@mui/core-downloads-tracker": "^5.15.14", - "@mui/system": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^18.2.0", - "react-transition-group": "^4.4.5" + "@babel/runtime": "^7.26.0", + "@mui/base": "5.0.0-beta.68", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -4277,9 +4282,11 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@mui/material": "^6.3.1", + "@mui/material-pigment-css": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4288,30 +4295,89 @@ "@emotion/styled": { "optional": true }, + "@mui/material-pigment-css": { + "optional": true + }, "@types/react": { "optional": true } } }, - "node_modules/@mui/private-theming": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", - "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", + "node_modules/@mui/material": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz", + "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.14", - "prop-types": "^15.8.1" + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.3.1", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz", + "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4320,17 +4386,20 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz", + "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -4339,7 +4408,7 @@ "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4351,21 +4420,22 @@ } }, "node_modules/@mui/system": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.14.tgz", - "integrity": "sha512-auXLXzUaCSSOLqJXmsAaq7P96VPRXg2Rrz6OHNV7lr+kB8lobUF+/N84Vd9C4G/wvCXYPs5TYuuGBRhcGbiBGg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz", + "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.14", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "clsx": "^2.1.0", + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.3.1", + "@mui/styled-engine": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -4374,8 +4444,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4390,11 +4460,12 @@ } }, "node_modules/@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4403,25 +4474,28 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.2.0" + "react-is": "^19.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -4429,42 +4503,58 @@ } } }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, "node_modules/@mui/x-data-grid": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.18.0.tgz", - "integrity": "sha512-js7Qhv+8XLgXilghKZmfBgUbkP7dYt7V1HLkM4C9285jFRUDFgAM9L6PVlRyUMn+YhVVPbvy+SWT+VWC8rYeQQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.23.3.tgz", + "integrity": "sha512-EiM5kut6N/0o0iEYx8A7M3fJqknAa1kcPvGhlX3hH50ERLDeuJaqoKzvRYLBbYKWydHIc+0hHIFcK5oQTXLenw==", "dependencies": { - "@babel/runtime": "^7.23.2", - "@mui/utils": "^5.14.16", - "clsx": "^2.0.0", + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "reselect": "^4.1.8" + "reselect": "^5.1.1" }, "engines": { "node": ">=14.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui" + "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } } }, "node_modules/@mui/x-date-pickers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.0.0.tgz", - "integrity": "sha512-/9mp4O2WMixHOso63DBoZVfJVYGrzOHF5voheV2tYQ4XqDdTKp2AdWS3oh8PGwrsvCzqkvb3quzTqhKoEsJUwA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz", + "integrity": "sha512-bjTYX/QzD5ZhVZNNnastMUS3j2Hy4p4IXmJgPJ0vKvQBvUdfEO+ZF42r3PJNNde0FVT1MmTzkmdTlz0JZ6ukdw==", "dependencies": { - "@babel/runtime": "^7.24.0", - "@mui/base": "^5.0.0-beta.40", - "@mui/system": "^5.15.14", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, @@ -4478,16 +4568,17 @@ "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14", - "date-fns": "^2.25.0 || ^3.2.0", - "date-fns-jalali": "^2.13.0-0", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", "dayjs": "^1.10.7", "luxon": "^3.0.2", "moment": "^2.29.4", - "moment-hijri": "^2.1.2", + "moment-hijri": "^2.1.2 || ^3.0.0", "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -4519,6 +4610,25 @@ } } }, + "node_modules/@mui/x-internals": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz", + "integrity": "sha512-bPclKpqUiJYIHqmTxSzMVZi6MH51cQsn5U+8jskaTlo3J4QiMeCYJn/gn7YbeR9GOZFp8hetyHjoQoVHKRXCig==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -5286,11 +5396,11 @@ "dev": true }, "node_modules/@tanstack/match-sorter-utils": { - "version": "8.11.8", - "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz", - "integrity": "sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==", + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", "dependencies": { - "remove-accents": "0.4.2" + "remove-accents": "0.5.0" }, "engines": { "node": ">=12" @@ -5301,11 +5411,11 @@ } }, "node_modules/@tanstack/react-table": { - "version": "8.13.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.13.2.tgz", - "integrity": "sha512-b6mR3mYkjRtJ443QZh9sc7CvGTce81J35F/XMr0OoWbx0KIM7TTTdyNP2XKObvkLpYnLpCrYDwI3CZnLezWvpg==", + "version": "8.20.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz", + "integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==", "dependencies": { - "@tanstack/table-core": "8.13.2" + "@tanstack/table-core": "8.20.5" }, "engines": { "node": ">=12" @@ -5315,30 +5425,30 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/@tanstack/react-virtual": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.1.3.tgz", - "integrity": "sha512-YCzcbF/Ws/uZ0q3Z6fagH+JVhx4JLvbSflgldMgLsuvB8aXjZLLb3HvrEVxY480F9wFlBiXlvQxOyXb5ENPrNA==", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", + "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==", "dependencies": { - "@tanstack/virtual-core": "3.1.3" + "@tanstack/virtual-core": "3.11.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@tanstack/table-core": { - "version": "8.13.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.13.2.tgz", - "integrity": "sha512-/2saD1lWBUV6/uNAwrsg2tw58uvMJ07bO2F1IWMxjFRkJiXKQRuc3Oq2aufeobD3873+4oIM/DRySIw7+QsPPw==", + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", "engines": { "node": ">=12" }, @@ -5348,9 +5458,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.1.3.tgz", - "integrity": "sha512-Y5B4EYyv1j9V8LzeAoOVeTg0LI7Fo5InYKgAjkY1Pu9GjtUwX/EKxNcU7ng3sKr99WEf+bPTcktAeybyMOYo+g==", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", + "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -5797,9 +5907,9 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" }, "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/q": { "version": "1.5.8", @@ -5835,10 +5945,10 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", - "dependencies": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { "@types/react": "*" } }, @@ -7465,6 +7575,20 @@ "node": ">=4" } }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -7736,9 +7860,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -8585,6 +8709,18 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -9158,23 +9294,23 @@ } }, "node_modules/engine.io-client": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", - "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -9186,29 +9322,9 @@ } }, "node_modules/engine.io-client/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/engine.io-parser": { "version": "5.2.1", @@ -9442,6 +9558,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -10057,6 +10221,20 @@ "node": ">=6" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -10136,6 +10314,15 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -10323,6 +10510,14 @@ } ] }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11128,12 +11323,12 @@ } }, "node_modules/highlight-words": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.2.tgz", - "integrity": "sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz", + "integrity": "sha512-If5n+IhSBRXTScE7wl16VPmd+44Vy7kof24EdqhjsZsDuHikpv1OCagVcJFpB4fS4UPUniedlWqrjIO8vWOsIQ==", "engines": { - "node": ">= 16", - "npm": ">= 8" + "node": ">= 20", + "npm": ">= 9" } }, "node_modules/hoist-non-react-statics": { @@ -11905,6 +12100,11 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -14697,6 +14897,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, "node_modules/luxon": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", @@ -14754,14 +14962,14 @@ } }, "node_modules/material-react-table": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-2.12.1.tgz", - "integrity": "sha512-nqILE26I/6/UfiILEc7mnVhIoVdG78qvkav85dcbn5BbNXkAJeA57Slut50eYytd7aQHCbaq9MoLvsVeO1yaIw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-3.1.0.tgz", + "integrity": "sha512-/zPn38QhxQE7mkwLex4CojX3UP2+/+/u7NVq7CHS5d6P8LdTteJPVYXVzym/uhaXzAzFB1ojsbP7zI/y6iUdtQ==", "dependencies": { - "@tanstack/match-sorter-utils": "8.11.8", - "@tanstack/react-table": "8.13.2", - "@tanstack/react-virtual": "3.1.3", - "highlight-words": "1.2.2" + "@tanstack/match-sorter-utils": "8.19.4", + "@tanstack/react-table": "8.20.6", + "@tanstack/react-virtual": "3.11.2", + "highlight-words": "2.0.0" }, "engines": { "node": ">=16" @@ -14771,13 +14979,13 @@ "url": "https://github.com/sponsors/kevinvandy" }, "peerDependencies": { - "@emotion/react": ">=11.11", - "@emotion/styled": ">=11.11", - "@mui/icons-material": ">=5.11", - "@mui/material": ">=5.13", - "@mui/x-date-pickers": ">=6.15.0", - "react": ">=17.0", - "react-dom": ">=17.0" + "@emotion/react": ">=11.13", + "@emotion/styled": ">=11.13", + "@mui/icons-material": ">=6", + "@mui/material": ">=6", + "@mui/x-date-pickers": ">=7.15", + "react": ">=18.0", + "react-dom": ">=18.0" } }, "node_modules/mdn-data": { @@ -14804,6 +15012,24 @@ "node": ">= 4.0.0" } }, + "node_modules/memoizee": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", + "dependencies": { + "d": "^1.0.2", + "es5-ext": "^0.10.64", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -15093,6 +15319,11 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -15148,6 +15379,18 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "optional": true, + "peer": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -18656,9 +18899,9 @@ } }, "node_modules/remove-accents": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", - "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" }, "node_modules/renderkid": { "version": "3.0.0", @@ -18694,9 +18937,9 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", @@ -18933,6 +19176,14 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/sanitize.css": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", @@ -19424,13 +19675,13 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", - "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -19438,11 +19689,11 @@ } }, "node_modules/socket.io-client/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -19454,9 +19705,9 @@ } }, "node_modules/socket.io-client/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/socket.io-parser": { "version": "4.2.4", @@ -20701,6 +20952,18 @@ "node": ">=8" } }, + "node_modules/timers-ext": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", + "dependencies": { + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -20802,6 +21065,14 @@ "tree-kill": "cli.js" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -20866,6 +21137,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -21162,11 +21438,30 @@ "requires-port": "^1.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/utf8": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==" }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -22352,9 +22647,9 @@ } }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, @@ -22382,9 +22677,9 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "engines": { "node": ">=0.4.0" } diff --git a/package.json b/package.json index 068b57a..0d10185 100644 --- a/package.json +++ b/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", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index e7b5bf6..bd7de5e 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -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" +} \ No newline at end of file diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index b93c71d..2e7381b 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -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": "" } diff --git a/public/locales/it-IT/translation.json b/public/locales/it-IT/translation.json new file mode 100644 index 0000000..a283733 --- /dev/null +++ b/public/locales/it-IT/translation.json @@ -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" +} diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index cb1f548..a16763b 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -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": "" } diff --git a/src/App.css b/src/App.css index 43e53f5..6b7a3ad 100644 --- a/src/App.css +++ b/src/App.css @@ -151,3 +151,7 @@ h2 { .hide-tab-titles { display: none !important; } + +::placeholder { + color: #a7a7a7 !important; /* Replace with your desired color */ +} diff --git a/src/lib/languages.jsx b/src/lib/languages.jsx index 033af18..6e7552b 100644 --- a/src/lib/languages.jsx +++ b/src/lib/languages.jsx @@ -11,4 +11,8 @@ export const languages = [ id: "zh-CN", description: "简体中文", }, + { + id: "it-IT", + description: "Italiano", + }, ]; diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 12eb53f..59ffa0d 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -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 ; @@ -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() {
- +
); diff --git a/src/pages/activity_time_line.jsx b/src/pages/activity_time_line.jsx new file mode 100644 index 0000000..9b1c59d --- /dev/null +++ b/src/pages/activity_time_line.jsx @@ -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 ? ( +
+
+

+ +

+
+
+
+ {!preselectedUser && ( + <> +
+ +
+ handleUserSelection(e.target.value)} + value={selectedUser} + className="w-md-75 rounded-0 rounded-end" + > + {users.map((user) => ( + + ))} + + + )} +
+
+
+ + + setShowLibraryFilters(false)} + > + + + + + + + + + + + +
+
+
+
+ {selectedUser && selectedLibraries?.length > 0 && ( + + )} +
+
+ ) : ( + + ); +} + +export default ActivityTimeline; diff --git a/src/pages/components/activity-timeline/activity-timeline-item.jsx b/src/pages/components/activity-timeline/activity-timeline-item.jsx new file mode 100644 index 0000000..79500ed --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline-item.jsx @@ -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 ( +
+ {MediaType === MEDIA_TYPES.Shows ? SeriesIcon : MovieIcon} +
+ ); +}; +const SeriesIcon = ; +const MovieIcon = ; + +export default function ActivityTimelineItem(entry) { + const { Title, SeasonName, NowPlayingItemId, EpisodeCount, MediaType } = + entry; + const [useDefaultImage, setUseDefaultImage] = useState(false); + return ( + + + +
+ + {!useDefaultImage ? ( + setUseDefaultImage(true)} + /> + ) : ( + + )} + +
+ +
+ + + {Title} + + {SeasonName && {SeasonName}} + {formatEntryDates(entry)} + {MediaType === MEDIA_TYPES.Shows && EpisodeCount && ( + + {EpisodeCount} + + )} + +
+ ); +} diff --git a/src/pages/components/activity-timeline/activity-timeline.jsx b/src/pages/components/activity-timeline/activity-timeline.jsx new file mode 100644 index 0000000..7e9712c --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline.jsx @@ -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 ? ( +
+ + {timelineEntries.map((entry) => ( + + ))} + +
+ ) : ( + + ); +} diff --git a/src/pages/components/activity-timeline/helpers.jsx b/src/pages/components/activity-timeline/helpers.jsx new file mode 100644 index 0000000..ab6af1a --- /dev/null +++ b/src/pages/components/activity-timeline/helpers.jsx @@ -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(); +} diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index d92cb1d..65ed9c8 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -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 }) => {formatTotalWatchTime(cell.getValue())}, }, { 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() ? : , @@ -417,6 +488,8 @@ export default function ActivityTable(props) { return ( + {isBusy && } + setIPModalVisible(false)} ipAddress={ipAddressLookup} /> - {data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'} - {data.MediaStreams ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video')?.RealFrameRate).toFixed(2) : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'} + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video') ? parseFloat(data.MediaStreams.find(stream => stream.Type === 'Video').RealFrameRate.toFixed(2)) : '-' : '-'} diff --git a/src/pages/components/general/busyLoader.jsx b/src/pages/components/general/busyLoader.jsx new file mode 100644 index 0000000..da7941f --- /dev/null +++ b/src/pages/components/general/busyLoader.jsx @@ -0,0 +1,12 @@ +import React from "react"; +import "../../css/loading.css"; + +function BusyLoader() { + return ( +
+
+
+ ); +} + +export default BusyLoader; diff --git a/src/pages/components/item-info.jsx b/src/pages/components/item-info.jsx index cd0bfd5..a2bdd5f 100644 --- a/src/pages/components/item-info.jsx +++ b/src/pages/components/item-info.jsx @@ -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() { : <>} - + + + diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 8e01370..ebb52f6 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -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) { { - 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) {
- +
); diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index d3eec2d..0139f24 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -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) {
- +
); diff --git a/src/pages/components/sessions/session-card.jsx b/src/pages/components/sessions/session-card.jsx index 3158f76..5f1c7af 100644 --- a/src/pages/components/sessions/session-card.jsx +++ b/src/pages/components/sessions/session-card.jsx @@ -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) { + {props.data.session.NowPlayingItem.VideoStream !== "" && + + {props.data.session.NowPlayingItem.VideoStream} + + } - {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 !== "" && + {props.data.session.NowPlayingItem.AudioStream} + } - - {props.data.session.NowPlayingItem.SubtitleStream} - + {props.data.session.NowPlayingItem.SubtitleStream !== "" && + + {props.data.session.NowPlayingItem.SubtitleStream} + + } @@ -213,7 +191,14 @@ function SessionCard(props) { - ) : ( + ) : (props.data.session.NowPlayingItem.Type === "Audio" && props.data.session.NowPlayingItem.Artists.length > 0) ? ( + + + {props.data.session.NowPlayingItem.Artists[0]} + + + ) : + ( <> )} diff --git a/src/pages/components/sessions/sessions.jsx b/src/pages/components/sessions/sessions.jsx index ebe92aa..d7597f0 100644 --- a/src/pages/components/sessions/sessions.jsx +++ b/src/pages/components/sessions/sessions.jsx @@ -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; diff --git a/src/pages/components/settings/apiKeys.jsx b/src/pages/components/settings/apiKeys.jsx index a71c5a8..799e3d7 100644 --- a/src/pages/components/settings/apiKeys.jsx +++ b/src/pages/components/settings/apiKeys.jsx @@ -150,7 +150,7 @@ async function handleFormSubmit(event) {

{showAlert && showAlert.visible && ( - + {showAlert.title}

{showAlert.message} diff --git a/src/pages/components/settings/backupfiles.jsx b/src/pages/components/settings/backupfiles.jsx index a109afe..c109e3b 100644 --- a/src/pages/components/settings/backupfiles.jsx +++ b/src/pages/components/settings/backupfiles.jsx @@ -133,14 +133,14 @@ function Row(file) {

downloadBackup(data.name)}> - + restoreBackup(data.name)}> - + deleteBackup(data.name)}> - +
@@ -222,7 +222,7 @@ export default function BackupFiles() { {showAlert && showAlert.visible && ( - + {showAlert.title}

{showAlert.message}

@@ -233,10 +233,14 @@ export default function BackupFiles() { - + + + + + + + - - @@ -249,7 +253,7 @@ export default function BackupFiles() { {files.length === 0 ? ( - + ) : ( @@ -275,11 +279,11 @@ export default function BackupFiles() {
{`${page * rowsPerPage + 1}-${Math.min( @@ -288,7 +292,7 @@ export default function BackupFiles() { )} of ${files.length}`}
diff --git a/src/pages/components/settings/security.jsx b/src/pages/components/settings/security.jsx index 4ff3e46..68ae4ba 100644 --- a/src/pages/components/settings/security.jsx +++ b/src/pages/components/settings/security.jsx @@ -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" ? ( - {submissionMessage} + {submissionMessage} ) : ( - {submissionMessage} + {submissionMessage} ) ) : ( <> diff --git a/src/pages/components/settings/settingsConfig.jsx b/src/pages/components/settings/settingsConfig.jsx index 559a15d..d1558c1 100644 --- a/src/pages/components/settings/settingsConfig.jsx +++ b/src/pages/components/settings/settingsConfig.jsx @@ -231,9 +231,9 @@ export default function SettingsConfig() { {isSubmitted !== "" ? ( isSubmitted === "Failed" ? ( - {submissionMessage} + {submissionMessage} ) : ( - {submissionMessage} + {submissionMessage} ) ) : ( <> @@ -263,9 +263,9 @@ export default function SettingsConfig() { {isSubmittedExternal !== "" ? ( isSubmittedExternal === "Failed" ? ( - {submissionMessageExternal} + {submissionMessageExternal} ) : ( - {submissionMessageExternal} + {submissionMessageExternal} ) ) : ( <> diff --git a/src/pages/components/statCards/ItemStatComponent.jsx b/src/pages/components/statCards/ItemStatComponent.jsx index 012ac2c..506ed66 100644 --- a/src/pages/components/statCards/ItemStatComponent.jsx +++ b/src/pages/components/statCards/ItemStatComponent.jsx @@ -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 (
- - + + {props.icon ? (
{props.icon}
) : ( @@ -80,7 +81,7 @@ function ItemStatComponent(props) { )} - +
@@ -98,30 +99,30 @@ function ItemStatComponent(props) { {item.UserId ? ( - {item.Name} + {item.Name} ) : !item.Client && !props.icon ? ( - {item.Name} + {item.Name} ) : !item.Client && props.icon ? ( item.Id ? ( - {item.Name} + {item.Name} ) : ( - {item.Name} + {item.Name} ) ) : ( - {item.Client} + {item.Client} )}
diff --git a/src/pages/components/user-info.jsx b/src/pages/components/user-info.jsx index 0e4290c..e76510d 100644 --- a/src/pages/components/user-info.jsx +++ b/src/pages/components/user-info.jsx @@ -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() { ) : ( @@ -103,11 +109,23 @@ function UserInfo() { > +
- + + + + ); diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index b252d1f..ccdc360 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -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) { { - 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) {
- +
); diff --git a/src/pages/css/activity.css b/src/pages/css/activity.css index 97e441e..f463011 100644 --- a/src/pages/css/activity.css +++ b/src/pages/css/activity.css @@ -1,3 +1,4 @@ .Activity { /* margin-top: 10px; */ + position: relative; } diff --git a/src/pages/css/items/item-stat-component.css b/src/pages/css/items/item-stat-component.css new file mode 100644 index 0000000..24c0a8a --- /dev/null +++ b/src/pages/css/items/item-stat-component.css @@ -0,0 +1,5 @@ +.overflow-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/pages/css/loading.css b/src/pages/css/loading.css index 0b6c992..3d2bca4 100644 --- a/src/pages/css/loading.css +++ b/src/pages/css/loading.css @@ -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); } - \ No newline at end of file +} diff --git a/src/pages/css/statCard.css b/src/pages/css/statCard.css index 29dc651..1f75850 100644 --- a/src/pages/css/statCard.css +++ b/src/pages/css/statCard.css @@ -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; } - diff --git a/src/pages/css/timeline/activity-timeline.css b/src/pages/css/timeline/activity-timeline.css new file mode 100644 index 0000000..a83bc84 --- /dev/null +++ b/src/pages/css/timeline/activity-timeline.css @@ -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; +} diff --git a/src/routes.jsx b/src/routes.jsx index 8fb7873..5e7788c 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -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: , exact: true, }, + { + path: "/timeline", + element: , + exact: true, + }, { path: "/about", element: ,