Merge pull request #280 from CyferShepard/unstable

V1.1.2 Release
This commit is contained in:
Thegan Govender
2024-12-25 09:58:32 +02:00
committed by GitHub
35 changed files with 636 additions and 66 deletions

View File

@@ -24,26 +24,29 @@
## Environmental Variables
| Env | Default | Example | Description |
| ------------------------------- | -------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| POSTGRES_USER `REQUIRED` | `null` | `postgres` | Username that will be used in postgres database |
| POSTGRES_PASSWORD `REQUIRED` | `null` | `postgres` | Password that will be used in postgres database |
| POSTGRES_IP `REQUIRED` | `null` | `jellystat-db` or `192.168.0.5` | Hostname/IP of postgres instance |
| POSTGRES_PORT `REQUIRED` | `null` | `5432` | Port Postgres is running on |
| JWT_SECRET `REQUIRED` | `null` | `my-secret-jwt-key` | JWT Key to be used to encrypt JWT tokens for authentication |
| JS_BASE_URL | `/` | `/` | Base url |
| JS_USER | `null` | `User` | Master Override User in case username or password used during setup is forgotten (Both `JS_USER` and `JS_PASSWORD` required to work) |
| JS_PASSWORD | `null` | `Password` | Master Override Password in case username or password used during setup is forgotten (Both `JS_USER` and `JS_PASSWORD` required to work) |
| POSTGRES_DB | `jfstat` | `jfstat` | Name of postgres database |
| REJECT_SELF_SIGNED_CERTIFICATES | `true` | `false` | Allow or deny self signed SSL certificates |
| JS_GEOLITE_ACCOUNT_ID | `null` | `123456` | maxmind.com user id to be used for Geolocating IP Addresses (Can be found at https://www.maxmind.com/en/accounts/current/edit) |
| JS_GEOLITE_LICENSE_KEY | `null` | `ASDWdaSdawe2sd186` | License key you need to generate on maxmind to use their services |
| Env | Default | Example | Description |
|-------------------------------------|----------|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| POSTGRES_USER `REQUIRED` | `null` | `postgres` | Username that will be used in postgres database |
| POSTGRES_PASSWORD `REQUIRED` | `null` | `postgres` | Password that will be used in postgres database |
| POSTGRES_IP `REQUIRED` | `null` | `jellystat-db` or `192.168.0.5` | Hostname/IP of postgres instance |
| POSTGRES_PORT `REQUIRED` | `null` | `5432` | Port Postgres is running on |
| JWT_SECRET `REQUIRED` | `null` | `my-secret-jwt-key` | JWT Key to be used to encrypt JWT tokens for authentication |
| TZ `REQUIRED` | `null` | `Etc/UTC` | Server timezone (Can be found at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) |
| JS_BASE_URL | `/` | `/` | Base url |
| JS_USER | `null` | `User` | Master Override User in case username or password used during setup is forgotten (Both `JS_USER` and `JS_PASSWORD` required to work) |
| JS_PASSWORD | `null` | `Password` | Master Override Password in case username or password used during setup is forgotten (Both `JS_USER` and `JS_PASSWORD` required to work) |
| POSTGRES_DB | `jfstat` | `jfstat` | Name of postgres database |
| REJECT_SELF_SIGNED_CERTIFICATES | `true` | `false` | Allow or deny self signed SSL certificates |
| JS_GEOLITE_ACCOUNT_ID | `null` | `123456` | maxmind.com user id to be used for Geolocating IP Addresses (Can be found at https://www.maxmind.com/en/accounts/current/edit) |
| JS_GEOLITE_LICENSE_KEY | `null` | `ASDWdaSdawe2sd186` | License key you need to generate on maxmind to use their services |
| MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK | `1` | `10` | The minimum time (in seconds) to include a playback record, which can be used to exclude short playbacks |
## Getting Started with Development
- Clone the project from git
- set your env variables before strating the server (Variable names as per Environmental Variables above).
- Set your env variables before starting the server (Variable names as per [Environmental Variables](#environmental-variables) above).
- Run `npm install` to install necessary packages
- Run `npm run build` to build local files ready to run
- Run `npm run start-server` to only run the backend nodejs server
- Run `npm run start-client` to only run the frontend React UI
- Run `npm run start-app` to run both backend and frontend at the same time

View File

@@ -0,0 +1,81 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_user_stats(integer, text);
CREATE OR REPLACE FUNCTION public.fs_user_stats(
hours integer,
userid text)
RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "UserId" text, "Name" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
count(a.*) AS "Plays",
COALESCE(sum(a."PlaybackDuration"),0) AS total_playback_duration,
u."Id",
u."Name"
FROM
jf_users u
left join jf_playback_activity a
on a."UserId"=u."Id"
AND a."ActivityDateInserted" > NOW() - INTERVAL '1 hour' * hours
WHERE
u."Id" = userid
GROUP BY u."Id", u."Name"
ORDER BY count(a.*) DESC;
END;
$BODY$;
ALTER FUNCTION public.fs_user_stats(integer, text)
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.fs_user_stats(integer, text);
CREATE OR REPLACE FUNCTION public.fs_user_stats(
hours integer,
userid text)
RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "UserId" text, "Name" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
count(*) AS "Plays",
sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration,
jf_playback_activity."UserId",
jf_playback_activity."UserName" AS "Name"
FROM jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" > NOW() - INTERVAL '1 hour' * hours
AND jf_playback_activity."UserId" = userid
GROUP BY jf_playback_activity."UserId", jf_playback_activity."UserName"
ORDER BY count(*) DESC;
END;
$BODY$;
ALTER FUNCTION public.fs_user_stats(integer, text)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -312,6 +312,45 @@ router.post("/setconfig", async (req, res) => {
console.log(error);
}
});
router.post("/setExternalUrl", async (req, res) => {
try {
const { ExternalUrl } = req.body;
if (ExternalUrl === undefined) {
res.status(400);
res.send("ExternalUrl is required for configuration");
return;
}
const config = await new configClass().getConfig();
const validation = await API.validateSettings(ExternalUrl, config.JF_API_KEY);
if (validation.isValid === false) {
res.status(validation.status);
res.send(validation);
return;
}
try {
const settings = config.settings || {};
settings.EXTERNAL_URL = ExternalUrl;
const query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
config.settings = settings;
res.send(config);
} catch (error) {
res.status(503);
res.send({ error: "Error: " + error });
}
} catch (error) {
console.log(error);
res.status(503);
res.send({ error: "Error: " + error });
}
});
router.post("/setPreferredAdmin", async (req, res) => {
try {
const { userid, username } = req.body;

View File

@@ -26,6 +26,9 @@ router.get("/web/assets/img/devices/", async (req, res) => {
})
.then((response) => {
res.set("Content-Type", "image/svg+xml");
if (config.IS_JELLYFIN == false) {
res.set("Content-Type", "image/png");
}
res.status(200);
if (response.headers["content-type"].startsWith("image/")) {

View File

@@ -35,8 +35,7 @@ const { setupWebSocketServer } = require("./ws");
const writeEnvVariables = require("./classes/env");
process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres";
process.env.POSTGRES_ROLE =
process.env.POSTGRES_ROLE ?? process.env.POSTGRES_USER;
process.env.POSTGRES_ROLE = process.env.POSTGRES_ROLE ?? process.env.POSTGRES_USER;
const app = express();
const db = knex(knexConfig.development);
@@ -110,7 +109,7 @@ app.use((req, res, next) => {
return res.redirect(BASE_NAME);
}
// Ignore requests containing 'socket.io'
if (req.url.includes("socket.io") || req.url.includes("swagger")) {
if (req.url.includes("socket.io") || req.url.includes("swagger") || req.url.startsWith("/backup")) {
return next();
}
@@ -206,9 +205,7 @@ async function authenticate(req, res, next) {
}
} else {
if (apiKey) {
const keysjson = await dbInstance
.query('SELECT api_keys FROM app_config where "ID"=1')
.then((res) => res.rows[0].api_keys);
const keysjson = await dbInstance.query('SELECT api_keys FROM app_config where "ID"=1').then((res) => res.rows[0].api_keys);
if (!keysjson || Object.keys(keysjson).length === 0) {
return res.status(404).json({ message: "No API keys configured" });

View File

@@ -7,6 +7,7 @@ const configClass = require("../classes/config");
const API = require("../classes/api-loader");
const { sendUpdate } = require("../ws");
const { isNumber } = require("@mui/x-data-grid/internals");
const MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK = process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK ? Number(process.env.MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK) : 1;
async function getSessionsInWatchDog(SessionData, WatchdogData) {
let existingData = await WatchdogData.filter((wdData) => {
@@ -180,7 +181,7 @@ async function ActivityMonitor(interval) {
let ExistingDataToUpdate = [];
//for each item in playbackToInsert, check if it exists in the recent playback activity and update accordingly. insert new row if updating existing exceeds the runtime
if (playbackToInsert.length > 0 && ExistingRecords.length > 0) {
if (playbackToInsert.length >0 && ExistingRecords.length >0) {
ExistingDataToUpdate = playbackToInsert.filter((playbackData) => {
const existingrow = ExistingRecords.find((existing) => {
let newDurationWithingRunTime = true;
@@ -211,7 +212,7 @@ async function ActivityMonitor(interval) {
//remove items from playbackToInsert that already exists in the recent playback activity so it doesnt duplicate or where PlaybackDuration===0
playbackToInsert = playbackToInsert.filter(
(pb) =>
pb.PlaybackDuration > 0 &&
pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK &&
!ExistingRecords.some(
(er) => er.NowPlayingItemId === pb.NowPlayingItemId && er.EpisodeId === pb.EpisodeId && er.UserId === pb.UserId
)
@@ -219,7 +220,7 @@ async function ActivityMonitor(interval) {
//remove items where PlaybackDuration===0
ExistingDataToUpdate = ExistingDataToUpdate.filter((pb) => pb.PlaybackDuration > 0);
ExistingDataToUpdate = ExistingDataToUpdate.filter((pb) => pb.PlaybackDuration >= MINIMUM_SECONDS_TO_INCLUDE_PLAYBACK);
if (toDeleteIds.length > 0) {
await db.deleteBulk("jf_activity_watchdog", toDeleteIds, "ActivityId");

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jfstat",
"version": "1.1.1",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jfstat",
"version": "1.1.1",
"version": "1.1.2",
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.1",
"version": "1.1.2",
"private": true,
"main": "src/index.jsx",
"scripts": {

View File

@@ -177,6 +177,7 @@
"SIZE": "Size",
"JELLYFIN_URL": "Jellyfin URL",
"EMBY_URL": "Emby URL",
"EXTERNAL_URL": "External URL",
"API_KEY": "API Key",
"API_KEYS": "API Keys",
"KEY_NAME": "Key Name",

View File

@@ -177,6 +177,7 @@
"SIZE": "Taille",
"JELLYFIN_URL": "URL du serveur Jellyfin",
"EMBY_URL": "URL du serveur Emby",
"EXTERNAL_URL": "External URL",
"API_KEY": "Clé API",
"API_KEYS": "Clés API",
"KEY_NAME": "Nom de la clé",

View File

@@ -0,0 +1,305 @@
{
"JELLYSTAT": "Jellystat",
"MENU_TABS": {
"HOME": "主页",
"LIBRARIES": "媒体库",
"USERS": "用户",
"ACTIVITY": "活动",
"STATISTICS": "统计",
"SETTINGS": "设置",
"ABOUT": "关于",
"LOGOUT": "注销"
},
"HOME_PAGE": {
"SESSIONS": "会话",
"RECENTLY_ADDED": "最近添加",
"WATCH_STATISTIC": "观看统计",
"LIBRARY_OVERVIEW": "媒体库总览"
},
"SESSIONS": {
"NO_SESSIONS": "未找到活动会话"
},
"STAT_CARDS": {
"MOST_VIEWED_MOVIES": "最多观看电影",
"MOST_POPULAR_MOVIES": "最热门电影",
"MOST_VIEWED_SERIES": "最多观看剧集",
"MOST_POPULAR_SERIES": "最热门剧集",
"MOST_LISTENED_MUSIC": "最多播放音乐",
"MOST_POPULAR_MUSIC": "最热门音乐",
"MOST_VIEWED_LIBRARIES": "最多播放媒体库",
"MOST_USED_CLIENTS": "最多使用客户端",
"MOST_ACTIVE_USERS": "最活跃用户",
"CONCURRENT_STREAMS": "并发视频流"
},
"LIBRARY_OVERVIEW": {
"MOVIE_LIBRARIES": "电影媒体库",
"SHOW_LIBRARIES": "剧集媒体库",
"MUSIC_LIBRARIES": "音乐媒体库",
"MIXED_LIBRARIES": "混合内容媒体库"
},
"LIBRARY_CARD": {
"LIBRARY": "媒体库",
"TOTAL_TIME": "总时长",
"TOTAL_FILES": "总文件",
"LIBRARY_SIZE": "媒体库大小",
"TOTAL_PLAYBACK": "总播放时间",
"LAST_PLAYED": "最后播放",
"LAST_ACTIVITY": "最后活动",
"TRACKED": "跟踪"
},
"GLOBAL_STATS": {
"LAST_24_HRS": "过去 24 小时",
"LAST_7_DAYS": "过去 7 天",
"LAST_30_DAYS": "过去 30 天",
"LAST_180_DAYS": "过去 180 天",
"LAST_365_DAYS": "过去 365 天",
"ALL_TIME": "所有时间",
"ITEM_STATS": "项目状态"
},
"ITEM_INFO": {
"FILE_PATH": "文件路径",
"FILE_SIZE": "文件大小",
"RUNTIME": "时长",
"AVERAGE_RUNTIME": "平均时长",
"OPEN_IN_JELLYFIN": "在 Jellyfin 中打开",
"ARCHIVED_DATA_OPTIONS": "归档数据选项",
"PURGE": "清除",
"CONFIRM_ACTION": "确认操作",
"CONFIRM_ACTION_MESSAGE": "确定要清除该项目",
"CONFIRM_ACTION_MESSAGE_2": "以及 相关播放活动"
},
"LIBRARY_INFO": {
"LIBRARY_STATS": "媒体库状态",
"LIBRARY_ACTIVITY": "媒体库活动"
},
"TAB_CONTROLS": {
"OVERVIEW": "总览",
"ACTIVITY": "活动",
"OPTIONS": "选项"
},
"ITEM_ACTIVITY": "活动项目",
"ACTIVITY_TABLE": {
"MODAL": {
"HEADER": "视频流信息"
},
"IP_ADDRESS": "IP 地址",
"CLIENT": "客户端",
"DEVICE": "设备",
"PLAYBACK_DURATION": "播放时长",
"TOTAL_PLAYBACK": "总播放时长",
"EXPAND": "展开",
"COLLAPSE": "折叠",
"SORT_BY": "排序条件:",
"ASCENDING": "升序",
"DESCENDING": "降序",
"CLEAR_SORT": "清除排序",
"CLEAR_FILTER": "清除筛选",
"FILTER_BY": "筛选条件:",
"COLUMN_ACTIONS": "列操作",
"TOGGLE_SELECT_ROW": "切换选择行",
"TOGGLE_SELECT_ALL": "切换全选",
"MIN": "最小",
"MAX": "最大"
},
"TABLE_NAV_BUTTONS": {
"FIRST": "首页",
"LAST": "尾页",
"NEXT": "下一页",
"PREVIOUS": "上一页"
},
"PURGE_OPTIONS": {
"PURGE_CACHE": "清除已缓存项目",
"PURGE_CACHE_WITH_ACTIVITY": "清除已缓存项目及播放活动",
"PURGE_LIBRARY_CACHE": "清除已缓存媒体库及项目",
"PURGE_LIBRARY_CACHE_WITH_ACTIVITY": "清除已缓存媒体库、项目及活动",
"PURGE_LIBRARY_ITEMS_CACHE": "仅清除已缓存媒体库项目",
"PURGE_LIBRARY_ITEMS_CACHE_WITH_ACTIVITY": "仅清除已缓存媒体库项目和活动",
"PURGE_ACTIVITY": "确定要清除已选中的播放活动?"
},
"ERROR_MESSAGES": {
"FETCH_THIS_ITEM": "从 Jellyfin 获取此项目",
"NO_ACTIVITY": "未找到活动",
"NEVER": "无",
"N/A": "未知",
"NO_STATS": "无可显示状态",
"NO_BACKUPS": "未找到备份",
"NO_LOGS": "未找到日志",
"NO_API_KEYS": "未找到密钥",
"NETWORK_ERROR": "无法连接到 Jellyfin 服务器",
"INVALID_LOGIN": "用户名或密码无效",
"INVALID_URL": "错误 {STATUS}: 未找到请求的 URL",
"UNAUTHORIZED": "错误 {STATUS}: 未授权",
"PASSWORD_LENGTH": "密码必须至少为 6 个字符",
"USERNAME_REQUIRED": "用户名是必填项"
},
"SHOW_ARCHIVED_LIBRARIES": "显示已归档媒体库",
"HIDE_ARCHIVED_LIBRARIES": "隐藏已归档媒体库",
"UNITS": {
"YEAR": "年",
"YEARS": "年",
"MONTH": "月",
"MONTHS": "月",
"DAY": "天",
"DAYS": "天",
"HOUR": "时",
"HOURS": "时",
"MINUTE": "分",
"MINUTES": "分",
"SECOND": "秒",
"SECONDS": "秒",
"PLAYS": "播放",
"ITEMS": "项目",
"STREAMS": "视频流"
},
"USERS_PAGE": {
"ALL_USERS": "全部用户",
"LAST_CLIENT": "最后使用客户端",
"LAST_SEEN": "最后活动",
"AGO": "前",
"AGO_ALT": "",
"USER_STATS": "用户状态",
"USER_ACTIVITY": "用户活动"
},
"STAT_PAGE": {
"STATISTICS": "统计",
"DAILY_PLAY_PER_LIBRARY": "媒体库每日播放次数",
"PLAY_COUNT_BY": "播放计数:"
},
"SETTINGS_PAGE": {
"SETTINGS": "设置",
"LANGUAGE": "语言",
"SELECT_AN_ADMIN": "选择首选管理员",
"LIBRARY_SETTINGS": "媒体库设置",
"BACKUP": "备份",
"BACKUPS": "备份",
"CHOOSE_FILE": "选择文件",
"LOGS": "日志",
"SIZE": "大小",
"JELLYFIN_URL": "Jellyfin URL",
"EMBY_URL": "Emby URL",
"EXTERNAL_URL": "External URL",
"API_KEY": "API 密钥",
"API_KEYS": "API 密钥",
"KEY_NAME": "密钥名称",
"KEY": "密钥",
"NAME": "名称",
"ADD_KEY": "添加密钥",
"DURATION": "用时",
"EXECUTION_TYPE": "执行类型",
"RESULTS": "结果",
"SELECT_ADMIN": "选择首选管理员账户",
"HOUR_FORMAT": "时间格式",
"HOUR_FORMAT_12": "12 小时制",
"HOUR_FORMAT_24": "24 小时制",
"SECURITY": "安全",
"CURRENT_PASSWORD": "当前密码",
"NEW_PASSWORD": "新密码",
"UPDATE": "更新",
"REQUIRE_LOGIN": "需要登录",
"TASK": "任务",
"TASKS": "任务",
"INTERVAL": "间隔",
"INTERVALS": {
"15_MIN": "15 分钟",
"30_MIN": "30 分钟",
"1_HOUR": "1 小时",
"12_HOURS": "12 小时",
"1_DAY": "1 天",
"1_WEEK": "1 周"
},
"SELECT_LIBRARIES_TO_IMPORT": "选择要导入的媒体库",
"SELECT_LIBRARIES_TO_IMPORT_TOOLTIP": "这些媒体库中的项目活动仍然被追踪——即使它们没有被导入",
"DATE_ADDED": "添加日期"
},
"TASK_TYPE": {
"JOB": "工作",
"IMPORT": "导入"
},
"TASK_DESCRIPTION": {
"PartialJellyfinSync": "最近添加项目同步",
"JellyfinSync": "与 Jellyfin 完全同步",
"Jellyfin_Playback_Reporting_Plugin_Sync": "导入 Playback Reporting 插件数据",
"Backup": "备份 Jellystat"
},
"ABOUT_PAGE": {
"ABOUT_JELLYSTAT": "关于 Jellystat",
"VERSION": "版本",
"UPDATE_AVAILABLE": "可用更新",
"GITHUB": "Github",
"Backup": "备份 Jellystat"
},
"SEARCH": "搜索",
"TOTAL": "Total",
"LAST": "过去",
"SERIES": "剧集",
"SEASON": "季",
"SEASONS": "季",
"EPISODE": "集",
"EPISODES": "集",
"MOVIES": "电影",
"MUSIC": "音乐",
"SONGS": "音乐",
"FILES": "文件",
"LIBRARIES": "媒体库",
"USER": "用户",
"USERS": "用户",
"TYPE": "类型",
"NEW_VERSION_AVAILABLE": "新版本可用",
"ARCHIVED": "已归档",
"NOT_ARCHIVED": "未归档",
"ALL": "全部",
"CLOSE": "关闭",
"TOTAL_PLAYS": "总播放次数",
"TITLE": "标题",
"VIEWS": "观看",
"WATCH_TIME": "观看时长",
"LAST_WATCHED": "最后观看",
"MEDIA": "媒体",
"SAVE": "保存",
"YES": "是",
"NO": "否",
"FILE_NAME": "文件名",
"DATE": "日期",
"START": "开始",
"DOWNLOAD": "下载",
"RESTORE": "恢复",
"ACTIONS": "操作",
"DELETE": "删除",
"BITRATE": "比特率",
"CONTAINER": "容器",
"VIDEO": "视频",
"CODEC": "编码",
"WIDTH": "宽度",
"HEIGHT": "高度",
"FRAMERATE": "帧率",
"DYNAMIC_RANGE": "动态范围",
"ASPECT_RATIO": "纵横比",
"AUDIO": "音频",
"CHANNELS": "声道",
"LANGUAGE": "语言",
"STREAM_DETAILS": "视频流信息",
"SOURCE_DETAILS": "媒体源信息",
"DIRECT": "直接播放",
"TRANSCODE": "转码",
"USERNAME": "用户名",
"PASSWORD": "密码",
"LOGIN": "登录",
"FT_SETUP_PROGRESS": "初始设置步骤 {STEP} / {TOTAL}",
"VALIDATING": "正在验证",
"SAVE_JELLYFIN_DETAILS": "保存 Jellyfin 详细信息",
"SETTINGS_SAVED": "设置已保存",
"SUCCESS": "成功",
"PASSWORD_UPDATE_SUCCESS": "密码更新成功",
"CREATE_USER": "创建用户",
"GEOLOCATION_INFO_FOR": "地理位置信息",
"CITY": "城市",
"REGION": "地区",
"COUNTRY": "国家",
"ORGANIZATION": "组织",
"ISP": "网络服务提供商",
"LATITUDE": "纬度",
"LONGITUDE": "经度",
"TIMEZONE": "时区",
"POSTCODE": "邮编",
"X_ROWS_SELECTED": "已选中 {ROWS} 行"
}

View File

@@ -5,6 +5,10 @@ export const languages = [
},
{
id: "fr-FR",
description: "French",
description: "Français",
},
{
id: "zh-CN",
description: "简体中文",
},
];

View File

@@ -157,7 +157,9 @@ function Activity() {
filteredData = filteredData.filter(
(item) =>
(libraryFilters.includes(item.ParentId) || item.ParentId == null) &&
(streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter)
(streamTypeFilter == "All"
? true
: item.PlayMethod === (config?.IS_JELLYFIN ? streamTypeFilter : streamTypeFilter.replace("Play", "Stream")))
);
return (
@@ -183,7 +185,7 @@ function Activity() {
<Trans i18nKey="MENU_TABS.ACTIVITY" />
</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-column flex-md-row" style={{ whiteSpace: "nowrap" }}>
<Button onClick={() => setShowLibraryFilters(true)} className="ms-md-3 mb-3 my-md-3">
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Button>
@@ -211,7 +213,7 @@ function Activity() {
</FormSelect>
</div>
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="UNITS.ITEMS" />
</div>

View File

@@ -73,7 +73,7 @@ function SelectionCard(props) {
<Row className="space-between-end card-row">
<Col className="card-label"><Trans i18nKey={"TYPE"}/></Col>
<Col className="text-end">{props.data.CollectionType==='tvshows' ? 'Series' : props.data.CollectionType==='movies'? "Movies" : props.data.CollectionType==='music'? "Music" : 'Mixed'}</Col>
<Col className="text-end">{props.data.CollectionType==='tvshows' ? <Trans i18nKey="SERIES" /> : props.data.CollectionType==='movies'? <Trans i18nKey="MOVIES" /> : props.data.CollectionType==='music'? <Trans i18nKey="MUSIC" /> : 'Mixed'}</Col>
</Row>
<Row className="space-between-end card-row">

View File

@@ -30,11 +30,11 @@ function formatTotalWatchTime(seconds) {
let timeString = "";
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? "hr" : "hrs"} `;
timeString += `${hours} ${hours === 1 ? i18next.t("UNITS.HOUR").toLowerCase() : i18next.t("UNITS.HOURS").toLowerCase()} `;
}
if (minutes > 0) {
timeString += `${minutes} ${minutes === 1 ? "min" : "mins"} `;
timeString += `${minutes} ${minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase()} `;
}
if (remainingSeconds > 0) {

View File

@@ -257,7 +257,7 @@ function ItemInfo() {
<Link
className="px-2"
to={
config.hostUrl +
(config.settings?.EXTERNAL_URL ?? config.hostUrl) +
`/web/index.html#!/${config.IS_JELLYFIN ? "details" : "item"}?id=` +
(data.EpisodeId || data.Id) +
(config.settings.ServerID ? "&serverId=" + config.settings.ServerID : "")

View File

@@ -4,6 +4,7 @@ import ActivityTable from "../activity/activity-table";
import { Trans } from "react-i18next";
import { FormControl, FormSelect } from "react-bootstrap";
import i18next from "i18next";
import Config from "../../../lib/config.jsx";
function ItemActivity(props) {
const [data, setData] = useState();
@@ -11,8 +12,22 @@ function ItemActivity(props) {
const [itemCount, setItemCount] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const [streamTypeFilter, setStreamTypeFilter] = useState("All");
const [config, setConfig] = useState();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (!config) {
fetchConfig();
}
const fetchData = async () => {
try {
const itemData = await axios.post(
@@ -56,7 +71,11 @@ function ItemActivity(props) {
);
}
filteredData = filteredData.filter((item) => (streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter));
filteredData = filteredData.filter((item) =>
streamTypeFilter == "All"
? true
: item.PlayMethod === (config?.IS_JELLYFIN ? streamTypeFilter : streamTypeFilter.replace("Play", "Stream"))
);
return (
<div className="Activity">
@@ -65,7 +84,7 @@ function ItemActivity(props) {
<Trans i18nKey="ITEM_ACTIVITY" />
</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-column flex-md-row" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3">
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="TYPE" />
@@ -89,7 +108,7 @@ function ItemActivity(props) {
</FormSelect>
</div>
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1 my-md-3">
<Trans i18nKey="UNITS.ITEMS" />
</div>

View File

@@ -71,7 +71,7 @@ function MoreItems(props) {
<div className="last-played-container">
{data.sort((a,b) => a.IndexNumber-b.IndexNumber).map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id}/>
<MoreItemCards data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} key={item.Id}/>
))}
</div>

View File

@@ -62,7 +62,7 @@ function LibraryLastWatched(props) {
<h1 className="my-3"><Trans i18nKey="LAST_WATCHED"/></h1>
<div className="last-played-container">
{data.map((item) => (
<LastWatchedCard data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
<LastWatchedCard data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>
))}
</div>

View File

@@ -5,6 +5,7 @@ import ActivityTable from "../activity/activity-table";
import { Trans } from "react-i18next";
import { FormControl, FormSelect } from "react-bootstrap";
import i18next from "i18next";
import Config from "../../../lib/config.jsx";
function LibraryActivity(props) {
const [data, setData] = useState();
@@ -14,6 +15,7 @@ function LibraryActivity(props) {
const [streamTypeFilter, setStreamTypeFilter] = useState(
localStorage.getItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter") ?? "All"
);
const [config, setConfig] = useState();
function setItemLimit(limit) {
setItemCount(limit);
@@ -26,6 +28,18 @@ function LibraryActivity(props) {
}
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (!config) {
fetchConfig();
}
const fetchData = async () => {
try {
const libraryrData = await axios.post(
@@ -68,7 +82,11 @@ function LibraryActivity(props) {
);
}
filteredData = filteredData.filter((item) => (streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter));
filteredData = filteredData.filter((item) =>
streamTypeFilter == "All"
? true
: item.PlayMethod === (config?.IS_JELLYFIN ? streamTypeFilter : streamTypeFilter.replace("Play", "Stream"))
);
return (
<div className="Activity">
@@ -78,7 +96,7 @@ function LibraryActivity(props) {
</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="TYPE" />
</div>
@@ -101,7 +119,7 @@ function LibraryActivity(props) {
</FormSelect>
</div>
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1 my-md-3">
<Trans i18nKey="UNITS.ITEMS" />
</div>

View File

@@ -157,7 +157,7 @@ function LibraryCard(props) {
</Link>
<Card.Body className="library-card-details">
<Card.Body className="library-card-details" style={{whiteSpace: "nowrap"}}>
<Row className="space-between-end card-row">
<Col className="card-label"><Trans i18nKey="LIBRARY_CARD.LIBRARY" /></Col>
<Col className="text-end">{props.data.Name}</Col>
@@ -195,7 +195,7 @@ function LibraryCard(props) {
<Row className="space-between-end card-row">
<Col className="card-label"><Trans i18nKey="LIBRARY_CARD.LAST_PLAYED" /></Col>
<Col className="text-end">{props.data.ItemName || 'n/a'}</Col>
<Col className="text-end">{props.data.ItemName || `${i18next.t("ERROR_MESSAGES.N/A")}`}</Col>
</Row>
<Row className="space-between-end card-row">

View File

@@ -12,6 +12,7 @@ import "../../css/width_breakpoint_css.css";
import "../../css/radius_breakpoint_css.css";
import { Trans } from "react-i18next";
import Loading from "../general/loading";
import i18next from "i18next";
function LibraryItems(props) {
const [data, setData] = useState();
@@ -169,7 +170,7 @@ function LibraryItems(props) {
</div>
<FormControl
type="text"
placeholder="Search"
placeholder={i18next.t("SEARCH")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ms-md-3 mt-2 mb-2 my-md-3 w-sm-100 w-md-75"
@@ -211,7 +212,7 @@ function LibraryItems(props) {
}
})
.map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id + item.SeasonNumber + item.EpisodeNumber} />
<MoreItemCards data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} key={item.Id + item.SeasonNumber + item.EpisodeNumber} />
))}
</div>
</div>

View File

@@ -26,9 +26,12 @@ export default function SettingsConfig() {
const [selectedLanguage, setSelectedLanguage] = useState(localStorage.getItem("i18nextLng") ?? "en-US");
const [showKey, setKeyState] = useState(false);
const [formValues, setFormValues] = useState({});
const [formValuesExternal, setFormValuesExternal] = useState({});
const [isSubmitted, setisSubmitted] = useState("");
const [isSubmittedExternal, setisSubmittedExternal] = useState("");
const [loadSate, setloadSate] = useState("Loading");
const [submissionMessage, setsubmissionMessage] = useState("");
const [submissionMessageExternal, setsubmissionMessageExternal] = useState("");
const token = localStorage.getItem("token");
const [twelve_hr, set12hr] = useState(localStorage.getItem("12hr") === "true");
@@ -45,6 +48,7 @@ export default function SettingsConfig() {
Config.getConfig()
.then((config) => {
setFormValues({ JF_HOST: config.hostUrl });
setFormValuesExternal({ ExternalUrl: config.settings?.EXTERNAL_URL });
setConfig(config);
setSelectedAdmin(config.settings?.preferred_admin);
setloadSate("Loaded");
@@ -94,13 +98,42 @@ export default function SettingsConfig() {
setisSubmitted("Failed");
setsubmissionMessage(`Error Updating Configuration: ${errorMessage}`);
});
Config.setConfig();
Config.setConfig();
}
async function handleFormSubmitExternal(event) {
event.preventDefault();
setisSubmittedExternal("");
axios
.post("/api/setExternalUrl/", formValuesExternal, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
console.log("Config updated successfully:", response.data);
setisSubmittedExternal("Success");
setsubmissionMessageExternal("Successfully updated configuration");
})
.catch((error) => {
let errorMessage = error.response.data.errorMessage;
console.log("Error updating config:", errorMessage);
setisSubmittedExternal("Failed");
setsubmissionMessageExternal(`Error Updating Configuration: ${errorMessage}`);
});
Config.setConfig();
}
function handleFormChange(event) {
setFormValues({ ...formValues, [event.target.name]: event.target.value });
}
function handleFormChangeExternal(event) {
setFormValuesExternal({ ...formValuesExternal, [event.target.name]: event.target.value });
}
function updateAdmin(event) {
const username = event.target.textContent;
const userid = event.target.getAttribute("value");
@@ -130,7 +163,7 @@ export default function SettingsConfig() {
setisSubmitted("Failed");
setsubmissionMessage("Error Updating Configuration: ", error);
});
Config.setConfig();
Config.setConfig();
}
function updateLanguage(event) {
@@ -160,8 +193,11 @@ export default function SettingsConfig() {
<Form onSubmit={handleFormSubmit} className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">
{config.settings?.IS_JELLYFIN ? <Trans i18nKey={"SETTINGS_PAGE.JELLYFIN_URL"} /> : <Trans i18nKey={"SETTINGS_PAGE.EMBY_URL"} /> }
{config?.IS_JELLYFIN ? (
<Trans i18nKey={"SETTINGS_PAGE.JELLYFIN_URL"} />
) : (
<Trans i18nKey={"SETTINGS_PAGE.EMBY_URL"} />
)}
</Form.Label>
<Col sm="10">
<Form.Control
@@ -208,6 +244,39 @@ export default function SettingsConfig() {
</Button>
</div>
</Form>
<Form onSubmit={handleFormSubmitExternal} className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">
<Trans i18nKey={"SETTINGS_PAGE.EXTERNAL_URL"} />
</Form.Label>
<Col sm="10">
<Form.Control
id="ExternalUrl"
name="ExternalUrl"
value={formValuesExternal.ExternalUrl || ""}
onChange={handleFormChangeExternal}
placeholder="http://example.jellyfin.server"
/>
</Col>
</Form.Group>
{isSubmittedExternal !== "" ? (
isSubmittedExternal === "Failed" ? (
<Alert variant="danger">{submissionMessageExternal}</Alert>
) : (
<Alert variant="success">{submissionMessageExternal}</Alert>
)
) : (
<></>
)}
<div className="d-flex flex-column flex-md-row justify-content-end align-items-md-center">
<Button variant="outline-success" type="submit">
<Trans i18nKey={"SETTINGS_PAGE.UPDATE"} />
</Button>
</div>
</Form>
<Form className="settings-form">
<Form.Group as={Row} className="mb-3">
<Form.Label column className="">

View File

@@ -76,7 +76,7 @@ function MPMovies(props) {
}
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_MOVIES" />} units={<Trans i18nKey="USERS" />}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_MOVIES" />} units={<Trans i18nKey="USERS" />}/>
);
}

View File

@@ -70,7 +70,7 @@ function MPMusic(props) {
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_MUSIC" />} units={<Trans i18nKey="USERS" />} isAudio={true}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_MUSIC" />} units={<Trans i18nKey="USERS" />} isAudio={true}/>
);
}

View File

@@ -67,7 +67,7 @@ function MPSeries(props) {
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_SERIES" />} units={<Trans i18nKey="USERS" />}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_POPULAR_SERIES" />} units={<Trans i18nKey="USERS" />}/>
);
}

View File

@@ -74,7 +74,7 @@ function MVMusic(props) {
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_VIEWED_MOVIES" />} units={<Trans i18nKey="UNITS.PLAYS" />}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_VIEWED_MOVIES" />} units={<Trans i18nKey="UNITS.PLAYS" />}/>
);
}

View File

@@ -69,7 +69,7 @@ function MVMovies(props) {
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_LISTENED_MUSIC" />} units={<Trans i18nKey="UNITS.PLAYS" />} isAudio={true}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_LISTENED_MUSIC" />} units={<Trans i18nKey="UNITS.PLAYS" />} isAudio={true}/>
);
}

View File

@@ -70,7 +70,7 @@ function MVSeries(props) {
return (
<ItemStatComponent base_url={config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_VIEWED_SERIES" />} units={<Trans i18nKey="UNITS.PLAYS" />}/>
<ItemStatComponent base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} data={data} heading={<Trans i18nKey="STAT_CARDS.MOST_VIEWED_SERIES" />} units={<Trans i18nKey="UNITS.PLAYS" />}/>
);
}

View File

@@ -77,7 +77,7 @@ function PlaybackMethodStats(props) {
return (
<ItemStatComponent
base_url={config.hostUrl}
base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl}
data={data.map((stream) =>
stream.Name == "DirectPlay" ? { ...stream, Name: translations.DirectPlay } : { ...stream, Name: translations.Transocde }
)}

View File

@@ -67,7 +67,7 @@ function LastPlayed(props) {
<div className="last-played-container">
{data.map((item, index) => (
<ErrorBoundary key={item.Id + item.EpisodeNumber + index}>
<LastWatchedCard data={item} base_url={config.hostUrl} />
<LastWatchedCard data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} />
</ErrorBoundary>
))}
</div>

View File

@@ -8,6 +8,7 @@ import "../../css/radius_breakpoint_css.css";
import "../../css/users/user-activity.css";
import i18next from "i18next";
import LibraryFilterModal from "../library/library-filter-modal";
import Config from "../../../lib/config.jsx";
function UserActivity(props) {
const [data, setData] = useState();
@@ -18,6 +19,25 @@ function UserActivity(props) {
const [libraryFilters, setLibraryFilters] = useState([]);
const [libraries, setLibraries] = useState([]);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
const [config, setConfig] = useState();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
if (!config) {
fetchConfig();
}
const intervalId = setInterval(config, 60000 * 5);
return () => clearInterval(intervalId);
}, [config]);
const handleLibraryFilter = (selectedOptions) => {
setLibraryFilters(selectedOptions);
@@ -101,7 +121,9 @@ function UserActivity(props) {
filteredData = filteredData.filter(
(item) =>
(libraryFilters.includes(item.ParentId) || item.ParentId == null) &&
(streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter)
(streamTypeFilter == "All"
? true
: item.PlayMethod === (config?.IS_JELLYFIN ? streamTypeFilter : streamTypeFilter.replace("Play", "Stream")))
);
return (
<div className="Activity">
@@ -126,7 +148,7 @@ function UserActivity(props) {
<Trans i18nKey="ITEM_ACTIVITY" />
</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-column flex-md-row" style={{ whiteSpace: "nowrap" }}>
<Button onClick={() => setShowLibraryFilters(true)} className="ms-md-3 mb-3 my-md-3">
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Button>
@@ -154,7 +176,7 @@ function UserActivity(props) {
</FormSelect>
</div>
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3">
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 ms-md-3" style={{ whiteSpace: "nowrap" }}>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1 my-md-3">
<Trans i18nKey="UNITS.ITEMS" />
</div>

View File

@@ -107,7 +107,11 @@ function Libraries() {
.sort((a, b) => a.Name - b.Name)
.map((item) => (
<ErrorBoundary key={item.Id}>
<LibraryCard data={item} metadata={metadata.find((data) => data.Id === item.Id)} base_url={config.hostUrl} />
<LibraryCard
data={item}
metadata={metadata.find((data) => data.Id === item.Id)}
base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl}
/>
</ErrorBoundary>
))}
</div>

View File

@@ -75,7 +75,7 @@ function LibrarySelector() {
{data &&
data.map((item) => (
<ErrorBoundary key={item.Id}>
<SelectionCard data={item} base_url={config.hostUrl} />
<SelectionCard data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} />
</ErrorBoundary>
))}
</div>

View File

@@ -419,7 +419,7 @@ function Users() {
<Trans i18nKey="USERS_PAGE.ALL_USERS" />
</h1>
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-column flex-md-row" style={{whiteSpace: "nowrap"}}>
<div className="d-flex flex-row w-100">
<div className="d-flex flex-col my-md-3 rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="UNITS.ITEMS" />
@@ -454,7 +454,7 @@ function Users() {
<EnhancedTableHead order={order} orderBy={orderBy} onRequestSort={handleRequestSort} rowCount={rowsPerPage} />
<TableBody>
{filteredData.map((row) => (
<Row key={row.UserId} data={row} updateTrackedState={updateTrackedState} hostUrl={config.hostUrl} />
<Row key={row.UserId} data={row} updateTrackedState={updateTrackedState} hostUrl={config.settings?.EXTERNAL_URL ?? config.hostUrl} />
))}
{data.length === 0 ? (
<tr>