mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
33
README.md
33
README.md
@@ -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
|
||||
|
||||
81
backend/migrations/078_refactor_fs_user_stats.js
Normal file
81
backend/migrations/078_refactor_fs_user_stats.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jfstat",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"main": "src/index.jsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
305
public/locales/zh-CN/translation.json
Normal file
305
public/locales/zh-CN/translation.json
Normal 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} 行"
|
||||
}
|
||||
@@ -5,6 +5,10 @@ export const languages = [
|
||||
},
|
||||
{
|
||||
id: "fr-FR",
|
||||
description: "French",
|
||||
description: "Français",
|
||||
},
|
||||
{
|
||||
id: "zh-CN",
|
||||
description: "简体中文",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 : "")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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" />}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user