Merge pull request #269 from CyferShepard/unstable

V 1.1.1 Release
This commit is contained in:
Thegan Govender
2024-11-24 22:01:10 +02:00
committed by GitHub
154 changed files with 6260 additions and 2666 deletions

View File

@@ -46,3 +46,5 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
cache-from: type=gha
cache-to: type=gha,mode=max

58
.vscode/launch.json vendored
View File

@@ -1,27 +1,33 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://10.0.0.20:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: start",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
}
,
{
"type": "node-terminal",
"name": "Run Script: start-server",
"request": "launch",
"command": "npm run start-server",
"cwd": "${workspaceFolder}"
}
]
}
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://10.0.0.20:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: start",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: start client",
"request": "launch",
"command": "npm run start-client",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"name": "Run Script: start-server",
"request": "launch",
"command": "npm run start-server",
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -16,11 +16,23 @@ RUN npm run build
# Stage 2: Create the production image
FROM node:slim
RUN apt-get update && \
apt-get install -yqq --no-install-recommends wget && \
apt-get autoremove && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app .
COPY --chmod=755 entry.sh /entry.sh
HEALTHCHECK --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/auth/isconfigured" ]
EXPOSE 3000
CMD ["/entry.sh"]

View File

@@ -0,0 +1,13 @@
const JellyfinAPI = require("./jellyfin-api");
const EmbyAPI = require("./emby-api");
function API() {
const USE_EMBY_API = (process.env.IS_EMBY_API || "false").toLowerCase() === "true";
if (USE_EMBY_API) {
return new EmbyAPI();
} else {
return new JellyfinAPI();
}
}
module.exports = API();

161
backend/classes/backup.js Normal file
View File

@@ -0,0 +1,161 @@
const { Pool } = require("pg");
const fs = require("fs");
const path = require("path");
const configClass = require("./config");
const moment = require("moment");
const Logging = require("./logging");
const taskstate = require("../logging/taskstate");
const { tables } = require("../global/backup_tables");
// Database connection parameters
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";
function checkFolderWritePermission(folderPath) {
try {
const testFile = `${folderPath}/.writableTest`;
fs.writeFileSync(testFile, "");
fs.unlinkSync(testFile);
return true;
} catch (error) {
return false;
}
}
// Backup function
async function backup(refLog) {
const config = await new configClass().getConfig();
if (config.error) {
refLog.logData.push({ color: "red", Message: "Backup Failed: Failed to get config" });
refLog.logData.push({ color: "red", Message: "Backup Failed with errors" });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
return;
}
refLog.logData.push({ color: "lawngreen", Message: "Starting Backup" });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
host: postgresIp,
port: postgresPort,
database: postgresDatabase,
});
// Get data from each table and append it to the backup file
try {
let now = moment();
const backuppath = "./" + backupfolder;
if (!fs.existsSync(backuppath)) {
fs.mkdirSync(backuppath);
console.log("Directory created successfully!");
}
if (!checkFolderWritePermission(backuppath)) {
console.error("No write permissions for the folder:", backuppath);
refLog.logData.push({ color: "red", Message: "Backup Failed: No write permissions for the folder: " + backuppath });
refLog.logData.push({ color: "red", Message: "Backup Failed with errors" });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
await pool.end();
return;
}
const ExcludedTables = config.settings?.ExcludedTables || [];
let filteredTables = tables.filter((table) => !ExcludedTables.includes(table.value));
if (filteredTables.length === 0) {
refLog.logData.push({ color: "red", Message: "Backup Failed: No tables to backup" });
refLog.logData.push({ color: "red", Message: "Backup Failed with errors" });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
await pool.end();
return;
}
// const backupPath = `../backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`;
const directoryPath = path.join(__dirname, "..", backupfolder, `backup_${now.format("yyyy-MM-DD HH-mm-ss")}.json`);
refLog.logData.push({ color: "yellow", Message: "Begin Backup " + directoryPath });
const stream = fs.createWriteStream(directoryPath, { flags: "a" });
stream.on("error", (error) => {
refLog.logData.push({ color: "red", Message: "Backup Failed: " + error });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
return;
});
const backup_data = [];
for (let table of filteredTables) {
const query = `SELECT * FROM ${table.value}`;
const { rows } = await pool.query(query);
refLog.logData.push({ color: "dodgerblue", Message: `Saving ${rows.length} rows for table ${table.value}` });
backup_data.push({ [table.value]: rows });
}
await stream.write(JSON.stringify(backup_data));
stream.end();
refLog.logData.push({ color: "lawngreen", Message: "Backup Complete" });
refLog.logData.push({ color: "dodgerblue", Message: "Removing old backups" });
//Cleanup excess backups
let deleteCount = 0;
const directoryPathDelete = path.join(__dirname, "..", backupfolder);
const files = await new Promise((resolve, reject) => {
fs.readdir(directoryPathDelete, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
let fileData = files
.filter((file) => file.endsWith(".json"))
.map((file) => {
const filePath = path.join(directoryPathDelete, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime,
};
});
fileData = fileData.sort((a, b) => new Date(b.datecreated) - new Date(a.datecreated)).slice(5);
for (var oldBackup of fileData) {
const oldBackupFile = path.join(__dirname, "..", backupfolder, oldBackup.name);
await new Promise((resolve, reject) => {
fs.unlink(oldBackupFile, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
deleteCount += 1;
refLog.logData.push({ color: "yellow", Message: `${oldBackupFile} has been deleted.` });
}
refLog.logData.push({ color: "lawngreen", Message: deleteCount + " backups removed." });
} catch (error) {
console.log(error);
refLog.logData.push({ color: "red", Message: "Backup Failed: " + error });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
}
await pool.end();
}
module.exports = backup;

View File

@@ -3,6 +3,10 @@ const db = require("../db");
class Config {
async getConfig() {
try {
//Manual overrides
process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres";
//
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
const state = this.#getConfigState(config);
@@ -12,14 +16,15 @@ class Config {
}
return {
JF_HOST: config[0].JF_HOST,
JF_API_KEY: config[0].JF_API_KEY,
JF_HOST: process.env.JF_HOST ?? config[0].JF_HOST,
JF_API_KEY: process.env.JF_API_KEY ?? config[0].JF_API_KEY,
APP_USER: config[0].APP_USER,
APP_PASSWORD: config[0].APP_PASSWORD,
REQUIRE_LOGIN: config[0].REQUIRE_LOGIN,
settings: config[0].settings,
api_keys: config[0].api_keys,
state: state,
IS_JELLYFIN: (process.env.IS_EMBY_API || "false").toLowerCase() === "false",
};
} catch (error) {
return { error: "Config Details Not Found" };
@@ -31,6 +36,11 @@ class Config {
return config.settings?.preferred_admin?.userid;
}
async getExcludedLibraries() {
const config = await this.getConfig();
return config.settings?.ExcludedLibraries ?? [];
}
#getConfigState(Configured) {
let state = 0;
try {

542
backend/classes/emby-api.js Normal file
View File

@@ -0,0 +1,542 @@
const configClass = require("./config");
const { axios } = require("./axios");
class EmbyAPI {
constructor() {
this.config = null;
this.configReady = false;
this.#checkReadyStatus();
}
//Helper classes
#checkReadyStatus() {
let checkConfigError = setInterval(async () => {
const _config = await new configClass().getConfig();
if (!_config.error && _config.state === 2) {
clearInterval(checkConfigError);
this.config = _config;
this.configReady = true;
}
}, 5000); // Check every 5 seconds
}
#errorHandler(error, url) {
if (error.response) {
console.log("[EMBY-API]: " + this.#httpErrorMessageHandler(error));
} else {
console.log("[EMBY-API]", {
ErrorAt: this.#getErrorLineNumber(error),
ErrorLines: this.#getErrorLineNumbers(error),
Message: error.message,
url: url,
// StackTrace: this.#getStackTrace(error),
});
}
}
#httpErrorMessageHandler(error) {
let message = "";
switch (error.response.status) {
case 400:
message = "400 Bad Request";
break;
case 401:
message = "401 Unauthorized";
break;
case 403:
message = "403 Access Forbidden";
break;
case 404:
message = `404 URL Not Found : ${error.request.path}`;
break;
case 503:
message = `503 Service Unavailable : ${error.request.path}`;
break;
default:
message = `Unexpected status code: ${error.response.status}`;
}
return message;
}
#getErrorLineNumber(error) {
const stackTrace = this.#getStackTrace(error);
const errorLine = stackTrace[1].trim();
const lineNumber = errorLine.substring(errorLine.lastIndexOf("\\") + 1, errorLine.lastIndexOf(")"));
return lineNumber;
}
#getErrorLineNumbers(error) {
const stackTrace = this.#getStackTrace(error);
let errorLines = [];
for (const [index, line] of stackTrace.entries()) {
if (line.trim().startsWith("at")) {
const errorLine = line.trim();
const startSubstring = errorLine.lastIndexOf("\\") == -1 ? errorLine.indexOf("(") + 1 : errorLine.lastIndexOf("\\") + 1;
const endSubstring = errorLine.lastIndexOf(")") == -1 ? errorLine.length : errorLine.lastIndexOf(")");
const lineNumber = errorLine.substring(startSubstring, endSubstring);
errorLines.push({ TraceIndex: index, line: lineNumber });
}
}
return errorLines;
}
#getStackTrace(error) {
const stackTrace = error.stack.split("\n");
return stackTrace;
}
#delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
//Functions
async getUsers() {
if (!this.configReady) {
return [];
}
try {
const url = `${this.config.JF_HOST}/Users`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
if (Array.isArray(response?.data)) {
return response?.data || [];
}
console.log("[JELLYFIN-API] : getUsers - " + (response?.data || response));
return [];
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getAdmins() {
try {
const users = await this.getUsers();
return users?.filter((user) => user.Policy.IsAdministrator) || [];
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getItemsByID({ ids, params }) {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/Items?ids=${ids}`;
let startIndex = params && params.startIndex ? params.startIndex : 0;
let increment = params && params.increment ? params.increment : 200;
let limit = params && params.limit !== undefined ? params.limit : increment;
let recursive = params && params.recursive !== undefined ? params.recursive : true;
let total = 200;
let final_response = [];
while (startIndex < total || total === undefined) {
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
startIndex: startIndex,
recursive: recursive,
limit: limit,
isMissing: false,
excludeLocationTypes: "Virtual",
},
});
total = response?.data?.TotalRecordCount ?? 0;
startIndex += increment;
const result = response?.data?.Items || [];
final_response.push(...result);
if (response.data.TotalRecordCount === undefined || final_response.length >= limit) {
break;
}
await this.#delay(10);
}
return final_response;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getItemsFromParentId({ id, itemid, params, ws, syncTask, wsMessage }) {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/Items?ParentId=${id}`;
let userid;
if (!userid || userid == null) {
await new configClass().getPreferedAdmin().then(async (adminid) => {
if (!adminid || adminid == null) {
userid = (await this.getAdmins())[0].Id;
} else {
userid = adminid;
}
});
}
url += `&userId=${userid}`;
if (itemid && itemid != null) {
url += `&Ids=${itemid}`;
}
let startIndex = params && params.startIndex !== undefined ? params.startIndex : 0;
let increment = params && params.increment !== undefined ? params.increment : 200;
let recursive = params && params.recursive !== undefined ? params.recursive : true;
let limit = params && params.limit !== undefined ? params.limit : increment;
let total = startIndex + increment;
let AllItems = [];
while (startIndex < total || total === undefined) {
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
startIndex: startIndex,
recursive: recursive,
limit: limit,
isMissing: false,
excludeLocationTypes: "Virtual",
sortBy: "DateCreated",
sortOrder: "Descending",
},
});
total = response?.data?.TotalRecordCount || 0;
startIndex += increment;
const result = response?.data?.Items || [];
AllItems.push(...result);
if (ws && syncTask && wsMessage) {
ws(syncTask.wsKey, {
type: "Update",
message: `${wsMessage} - ${((Math.min(startIndex, total) / total) * 100).toFixed(2)}%`,
});
}
if (
response.data.TotalRecordCount === undefined ||
(params && params.startIndex !== undefined) ||
AllItems.length >= limit
) {
break;
}
await this.#delay(10);
}
return AllItems;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getItemInfo({ itemID, userid }) {
if (!this.configReady) {
return [];
}
try {
if (!userid || userid == null) {
await new configClass().getPreferedAdmin().then(async (adminid) => {
if (!adminid || adminid == null) {
userid = (await this.getAdmins())[0].Id;
} else {
userid = adminid;
}
});
}
let url = `${this.config.JF_HOST}/Items/${itemID}/playbackinfo?userId=${userid}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response?.data?.MediaSources || 0;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getLibraries() {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/Library/MediaFolders`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
const libraries =
response?.data?.Items?.filter((library) => !["boxsets", "playlists"].includes(library.CollectionType)) || [];
return libraries;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getSeasons(SeriesId) {
if (!this.configReady) {
return [];
}
let url = `${this.config.JF_HOST}/Shows/${SeriesId}/Seasons`;
try {
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response?.data?.Items?.filter((item) => item.LocationType !== "Virtual") || [];
} catch (error) {
this.#errorHandler(error, url);
return [];
}
}
async getEpisodes({ SeriesId, SeasonId }) {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/Shows/${SeriesId}/Episodes?seasonId=${SeasonId}`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response?.data?.Items?.filter((item) => item.LocationType !== "Virtual") || [];
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getRecentlyAdded({ libraryid, limit = 20, userid }) {
if (!this.configReady) {
return [];
}
try {
if (!userid || userid == null) {
let adminid = await new configClass().getPreferedAdmin();
if (!adminid || adminid == null) {
const admins = await this.getAdmins();
if (admins.length > 0) {
userid = admins[0].Id;
}
} else {
userid = adminid;
}
}
if (!userid || userid == null) {
console.log("[JELLYFIN-API]: getRecentlyAdded - No Admins/UserIds found");
return [];
}
let url = `${this.config.JF_HOST}/Users/${userid}/Items/Latest?Limit=${limit}`;
if (libraryid && libraryid != null) {
url += `&ParentId=${libraryid}`;
}
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
},
});
const items = response?.data?.filter((item) => item.LocationType !== "Virtual") || [];
return items;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getSessions() {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/sessions`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
let result = response.data && Array.isArray(response.data) ? response.data : [];
if (result.length > 0) {
result = result.filter(
(session) =>
session.NowPlayingItem !== undefined &&
session.NowPlayingItem.Type != "Trailer" &&
session.NowPlayingItem.ProviderIds["prerolls.video"] == undefined
);
}
return result;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async getInstalledPlugins() {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/plugins`;
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response.data;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
async StatsSubmitCustomQuery(query) {
if (!this.configReady) {
return [];
}
try {
let url = `${this.config.JF_HOST}/user_usage_stats/submit_custom_query`;
const response = await axios.post(
url,
{
CustomQueryString: query,
},
{
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
}
);
return response.data.results;
} catch (error) {
this.#errorHandler(error);
return [];
}
}
#isValidUrl(string) {
try {
new URL(string);
return true;
} catch (err) {
return false;
}
}
async validateSettings(url, apikey) {
let result = { isValid: false, status: 400, errorMessage: "Invalid URL", url: url, cleanedUrl: "" };
try {
let _url = url.replace(/\/web\/index\.html#!\/home\.html$/, "");
_url = _url.replace(/\/$/, "");
if (!/^https?:\/\//i.test(_url)) {
_url = "http://" + _url;
}
if (!url.includes("/emby")) {
_url = _url + "/emby";
}
result.cleanedUrl = _url;
console.log(_url, this.#isValidUrl(_url));
if (!this.#isValidUrl(_url)) {
return result;
}
const validation_url = _url.replace(/\/$/, "") + "/system/configuration";
const response = await axios.get(validation_url, {
headers: {
"X-MediaBrowser-Token": apikey,
},
});
result.isValid = response.status == 200;
return result;
} catch (error) {
this.#errorHandler(error);
result.isValid = false;
result.status = error?.response?.status ?? 400;
result.errorMessage =
error?.response != null
? this.#httpErrorMessageHandler(error)
: error.code == "ENOTFOUND"
? "Unable to connect. Please check the URL and your network connection."
: error.message;
return result;
}
}
async systemInfo() {
if (!this.configReady) {
return [];
}
let url = `${this.config.JF_HOST}/system/info`;
try {
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response?.data || {};
} catch (error) {
this.#errorHandler(error, url);
return {};
}
}
}
module.exports = EmbyAPI;

31
backend/classes/env.js Normal file
View File

@@ -0,0 +1,31 @@
const fs = require("fs");
const path = require("path");
async function writeEnvVariables() {
//Define sensitive variables that should not be exposed
const excludedVariables = ["JS_GEOLITE_LICENSE_KEY", "JS_USER", "JS_PASSWORD"];
// Fetch environment variables that start with JS_
const envVariables = Object.keys(process.env).reduce((acc, key) => {
if (key.startsWith("JS_") && !excludedVariables.includes(key)) {
acc[key] = process.env[key];
}
return acc;
}, {});
// Convert the environment variables to a JavaScript object string
const envContent = `window.env = ${JSON.stringify(envVariables, null, 2)};`;
// Define the output file path
const outputPath = path.join(__dirname, "..", "..", "dist", "env.js");
// Write the environment variables to the file
fs.writeFile(outputPath, envContent, "utf8", (err) => {
if (err) {
console.error("Error writing env.js file:", err);
} else {
console.log("env.js file has been saved successfully.");
}
});
}
module.exports = writeEnvVariables;

View File

@@ -104,8 +104,13 @@ class JellyfinAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
if (Array.isArray(response?.data)) {
return response?.data || [];
}
return response?.data || [];
console.log("[JELLYFIN-API] : getUsers - " + (response?.data || response));
return [];
} catch (error) {
this.#errorHandler(error);
return [];
@@ -130,6 +135,7 @@ class JellyfinAPI {
let url = `${this.config.JF_HOST}/Items?ids=${ids}`;
let startIndex = params && params.startIndex ? params.startIndex : 0;
let increment = params && params.increment ? params.increment : 200;
let limit = params && params.limit !== undefined ? params.limit : increment;
let recursive = params && params.recursive !== undefined ? params.recursive : true;
let total = 200;
@@ -143,7 +149,7 @@ class JellyfinAPI {
fields: "MediaSources,DateCreated",
startIndex: startIndex,
recursive: recursive,
limit: increment,
limit: limit,
isMissing: false,
excludeLocationTypes: "Virtual",
},
@@ -155,7 +161,7 @@ class JellyfinAPI {
const result = response?.data?.Items || [];
final_response.push(...result);
if (response.data.TotalRecordCount === undefined) {
if (response.data.TotalRecordCount === undefined || final_response.length >= limit) {
break;
}
@@ -175,14 +181,26 @@ class JellyfinAPI {
}
try {
let url = `${this.config.JF_HOST}/Items?ParentId=${id}`;
let userid;
if (!userid || userid == null) {
await new configClass().getPreferedAdmin().then(async (adminid) => {
if (!adminid || adminid == null) {
userid = (await this.getAdmins())[0].Id;
} else {
userid = adminid;
}
});
}
url += `&userId=${userid}`;
if (itemid && itemid != null) {
url += `&Ids=${itemid}`;
}
let startIndex = params && params.startIndex ? params.startIndex : 0;
let increment = params && params.increment ? params.increment : 200;
let startIndex = params && params.startIndex !== undefined ? params.startIndex : 0;
let increment = params && params.increment !== undefined ? params.increment : 200;
let limit = params && params.limit !== undefined ? params.limit : increment;
let recursive = params && params.recursive !== undefined ? params.recursive : true;
let total = 200;
let total = startIndex + increment;
let AllItems = [];
while (startIndex < total || total === undefined) {
@@ -194,9 +212,11 @@ class JellyfinAPI {
fields: "MediaSources,DateCreated",
startIndex: startIndex,
recursive: recursive,
limit: increment,
limit: limit,
isMissing: false,
excludeLocationTypes: "Virtual",
sortBy: "DateCreated",
sortOrder: "Descending",
},
});
@@ -207,11 +227,19 @@ class JellyfinAPI {
AllItems.push(...result);
if (response.data.TotalRecordCount === undefined) {
break;
}
if (ws && syncTask && wsMessage) {
ws(syncTask.wsKey, { type: "Update", message: `${wsMessage} - ${((startIndex / total) * 100).toFixed(2)}%` });
ws(syncTask.wsKey, {
type: "Update",
message: `${wsMessage} - ${((Math.min(startIndex, total) / total) * 100).toFixed(2)}%`,
});
}
if (
response.data.TotalRecordCount === undefined ||
(params && params.startIndex !== undefined) ||
AllItems.length >= limit
) {
break;
}
await this.#delay(10);
@@ -324,12 +352,20 @@ class JellyfinAPI {
if (!userid || userid == null) {
let adminid = await new configClass().getPreferedAdmin();
if (!adminid || adminid == null) {
userid = (await this.getAdmins())[0].Id;
const admins = await this.getAdmins();
if (admins.length > 0) {
userid = admins[0].Id;
}
} else {
userid = adminid;
}
}
if (!userid || userid == null) {
console.log("[JELLYFIN-API]: getRecentlyAdded - No Admins/UserIds found");
return [];
}
let url = `${this.config.JF_HOST}/Users/${userid}/Items/Latest?Limit=${limit}`;
if (libraryid && libraryid != null) {
@@ -367,8 +403,14 @@ class JellyfinAPI {
},
});
let result = response.data && Array.isArray(response.data) ? response.data : [];
if (result.length > 0) {
result = result.filter((session) => session.NowPlayingItem !== undefined && session.NowPlayingItem.Type != "Trailer");
result = result.filter(
(session) =>
session.NowPlayingItem !== undefined &&
session.NowPlayingItem.Type != "Trailer" &&
session.NowPlayingItem.ProviderIds["prerolls.video"] == undefined
);
}
return result;
} catch (error) {
@@ -469,6 +511,25 @@ class JellyfinAPI {
return result;
}
}
async systemInfo() {
if (!this.configReady) {
return [];
}
let url = `${this.config.JF_HOST}/system/info`;
try {
const response = await axios.get(url, {
headers: {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
});
return response?.data || {};
} catch (error) {
this.#errorHandler(error, url);
return {};
}
}
}
module.exports = JellyfinAPI;

View File

@@ -0,0 +1,60 @@
const db = require("../db");
const moment = require("moment");
const taskstate = require("../logging/taskstate");
const { jf_logging_columns, jf_logging_mapping } = require("../models/jf_logging");
async function insertLog(uuid, triggertype, taskType) {
try {
let startTime = moment();
const log = {
Id: uuid,
Name: taskType,
Type: "Task",
ExecutionType: triggertype,
Duration: 0,
TimeRun: startTime,
Log: JSON.stringify([{}]),
Result: taskstate.RUNNING,
};
await db.insertBulk("jf_logging", log, jf_logging_columns);
} catch (error) {
console.log(error);
return [];
}
}
async function updateLog(uuid, data, taskstate) {
try {
const { rows: task } = await db.query(`SELECT "TimeRun" FROM jf_logging WHERE "Id" = '${uuid}';`);
if (task.length === 0) {
console.log("Unable to find task to update");
} else {
let endtime = moment();
let startTime = moment(task[0].TimeRun);
let duration = endtime.diff(startTime, "seconds");
const log = {
Id: uuid,
Name: "NULL Placeholder",
Type: "Task",
ExecutionType: "NULL Placeholder",
Duration: duration,
TimeRun: startTime,
Log: JSON.stringify(data),
Result: taskstate,
};
await db.insertBulk("jf_logging", log, jf_logging_columns);
}
} catch (error) {
console.log(error);
return [];
}
}
module.exports = {
insertLog,
updateLog,
};

View File

@@ -28,7 +28,7 @@ pool.on("error", (err, client) => {
//process.exit(-1);
});
async function deleteBulk(table_name, data) {
async function deleteBulk(table_name, data, pkName) {
const client = await pool.connect();
let result = "SUCCESS";
let message = "";
@@ -37,7 +37,7 @@ async function deleteBulk(table_name, data) {
if (data && data.length !== 0) {
const deleteQuery = {
text: `DELETE FROM ${table_name} WHERE "Id" IN (${pgp.as.csv(data)})`,
text: `DELETE FROM ${table_name} WHERE "${pkName ?? "Id"}" IN (${pgp.as.csv(data)})`,
};
// console.log(deleteQuery);
await client.query(deleteQuery);
@@ -109,10 +109,10 @@ async function insertBulk(table_name, data, columns) {
try {
await client.query("BEGIN");
const update_query = update_query_map.find((query) => query.table === table_name).query;
await client.query("COMMIT");
const cs = new pgp.helpers.ColumnSet(columns, { table: table_name });
const query = pgp.helpers.insert(data, cs) + update_query; // Update the column names accordingly
await client.query(query);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
message = "" + error;
@@ -149,10 +149,24 @@ async function query(text, params) {
}
}
async function querySingle(sql, params) {
try {
const { rows: results } = await query(sql, params);
if (results.length > 0) {
return results[0];
} else {
return null;
}
} catch (error) {
throw error;
}
}
module.exports = {
query: query,
deleteBulk: deleteBulk,
insertBulk: insertBulk,
updateSingleFieldBulk: updateSingleFieldBulk,
querySingle: querySingle,
// initDB: initDB,
};

View File

@@ -0,0 +1,12 @@
const tables = [
{ value: "jf_libraries", name: "Libraries" },
{ value: "jf_library_items", name: "Library Items" },
{ value: "jf_library_seasons", name: "Seasons" },
{ value: "jf_library_episodes", name: "Episodes" },
{ value: "jf_users", name: "Users" },
{ value: "jf_playback_activity", name: "Activity" },
{ value: "jf_playback_reporting_plugin_data", name: "Playback Reporting Plugin Data" },
{ value: "jf_item_info", name: "Item Info" },
];
module.exports = { tables };

View File

@@ -1,3 +1,6 @@
process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres";
process.env.POSTGRES_ROLE =
process.env.POSTGRES_ROLE ?? process.env.POSTGRES_USER;
module.exports = {
development: {
@@ -55,4 +58,4 @@ module.exports = {
}
}
};

View File

@@ -10,7 +10,7 @@ exports.up = async function(knex) {
table.text('APP_USER');
table.text('APP_PASSWORD');
});
await knex.raw(`ALTER TABLE app_config OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE app_config OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
}catch (error) {
console.error(error);

View File

@@ -20,7 +20,7 @@ exports.up = async function(knex) {
table.timestamp('ActivityDateInserted').defaultTo(knex.fn.now());
table.text('PlayMethod');
});
await knex.raw(`ALTER TABLE jf_activity_watchdog OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE jf_activity_watchdog OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -11,7 +11,7 @@ exports.up = async function(knex) {
table.text('CollectionType').notNullable();
table.text('ImageTagsPrimary');
});
await knex.raw(`ALTER TABLE jf_libraries OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE jf_libraries OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);
@@ -25,4 +25,4 @@ exports.up = async function(knex) {
console.error(error);
}
};

View File

@@ -22,7 +22,7 @@ exports.up = async function(knex) {
table.text('ParentId').notNullable().references('Id').inTable('jf_libraries').onDelete('SET NULL').onUpdate('NO ACTION');
table.text('PrimaryImageHash');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_library_items OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_library_items OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {

View File

@@ -16,7 +16,7 @@ exports.up = async function(knex) {
table.text('SeriesPrimaryImageTag');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_library_seasons OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_library_seasons OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -24,7 +24,7 @@ exports.up = async function(knex) {
table.text('SeriesName');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_library_episodes OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_library_episodes OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -21,7 +21,7 @@ exports.up = async function(knex) {
table.text('PlayMethod');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_playback_activity OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_playback_activity OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -11,7 +11,7 @@
table.boolean('IsAdministrator');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_users OWNER TO "${process.env.POSTGRES_USER}";`);;
await knex.raw(`ALTER TABLE IF EXISTS jf_users OWNER TO "${process.env.POSTGRES_ROLE}";`);;
}
} catch (error) {
console.error(error);
@@ -26,4 +26,4 @@
}
};

View File

@@ -59,4 +59,4 @@ exports.up = async function(knex) {
exports.down = async function(knex) {
await knex.raw(`DROP VIEW jf_all_user_activity;`);
};

View File

@@ -44,7 +44,7 @@ exports.up = function(knex) {
$BODY$;
ALTER FUNCTION fs_last_user_activity(text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -55,4 +55,4 @@ exports.up = function(knex) {
DROP FUNCTION IF EXISTS fs_last_user_activity(text);
`);
};

View File

@@ -28,7 +28,7 @@ exports.up = async function(knex) {
$BODY$;
ALTER FUNCTION fs_library_stats(integer, text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -39,4 +39,4 @@ exports.up = async function(knex) {
DROP FUNCTION IF EXISTS fs_library_stats(integer, text);
`);
};

View File

@@ -20,7 +20,7 @@ exports.up = function(knex) {
END;
$BODY$;
ALTER FUNCTION fs_most_active_user(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -29,4 +29,4 @@ exports.up = function(knex) {
exports.down = function(knex) {
return knex.raw('DROP FUNCTION IF EXISTS fs_most_active_user(integer)');
};

View File

@@ -47,7 +47,7 @@ exports.up = async function(knex) {
$BODY$;
ALTER FUNCTION fs_most_played_items(integer, text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -56,4 +56,4 @@ exports.up = async function(knex) {
exports.down = async function(knex) {
await knex.raw('DROP FUNCTION IF EXISTS fs_most_played_items(integer, text)');
};

View File

@@ -53,7 +53,7 @@ exports.up = async function(knex) {
END;
$BODY$;
ALTER FUNCTION fs_most_popular_items(integer, text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -62,4 +62,4 @@ exports.up = async function(knex) {
exports.down = async function(knex) {
await knex.raw(`DROP FUNCTION fs_most_popular_items(integer, text);`);
};

View File

@@ -22,7 +22,7 @@ exports.up = async function(knex) {
$BODY$;
ALTER FUNCTION fs_most_used_clients(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -31,4 +31,4 @@ exports.up = async function(knex) {
exports.down = async function(knex) {
await knex.raw(`DROP FUNCTION fs_most_used_clients(integer);`);
};

View File

@@ -51,7 +51,7 @@ exports.up = function(knex) {
$BODY$;
ALTER FUNCTION fs_most_viewed_libraries(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -62,4 +62,4 @@ exports.up = function(knex) {
DROP FUNCTION IF EXISTS fs_most_viewed_libraries(integer);
`);
};

View File

@@ -32,7 +32,7 @@ exports.up = async function(knex) {
$BODY$;
ALTER FUNCTION fs_user_stats(integer, text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});

View File

@@ -48,7 +48,7 @@ exports.up = async function (knex) {
$BODY$;
ALTER FUNCTION fs_watch_stats_over_time(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});

View File

@@ -56,7 +56,7 @@ exports.up =async function(knex) {
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_popular_days_of_week(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});

View File

@@ -42,7 +42,7 @@ exports.up =async function(knex) {
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_popular_hour_of_day(integer)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});

View File

@@ -12,7 +12,7 @@ exports.up = async function(knex) {
table.text('Type');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_item_info OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_item_info OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -37,7 +37,7 @@ exports.up = function(knex) {
$BODY$;
ALTER FUNCTION fs_last_user_activity(text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`).catch(function(error) {
console.error(error);
});
@@ -90,7 +90,7 @@ exports.up = function(knex) {
$BODY$;
ALTER FUNCTION fs_last_user_activity(text)
OWNER TO "${process.env.POSTGRES_USER}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
};

View File

@@ -15,7 +15,7 @@ exports.up = async function(knex) {
table.bigInteger('PlayDuration');
});
await knex.raw(`ALTER TABLE IF EXISTS jf_playback_reporting_plugin_data OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE IF EXISTS jf_playback_reporting_plugin_data OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -12,7 +12,7 @@ exports.up = async function(knex) {
table.json('Log');
table.text('Result');
});
await knex.raw(`ALTER TABLE jf_logging OWNER TO "${process.env.POSTGRES_USER}";`);
await knex.raw(`ALTER TABLE jf_logging OWNER TO "${process.env.POSTGRES_ROLE}";`);
}
} catch (error) {
console.error(error);

View File

@@ -54,7 +54,7 @@ exports.up = async function (knex) {
$BODY$;
ALTER PROCEDURE public.ji_insert_playback_plugin_data_to_activity_table()
OWNER TO "${process.env.POSTGRES_USER ?? postgres}";`);
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
@@ -114,7 +114,7 @@ exports.down = async function (knex) {
$BODY$;
ALTER PROCEDURE public.ji_insert_playback_plugin_data_to_activity_table()
OWNER TO "${process.env.POSTGRES_USER ?? postgres}";`);
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}

View File

@@ -55,7 +55,7 @@ exports.up = async function (knex) {
$BODY$;
ALTER PROCEDURE public.ji_insert_playback_plugin_data_to_activity_table()
OWNER TO "${process.env.POSTGRES_USER ?? postgres}";
OWNER TO "${process.env.POSTGRES_ROLE}";
`);
} catch (error) {
console.error(error);
@@ -118,7 +118,7 @@ exports.down = async function (knex) {
$BODY$;
ALTER PROCEDURE public.ji_insert_playback_plugin_data_to_activity_table()
OWNER TO "${process.env.POSTGRES_USER ?? postgres}";`);
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}

View File

@@ -0,0 +1,119 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_over_time(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_over_time(
days integer)
RETURNS TABLE("Date" date, "Count" bigint, "Library" text, "LibraryID" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
dates."Date",
COALESCE(counts."Count", 0) AS "Count",
l."Name" as "Library",
l."Id" as "LibraryID"
FROM
(SELECT generate_series(
DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)),
DATE_TRUNC('day', NOW()),
'1 day')::DATE AS "Date"
) dates
CROSS JOIN jf_libraries l
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
l."Name" as "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."Date" = dates."Date" AND counts."Library" = l."Name"
where l.archived=false
ORDER BY
"Date", "Library";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_over_time(integer)
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_watch_stats_over_time(integer);
CREATE OR REPLACE FUNCTION fs_watch_stats_over_time(
days integer
)
RETURNS TABLE(
"Date" date,
"Count" bigint,
"Library" text
)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
dates."Date",
COALESCE(counts."Count", 0) AS "Count",
l."Name" as "Library"
FROM
(SELECT generate_series(
DATE_TRUNC('day', NOW() - CAST(days || ' days' as INTERVAL)),
DATE_TRUNC('day', NOW()),
'1 day')::DATE AS "Date"
) dates
CROSS JOIN jf_libraries l
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
l."Name" as "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."Date" = dates."Date" AND counts."Library" = l."Name"
ORDER BY
"Date", "Library";
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_over_time(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,139 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_days_of_week(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week(
days integer)
RETURNS TABLE("Day" text, "Count" bigint, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH library_days AS (
SELECT
l."Name" AS "Library",
d.day_of_week,
d.day_name
FROM
jf_libraries l,
(SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL
SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL
SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL
SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL
SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL
SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL
SELECT 6 AS "day_of_week", 'Saturday' AS "day_name"
) d
where l.archived=false
)
SELECT
library_days.day_name AS "Day",
COALESCE(SUM(counts."Count"), 0)::bigint AS "Count",
library_days."Library" AS "Library"
FROM
library_days
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW",
l."Name" AS "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id" and l.archived=false
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library"
GROUP BY
library_days.day_name, library_days.day_of_week, library_days."Library"
ORDER BY
library_days.day_of_week, library_days."Library";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_days_of_week(integer)
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_watch_stats_popular_days_of_week(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_days_of_week(
days integer)
RETURNS TABLE("Day" text, "Count" bigint, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH library_days AS (
SELECT
l."Name" AS "Library",
d.day_of_week,
d.day_name
FROM
jf_libraries l,
(SELECT 0 AS "day_of_week", 'Sunday' AS "day_name" UNION ALL
SELECT 1 AS "day_of_week", 'Monday' AS "day_name" UNION ALL
SELECT 2 AS "day_of_week", 'Tuesday' AS "day_name" UNION ALL
SELECT 3 AS "day_of_week", 'Wednesday' AS "day_name" UNION ALL
SELECT 4 AS "day_of_week", 'Thursday' AS "day_name" UNION ALL
SELECT 5 AS "day_of_week", 'Friday' AS "day_name" UNION ALL
SELECT 6 AS "day_of_week", 'Saturday' AS "day_name"
) d
)
SELECT
library_days.day_name AS "Day",
COALESCE(SUM(counts."Count"), 0)::bigint AS "Count",
library_days."Library" AS "Library"
FROM
library_days
LEFT JOIN
(SELECT
DATE_TRUNC('day', a."ActivityDateInserted")::DATE AS "Date",
COUNT(*) AS "Count",
EXTRACT(DOW FROM a."ActivityDateInserted") AS "DOW",
l."Name" AS "Library"
FROM
jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
WHERE
a."ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' as INTERVAL) AND NOW()
GROUP BY
l."Name", EXTRACT(DOW FROM a."ActivityDateInserted"), DATE_TRUNC('day', a."ActivityDateInserted")
) counts
ON counts."DOW" = library_days.day_of_week AND counts."Library" = library_days."Library"
GROUP BY
library_days.day_name, library_days.day_of_week, library_days."Library"
ORDER BY
library_days.day_of_week, library_days."Library";
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_popular_days_of_week(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,113 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_watch_stats_popular_hour_of_day(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day(
days integer)
RETURNS TABLE("Hour" integer, "Count" integer, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
h."Hour",
COUNT(a."Id")::integer AS "Count",
l."Name" AS "Library"
FROM
(
SELECT
generate_series(0, 23) AS "Hour"
) h
CROSS JOIN jf_libraries l
LEFT JOIN jf_library_items i ON i."ParentId" = l."Id"
LEFT JOIN (
SELECT
"NowPlayingItemId",
DATE_PART('hour', "ActivityDateInserted") AS "Hour",
"Id"
FROM
jf_playback_activity
WHERE
"ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW()
) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour"
WHERE
l.archived=false
and l."Id" IN (SELECT "Id" FROM jf_libraries)
GROUP BY
h."Hour",
l."Name"
ORDER BY
l."Name",
h."Hour";
END;
$BODY$;
ALTER FUNCTION public.fs_watch_stats_popular_hour_of_day(integer)
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_watch_stats_popular_hour_of_day(integer);
CREATE OR REPLACE FUNCTION public.fs_watch_stats_popular_hour_of_day(
days integer)
RETURNS TABLE("Hour" integer, "Count" integer, "Library" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
h."Hour",
COUNT(a."Id")::integer AS "Count",
l."Name" AS "Library"
FROM
(
SELECT
generate_series(0, 23) AS "Hour"
) h
CROSS JOIN jf_libraries l
LEFT JOIN jf_library_items i ON i."ParentId" = l."Id"
LEFT JOIN (
SELECT
"NowPlayingItemId",
DATE_PART('hour', "ActivityDateInserted") AS "Hour",
"Id"
FROM
jf_playback_activity
WHERE
"ActivityDateInserted" BETWEEN NOW() - CAST(days || ' days' AS INTERVAL) AND NOW()
) a ON a."NowPlayingItemId" = i."Id" AND a."Hour"::integer = h."Hour"
WHERE
l."Id" IN (SELECT "Id" FROM jf_libraries)
GROUP BY
h."Hour",
l."Name"
ORDER BY
l."Name",
h."Hour";
END;
$BODY$;
ALTER FUNCTION fs_watch_stats_popular_hour_of_day(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,78 @@
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(*) 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);
}
};
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" BETWEEN CURRENT_DATE - INTERVAL '1 hour' * hours AND NOW()
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

@@ -0,0 +1,125 @@
exports.up = async function (knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.jf_recent_playback_activity(integer);
CREATE OR REPLACE FUNCTION public.jf_recent_playback_activity(
hour_offset integer)
RETURNS TABLE("RunTimeTicks" bigint, "Progress" numeric, "Id" text, "IsPaused" boolean, "UserId" text, "UserName" text, "Client" text, "DeviceName" text, "DeviceId" text, "ApplicationVersion" text, "NowPlayingItemId" text, "NowPlayingItemName" text, "SeasonId" text, "SeriesName" text, "EpisodeId" text, "PlaybackDuration" bigint, "ActivityDateInserted" timestamp with time zone, "PlayMethod" text, "MediaStreams" json, "TranscodingInfo" json, "PlayState" json, "OriginalContainer" text, "RemoteEndPoint" text, "ServerId" text, "Imported" boolean, "RowNum" bigint)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH rankedactivities AS (
SELECT COALESCE(i."RunTimeTicks", e."RunTimeTicks") AS "RunTimeTicks",
CASE
WHEN COALESCE(i."RunTimeTicks"::numeric(100,0), e."RunTimeTicks"::numeric(100,0), 1.0) > 0 THEN ((a."PlaybackDuration" * 10000000)::numeric(100,0) / COALESCE(i."RunTimeTicks"::numeric(100,0), e."RunTimeTicks"::numeric(100,0), 1.0) * 100::numeric)::numeric(100,2)
ELSE 1.0
END AS "Progress",
a."Id",
a."IsPaused",
a."UserId",
a."UserName",
a."Client",
a."DeviceName",
a."DeviceId",
a."ApplicationVersion",
a."NowPlayingItemId",
a."NowPlayingItemName",
a."SeasonId",
a."SeriesName",
a."EpisodeId",
a."PlaybackDuration",
a."ActivityDateInserted",
a."PlayMethod",
a."MediaStreams",
a."TranscodingInfo",
a."PlayState",
a."OriginalContainer",
a."RemoteEndPoint",
a."ServerId",
a.imported,
row_number() OVER (PARTITION BY a."NowPlayingItemId",a."EpisodeId",a."UserId" ORDER BY a."ActivityDateInserted" DESC) AS rownum
FROM jf_playback_activity a
LEFT JOIN jf_library_items i ON a."NowPlayingItemId" = i."Id"
LEFT JOIN jf_library_episodes e ON a."EpisodeId" = e."EpisodeId"
WHERE a."ActivityDateInserted" > (CURRENT_TIMESTAMP - (hour_offset || ' hours')::interval)
ORDER BY a."ActivityDateInserted" DESC
)
SELECT * FROM rankedactivities WHERE rankedactivities.rownum = 1;
END;
$BODY$;
ALTER FUNCTION public.jf_recent_playback_activity(integer)
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.jf_recent_playback_activity(integer);
CREATE OR REPLACE FUNCTION public.jf_recent_playback_activity(
hour_offset integer)
RETURNS TABLE("RunTimeTicks" bigint, "Progress" numeric, "Id" text, "IsPaused" boolean, "UserId" text, "UserName" text, "Client" text, "DeviceName" text, "DeviceId" text, "ApplicationVersion" text, "NowPlayingItemId" text, "NowPlayingItemName" text, "SeasonId" text, "SeriesName" text, "EpisodeId" text, "PlaybackDuration" bigint, "ActivityDateInserted" timestamp with time zone, "PlayMethod" text, "MediaStreams" json, "TranscodingInfo" json, "PlayState" json, "OriginalContainer" text, "RemoteEndPoint" text, "ServerId" text, "Imported" boolean, "RowNum" bigint)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
WITH rankedactivities AS (
SELECT COALESCE(i."RunTimeTicks", e."RunTimeTicks") AS "RunTimeTicks",
((a."PlaybackDuration" * 10000000)::numeric(100,0) / COALESCE(i."RunTimeTicks"::numeric(100,0), e."RunTimeTicks"::numeric(100,0), 1.0) * 100::numeric)::numeric(100,2) AS "Progress",
a."Id",
a."IsPaused",
a."UserId",
a."UserName",
a."Client",
a."DeviceName",
a."DeviceId",
a."ApplicationVersion",
a."NowPlayingItemId",
a."NowPlayingItemName",
a."SeasonId",
a."SeriesName",
a."EpisodeId",
a."PlaybackDuration",
a."ActivityDateInserted",
a."PlayMethod",
a."MediaStreams",
a."TranscodingInfo",
a."PlayState",
a."OriginalContainer",
a."RemoteEndPoint",
a."ServerId",
a.imported,
row_number() OVER (PARTITION BY a."NowPlayingItemId",a."EpisodeId",a."UserId" ORDER BY a."ActivityDateInserted" DESC) AS rownum
FROM jf_playback_activity a
LEFT JOIN jf_library_items i ON a."NowPlayingItemId" = i."Id"
LEFT JOIN jf_library_episodes e ON a."EpisodeId" = e."EpisodeId"
WHERE a."ActivityDateInserted" > (CURRENT_TIMESTAMP - (hour_offset || ' hours')::interval)
ORDER BY a."ActivityDateInserted" DESC
)
SELECT * FROM rankedactivities WHERE rankedactivities.rownum = 1;
END;
$BODY$;
ALTER FUNCTION public.jf_recent_playback_activity(integer)
OWNER TO "${process.env.POSTGRES_ROLE}";`);
} catch (error) {
console.error(error);
}
};

View File

@@ -1,25 +1,16 @@
const jf_item_info_columns = ["Id", "Path", "Name", "Size", "Bitrate", "MediaStreams", "Type"];
const jf_item_info_columns = [
"Id",
"Path",
"Name",
"Size",
"Bitrate",
"MediaStreams",
"Type",
];
const jf_item_info_mapping = (item, typeOverride) => ({
Id: item.ItemId || item.EpisodeId || item.Id,
Path: item.Path,
Name: item.Name,
Size: item.Size,
Bitrate: item.Bitrate,
MediaStreams: JSON.stringify(item.MediaStreams),
Type: typeOverride !== undefined ? typeOverride : item.Type,
});
const jf_item_info_mapping = (item, typeOverride) => ({
Id: item.EpisodeId || item.Id,
Path: item.Path,
Name: item.Name,
Size: item.Size,
Bitrate: item.Bitrate,
MediaStreams:JSON.stringify(item.MediaStreams),
Type: typeOverride !== undefined ? typeOverride : item.Type,
});
module.exports = {
jf_item_info_columns,
jf_item_info_mapping,
};
module.exports = {
jf_item_info_columns,
jf_item_info_mapping,
};

3
backend/nodemon.json Normal file
View File

@@ -0,0 +1,3 @@
{
"ignore": ["backend/backup-data", "*.json"]
}

View File

@@ -8,12 +8,12 @@ const { randomUUID } = require("crypto");
const { axios } = require("../classes/axios");
const configClass = require("../classes/config");
const { checkForUpdates } = require("../version-control");
const JellyfinAPI = require("../classes/jellyfin-api");
const API = require("../classes/api-loader");
const { sendUpdate } = require("../ws");
const moment = require("moment");
const { tables } = require("../global/backup_tables");
const router = express.Router();
const Jellyfin = new JellyfinAPI();
//Functions
function groupActivity(rows) {
@@ -47,6 +47,29 @@ function groupActivity(rows) {
return groupedResults;
}
function groupRecentlyAdded(rows) {
const groupedResults = {};
rows.forEach((row) => {
if (row.Type != "Movie") {
const key = row.SeriesId + row.SeasonId;
if (groupedResults[key]) {
groupedResults[key].NewEpisodeCount++;
} else {
groupedResults[key] = { ...row };
if (row.Type != "Series" && row.Type != "Movie") {
groupedResults[key].NewEpisodeCount = 1;
}
}
} else {
groupedResults[row.Id] = {
...row,
};
}
});
return Object.values(groupedResults);
}
async function purgeLibraryItems(id, withActivity, purgeAll = false) {
let items_query = `select * from jf_library_items where "ParentId"=$1`;
@@ -118,6 +141,7 @@ router.get("/getconfig", async (req, res) => {
APP_USER: config.APP_USER,
settings: config.settings,
REQUIRE_LOGIN: config.REQUIRE_LOGIN,
IS_JELLYFIN: config.IS_JELLYFIN,
};
res.send(payload);
@@ -126,13 +150,26 @@ router.get("/getconfig", async (req, res) => {
}
});
router.get("/getLibraries", async (req, res) => {
try {
const libraries = await db.query("SELECT * FROM jf_libraries").then((res) => res.rows);
res.send(libraries);
} catch (error) {
res.status(503);
res.send(error);
}
});
router.get("/getRecentlyAdded", async (req, res) => {
try {
const { libraryid, limit = 10 } = req.query;
const { libraryid, limit = 50, GroupResults = true } = req.query;
let recentlyAddedFronJellystat = await Jellyfin.getRecentlyAdded({ libraryid: libraryid });
const config = await new configClass().getConfig();
const excluded_libraries = config.settings.ExcludedLibraries || [];
let recentlyAddedFronJellystatMapped = recentlyAddedFronJellystat.map((item) => {
let recentlyAddedFromJellystat = await API.getRecentlyAdded({ libraryid: libraryid });
let recentlyAddedFromJellystatMapped = recentlyAddedFromJellystat.map((item) => {
return {
Name: item.Name,
SeriesName: item.SeriesName,
@@ -159,12 +196,12 @@ router.get("/getRecentlyAdded", async (req, res) => {
if (libraryid !== undefined) {
const { rows } = await db.query(
`SELECT i."Name", null "SeriesName", "Id", null "SeriesId", null "SeasonId", null "EpisodeId", null "SeasonNumber", null "EpisodeNumber", "PrimaryImageHash",i."DateCreated", "Type"
`SELECT i."Name", null "SeriesName", "Id", null "SeriesId", null "SeasonId", null "EpisodeId", null "SeasonNumber", null "EpisodeNumber", "PrimaryImageHash",i."DateCreated", "Type", i."ParentId"
FROM public.jf_library_items i
where i.archived=false
and i."ParentId"=$1
union
SELECT e."Name", e."SeriesName",e."Id" , e."SeriesId", e."SeasonId", e."EpisodeId", e."ParentIndexNumber" "SeasonNumber", e."IndexNumber" "EpisodeNumber", e."PrimaryImageHash", e."DateCreated", e."Type"
SELECT e."Name", e."SeriesName",e."Id" , e."SeriesId", e."SeasonId", e."EpisodeId", e."ParentIndexNumber" "SeasonNumber", e."IndexNumber" "EpisodeNumber", e."PrimaryImageHash", e."DateCreated", e."Type", i."ParentId"
FROM public.jf_library_episodes e
JOIN public.jf_library_items i
on i."Id"=e."SeriesId"
@@ -177,21 +214,25 @@ router.get("/getRecentlyAdded", async (req, res) => {
if (rows[0].DateCreated !== undefined && rows[0].DateCreated !== null) {
let lastSynctedItemDate = moment(rows[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ");
recentlyAddedFronJellystatMapped = recentlyAddedFronJellystatMapped.filter((item) =>
recentlyAddedFromJellystatMapped = recentlyAddedFromJellystatMapped.filter((item) =>
moment(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate)
);
}
res.send([...recentlyAddedFronJellystatMapped, ...rows]);
const filteredDbRows = rows.filter((item) => !excluded_libraries.includes(item.ParentId));
res.send([...recentlyAddedFromJellystatMapped, ...filteredDbRows]);
return;
}
const { rows } = await db.query(
`SELECT i."Name", null "SeriesName", "Id", null "SeriesId", null "SeasonId", null "EpisodeId", null "SeasonNumber" , null "EpisodeNumber" , "PrimaryImageHash",i."DateCreated", "Type"
`SELECT i."Name", null "SeriesName", "Id", null "SeriesId", null "SeasonId", null "EpisodeId", null "SeasonNumber" , null "EpisodeNumber" , "PrimaryImageHash",i."DateCreated", "Type", i."ParentId"
FROM public.jf_library_items i
where i.archived=false
union
SELECT e."Name", e."SeriesName",e."Id" , e."SeriesId", e."SeasonId", e."EpisodeId", e."ParentIndexNumber" "SeasonNumber", e."IndexNumber" "EpisodeNumber", e."PrimaryImageHash", e."DateCreated", e."Type"
SELECT e."Name", e."SeriesName",e."Id" , e."SeriesId", e."SeasonId", e."EpisodeId", e."ParentIndexNumber" "SeasonNumber", e."IndexNumber" "EpisodeNumber", e."PrimaryImageHash", e."DateCreated", e."Type", i."ParentId"
FROM public.jf_library_episodes e
JOIN public.jf_library_items i
on i."Id"=e."SeriesId"
where e.archived=false
order by "DateCreated" desc
limit $1`,
@@ -201,12 +242,21 @@ router.get("/getRecentlyAdded", async (req, res) => {
if (rows[0].DateCreated !== undefined && rows[0].DateCreated !== null) {
let lastSynctedItemDate = moment(rows[0].DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ");
recentlyAddedFronJellystatMapped = recentlyAddedFronJellystatMapped.filter((item) =>
recentlyAddedFromJellystatMapped = recentlyAddedFromJellystatMapped.filter((item) =>
moment(item.DateCreated, "YYYY-MM-DD HH:mm:ss.SSSZ").isAfter(lastSynctedItemDate)
);
}
res.send([...recentlyAddedFronJellystatMapped, ...rows]);
const filteredDbRows = rows.filter((item) => !excluded_libraries.includes(item.ParentId));
let recentlyAdded = [...recentlyAddedFromJellystatMapped, ...filteredDbRows];
recentlyAdded = recentlyAdded.filter((item) => item.Type !== "Series");
if (GroupResults == true) {
recentlyAdded = groupRecentlyAdded(recentlyAdded);
}
res.send(recentlyAdded);
return;
} catch (error) {
res.status(503);
@@ -226,7 +276,7 @@ router.post("/setconfig", async (req, res) => {
var url = JF_HOST;
const validation = await Jellyfin.validateSettings(url, JF_API_KEY);
const validation = await API.validateSettings(url, JF_API_KEY);
if (validation.isValid === false) {
res.status(validation.status);
res.send(validation);
@@ -241,6 +291,22 @@ router.post("/setconfig", async (req, res) => {
}
const { rows } = await db.query(query, [validation.cleanedUrl, JF_API_KEY]);
const systemInfo = await API.systemInfo();
if (systemInfo && systemInfo != {}) {
const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows);
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
settings.ServerID = systemInfo?.Id || null;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}
}
res.send(rows);
} catch (error) {
console.log(error);
@@ -400,7 +466,7 @@ router.get("/TrackedLibraries", async (req, res) => {
}
try {
const libraries = await Jellyfin.getLibraries();
const libraries = await API.getLibraries();
const ExcludedLibraries = config.settings?.ExcludedLibraries || [];
@@ -585,6 +651,12 @@ router.post("/setTaskSettings", async (req, res) => {
return;
}
if (!Number.isInteger(Interval) && Interval <= 0) {
res.status(400);
res.send("A valid Interval(int) which is > 0 minutes is required");
return;
}
try {
const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows);
@@ -723,7 +795,7 @@ router.post("/getEpisodes", async (req, res) => {
}
const { rows } = await db.query(
`SELECT e.*, i."PrimaryImageHash" FROM jf_library_episodes e left join jf_library_items i on i."Id"=e."SeriesId" where "SeasonId"=$1`,
`SELECT e.*, i."PrimaryImageHash", ii."Size" FROM jf_library_episodes e left join jf_library_items i on i."Id"=e."SeriesId" join jf_item_info ii on ii."Id"=e."EpisodeId" where "SeasonId"=$1`,
[Id]
);
res.send(rows);
@@ -742,6 +814,8 @@ router.post("/getItemDetails", async (req, res) => {
}
// let query = `SELECT im."Name" "FileName",im.*,i.* FROM jf_library_items i left join jf_item_info im on i."Id" = im."Id" where i."Id"=$1`;
let query = `SELECT im."Name" "FileName",im."Id",im."Path",im."Name",im."Bitrate",im."MediaStreams",im."Type", COALESCE(im."Size" ,(SELECT SUM(im."Size") FROM jf_library_seasons s JOIN jf_library_episodes e on s."Id"=e."SeasonId" JOIN jf_item_info im ON im."Id" = e."EpisodeId" WHERE s."SeriesId" = i."Id")) "Size",i.*, (select "Name" from jf_libraries l where l."Id"=i."ParentId") "LibraryName" FROM jf_library_items i left join jf_item_info im on i."Id" = im."Id" where i."Id"=$1`;
let maxActivityQuery = `SELECT MAX("ActivityDateInserted") "LastActivityDate" FROM public.jf_playback_activity`;
let activityCountQuery = `SELECT Count("ActivityDateInserted") "times_played", SUM("PlaybackDuration") "total_play_time" FROM public.jf_playback_activity`;
const { rows: items } = await db.query(query, [Id]);
@@ -755,14 +829,44 @@ router.post("/getItemDetails", async (req, res) => {
const { rows: episodes } = await db.query(query, [Id]);
if (episodes.length !== 0) {
maxActivityQuery = `${maxActivityQuery} where "EpisodeId"=$1`;
activityCountQuery = `${activityCountQuery} where "EpisodeId"=$1`;
const LastActivityDate = await db.querySingle(maxActivityQuery, [Id]);
const TimesPlayed = await db.querySingle(activityCountQuery, [Id]);
episodes.forEach((episode) => {
episode.LastActivityDate = LastActivityDate.LastActivityDate ?? null;
episode.times_played = TimesPlayed.times_played ?? null;
episode.total_play_time = TimesPlayed.total_play_time ?? null;
});
res.send(episodes);
} else {
res.status(404).send("Item not found");
}
} else {
maxActivityQuery = `${maxActivityQuery} where "SeasonId"=$1`;
activityCountQuery = `${activityCountQuery} where "SeasonId"=$1`;
const LastActivityDate = await db.querySingle(maxActivityQuery, [Id]);
const TimesPlayed = await db.querySingle(activityCountQuery, [Id]);
seasons.forEach((season) => {
season.LastActivityDate = LastActivityDate.LastActivityDate ?? null;
season.times_played = TimesPlayed.times_played ?? null;
season.total_play_time = TimesPlayed.total_play_time ?? null;
});
res.send(seasons);
}
} else {
maxActivityQuery = `${maxActivityQuery} where "NowPlayingItemId"=$1`;
activityCountQuery = `${activityCountQuery} where "NowPlayingItemId"=$1`;
const LastActivityDate = await db.querySingle(maxActivityQuery, [Id]);
const TimesPlayed = await db.querySingle(activityCountQuery, [Id]);
items.forEach((item) => {
item.LastActivityDate = LastActivityDate.LastActivityDate ?? null;
item.times_played = TimesPlayed.times_played ?? null;
item.total_play_time = TimesPlayed.total_play_time ?? null;
});
res.send(items);
}
} catch (error) {
@@ -881,15 +985,76 @@ router.delete("/libraryItems/purge", async (req, res) => {
}
});
router.get("/getBackupTables", async (req, res) => {
try {
const config = await new configClass().getConfig();
const excluded_tables = config.settings.ExcludedTables || [];
let backupTables = tables.map((table) => {
return {
...table,
Excluded: excluded_tables.includes(table.value),
};
});
res.send(backupTables);
return;
} catch (error) {
res.status(503);
res.send(error);
}
});
router.post("/setExcludedBackupTable", async (req, res) => {
const { table } = req.body;
if (table === undefined || tables.map((item) => item.value).indexOf(table) === -1) {
res.status(400);
res.send("Invalid table provided");
return;
}
const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows);
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
let excludedTables = settings.ExcludedTables || [];
if (excludedTables.includes(table)) {
excludedTables = excludedTables.filter((item) => item !== table);
} else {
excludedTables.push(table);
}
settings.ExcludedTables = excludedTables;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
let backupTables = tables.map((table) => {
return {
...table,
Excluded: settings.ExcludedTables.includes(table.value),
};
});
res.send(backupTables);
} else {
res.status(404);
res.send("Settings not found");
}
});
//DB Queries - History
router.get("/getHistory", async (req, res) => {
try {
const { rows } = await db.query(`
SELECT a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber"
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 groupedResults = groupActivity(rows);
@@ -976,11 +1141,13 @@ router.post("/getUserHistory", async (req, res) => {
}
const { rows } = await db.query(
`select a.*, e."IndexNumber" "EpisodeNumber",e."ParentIndexNumber" "SeasonNumber"
`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]
);
@@ -1015,4 +1182,9 @@ router.post("/deletePlaybackActivity", async (req, res) => {
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports = router;

View File

@@ -4,8 +4,7 @@ const db = require("../db");
const jwt = require("jsonwebtoken");
const configClass = require("../classes/config");
const packageJson = require("../../package.json");
const JellyfinAPI = require("../classes/jellyfin-api");
const Jellyfin = new JellyfinAPI();
const API = require("../classes/api-loader");
const JWT_SECRET = process.env.JWT_SECRET;
const JS_USER = process.env.JS_USER;
@@ -98,7 +97,7 @@ router.post("/configSetup", async (req, res) => {
var url = JF_HOST;
const validation = await Jellyfin.validateSettings(url, JF_API_KEY);
const validation = await API.validateSettings(url, JF_API_KEY);
if (validation.isValid === false) {
res.status(validation.status);
res.send(validation);
@@ -114,6 +113,22 @@ router.post("/configSetup", async (req, res) => {
}
const { rows } = await db.query(query, [validation.cleanedUrl, JF_API_KEY]);
const systemInfo = await API.systemInfo();
if (systemInfo && systemInfo != {}) {
const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows);
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
settings.Tasks = systemInfo?.Id || null;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}
}
res.send(rows);
} else {
res.sendStatus(500);
@@ -123,4 +138,9 @@ router.post("/configSetup", async (req, res) => {
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports = router;

View File

@@ -1,18 +1,17 @@
const express = require("express");
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const moment = require('moment');
const { randomUUID } = require('crypto');
const multer = require('multer');
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('./logging');
const triggertype = require('../logging/triggertype');
const taskstate = require('../logging/taskstate');
const taskName = require('../logging/taskName');
const { sendUpdate } = require('../ws');
const { sendUpdate } = require("../ws");
const db = require("../db");
const router = express.Router();
@@ -22,149 +21,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';
// Tables to back up
const tables = ['jf_libraries', 'jf_library_items', 'jf_library_seasons','jf_library_episodes','jf_users','jf_playback_activity','jf_playback_reporting_plugin_data','jf_item_info'];
function checkFolderWritePermission(folderPath) {
try {
const testFile = `${folderPath}/.writableTest`;
fs.writeFileSync(testFile, '');
fs.unlinkSync(testFile);
return true;
} catch (error) {
return false;
}
}
// Backup function
async function backup(refLog) {
refLog.logData.push({ color: "lawngreen", Message: "Starting Backup" });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
host: postgresIp,
port: postgresPort,
database: postgresDatabase
});
// Get data from each table and append it to the backup file
try{
let now = moment();
const backuppath='./'+backupfolder;
if (!fs.existsSync(backuppath)) {
fs.mkdirSync(backuppath);
console.log('Directory created successfully!');
}
if (!checkFolderWritePermission(backuppath)) {
console.error('No write permissions for the folder:', backuppath);
refLog.logData.push({ color: "red", Message: "Backup Failed: No write permissions for the folder: "+backuppath });
refLog.logData.push({ color: "red", Message: "Backup Failed with errors"});
Logging.updateLog(refLog.uuid,refLog.loggedData,taskstate.FAILED);
await pool.end();
return;
}
// const backupPath = `../backup-data/backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`;
const directoryPath = path.join(__dirname, '..', backupfolder,`backup_${now.format('yyyy-MM-DD HH-mm-ss')}.json`);
const stream = fs.createWriteStream(directoryPath, { flags: 'a' });
stream.on('error', (error) => {
refLog.logData.push({ color: "red", Message: "Backup Failed: "+error });
Logging.updateLog(refLog.uuid,refLog.loggedData,taskstate.FAILED);
return;
});
const backup_data=[];
refLog.logData.push({ color: "yellow", Message: "Begin Backup "+directoryPath });
for (let table of tables) {
const query = `SELECT * FROM ${table}`;
const { rows } = await pool.query(query);
refLog.logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
backup_data.push({[table]:rows});
}
await stream.write(JSON.stringify(backup_data));
stream.end();
refLog.logData.push({ color: "lawngreen", Message: "Backup Complete" });
refLog.logData.push({ color: "dodgerblue", Message: "Removing old backups" });
//Cleanup excess backups
let deleteCount=0;
const directoryPathDelete = path.join(__dirname, '..', backupfolder);
const files = await new Promise((resolve, reject) => {
fs.readdir(directoryPathDelete, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
let fileData = files.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(directoryPathDelete, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime
};
});
fileData = fileData.sort((a, b) => new Date(b.datecreated) - new Date(a.datecreated)).slice(5);
for (var oldBackup of fileData) {
const oldBackupFile = path.join(__dirname, '..', backupfolder, oldBackup.name);
await new Promise((resolve, reject) => {
fs.unlink(oldBackupFile, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
deleteCount += 1;
refLog.logData.push({ color: "yellow", Message: `${oldBackupFile} has been deleted.` });
}
refLog.logData.push({ color: "lawngreen", Message: deleteCount+" backups removed." });
}catch(error)
{
console.log(error);
refLog.logData.push({ color: "red", Message: "Backup Failed: "+error });
Logging.updateLog(refLog.uuid,refLog.loggedData,taskstate.FAILED);
}
await pool.end();
}
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;
@@ -175,223 +39,193 @@ function readFile(path) {
});
}
async function restore(file,refLog) {
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: "yellow", Message: "Restoring from Backup: " + file });
const pool = new Pool({
user: postgresUser,
password: postgresPassword,
host: postgresIp,
port: postgresPort,
database: postgresDatabase
database: postgresDatabase,
});
const backupPath = file;
const backupPath = file;
let jsonData;
try {
// 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`});
Logging.updateLog(refLog.uuid,refLog.logData,taskstate.FAILED);
console.error(err);
}
// console.log(jsonData);
if(!jsonData)
{
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}`});
for(let index in data)
{
const keysWithQuotes = Object.keys(data[index]).map(key => `"${key}"`);
const keyString = keysWithQuotes.join(", ");
const valuesWithQuotes = Object.values(data[index]).map(col => {
if (col === null) {
return 'NULL';
} else if (typeof col === 'string') {
return `'${col.replace(/'/g, "''")}'`;
}else if (typeof col === 'object') {
return `'${JSON.stringify(col).replace(/'/g, "''")}'`;
} else {
return `'${col}'`;
}
});
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" });
let jsonData;
try {
// 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` });
Logging.updateLog(refLog.uuid, refLog.logData, taskstate.FAILED);
console.error(err);
}
// console.log(jsonData);
if (!jsonData) {
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}` });
for (let index in data) {
const keysWithQuotes = Object.keys(data[index]).map((key) => `"${key}"`);
const keyString = keysWithQuotes.join(", ");
const valuesWithQuotes = Object.values(data[index]).map((col) => {
if (col === null) {
return "NULL";
} else if (typeof col === "string") {
return `'${col.replace(/'/g, "''")}'`;
} else if (typeof col === "object") {
return `'${JSON.stringify(col).replace(/'/g, "''")}'`;
} else {
return `'${col}'`;
}
});
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" });
}
// Route handler for backup endpoint
router.get('/beginBackup', async (req, res) => {
router.get("/beginBackup", async (req, res) => {
try {
const last_execution=await db.query( `SELECT "Result"
const last_execution = await db
.query(
`SELECT "Result"
FROM public.jf_logging
WHERE "Name"='${taskName.backup}'
ORDER BY "TimeRun" DESC
LIMIT 1`).then((res) => res.rows);
LIMIT 1`
)
.then((res) => res.rows);
if(last_execution.length!==0)
{
if(last_execution[0].Result ===taskstate.RUNNING)
{
sendUpdate("TaskError","Error: Backup is already running");
res.send();
return;
if (last_execution.length !== 0) {
if (last_execution[0].Result === taskstate.RUNNING) {
sendUpdate("TaskError", "Error: Backup is already running");
res.send();
return;
}
}
const uuid = randomUUID();
let refLog={logData:[],uuid:uuid};
Logging.insertLog(uuid,triggertype.Manual,taskName.backup);
let refLog = { logData: [], uuid: uuid };
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"});
Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS);
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);
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 filePath = path.join(__dirname, '..', backupfolder, req.params.filename);
await restore(filePath, refLog);
Logging.updateLog(uuid, refLog.logData, taskstate.SUCCESS);
await restore(filePath,refLog);
Logging.updateLog(uuid,refLog.logData,taskstate.SUCCESS);
res.send("Restore completed successfully");
sendUpdate("TaskComplete", { message: "Restore completed successfully" });
} catch (error) {
console.error(error);
res.status(500).send("Restore failed");
}
});
res.send('Restore completed successfully');
sendUpdate("TaskComplete",{message:"Restore completed successfully"});
} catch (error) {
console.error(error);
res.status(500).send('Restore failed');
}
});
router.get('/files', (req, res) => {
try
{
const directoryPath = path.join(__dirname, '..', backupfolder);
router.get("/files", (req, res) => {
try {
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 => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime
};
});
const fileData = files
.filter((file) => file.endsWith(".json"))
.map((file) => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
datecreated: stats.birthtime,
};
});
res.json(fileData);
}
});
} catch (error) {
console.log(error);
}
});
}catch(error)
{
console.log(error);
}
//download backup file
router.get("/files/:filename", (req, res) => {
const filePath = path.join(__dirname, "..", backupfolder, req.params.filename);
res.download(filePath);
});
});
//delete backup
router.delete("/files/:filename", (req, res) => {
try {
const filePath = path.join(__dirname, "..", backupfolder, req.params.filename);
fs.unlink(filePath, (err) => {
if (err) {
console.error(err);
res.status(500).send("An error occurred while deleting the file.");
return;
}
//download backup file
router.get('/files/:filename', (req, res) => {
const filePath = path.join(__dirname, '..', backupfolder, req.params.filename);
res.download(filePath);
});
//delete backup
router.delete('/files/:filename', (req, res) => {
try{
const filePath = path.join(__dirname, '..', backupfolder, req.params.filename);
fs.unlink(filePath, (err) => {
if (err) {
console.error(err);
res.status(500).send('An error occurred while deleting the file.');
return;
}
console.log(`${filePath} has been deleted.`);
res.status(200).send(`${filePath} has been deleted.`);
});
}catch(error)
{
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
},
filename: function (req, file, cb) {
cb(null, file.originalname); // Set the file name
},
});
const upload = multer({ storage: storage });
router.post("/upload", upload.single("file"), (req, res) => {
// Handle the uploaded file here
res.json({
fileName: req.file.originalname,
filePath: req.file.path,
console.log(`${filePath} has been deleted.`);
res.status(200).send(`${filePath} has been deleted.`);
});
} catch (error) {
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
},
filename: function (req, file, cb) {
cb(null, file.originalname); // Set the file name
},
});
const upload = multer({ storage: storage });
router.post("/upload", upload.single("file"), (req, res) => {
// Handle the uploaded file here
res.json({
fileName: req.file.originalname,
filePath: req.file.path,
});
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports =
{
router,
backup
};
module.exports = router;

View File

@@ -1,8 +1,5 @@
const db = require("../db");
const moment = require("moment");
const taskstate = require("../logging/taskstate");
const { jf_logging_columns, jf_logging_mapping } = require("../models/jf_logging");
const express = require("express");
const router = express.Router();
// #swagger.tags = ['Logs']
@@ -15,54 +12,9 @@ router.get("/getLogs", async (req, res) => {
}
});
async function insertLog(uuid, triggertype, taskType) {
try {
let startTime = moment();
const log = {
Id: uuid,
Name: taskType,
Type: "Task",
ExecutionType: triggertype,
Duration: 0,
TimeRun: startTime,
Log: JSON.stringify([{}]),
Result: taskstate.RUNNING,
};
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
await db.insertBulk("jf_logging", log, jf_logging_columns);
} catch (error) {
console.log(error);
return [];
}
}
async function updateLog(uuid, data, taskstate) {
try {
const { rows: task } = await db.query(`SELECT "TimeRun" FROM jf_logging WHERE "Id" = '${uuid}';`);
if (task.length === 0) {
console.log("Unable to find task to update");
} else {
let endtime = moment();
let startTime = moment(task[0].TimeRun);
let duration = endtime.diff(startTime, "seconds");
const log = {
Id: uuid,
Name: "NULL Placeholder",
Type: "Task",
ExecutionType: "NULL Placeholder",
Duration: duration,
TimeRun: startTime,
Log: JSON.stringify(data),
Result: taskstate,
};
await db.insertBulk("jf_logging", log, jf_logging_columns);
}
} catch (error) {
console.log(error);
return [];
}
}
module.exports = { router, insertLog, updateLog };
module.exports = router;

View File

@@ -2,9 +2,8 @@ const express = require("express");
const { axios } = require("../classes/axios");
const configClass = require("../classes/config");
const JellyfinAPI = require("../classes/jellyfin-api");
const API = require("../classes/api-loader");
const Jellyfin = new JellyfinAPI();
const router = express.Router();
router.get("/web/assets/img/devices/", async (req, res) => {
@@ -17,6 +16,9 @@ router.get("/web/assets/img/devices/", async (req, res) => {
}
let url = `${config.JF_HOST}/web/assets/img/devices/${devicename}.svg`;
if (config.IS_JELLYFIN == false) {
url = `https://raw.githubusercontent.com/MediaBrowser/Emby.Resources/master/images/devices/${devicename}.png`;
}
axios
.get(url, {
@@ -133,7 +135,7 @@ router.get("/Users/Images/Primary/", async (req, res) => {
router.get("/getSessions", async (req, res) => {
try {
const sessions = await Jellyfin.getSessions();
const sessions = await API.getSessions();
res.send(sessions);
} catch (error) {
res.status(503);
@@ -143,7 +145,7 @@ router.get("/getSessions", async (req, res) => {
router.get("/getAdminUsers", async (req, res) => {
try {
const adminUser = await Jellyfin.getAdmins();
const adminUser = await API.getAdmins();
res.send(adminUser);
} catch (error) {
res.status(503);
@@ -155,7 +157,7 @@ router.get("/getRecentlyAdded", async (req, res) => {
try {
const { libraryid } = req.query;
const recentlyAdded = await Jellyfin.getRecentlyAdded({ libraryid: libraryid });
const recentlyAdded = await API.getRecentlyAdded({ libraryid: libraryid });
res.send(recentlyAdded);
} catch (error) {
res.status(503);
@@ -163,7 +165,7 @@ router.get("/getRecentlyAdded", async (req, res) => {
}
});
//Jellyfin related functions
//API related functions
router.post("/validateSettings", async (req, res) => {
const { url, apikey } = req.body;
@@ -174,7 +176,7 @@ router.post("/validateSettings", async (req, res) => {
return;
}
const validation = await Jellyfin.validateSettings(url, apikey);
const validation = await API.validateSettings(url, apikey);
if (validation.isValid === false) {
res.status(validation.status);
res.send(validation.errorMessage);
@@ -183,4 +185,9 @@ router.post("/validateSettings", async (req, res) => {
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports = router;

View File

@@ -1,10 +1,39 @@
// api.js
const express = require("express");
const db = require("../db");
const moment = require("moment");
const router = express.Router();
//functions
function countOverlapsPerHour(records) {
const hourCounts = {};
records.forEach((record) => {
const start = moment(record.StartTime).subtract(1, "hour");
const end = moment(record.EndTime).add(1, "hour");
// Iterate through each hour from start to end
for (let hour = start.clone().startOf("hour"); hour.isBefore(end); hour.add(1, "hour")) {
const hourKey = hour.format("MMM DD, YY HH:00");
if (!hourCounts[hourKey]) {
hourCounts[hourKey] = { Transcodes: 0, DirectPlays: 0 };
}
if (record.PlayMethod === "Transcode") {
hourCounts[hourKey].Transcodes++;
} else {
hourCounts[hourKey].DirectPlays++;
}
}
});
// Convert the hourCounts object to an array of key-value pairs, sort it, and convert it back to an object
const sortedHourCounts = Object.fromEntries(Object.entries(hourCounts).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)));
return sortedHourCounts;
}
//endpoints
router.get("/getLibraryOverview", async (req, res) => {
try {
@@ -18,35 +47,29 @@ router.get("/getLibraryOverview", async (req, res) => {
router.post("/getMostViewedByType", async (req, res) => {
try {
const { days,type } = req.body;
const { days, type } = req.body;
const valid_types=['Audio','Movie','Series'];
const valid_types = ["Audio", "Movie", "Series"];
let _days = days;
if (days === undefined) {
_days = 30;
}
if(!valid_types.includes(type))
{
if (!valid_types.includes(type)) {
res.status(503);
return res.send(`Invalid Type Value.\nValid Types: ${JSON.stringify(valid_types)}`);
}
if(isNaN(parseFloat(days)))
{
if (isNaN(parseFloat(days))) {
res.status(503);
return res.send(`Days needs to be a number.`);
}
if(Number(days)<0)
{
if (Number(days) < 0) {
res.status(503);
return res.send(`Days cannot be less than 0`);
}
const { rows } = await db.query(
`select * from fs_most_played_items($1,'${type}') limit 5`, [_days-1]
);
const { rows } = await db.query(`select * from fs_most_played_items($1,'${type}') limit 5`, [_days - 1]);
res.send(rows);
} catch (error) {
res.status(503);
@@ -56,24 +79,21 @@ router.post("/getMostViewedByType", async (req, res) => {
router.post("/getMostPopularByType", async (req, res) => {
try {
const { days,type } = req.body;
const { days, type } = req.body;
const valid_types=['Audio','Movie','Series'];
const valid_types = ["Audio", "Movie", "Series"];
let _days = days;
if (days === undefined) {
_days = 30;
}
if(!valid_types.includes(type))
{
if (!valid_types.includes(type)) {
res.status(503);
return res.send('Invalid Type Value');
return res.send("Invalid Type Value");
}
const { rows } = await db.query(
`select * from fs_most_popular_items($1,$2) limit 5`, [_days-1, type]
);
const { rows } = await db.query(`select * from fs_most_popular_items($1,$2) limit 5`, [_days - 1, type]);
res.send(rows);
} catch (error) {
res.status(503);
@@ -81,8 +101,6 @@ router.post("/getMostPopularByType", async (req, res) => {
}
});
router.post("/getMostViewedLibraries", async (req, res) => {
try {
const { days } = req.body;
@@ -90,9 +108,7 @@ router.post("/getMostViewedLibraries", async (req, res) => {
if (days === undefined) {
_days = 30;
}
const { rows } = await db.query(
`select * from fs_most_viewed_libraries($1)`, [_days-1]
);
const { rows } = await db.query(`select * from fs_most_viewed_libraries($1)`, [_days - 1]);
res.send(rows);
} catch (error) {
res.status(503);
@@ -107,9 +123,7 @@ router.post("/getMostUsedClient", async (req, res) => {
if (days === undefined) {
_days = 30;
}
const { rows } = await db.query(
`select * from fs_most_used_clients($1) limit 5`, [_days-1]
);
const { rows } = await db.query(`select * from fs_most_used_clients($1) limit 5`, [_days - 1]);
res.send(rows);
} catch (error) {
res.status(503);
@@ -124,17 +138,14 @@ router.post("/getMostActiveUsers", async (req, res) => {
if (days === undefined) {
_days = 30;
}
const { rows } = await db.query(
`select * from fs_most_active_user($1) limit 5`, [_days-1]
);
res.send(rows);
const { rows } = await db.query(`select * from fs_most_active_user($1) limit 5`, [_days - 1]);
res.send(rows);
} catch (error) {
res.status(503);
res.send(error);
}
});
router.get("/getPlaybackActivity", async (req, res) => {
try {
const { rows } = await db.query("SELECT * FROM jf_playback_activity");
@@ -154,13 +165,10 @@ router.get("/getAllUserActivity", async (req, res) => {
}
});
router.post("/getUserLastPlayed", async (req, res) => {
try {
const { userid } = req.body;
const { rows } = await db.query(
`select * from fs_last_user_activity($1) limit 15`, [userid]
);
const { rows } = await db.query(`select * from fs_last_user_activity($1) limit 15`, [userid]);
res.send(rows);
} catch (error) {
console.log(error);
@@ -172,14 +180,12 @@ router.post("/getUserLastPlayed", async (req, res) => {
//Global Stats
router.post("/getGlobalUserStats", async (req, res) => {
try {
const { hours,userid } = req.body;
const { hours, userid } = req.body;
let _hours = hours;
if (hours === undefined) {
_hours = 24;
}
const { rows } = await db.query(
`select * from fs_user_stats($1,$2)`, [_hours, userid]
);
const { rows } = await db.query(`select * from fs_user_stats($1,$2)`, [_hours, userid]);
res.send(rows[0]);
} catch (error) {
console.log(error);
@@ -190,7 +196,7 @@ router.post("/getGlobalUserStats", async (req, res) => {
router.post("/getGlobalItemStats", async (req, res) => {
try {
const { hours,itemid } = req.body;
const { hours, itemid } = req.body;
let _hours = hours;
if (hours === undefined) {
_hours = 24;
@@ -201,7 +207,8 @@ router.post("/getGlobalItemStats", async (req, res) => {
from jf_playback_activity jf_playback_activity
where
("EpisodeId"=$1 OR "SeasonId"=$1 OR "NowPlayingItemId"=$1)
AND jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - INTERVAL '1 hour' * $2 AND NOW();`, [itemid, _hours]
AND jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - INTERVAL '1 hour' * $2 AND NOW();`,
[itemid, _hours]
);
res.send(rows[0]);
} catch (error) {
@@ -213,14 +220,12 @@ router.post("/getGlobalItemStats", async (req, res) => {
router.post("/getGlobalLibraryStats", async (req, res) => {
try {
const { hours,libraryid } = req.body;
const { hours, libraryid } = req.body;
let _hours = hours;
if (hours === undefined) {
_hours = 24;
}
const { rows } = await db.query(
`select * from fs_library_stats($1,$2)`, [_hours, libraryid]
);
const { rows } = await db.query(`select * from fs_library_stats($1,$2)`, [_hours, libraryid]);
res.send(rows[0]);
} catch (error) {
console.log(error);
@@ -241,16 +246,13 @@ router.get("/getLibraryCardStats", async (req, res) => {
router.post("/getLibraryCardStats", async (req, res) => {
try {
const {libraryid } = req.body;
if(libraryid === undefined)
{
const { libraryid } = req.body;
if (libraryid === undefined) {
res.status(503);
return res.send('Invalid Library Id');
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 * from js_library_stats_overview where "Id"=$1`, [libraryid]);
res.send(rows[0]);
} catch (error) {
console.log(error);
@@ -259,8 +261,6 @@ router.post("/getLibraryCardStats", async (req, res) => {
}
});
router.get("/getLibraryMetadata", async (req, res) => {
try {
const { rows } = await db.query("select * from js_library_metadata");
@@ -272,29 +272,127 @@ router.get("/getLibraryMetadata", async (req, res) => {
});
router.post("/getLibraryItemsWithStats", async (req, res) => {
try{
const {libraryid} = req.body;
const { rows } = await db.query(
`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"=$1`, [libraryid]
);
try {
const { libraryid } = req.body;
const { rows } = await db.query(`SELECT * FROM jf_library_items_with_playcount_playtime where "ParentId"=$1`, [libraryid]);
res.send(rows);
}catch(error)
{
} catch (error) {
console.log(error);
}
});
router.post("/getLibraryItemsPlayMethodStats", async (req, res) => {
try {
let { libraryid, startDate, endDate = moment(), hours = 24 } = req.body;
// Validate startDate and endDate using moment
if (
startDate !== undefined &&
(!moment(startDate, moment.ISO_8601, true).isValid() || !moment(endDate, moment.ISO_8601, true).isValid())
) {
return res.status(400).send({ error: "Invalid date format" });
}
if (hours < 1) {
return res.status(400).send({ error: "Hours cannot be less than 1" });
}
if (libraryid === undefined) {
return res.status(400).send({ error: "Invalid Library Id" });
}
if (startDate === undefined) {
startDate = moment(endDate).subtract(hours, "hour").format("YYYY-MM-DD HH:mm:ss");
}
const { rows } = await db.query(
`select a.*,i."ParentId"
from jf_playback_activity a
left
join jf_library_episodes e
on a."EpisodeId"=e."EpisodeId"
join jf_library_items i
on i."Id"=a."NowPlayingItemId" or e."SeriesId"=i."Id"
where i."ParentId"=$1
and a."ActivityDateInserted" BETWEEN $2 AND $3
order by a."ActivityDateInserted" desc;
`,
[libraryid, startDate, endDate]
);
const stats = rows.map((item) => {
return {
Id: item.NowPlayingItemId,
UserId: item.UserId,
UserName: item.UserName,
Client: item.Client,
DeviceName: item.DeviceName,
NowPlayingItemName: item.NowPlayingItemName,
EpisodeId: item.EpisodeId || null,
SeasonId: item.SeasonId || null,
StartTime: moment(item.ActivityDateInserted).subtract(item.PlaybackDuration, "seconds").format("YYYY-MM-DD HH:mm:ss"),
EndTime: moment(item.ActivityDateInserted).format("YYYY-MM-DD HH:mm:ss"),
PlaybackDuration: item.PlaybackDuration,
PlayMethod: item.PlayMethod,
TranscodedVideo: item.TranscodingInfo?.IsVideoDirect || false,
TranscodedAudio: item.TranscodingInfo?.IsAudioDirect || false,
ParentId: item.ParentId,
};
});
let countedstats = countOverlapsPerHour(stats);
let hoursRes = {
types: [
{ Id: "Transcodes", Name: "Transcodes" },
{ Id: "DirectPlays", Name: "DirectPlays" },
],
stats: Object.keys(countedstats).map((key) => {
return {
Key: key,
Transcodes: countedstats[key].Transcodes,
DirectPlays: countedstats[key].DirectPlays,
};
}),
};
res.send(hoursRes);
} catch (error) {
console.log(error);
res.send(error);
}
});
router.post("/getPlaybackMethodStats", async (req, res) => {
try {
const { days = 30 } = req.body;
if (days < 0) {
res.status(503);
return res.send("Days cannot be less than 0");
}
const { rows } = await db.query(
`select a."PlayMethod" "Name",count(a."PlayMethod") "Count"
from jf_playback_activity a
WHERE a."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => $1) AND NOW()
Group by a."PlayMethod"
ORDER BY (count(*)) DESC;
`,
[days - 1]
);
res.send(rows);
} catch (error) {
console.log(error);
res.send(error);
}
});
router.post("/getLibraryLastPlayed", async (req, res) => {
try {
const { libraryid } = req.body;
const { rows } = await db.query(
`select * from fs_last_library_activity($1) limit 15`, [libraryid]
);
const { rows } = await db.query(`select * from fs_last_library_activity($1) limit 15`, [libraryid]);
res.send(rows);
} catch (error) {
console.log(error);
@@ -303,44 +401,37 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
}
});
router.post("/getViewsOverTime", async (req, res) => {
try {
const { days } = req.body;
let _days = days;
if (days=== undefined) {
if (days === undefined) {
_days = 30;
}
const { rows:stats } = await db.query(
`select * from fs_watch_stats_over_time($1)`, [_days]
);
const { rows: stats } = await db.query(`select * from fs_watch_stats_over_time($1)`, [_days]);
const { rows:libraries } = await db.query(
`select distinct "Id","Name" from jf_libraries`
);
const { rows: libraries } = await db.query(`select distinct "Id","Name" from jf_libraries where archived=false`);
const reorganizedData = {};
const reorganizedData = {};
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const date = new Date(item.Date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit'
});
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const date = new Date(item.Date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
});
if (!reorganizedData[date]) {
reorganizedData[date] = {
Key: date,
};
}
if (!reorganizedData[date]) {
reorganizedData[date] = {
Key:date
};
}
reorganizedData[date]= { ...reorganizedData[date], [library]: count};
});
const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
reorganizedData[date] = { ...reorganizedData[date], [library]: count };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);
} catch (error) {
console.log(error);
@@ -353,35 +444,29 @@ router.post("/getViewsByDays", async (req, res) => {
try {
const { days } = req.body;
let _days = days;
if (days=== undefined) {
if (days === undefined) {
_days = 30;
}
const { rows:stats } = await db.query(
`select * from fs_watch_stats_popular_days_of_week($1)`, [_days]
);
const { rows: stats } = await db.query(`select * from fs_watch_stats_popular_days_of_week($1)`, [_days]);
const { rows:libraries } = await db.query(
`select distinct "Id","Name" from jf_libraries`
);
const { rows: libraries } = await db.query(`select distinct "Id","Name" from jf_libraries where archived=false`);
const reorganizedData = {};
const reorganizedData = {};
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const day = item.Day;
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const day = item.Day;
if (!reorganizedData[day]) {
reorganizedData[day] = {
Key: day,
};
}
if (!reorganizedData[day]) {
reorganizedData[day] = {
Key:day
};
}
reorganizedData[day]= { ...reorganizedData[day], [library]: count};
});
const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
reorganizedData[day] = { ...reorganizedData[day], [library]: count };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);
} catch (error) {
console.log(error);
@@ -394,35 +479,29 @@ router.post("/getViewsByHour", async (req, res) => {
try {
const { days } = req.body;
let _days = days;
if (days=== undefined) {
if (days === undefined) {
_days = 30;
}
const { rows:stats } = await db.query(
`select * from fs_watch_stats_popular_hour_of_day($1)`, [_days]
);
const { rows: stats } = await db.query(`select * from fs_watch_stats_popular_hour_of_day($1)`, [_days]);
const { rows:libraries } = await db.query(
`select distinct "Id","Name" from jf_libraries`
);
const { rows: libraries } = await db.query(`select distinct "Id","Name" from jf_libraries where archived=false`);
const reorganizedData = {};
const reorganizedData = {};
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const hour = item.Hour;
stats.forEach((item) => {
const library = item.Library;
const count = item.Count;
const hour = item.Hour;
if (!reorganizedData[hour]) {
reorganizedData[hour] = {
Key: hour,
};
}
if (!reorganizedData[hour]) {
reorganizedData[hour] = {
Key:hour
};
}
reorganizedData[hour]= { ...reorganizedData[hour], [library]: count};
});
const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
reorganizedData[hour] = { ...reorganizedData[hour], [library]: count };
});
const finalData = { libraries: libraries, stats: Object.values(reorganizedData) };
res.send(finalData);
} catch (error) {
console.log(error);
@@ -431,7 +510,9 @@ const finalData = {libraries:libraries,stats:Object.values(reorganizedData)};
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports = router;

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,37 @@
const {axios} = require("../classes/axios");
const { axios } = require("../classes/axios");
const express = require("express");
const router = express.Router();
const geoliteUrlBase = 'https://geolite.info/geoip/v2.1/city';
const geoliteUrlBase = "https://geolite.info/geoip/v2.1/city";
const geoliteAccountId = process.env.JS_GEOLITE_ACCOUNT_ID;
const geoliteLicenseKey = process.env.JS_GEOLITE_LICENSE_KEY;
//https://stackoverflow.com/a/29268025
const ipRegex = new RegExp(/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/);
const ipRegex = new RegExp(
/\b(?!(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168))(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
);
router.post("/geolocateIp", async (req, res) => {
try {
if(!(geoliteAccountId && geoliteLicenseKey)) {
return res.status(501).send('GeoLite information missing!');
if (!(geoliteAccountId && geoliteLicenseKey)) {
return res.status(501).send("GeoLite information missing!");
}
const { ipAddress } = req.body;
ipRegex.lastIndex = 0;
if(!ipAddress || !ipRegex.test(ipAddress)) {
return res.status(400).send('Invalid IP address sent!');
if (!ipAddress || !ipRegex.test(ipAddress)) {
return res.status(400).send("Invalid IP address sent!");
}
const response = await axios.get(`${geoliteUrlBase}/${ipAddress}`, {
auth: {
username: geoliteAccountId,
password: geoliteLicenseKey
}
});
auth: {
username: geoliteAccountId,
password: geoliteLicenseKey,
},
});
return res.send(response.data);
} catch (error) {
res.status(503);
@@ -39,4 +39,9 @@ router.post("/geolocateIp", async (req, res) => {
}
});
module.exports = router;
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });
});
module.exports = router;

View File

@@ -1,6 +1,7 @@
// core
require("dotenv").config();
const http = require("http");
const fs = require("fs");
const path = require("path");
const express = require("express");
const compression = require("compression");
@@ -21,8 +22,8 @@ const apiRouter = require("./routes/api");
const proxyRouter = require("./routes/proxy");
const { router: syncRouter } = require("./routes/sync");
const statsRouter = require("./routes/stats");
const { router: backupRouter } = require("./routes/backup");
const { router: logRouter } = require("./routes/logging");
const backupRouter = require("./routes/backup");
const logRouter = require("./routes/logging");
const utilsRouter = require("./routes/utils");
// tasks
@@ -31,13 +32,29 @@ const tasks = require("./tasks/tasks");
// websocket
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;
const app = express();
const db = knex(knexConfig.development);
const ensureSlashes = (url) => {
if (!url.startsWith("/")) {
url = "/" + url;
}
if (url.endsWith("/")) {
url = url.slice(0, -1);
}
return url;
};
const PORT = 3000;
const LISTEN_IP = "0.0.0.0";
const JWT_SECRET = process.env.JWT_SECRET;
const BASE_NAME = process.env.JS_BASE_URL ? ensureSlashes(process.env.JS_BASE_URL) : "";
if (JWT_SECRET === undefined) {
console.log("JWT Secret cannot be undefined");
@@ -51,8 +68,79 @@ app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use(compression());
function typeInferenceMiddleware(req, res, next) {
Object.keys(req.query).forEach((key) => {
const value = req.query[key];
if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
// Convert to boolean
req.query[key] = value.toLowerCase() === "true";
} else if (!isNaN(value) && value.trim() !== "") {
// Convert to number if it's a valid number
req.query[key] = +value;
}
});
next();
}
app.use(typeInferenceMiddleware);
const findFile = (dir, fileName) => {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const result = findFile(fullPath, fileName);
if (result) {
return result;
}
} else if (file === fileName) {
return fullPath;
}
}
return null;
};
const root = path.join(__dirname, "..", "dist");
//hacky middleware to handle basename changes for UI
app.use((req, res, next) => {
if (BASE_NAME && BASE_NAME != "" && (req.url == "/" || req.url == "")) {
return res.redirect(BASE_NAME);
}
// Ignore requests containing 'socket.io'
if (req.url.includes("socket.io") || req.url.includes("swagger")) {
return next();
}
const fileRegex = /\/([^\/]+\.(css|ico|js|json|png))$/;
const match = req.url.match(fileRegex);
if (match) {
// Extract the file name
const fileName = match[1];
//Exclude translation.json from this hack as it messes up the translations by returning the first file regardless of language chosen
if (fileName != "translation.json") {
// Find the file
const filePath = findFile(root, fileName);
if (filePath) {
return res.sendFile(filePath);
} else {
return res.status(404).send("File not found");
}
}
}
if (BASE_NAME && req.url.startsWith(BASE_NAME) && req.url !== BASE_NAME) {
req.url = req.url.slice(BASE_NAME.length);
// console.log("URL: " + req.url);
}
next();
});
// initiate routes
app.use("/auth", authRouter, () => {
app.use(`/auth`, authRouter, () => {
/* #swagger.tags = ['Auth'] */
}); // mount the API router at /auth
app.use("/proxy", proxyRouter, () => {
@@ -81,10 +169,14 @@ app.use("/utils", authenticate, utilsRouter, () => {
app.use("/swagger", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
// for deployment of static page
const root = path.join(__dirname, "..", "dist");
app.use(express.static(root));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "..", "dist", "index.html"));
writeEnvVariables().then(() => {
app.use(express.static(root));
app.get("*", (req, res, next) => {
if (req.url.includes("socket.io")) {
return next();
}
res.sendFile(path.join(__dirname, "..", "dist", "index.html"));
});
});
// JWT middleware
@@ -114,7 +206,9 @@ 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" });
@@ -144,7 +238,7 @@ try {
db.migrate.latest().then(() => {
const server = http.createServer(app);
setupWebSocketServer(server);
setupWebSocketServer(server, BASE_NAME);
server.listen(PORT, LISTEN_IP, async () => {
console.log(`[JELLYSTAT] Server listening on http://127.0.0.1:${PORT}`);
ActivityMonitor.ActivityMonitor(1000);

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,117 @@
const db = require("../db");
const pgp = require("pg-promise")();
const moment = require("moment");
const { columnsPlayback, mappingPlayback } = require("../models/jf_playback_activity");
const { columnsPlayback } = require("../models/jf_playback_activity");
const { jf_activity_watchdog_columns, jf_activity_watchdog_mapping } = require("../models/jf_activity_watchdog");
const configClass = require("../classes/config");
const JellyfinAPI = require("../classes/jellyfin-api");
const API = require("../classes/api-loader");
const { sendUpdate } = require("../ws");
const { isNumber } = require("@mui/x-data-grid/internals");
async function getSessionsInWatchDog(SessionData, WatchdogData) {
let existingData = await WatchdogData.filter((wdData) => {
return SessionData.some((sessionData) => {
let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id;
let matchesEpisodeId =
sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true;
let matchingSessionFound =
// wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
wdData.NowPlayingItemId === NowPlayingItemId &&
matchesEpisodeId;
if (matchingSessionFound && wdData.IsPaused != sessionData.PlayState.IsPaused) {
wdData.IsPaused = sessionData.PlayState.IsPaused;
//if the playstate was paused, calculate the difference in seconds and add to the playback duration
if (sessionData.PlayState.IsPaused == true) {
let startTime = moment(wdData.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ");
let lastPausedDate = moment(sessionData.LastPausedDate);
let diffInSeconds = lastPausedDate.diff(startTime, "seconds");
wdData.PlaybackDuration = parseInt(wdData.PlaybackDuration) + diffInSeconds;
wdData.ActivityDateInserted = `${lastPausedDate.format("YYYY-MM-DD HH:mm:ss.SSSZ")}`;
} else {
wdData.ActivityDateInserted = moment().format("YYYY-MM-DD HH:mm:ss.SSSZ");
}
return true;
}
return false; // we return false if playstate didnt change to reduce db writes
});
});
return existingData;
}
async function getSessionsNotInWatchDog(SessionData, WatchdogData) {
let newData = await SessionData.filter((sessionData) => {
if (WatchdogData.length === 0) return true;
return !WatchdogData.some((wdData) => {
let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id;
let matchesEpisodeId =
sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true;
let matchingSessionFound =
// wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
wdData.NowPlayingItemId === NowPlayingItemId &&
matchesEpisodeId;
return matchingSessionFound;
});
}).map(jf_activity_watchdog_mapping);
return newData;
}
function getWatchDogNotInSessions(SessionData, WatchdogData) {
let removedData = WatchdogData.filter((wdData) => {
if (SessionData.length === 0) return true;
return !SessionData.some((sessionData) => {
let NowPlayingItemId = sessionData.NowPlayingItem.SeriesId || sessionData.NowPlayingItem.Id;
let matchesEpisodeId =
sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true;
let noMatchingSessionFound =
// wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
wdData.NowPlayingItemId === NowPlayingItemId &&
matchesEpisodeId;
return noMatchingSessionFound;
});
});
//this is to update the playback duration for the removed items where it was playing before stopped as duration is only updated on pause
removedData.map((obj) => {
obj.Id = obj.ActivityId;
let startTime = moment(obj.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ");
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, "seconds");
if (obj.IsPaused == false) {
obj.PlaybackDuration = parseInt(obj.PlaybackDuration) + diffInSeconds;
}
obj.ActivityDateInserted = endTime.format("YYYY-MM-DD HH:mm:ss.SSSZ");
const { ...rest } = obj;
return { ...rest };
});
return removedData;
}
async function ActivityMonitor(interval) {
const Jellyfin = new JellyfinAPI();
// console.log("Activity Interval: " + interval);
setInterval(async () => {
@@ -20,7 +122,7 @@ async function ActivityMonitor(interval) {
return;
}
const ExcludedUsers = config.settings?.ExcludedUsers || [];
const apiSessionData = await Jellyfin.getSessions();
const apiSessionData = await API.getSessions();
const SessionData = apiSessionData.filter((row) => row.NowPlayingItem !== undefined && !ExcludedUsers.includes(row.UserId));
sendUpdate("sessions", apiSessionData);
/////get data from jf_activity_monitor
@@ -30,98 +132,35 @@ async function ActivityMonitor(interval) {
if (SessionData.length === 0 && WatchdogData.length === 0) {
return;
}
// New Code
// //compare to sessiondata
let WatchdogDataToInsert = await getSessionsNotInWatchDog(SessionData, WatchdogData);
let WatchdogDataToUpdate = await getSessionsInWatchDog(SessionData, WatchdogData);
let dataToRemove = await getWatchDogNotInSessions(SessionData, WatchdogData);
let WatchdogDataToInsert = [];
let WatchdogDataToUpdate = [];
/////////////////
//filter fix if table is empty
if (WatchdogData.length === 0) {
// if there are no existing Ids in the table, map all items in the data array to the expected format
WatchdogDataToInsert = await SessionData.map(jf_activity_watchdog_mapping);
} else {
// otherwise, filter only new data to insert
WatchdogDataToInsert = await SessionData.filter((sessionData) => {
return !WatchdogData.some(
(wdData) =>
wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
(sessionData.NowPlayingItem.SeriesId != undefined
? wdData.NowPlayingItemId === sessionData.NowPlayingItem.SeriesId
: wdData.NowPlayingItemId === sessionData.NowPlayingItem.Id) &&
(sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true)
);
}).map(jf_activity_watchdog_mapping);
WatchdogDataToUpdate = WatchdogData.filter((wdData) => {
const session = SessionData.find(
(sessionData) =>
wdData.Id === sessionData.Id &&
wdData.UserId === sessionData.UserId &&
wdData.DeviceId === sessionData.DeviceId &&
(sessionData.NowPlayingItem.SeriesId != undefined
? wdData.NowPlayingItemId === sessionData.NowPlayingItem.SeriesId
: wdData.NowPlayingItemId === sessionData.NowPlayingItem.Id) &&
(sessionData.NowPlayingItem.SeriesId != undefined ? wdData.EpisodeId === sessionData.NowPlayingItem.Id : true)
);
if (session && session.PlayState) {
if (wdData.IsPaused != session.PlayState.IsPaused) {
wdData.IsPaused = session.PlayState.IsPaused;
if (session.PlayState.IsPaused == true) {
let startTime = moment(wdData.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ");
let lastPausedDate = moment(session.LastPausedDate);
let diffInSeconds = lastPausedDate.diff(startTime, "seconds");
wdData.PlaybackDuration = parseInt(wdData.PlaybackDuration) + diffInSeconds;
wdData.ActivityDateInserted = `${lastPausedDate.format("YYYY-MM-DD HH:mm:ss.SSSZ")}`;
}
return true;
}
}
return false;
});
}
if (WatchdogDataToInsert.length > 0) {
//insert new rows where not existing items
// console.log("Inserted " + WatchdogDataToInsert.length + " wd playback records");
db.insertBulk("jf_activity_watchdog", WatchdogDataToInsert, jf_activity_watchdog_columns);
console.log("New Data Inserted: ", WatchdogDataToInsert.length);
}
//update wd state
if (WatchdogDataToUpdate.length > 0) {
await db.insertBulk("jf_activity_watchdog", WatchdogDataToUpdate, jf_activity_watchdog_columns);
console.log("Existing Data Updated: ", WatchdogDataToUpdate.length);
}
//delete from db no longer in session data and insert into stats db
//Bulk delete from db thats no longer on api
const toDeleteIds = WatchdogData.filter((id) => !SessionData.some((row) => row.Id === id.Id)).map((row) => row.Id);
const toDeleteIds = dataToRemove.map((row) => row.ActivityId);
const playbackData = WatchdogData.filter((id) => !SessionData.some((row) => row.Id === id.Id));
let playbackToInsert = playbackData.map((obj) => {
obj.Id = obj.ActivityId;
let startTime = moment(obj.ActivityDateInserted, "YYYY-MM-DD HH:mm:ss.SSSZ");
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, "seconds");
if (obj.IsPaused == false) {
obj.PlaybackDuration = parseInt(obj.PlaybackDuration) + diffInSeconds;
}
obj.ActivityDateInserted = endTime.format("YYYY-MM-DD HH:mm:ss.SSSZ");
const { ...rest } = obj;
return { ...rest };
});
let playbackToInsert = dataToRemove;
if (playbackToInsert.length == 0 && toDeleteIds.length == 0) {
return;
@@ -143,14 +182,21 @@ async function ActivityMonitor(interval) {
//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) {
ExistingDataToUpdate = playbackToInsert.filter((playbackData) => {
const existingrow = ExistingRecords.find(
(existing) =>
const existingrow = ExistingRecords.find((existing) => {
let newDurationWithingRunTime = true;
if (existing.RunTimeTicks != undefined && isNumber(existing.RunTimeTicks)) {
newDurationWithingRunTime =
(Number(existing.PlaybackDuration) + Number(playbackData.PlaybackDuration)) * 10000000 <=
Number(existing.RunTimeTicks);
}
return (
existing.NowPlayingItemId === playbackData.NowPlayingItemId &&
existing.EpisodeId === playbackData.EpisodeId &&
existing.UserId === playbackData.UserId &&
(Number(existing.PlaybackDuration) + Number(playbackData.PlaybackDuration)) * 10000000 <=
Number(existing.RunTimeTicks)
);
newDurationWithingRunTime
);
});
if (existingrow) {
playbackData.Id = existingrow.Id;
@@ -176,10 +222,12 @@ async function ActivityMonitor(interval) {
ExistingDataToUpdate = ExistingDataToUpdate.filter((pb) => pb.PlaybackDuration > 0);
if (toDeleteIds.length > 0) {
await db.deleteBulk("jf_activity_watchdog", toDeleteIds);
await db.deleteBulk("jf_activity_watchdog", toDeleteIds, "ActivityId");
console.log("Removed Data from WD Count: ", dataToRemove.length);
}
if (playbackToInsert.length > 0) {
await db.insertBulk("jf_playback_activity", playbackToInsert, columnsPlayback);
console.log("Activity inserted/updated Count: ", playbackToInsert.length);
// console.log("Inserted " + playbackToInsert.length + " new playback records");
}
@@ -191,7 +239,7 @@ async function ActivityMonitor(interval) {
///////////////////////////
} catch (error) {
if (error?.code === "ECONNREFUSED") {
console.error("Error: Unable to connect to Jellyfin");
console.error("Error: Unable to connect to API"); //TO-DO Change this to correct API name
} else if (error?.code === "ERR_BAD_RESPONSE") {
console.warn(error.response?.data);
} else {

View File

@@ -1,8 +1,8 @@
const db = require("../db");
const Logging = require("../routes/logging");
const Logging = require("../classes/logging");
const configClass =require("../classes/config");
const backup = require("../routes/backup");
const backup = require("../classes/backup");
const moment = require('moment');
const { randomUUID } = require('crypto');
const taskstate = require("../logging/taskstate");
@@ -103,7 +103,7 @@ async function intervalCallback() {
Logging.insertLog(uuid,triggertype.Automatic,taskName.backup);
await backup.backup(refLog);
await backup(refLog);
Logging.updateLog(uuid,refLog.logData,taskstate.SUCCESS);

View File

@@ -1,123 +1,113 @@
const db = require("../db");
const moment = require('moment');
const moment = require("moment");
const sync = require("../routes/sync");
const taskName=require('../logging/taskName');
const taskName = require("../logging/taskName");
const taskstate = require("../logging/taskstate");
const triggertype = require("../logging/triggertype");
async function FullSyncTask() {
try{
try {
await db.query(
`UPDATE jf_logging SET "Result"='${taskstate.FAILED}' WHERE "Name"='${taskName.fullsync}' AND "Result"='${taskstate.RUNNING}'`
);
}
catch(error)
{
console.log('Error Cleaning up Sync Tasks: '+error);
}
`UPDATE jf_logging SET "Result"='${taskstate.FAILED}' WHERE "Name"='${taskName.fullsync}' AND "Result"='${taskstate.RUNNING}'`
);
} catch (error) {
console.log("Error Cleaning up Sync Tasks: " + error);
}
let interval=10000;
let interval = 10000;
let taskDelay=1440; //in minutes
let taskDelay = 1440; //in minutes
async function fetchTaskSettings() {
try {
//get interval from db
const settingsjson = await db.query('SELECT settings FROM app_config where "ID"=1').then((res) => res.rows);
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
async function fetchTaskSettings()
{
try{//get interval from db
let synctasksettings = settings.Tasks?.JellyfinSync || {};
const settingsjson = await db
.query('SELECT settings FROM app_config where "ID"=1')
.then((res) => res.rows);
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
let synctasksettings = settings.Tasks?.JellyfinSync || {};
if (synctasksettings.Interval) {
taskDelay=synctasksettings.Interval;
} else {
synctasksettings.Interval=taskDelay;
if(!settings.Tasks)
{
settings.Tasks = {};
if (synctasksettings.Interval) {
taskDelay = synctasksettings.Interval;
} else {
synctasksettings.Interval = taskDelay;
if (!settings.Tasks) {
settings.Tasks = {};
}
if (!settings.Tasks.JellyfinSync) {
settings.Tasks.JellyfinSync = {};
}
settings.Tasks.JellyfinSync = synctasksettings;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}
if(!settings.Tasks.JellyfinSync)
{
settings.Tasks.JellyfinSync = {};
}
settings.Tasks.JellyfinSync = synctasksettings;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}
} catch (error) {
console.log("Sync Task Settings Error: " + error);
}
}
catch(error)
{
console.log('Sync Task Settings Error: '+error);
}
}
async function intervalCallback() {
clearInterval(intervalTask);
try {
let current_time = moment();
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
async function intervalCallback() {
clearInterval(intervalTask);
try{
let current_time = moment();
const { rows: config } = await db.query(
'SELECT * FROM app_config where "ID"=1'
);
if (config.length===0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null)
{
if (config.length === 0 || config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
return;
}
const last_execution=await db.query( `SELECT "TimeRun","Result"
}
const last_execution = await db
.query(
`SELECT "TimeRun","Result"
FROM public.jf_logging
WHERE "Name"='${taskName.fullsync}'
ORDER BY "TimeRun" DESC
LIMIT 1`).then((res) => res.rows);
if(last_execution.length!==0)
{
LIMIT 1`
)
.then((res) => res.rows);
const last_execution_partialSync = await db
.query(
`SELECT "TimeRun","Result"
FROM public.jf_logging
WHERE "Name"='${taskName.partialsync}'
AND "Result"='${taskstate.RUNNING}'
ORDER BY "TimeRun" DESC
LIMIT 1`
)
.then((res) => res.rows);
if (last_execution.length !== 0) {
await fetchTaskSettings();
let last_execution_time = moment(last_execution[0].TimeRun).add(taskDelay, 'minutes');
let last_execution_time = moment(last_execution[0].TimeRun).add(taskDelay, "minutes");
if(!current_time.isAfter(last_execution_time) || last_execution[0].Result ===taskstate.RUNNING)
{
intervalTask = setInterval(intervalCallback, interval);
return;
if (
!current_time.isAfter(last_execution_time) ||
last_execution[0].Result === taskstate.RUNNING ||
last_execution_partialSync.length > 0
) {
intervalTask = setInterval(intervalCallback, interval);
return;
}
}
console.log("Running Scheduled Sync");
await sync.fullSync(triggertype.Automatic);
console.log("Scheduled Sync Complete");
} catch (error) {
console.log(error);
return [];
}
console.log('Running Scheduled Sync');
await sync.fullSync(triggertype.Automatic);
console.log('Scheduled Sync Complete');
} catch (error)
{
console.log(error);
return [];
}
intervalTask = setInterval(intervalCallback, interval);
intervalTask = setInterval(intervalCallback, interval);
}
let intervalTask = setInterval(intervalCallback, interval);
let intervalTask = setInterval(intervalCallback, interval);
}
module.exports = {

View File

@@ -14,7 +14,7 @@ async function RecentlyAddedItemsSyncTask() {
console.log("Error Cleaning up Sync Tasks: " + error);
}
let interval = 10000;
let interval = 11000;
let taskDelay = 60; //in minutes
@@ -71,11 +71,26 @@ async function RecentlyAddedItemsSyncTask() {
LIMIT 1`
)
.then((res) => res.rows);
const last_execution_FullSync = await db
.query(
`SELECT "TimeRun","Result"
FROM public.jf_logging
WHERE "Name"='${taskName.fullsync}'
AND "Result"='${taskstate.RUNNING}'
ORDER BY "TimeRun" DESC
LIMIT 1`
)
.then((res) => res.rows);
if (last_execution.length !== 0) {
await fetchTaskSettings();
let last_execution_time = moment(last_execution[0].TimeRun).add(taskDelay, "minutes");
if (!current_time.isAfter(last_execution_time) || last_execution[0].Result === taskstate.RUNNING) {
if (
!current_time.isAfter(last_execution_time) ||
last_execution[0].Result === taskstate.RUNNING ||
last_execution_FullSync.length > 0
) {
intervalTask = setInterval(intervalCallback, interval);
return;
}

View File

@@ -1,27 +1,30 @@
// ws.js
const socketIO = require('socket.io');
const socketIO = require("socket.io");
let io; // Store the socket.io server instance
const setupWebSocketServer = (server) => {
io = socketIO(server);
const setupWebSocketServer = (server, namespacePath) => {
io = socketIO(server, { path: namespacePath + "/socket.io" }); // Create the socket.io server
io.on('connection', (socket) => {
// console.log('Client connected');
socket.on('message', (message) => {
// console.log(`Received: ${message}`);
io.on("connection", (socket) => {
// console.log("Client connected to namespace:", namespacePath);
socket.on("message", (message) => {
console.log(`Received: ${message}`);
});
});
};
const sendToAllClients = (message) => {
io.emit('message', message);
if (io) {
io.emit("message", message);
}
};
const sendUpdate = (tag,message) => {
io.emit(tag, message);
const sendUpdate = (tag, message) => {
if (io) {
io.emit(tag, message);
}
};
module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate };

View File

@@ -2,22 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Jellyfin stats for the masses" />
<link rel="apple-touch-icon" href="/icon-b-192.png" />
<link rel="apple-touch-icon" href="icon-b-192.png" />
<script src="env.js"></script>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
Unlike "favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jfstat",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jfstat",
"version": "1.1.0",
"version": "1.1.1",
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
@@ -18,7 +18,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.3.0",
"axios": "^1.6.7",
"axios": "^1.7.2",
"axios-cache-interceptor": "^1.3.1",
"bootstrap": "^5.2.3",
"cacheable-lookup": "^6.1.0",
@@ -7009,11 +7009,11 @@
}
},
"node_modules/axios": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"dependencies": {
"follow-redirects": "^1.15.4",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -10557,9 +10557,9 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ=="
},
"node_modules/follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"main": "src/index.jsx",
"scripts": {
@@ -25,7 +25,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.3.0",
"axios": "^1.6.7",
"axios": "^1.7.2",
"axios-cache-interceptor": "^1.3.1",
"bootstrap": "^5.2.3",
"cacheable-lookup": "^6.1.0",

View File

@@ -28,7 +28,8 @@
"MOST_POPULAR_MUSIC": "MOST POPULAR MUSIC",
"MOST_VIEWED_LIBRARIES": "MOST VIEWED LIBRARIES",
"MOST_USED_CLIENTS": "MOST USED CLIENTS",
"MOST_ACTIVE_USERS": "MOST ACTIVE USERS"
"MOST_ACTIVE_USERS": "MOST ACTIVE USERS",
"CONCURRENT_STREAMS": "CONCURRENT STREAMS"
},
"LIBRARY_OVERVIEW": {
"MOVIE_LIBRARIES": "MOVIE LIBRARIES",
@@ -50,6 +51,8 @@
"LAST_24_HRS": "Last 24 Hours",
"LAST_7_DAYS": "Last 7 Days",
"LAST_30_DAYS": "Last 30 Days",
"LAST_180_DAYS": "Last 180 Days",
"LAST_365_DAYS": "Last 365 Days",
"ALL_TIME": "All Time",
"ITEM_STATS": "Item Stats"
},
@@ -132,6 +135,8 @@
"SHOW_ARCHIVED_LIBRARIES": "Show Archived Libraries",
"HIDE_ARCHIVED_LIBRARIES": "Hide Archived Libraries",
"UNITS": {
"YEAR": "Year",
"YEARS": "Years",
"MONTH": "Month",
"MONTHS": "Months",
"DAY": "Day",
@@ -143,7 +148,8 @@
"SECOND": "Second",
"SECONDS": "Seconds",
"PLAYS": "Plays",
"ITEMS": "Items"
"ITEMS": "Items",
"STREAMS": "Streams"
},
"USERS_PAGE": {
"ALL_USERS": "All Users",
@@ -170,6 +176,7 @@
"LOGS": "Logs",
"SIZE": "Size",
"JELLYFIN_URL": "Jellyfin URL",
"EMBY_URL": "Emby URL",
"API_KEY": "API Key",
"API_KEYS": "API Keys",
"KEY_NAME": "Key Name",

View File

@@ -11,7 +11,7 @@
"LOGOUT": "Déconnexion"
},
"HOME_PAGE": {
"SESSIONS": "Lectures",
"SESSIONS": "Lecture(s)",
"RECENTLY_ADDED": "Récemment ajouté",
"WATCH_STATISTIC": "Statistiques de lecture",
"LIBRARY_OVERVIEW": "Vue d'ensemble des médiathèques"
@@ -28,7 +28,8 @@
"MOST_POPULAR_MUSIC": "MUSIQUE LES PLUS POPULAIRES",
"MOST_VIEWED_LIBRARIES": "MÉDIATHÈQUES LES PLUS CONSULTÉES",
"MOST_USED_CLIENTS": "CLIENTS LES PLUS UTILISÉS",
"MOST_ACTIVE_USERS": "UTILISATEURS LES PLUS ACTIFS"
"MOST_ACTIVE_USERS": "UTILISATEURS LES PLUS ACTIFS",
"CONCURRENT_STREAMS": "DIFFÉRENTS FLUX"
},
"LIBRARY_OVERVIEW": {
"MOVIE_LIBRARIES": "MÉDIATHÈQUES DE FILMS",
@@ -50,8 +51,10 @@
"LAST_24_HRS": "24 dernières heures",
"LAST_7_DAYS": "7 derniers jours",
"LAST_30_DAYS": "30 derniers jours",
"LAST_180_DAYS": "180 derniers jours",
"LAST_365_DAYS": "365 derniers jours",
"ALL_TIME": "Tout le temps",
"ITEM_STATS": "Item Stats"
"ITEM_STATS": "Statistiques du média"
},
"ITEM_INFO": {
"FILE_PATH": "Chemin du fichier",
@@ -132,6 +135,8 @@
"SHOW_ARCHIVED_LIBRARIES": "Afficher les médiathèques archivées",
"HIDE_ARCHIVED_LIBRARIES": "Cacher les médiathèques archivées",
"UNITS": {
"YEAR": "An",
"YEARS": "Ans",
"MONTH": "Mois",
"MONTHS": "Mois",
"DAY": "Jour",
@@ -143,7 +148,8 @@
"SECOND": "Seconde",
"SECONDS": "Secondes",
"PLAYS": "Lectures",
"ITEMS": "Éléments"
"ITEMS": "Éléments",
"STREAMS": "Flux"
},
"USERS_PAGE": {
"ALL_USERS": "Tous les utilisateurs",
@@ -161,6 +167,8 @@
},
"SETTINGS_PAGE": {
"SETTINGS": "Paramètres",
"LANGUAGE": "Language",
"SELECT_AN_ADMIN": "Select a Preferred Admin",
"LIBRARY_SETTINGS": "Paramètres des médiathèques",
"BACKUP": "Sauvegarde",
"BACKUPS": "Sauvegardes",
@@ -168,6 +176,7 @@
"LOGS": "Journaux",
"SIZE": "Taille",
"JELLYFIN_URL": "URL du serveur Jellyfin",
"EMBY_URL": "URL du serveur Emby",
"API_KEY": "Clé API",
"API_KEYS": "Clés API",
"KEY_NAME": "Nom de la clé",
@@ -270,7 +279,7 @@
"STREAM_DETAILS": "Détails du flux",
"SOURCE_DETAILS": "Détails de la source",
"DIRECT": "Direct",
"TRANSCODE": "Transcode",
"TRANSCODE": "Transcodage",
"USERNAME": "Nom d'utilisateur",
"PASSWORD": "Mot de passe",
"LOGIN": "Connexion",
@@ -291,5 +300,5 @@
"LONGITUDE": "Longitude",
"TIMEZONE": "Fuseau horaire",
"POSTCODE": "Code postal",
"X_ROWS_SELECTED": "{ROWS} Lignes sélectionnées"
"X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)"
}

View File

@@ -2,7 +2,7 @@
import "./App.css";
import React, { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import axios from "axios";
import axios from "./lib/axios_instance";
import socket from "./socket";
import { ToastContainer, toast } from "react-toastify";
@@ -17,20 +17,8 @@ import Setup from "./pages/setup";
import Login from "./pages/login";
import Navbar from "./pages/components/general/navbar";
import Home from "./pages/home";
import Settings from "./pages/settings";
import Users from "./pages/users";
import UserInfo from "./pages/components/user-info";
import Libraries from "./pages/libraries";
import LibraryInfo from "./pages/components/library-info";
import ItemInfo from "./pages/components/item-info";
import ErrorPage from "./pages/components/general/error";
import About from "./pages/about";
import Testing from "./pages/testing";
import Activity from "./pages/activity";
import Statistics from "./pages/statistics";
import { t } from "i18next";
import routes from "./routes";
function App() {
const [setupState, setSetupState] = useState(0);
@@ -99,7 +87,7 @@ function App() {
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
if (!newConfig.response) {
setConfig(newConfig);
} else {
@@ -162,17 +150,9 @@ function App() {
<Navbar />
<main className="w-md-100">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
<Route path="/users" element={<Users />} />
<Route path="/users/:UserId" element={<UserInfo />} />
<Route path="/libraries" element={<Libraries />} />
<Route path="/libraries/:LibraryId" element={<LibraryInfo />} />
<Route path="/libraries/item/:Id" element={<ItemInfo />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/activity" element={<Activity />} />
<Route path="/testing" element={<Testing />} />
<Route path="/about" element={<About />} />
{routes.map((route, index) => (
<Route key={index} path={route.path} element={route.element} />
))}
</Routes>
</main>
</div>

View File

@@ -13,6 +13,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import Loading from "./pages/components/general/loading.jsx";
import baseUrl from "./lib/baseurl.jsx";
i18n
.use(Backend)
@@ -21,6 +22,9 @@ i18n
.init({
fallbackLng: "en-UK",
debug: false,
backend: {
loadPath: `${baseUrl}/locales/{{lng}}/{{ns}}.json`,
},
detection: {
order: ["cookie", "localStorage", "sessionStorage", "navigator", "htmlTag", "querystring", "path", "subdomain"],
cache: ["cookie"],
@@ -33,7 +37,7 @@ i18n
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<Suspense fallback={<Loading />} />
<BrowserRouter basename={import.meta.env.JS_BASE_URL ?? "/"}>
<BrowserRouter basename={baseUrl}>
<App />
</BrowserRouter>
</React.StrictMode>

View File

@@ -1,5 +1,6 @@
import Axios from "axios";
import baseUrl from "./baseurl";
const axios = Axios.create();
const axios = Axios.create({ baseURL: baseUrl });
export default axios;

11
src/lib/baseurl.jsx Normal file
View File

@@ -0,0 +1,11 @@
const ensureSlashes = (url) => {
if (!url.startsWith("/")) {
url = "/" + url;
}
if (url.endsWith("/")) {
url = url.slice(0, -1);
}
return url;
};
const baseUrl = window.env?.JS_BASE_URL ? ensureSlashes(window.env?.JS_BASE_URL) : "";
export default baseUrl;

View File

@@ -1,20 +1,46 @@
import axios from 'axios';
import axios from "../lib/axios_instance";
async function Config() {
const token = localStorage.getItem('token');
try {
const response = await axios.get('/api/getconfig', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { JF_HOST, APP_USER,REQUIRE_LOGIN, settings } = response.data;
return { hostUrl: JF_HOST, username: APP_USER, token:token, requireLogin:REQUIRE_LOGIN, settings:settings };
class Config {
async fetchConfig() {
const token = localStorage.getItem("token");
try {
const response = await axios.get("/api/getconfig", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { JF_HOST, APP_USER, REQUIRE_LOGIN, settings, IS_JELLYFIN } = response.data;
return {
hostUrl: JF_HOST,
username: APP_USER,
token: token,
requireLogin: REQUIRE_LOGIN,
settings: settings,
IS_JELLYFIN: IS_JELLYFIN,
};
} catch (error) {
// console.log(error);
return error;
}
}
} catch (error) {
// console.log(error);
return error;
async setConfig(config) {
if (config == undefined) {
config = await this.fetchConfig();
}
localStorage.setItem("config", JSON.stringify(config));
return config;
}
async getConfig(refreshConfig) {
let config = localStorage.getItem("config");
if (config != undefined && !refreshConfig) {
return JSON.parse(config);
} else {
return await this.setConfig();
}
}
}
export default Config;
export default new Config();

View File

@@ -1,31 +1,28 @@
import { useState, useEffect } from "react";
import axios from "axios";
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import axios from "../lib/axios_instance";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import Loading from "./components/general/loading";
import "./css/about.css";
import { Card } from "react-bootstrap";
import { Trans } from "react-i18next";
export default function SettingsAbout() {
const token = localStorage.getItem('token');
const token = localStorage.getItem("token");
const [data, setData] = useState();
useEffect(() => {
const fetchVersion = () => {
if (token) {
const url = `/api/CheckForUpdates`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.get(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
setData(data.data);
})
@@ -35,56 +32,51 @@ export default function SettingsAbout() {
}
};
if(!data)
{
fetchVersion();
if (!data) {
fetchVersion();
}
const intervalId = setInterval(fetchVersion, 60000 * 5);
return () => clearInterval(intervalId);
}, [data,token]);
}, [data, token]);
if(!data)
{
return <Loading/>;
if (!data) {
return <Loading />;
}
return (
<div className="tasks">
<h1 className="py-3"><Trans i18nKey={"ABOUT_PAGE.ABOUT_JELLYSTAT"}/></h1>
<Card className="about p-0" >
<Card.Body >
<Row>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.VERSION"}/>:
</Col>
<Col>
{data.current_version}
</Col>
</Row>
<Row style={{color:(data.update_available ? "#00A4DC": "White")}}>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.UPDATE_AVAILABLE"}/>:
</Col>
<Col>
{data.message}
</Col>
</Row>
<Row style={{height:'20px'}}></Row>
<Row>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.GITHUB"}/>:
</Col>
<Col>
<a href="https://github.com/CyferShepard/Jellystat" target="_blank" rel="noreferrer" > https://github.com/CyferShepard/Jellystat</a>
</Col>
</Row>
</Card.Body>
</Card>
</div>
);
return (
<div className="tasks">
<h1 className="py-3">
<Trans i18nKey={"ABOUT_PAGE.ABOUT_JELLYSTAT"} />
</h1>
<Card className="about p-0">
<Card.Body>
<Row>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.VERSION"} />:
</Col>
<Col>{data.current_version}</Col>
</Row>
<Row style={{ color: data.update_available ? "#00A4DC" : "White" }}>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.UPDATE_AVAILABLE"} />:
</Col>
<Col>{data.message}</Col>
</Row>
<Row style={{ height: "20px" }}></Row>
<Row>
<Col className="px-0">
<Trans i18nKey={"ABOUT_PAGE.GITHUB"} />:
</Col>
<Col>
<a href="https://github.com/CyferShepard/Jellystat" target="_blank" rel="noreferrer">
{" "}
https://github.com/CyferShepard/Jellystat
</a>
</Col>
</Row>
</Card.Body>
</Card>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../lib/axios_instance";
import "./css/activity.css";
import Config from "../lib/config";
@@ -8,19 +8,53 @@ import Config from "../lib/config";
import ActivityTable from "./components/activity/activity-table";
import Loading from "./components/general/loading";
import { Trans } from "react-i18next";
import { FormControl, FormSelect } from "react-bootstrap";
import { Button, FormControl, FormSelect, Modal } from "react-bootstrap";
import i18next from "i18next";
import LibraryFilterModal from "./components/library/library-filter-modal";
function Activity() {
const [data, setData] = useState();
const [config, setConfig] = useState(null);
const [streamTypeFilter, setStreamTypeFilter] = useState(localStorage.getItem("PREF_ACTIVITY_StreamTypeFilter") ?? "All");
const [searchQuery, setSearchQuery] = useState("");
const [itemCount, setItemCount] = useState(10);
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_ACTIVITY_ItemCount") ?? "10"));
const [libraryFilters, setLibraryFilters] = useState(
localStorage.getItem("PREF_ACTIVITY_libraryFilters") != undefined
? JSON.parse(localStorage.getItem("PREF_ACTIVITY_libraryFilters"))
: []
);
const [libraries, setLibraries] = useState([]);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
function setItemLimit(limit) {
setItemCount(limit);
localStorage.setItem("PREF_ACTIVITY_ItemCount", limit);
}
function setTypeFilter(filter) {
setStreamTypeFilter(filter);
localStorage.setItem("PREF_ACTIVITY_StreamTypeFilter", filter);
}
const handleLibraryFilter = (selectedOptions) => {
setLibraryFilters(selectedOptions);
localStorage.setItem("PREF_ACTIVITY_libraryFilters", JSON.stringify(selectedOptions));
};
const toggleSelectAll = () => {
if (libraryFilters.length > 0) {
setLibraryFilters([]);
localStorage.setItem("PREF_ACTIVITY_libraryFilters", JSON.stringify([]));
} else {
setLibraryFilters(libraries.map((library) => library.Id));
localStorage.setItem("PREF_ACTIVITY_libraryFilters", JSON.stringify(libraries.map((library) => library.Id)));
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
@@ -29,7 +63,7 @@ function Activity() {
}
};
const fetchLibraries = () => {
const fetchHistory = () => {
const url = `/api/getHistory`;
axios
.get(url, {
@@ -46,7 +80,39 @@ function Activity() {
});
};
const fetchLibraries = () => {
const url = `/api/getLibraries`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((data) => {
const fetchedLibraryFilters = data.data.map((library) => {
return {
Name: library.Name,
Id: library.Id,
Archived: library.archived,
};
});
setLibraries(fetchedLibraryFilters);
if (libraryFilters.length == 0) {
setLibraryFilters(fetchedLibraryFilters.map((library) => library.Id));
localStorage.setItem(
"PREF_ACTIVITY_libraryFilters",
JSON.stringify(fetchedLibraryFilters.map((library) => library.Id))
);
}
})
.catch((error) => {
console.log(error);
});
};
if (!data && config) {
fetchHistory();
fetchLibraries();
}
@@ -54,7 +120,7 @@ function Activity() {
fetchConfig();
}
const intervalId = setInterval(fetchLibraries, 60000 * 60);
const intervalId = setInterval(fetchHistory, 60000 * 60);
return () => clearInterval(intervalId);
}, [data, config]);
@@ -88,25 +154,73 @@ function Activity() {
.includes(searchQuery.toLowerCase())
);
}
filteredData = filteredData.filter(
(item) =>
(libraryFilters.includes(item.ParentId) || item.ParentId == null) &&
(streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter)
);
return (
<div className="Activity">
<Modal show={showLibraryFilters} onHide={() => setShowLibraryFilters(false)}>
<Modal.Header>
<Modal.Title>
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Modal.Title>
</Modal.Header>
<LibraryFilterModal libraries={libraries} selectedLibraries={libraryFilters} onSelectionChange={handleLibraryFilter} />
<Modal.Footer>
<Button variant="outline-primary" onClick={toggleSelectAll}>
<Trans i18nKey="ACTIVITY_TABLE.TOGGLE_SELECT_ALL" />
</Button>
<Button variant="outline-primary" onClick={() => setShowLibraryFilters(false)}>
<Trans i18nKey="CLOSE" />
</Button>
</Modal.Footer>
</Modal>
<div className="d-md-flex justify-content-between">
<h1 className="my-3">
<Trans i18nKey="MENU_TABS.ACTIVITY" />
</h1>
<div className="d-flex flex-column flex-md-row">
<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">
<Button onClick={() => setShowLibraryFilters(true)} className="ms-md-3 mb-3 my-md-3">
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Button>
<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" />
</div>
<FormSelect
onChange={(event) => {
setTypeFilter(event.target.value);
}}
value={streamTypeFilter}
className="w-md-75 rounded-0 rounded-end"
>
<option value="All">
<Trans i18nKey="ALL" />
</option>
<option value="Transcode">
<Trans i18nKey="TRANSCODE" />
</option>
<option value="DirectPlay">
<Trans i18nKey="DIRECT" />
</option>
</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-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="UNITS.ITEMS" />
</div>
<FormSelect
onChange={(event) => {
setItemCount(event.target.value);
setItemLimit(event.target.value);
}}
value={itemCount}
className="my-md-3 w-md-75 rounded-0 rounded-end"
className="w-md-75 rounded-0 rounded-end"
>
<option value="10">10</option>
<option value="25">25</option>
@@ -116,10 +230,10 @@ function Activity() {
</div>
<FormControl
type="text"
placeholder= {i18next.t("SEARCH")}
placeholder={i18next.t("SEARCH")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ms-md-3 my-3 w-sm-100 w-md-75"
className="ms-md-3 mb-3 my-md-3 w-sm-100 w-md-75"
/>
</div>
</div>

View File

@@ -12,10 +12,11 @@ import MPMusic from "./statCards/mp_music";
import "../css/statCard.css";
import { Trans } from "react-i18next";
import PlaybackMethodStats from "./statCards/playback_method_stats";
function HomeStatisticCards() {
const [days, setDays] = useState(30);
const [input, setInput] = useState(30);
const [days, setDays] = useState(localStorage.getItem("PREF_HOME_STAT_DAYS") ?? 30);
const [input, setInput] = useState(localStorage.getItem("PREF_HOME_STAT_DAYS") ?? 30);
const handleKeyDown = (event) => {
if (event.key === "Enter") {
@@ -24,6 +25,7 @@ function HomeStatisticCards() {
setDays(0);
} else {
setDays(parseInt(input));
localStorage.setItem("PREF_HOME_STAT_DAYS", input);
}
console.log(days);
@@ -32,9 +34,13 @@ function HomeStatisticCards() {
return (
<div className="watch-stats">
<div className="Heading my-3">
<h1><Trans i18nKey="HOME_PAGE.WATCH_STATISTIC" /></h1>
<h1>
<Trans i18nKey="HOME_PAGE.WATCH_STATISTIC" />
</h1>
<div className="date-range">
<div className="header"><Trans i18nKey="LAST" /></div>
<div className="header">
<Trans i18nKey="LAST" />
</div>
<div className="days">
<input
type="number"
@@ -44,24 +50,23 @@ function HomeStatisticCards() {
onKeyDown={handleKeyDown}
/>
</div>
<div className="trailer"><Trans i18nKey="UNITS.DAYS" /></div>
<div className="trailer">
<Trans i18nKey="UNITS.DAYS" />
</div>
</div>
</div>
<div className="grid-stat-cards">
<MVMovies days={days} />
<MPMovies days={days} />
<MVSeries days={days} />
<MPSeries days={days} />
<MVMusic days={days}/>
<MPMusic days={days}/>
<MVMusic days={days} />
<MPMusic days={days} />
<MVLibraries days={days} />
<MostUsedClient days={days} />
<MostActiveUsers days={days} />
</div>
<PlaybackMethodStats days={days} />
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import {useState} from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import "../../css/library/library-card.css";
import { Form ,Card,Row,Col } from 'react-bootstrap';
@@ -9,6 +9,7 @@ import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import { Trans } from "react-i18next";
import baseUrl from "../../../lib/baseurl";
function SelectionCard(props) {
const [imageLoaded, setImageLoaded] = useState(true);
@@ -54,7 +55,7 @@ function SelectionCard(props) {
<Card.Img
variant="top"
className="library-card-banner default_library_image"
src={"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
src={baseUrl+"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
onError={() =>setImageLoaded(false)}
/>
:

View File

@@ -1,6 +1,6 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useMemo } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import { enUS } from "@mui/material/locale";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
@@ -155,8 +155,7 @@ export default function ActivityTable(props) {
row = row.original;
if (
isRemoteSession(row.RemoteEndPoint) &&
import.meta.env.JS_GEOLITE_ACCOUNT_ID &&
import.meta.env.JS_GEOLITE_LICENSE_KEY
(window.env?.JS_GEOLITE_ACCOUNT_ID ?? import.meta.env.JS_GEOLITE_ACCOUNT_ID) != undefined
) {
return (
<Link className="text-decoration-none" onClick={() => showIPDataModal(row.RemoteEndPoint)}>
@@ -284,7 +283,8 @@ export default function ActivityTable(props) {
enableGlobalFilter: false,
enableBottomToolbar: false,
enableRowSelection: (row) => row.original.Id,
enableSubRowSelection: true,
enableMultiRowSelection: true,
enableBatchRowSelection: true,
onRowSelectionChange: setRowSelection,
positionToolbarAlertBanner: "bottom",
renderTopToolbarCustomActions: () => {

View File

@@ -101,7 +101,7 @@ function Row(logs) {
</TableRow>
<TableRow>
<TableCell className="py-0 pb-1" ><Trans i18nKey={"APECT_RATIO"}/></TableCell>
<TableCell className="py-0 pb-1" ><Trans i18nKey={"ASPECT_RATIO"}/></TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'}</TableCell>
<TableCell className="py-0 pb-1" >{data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'}</TableCell>
</TableRow>

View File

@@ -0,0 +1,168 @@
import { useState, useEffect } from "react";
import axios from "../../../lib/axios_instance";
import "../../css/globalstats.css";
import WatchTimeStats from "./globalstats/watchtimestats";
import { Trans } from "react-i18next";
import { Checkbox, FormControlLabel, IconButton, Menu } from "@mui/material";
import { GridMoreVertIcon } from "@mui/x-data-grid";
function GlobalStats(props) {
const [dayStats, setDayStats] = useState({});
const [weekStats, setWeekStats] = useState({});
const [monthStats, setMonthStats] = useState({});
const [d180Stats, setd180Stats] = useState({});
const [d365Stats, setd365Stats] = useState({});
const [allStats, setAllStats] = useState({});
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const [prefs, setPrefs] = useState(
localStorage.getItem("PREF_GLOBAL_STATS") != undefined ? JSON.parse(localStorage.getItem("PREF_GLOBAL_STATS")) : [180, 365]
);
const token = localStorage.getItem("token");
const stats = [
{
key: 1,
heading: <Trans i18nKey="GLOBAL_STATS.LAST_24_HRS" />,
data: dayStats,
setMethod: setDayStats,
},
{
key: 7,
heading: <Trans i18nKey="GLOBAL_STATS.LAST_7_DAYS" />,
data: weekStats,
setMethod: setWeekStats,
},
{
key: 30,
heading: <Trans i18nKey="GLOBAL_STATS.LAST_30_DAYS" />,
data: monthStats,
setMethod: setMonthStats,
},
{
key: 180,
heading: <Trans i18nKey="GLOBAL_STATS.LAST_180_DAYS" />,
data: d180Stats,
setMethod: setd180Stats,
},
{
key: 365,
heading: <Trans i18nKey="GLOBAL_STATS.LAST_365_DAYS" />,
data: d365Stats,
setMethod: setd365Stats,
},
{
key: 9999,
heading: <Trans i18nKey="GLOBAL_STATS.ALL_TIME" />,
data: allStats,
setMethod: setAllStats,
},
];
const handleMenuOpen = (event) => {
setAnchorEl(event.currentTarget);
};
function fetchStats(hours = 24, setMethod = setDayStats) {
axios
.post(
`/stats/${props.endpoint ?? "getGlobalUserStats"}`,
{
hours: hours,
[props.param]: props.id,
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
)
.then((dayData) => {
setMethod(dayData.data);
});
}
useEffect(() => {
const fetchData = async () => {
try {
for (let i = 0; i < stats.length; i++) {
if (!prefs.includes(stats[i].key)) fetchStats(24 * stats[i].key, stats[i].setMethod);
}
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [props.id, token]);
const handleMenuClose = () => {
setAnchorEl(null);
};
function toggleStat(stat) {
let newPrefs = prefs;
if (newPrefs.includes(stat.key)) {
newPrefs = newPrefs.filter((item) => item !== stat.key);
fetchStats(24 * stat.key, stat.setMethod);
} else {
newPrefs = [...newPrefs, stat.key];
}
setPrefs(newPrefs);
localStorage.setItem("PREF_GLOBAL_STATS", JSON.stringify(newPrefs));
}
return (
<div>
<div className="d-flex flex-row justify-content-between">
<h1 className="py-3">{props.title}</h1>
<IconButton aria-label="more" aria-controls="long-menu" aria-haspopup="true" onClick={handleMenuOpen}>
<GridMoreVertIcon />
</IconButton>
<Menu
id="long-menu"
anchorEl={anchorEl}
keepMounted
open={open}
onClose={handleMenuClose}
sx={{
"& .MuiPaper-root": {
backgroundColor: `var(--background-color)`,
color: "#fff",
minWidth: "200px",
},
"& .MuiMenuItem-root": {
"&:hover": {
backgroundColor: "#555",
},
},
}}
>
{stats.map((stat) => {
return (
<div key={stat.key} style={{ padding: "10px" }}>
<FormControlLabel
control={<Checkbox checked={!prefs.includes(stat.key)} onChange={() => toggleStat(stat)} name={stat.heading} />}
label={stat.heading}
/>
</div>
);
})}
</Menu>
</div>
<div className="global-stats-container">
{stats.map((stat) => {
if (!prefs.includes(stat.key)) return <WatchTimeStats data={stat.data} heading={stat.heading} />;
})}
</div>
</div>
);
}
export default GlobalStats;

View File

@@ -0,0 +1,77 @@
import React from "react";
import "../../../css/globalstats.css";
import { Trans } from "react-i18next";
function WatchTimeStats(props) {
function formatTime(totalSeconds, numberClassName, labelClassName) {
const units = [
{ label: "Year", seconds: 31557600 },
{ label: "Month", seconds: 2629743 },
{ label: "Day", seconds: 86400 },
{ label: "Hour", seconds: 3600 },
{ label: "Minute", seconds: 60 },
{ label: "Second", seconds: 1 },
];
const parts = units.reduce((result, { label, seconds }) => {
const value = Math.floor(totalSeconds / seconds);
if (value) {
const formattedValue = <p className={numberClassName}>{value}</p>;
const formattedLabel = (
<span className={labelClassName}>
<Trans i18nKey={`UNITS.${(label + (value === 1 ? "" : "s")).toUpperCase()}`} />
</span>
);
result.push(
<span key={label} className="time-part">
{formattedValue} {formattedLabel}
</span>
);
totalSeconds -= value * seconds;
}
return result;
}, []);
// Filter out minutes if months are included
const hasMonths = parts.some((part) => part.key === "Month");
let filteredParts = hasMonths ? parts.filter((part) => part.key !== "Minute") : parts;
const hasDays = filteredParts.some((part) => part.key === "Day");
filteredParts = hasDays ? filteredParts.filter((part) => part.key !== "Second") : filteredParts;
if (filteredParts.length === 0) {
return (
<>
<p className={numberClassName}>0</p>{" "}
<p className={labelClassName}>
<Trans i18nKey="UNITS.SECONDS" />
</p>
</>
);
}
return filteredParts;
}
return (
<div className="global-stats">
<div className="stats-header">
<div>{props.heading}</div>
</div>
<div className="play-duration-stats" key={props.data.UserId}>
<div className={"d-flex flex-row"}>
<p className="stat-value"> {props.data.Plays || 0}</p>
<p className="stat-unit">
<Trans i18nKey={`UNITS.PLAYS`} />
</p>
</div>
<div className="d-flex flex-row">{formatTime(props.data.total_playback_duration || 0, "stat-value", "stat-unit")}</div>
</div>
</div>
);
}
export default WatchTimeStats;

View File

@@ -6,6 +6,7 @@ import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon";
import "../../css/lastplayed.css";
import { Trans } from "react-i18next";
import i18next from "i18next";
import baseUrl from "../../../lib/baseurl";
function formatTime(time) {
const units = {
@@ -42,7 +43,7 @@ function LastWatchedCard(props) {
) : null}
{!props.data.archived ? (
<img
src={`${"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
src={`${baseUrl+"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { display: "block" } : { display: "none" }}

View File

@@ -10,9 +10,20 @@ import { Trans } from "react-i18next";
export default function Navbar() {
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("config");
deleteLibraryTabKeys();
window.location.reload();
};
const deleteLibraryTabKeys = () => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("PREF_")) {
localStorage.removeItem(key);
}
}
};
const location = useLocation();
return (

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';

View File

@@ -8,7 +8,7 @@ import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import axios from "axios";
import axios from "../../lib/axios_instance";
import { Trans } from "react-i18next";
export default function IpInfoModal(props) {

View File

@@ -1,6 +1,6 @@
/* eslint-disable react/prop-types */
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../lib/axios_instance";
import { useParams } from "react-router-dom";
import { Link } from "react-router-dom";
import { Blurhash } from "react-blurhash";
@@ -10,7 +10,6 @@ import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon";
import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon";
import ArrowLeftSLineIcon from "remixicon-react/ArrowLeftSLineIcon";
import GlobalStats from "./item-info/globalStats";
import "../css/items/item-details.css";
import MoreItems from "./item-info/more-items";
@@ -22,6 +21,12 @@ import Loading from "./general/loading";
import ItemOptions from "./item-info/item-options";
import { Trans } from "react-i18next";
import i18next from "i18next";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import baseUrl from "../../lib/baseurl";
import GlobalStats from "./general/globalStats";
function ItemInfo() {
const { Id } = useParams();
@@ -31,6 +36,15 @@ function ItemInfo() {
const [activeTab, setActiveTab] = useState("tabOverview");
const [loaded, setLoaded] = useState(false);
const [fallback, setFallback] = useState(false);
const SeriesIcon = <TvLineIcon size={"50%"} />;
const MovieIcon = <FilmLineIcon size={"50%"} />;
const MusicIcon = <FileMusicLineIcon size={"50%"} />;
const MixedIcon = <CheckboxMultipleBlankLineIcon size={"50%"} />;
const currentLibraryDefaultIcon =
data?.Type === "Movie" ? MovieIcon : data?.Type === "Episode" ? SeriesIcon : data?.Type === "Audio" ? MusicIcon : MixedIcon;
function formatFileSize(sizeInBytes) {
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
@@ -83,7 +97,7 @@ function ItemInfo() {
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
@@ -168,18 +182,33 @@ function ItemInfo() {
<Row>
<Col className="col-auto my-4 my-md-0 item-banner-image">
{!data.archived && data.PrimaryImageHash && data.PrimaryImageHash != null && !loaded ? (
<Blurhash
hash={data.PrimaryImageHash}
width={"200px"}
height={"300px"}
className="rounded-3 overflow-hidden"
style={{ display: "block" }}
/>
<div
className="d-flex flex-column justify-content-center align-items-center position-relative"
style={{ height: "100%", width: "200px" }}
>
{data.PrimaryImageHash && data.PrimaryImageHash != null ? (
<Blurhash
hash={data.PrimaryImageHash}
width={"100%"}
height={"100%"}
className="rounded-3 overflow-hidden position-absolute"
style={{ display: "block" }}
/>
) : null}
{fallback ? (
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
{currentLibraryDefaultIcon}
</div>
) : null}
</div>
) : null}
{!data.archived ? (
<img
className="item-image"
src={
baseUrl +
"/proxy/Items/Images/Primary?id=" +
(["Episode", "Season"].includes(data.Type) ? data.SeriesId : data.Id) +
"&fillWidth=200&quality=90"
@@ -189,6 +218,7 @@ function ItemInfo() {
display: loaded ? "block" : "none",
}}
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
/>
) : (
<div
@@ -226,7 +256,12 @@ function ItemInfo() {
</h1>
<Link
className="px-2"
to={config.hostUrl + "/web/index.html#!/details?id=" + (data.EpisodeId || data.Id)}
to={
config.hostUrl +
`/web/index.html#!/${config.IS_JELLYFIN ? "details" : "item"}?id=` +
(data.EpisodeId || data.Id) +
(config.settings.ServerID ? "&serverId=" + config.settings.ServerID : "")
}
title={i18next.t("ITEM_INFO.OPEN_IN_JELLYFIN")}
target="_blank"
>
@@ -313,7 +348,12 @@ function ItemInfo() {
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant="pills" className="hide-tab-titles">
<Tab eventKey="tabOverview" title="Overview" className="bg-transparent">
<GlobalStats ItemId={Id} />
<GlobalStats
id={Id}
param={"itemid"}
endpoint={"getGlobalItemStats"}
title={<Trans i18nKey="GLOBAL_STATS.ITEM_STATS" />}
/>
{["Series", "Season"].includes(data && data.Type) ? <MoreItems data={data} /> : <></>}
</Tab>
<Tab eventKey="tabActivity" title="Activity" className="bg-transparent">

View File

@@ -1,85 +0,0 @@
import { useState, useEffect } from "react";
import axios from "axios";
import "../../css/globalstats.css";
import WatchTimeStats from "./globalstats/watchtimestats";
import { Trans } from "react-i18next";
function GlobalStats(props) {
const [dayStats, setDayStats] = useState({});
const [weekStats, setWeekStats] = useState({});
const [monthStats, setMonthStats] = useState({});
const [allStats, setAllStats] = useState({});
const token = localStorage.getItem('token');
useEffect(() => {
const fetchData = async () => {
try {
const dayData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*1),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setDayStats(dayData.data);
const weekData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*7),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setWeekStats(weekData.data);
const monthData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*30),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setMonthStats(monthData.data);
const allData = await axios.post(`/stats/getGlobalItemStats`, {
hours: (24*999),
itemid: props.ItemId,
}, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAllStats(allData.data);
} catch (error) {
console.log(error);
}
};
fetchData();
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [props.ItemId,token]);
return (
<div>
<h1 className="py-3"><Trans i18nKey="GLOBAL_STATS.ITEM_STATS"/></h1>
<div className="global-stats-container">
<WatchTimeStats data={dayStats} heading={<Trans i18nKey="GLOBAL_STATS.LAST_24_HRS"/>} />
<WatchTimeStats data={weekStats} heading={<Trans i18nKey="GLOBAL_STATS.LAST_7_DAYS"/>} />
<WatchTimeStats data={monthStats} heading={<Trans i18nKey="GLOBAL_STATS.LAST_30_DAYS"/>} />
<WatchTimeStats data={allStats} heading={<Trans i18nKey="GLOBAL_STATS.ALL_TIME"/>} />
</div>
</div>
);
}
export default GlobalStats;

View File

@@ -1,64 +0,0 @@
import "../../../css/globalstats.css";
import i18next from "i18next";
import { Trans } from "react-i18next";
function WatchTimeStats(props) {
function formatTime(totalSeconds, numberClassName, labelClassName) {
const units = [
{ label: i18next.t("UNITS.DAY"), seconds: 86400 },
{ label: i18next.t("UNITS.HOUR"), seconds: 3600 },
{ label: i18next.t("UNITS.MINUTE"), seconds: 60 },
];
const parts = units.reduce((result, { label, seconds }) => {
const value = Math.floor(totalSeconds / seconds);
if (value) {
const formattedValue = <p className={numberClassName}>{value}</p>;
const formattedLabel = (
<span className={labelClassName}>
{value === 1 ? label : i18next.t(`UNITS.${label.toUpperCase()}S`) }
</span>
);
result.push(
<span key={label} className="time-part">
{formattedValue} {formattedLabel}
</span>
);
totalSeconds -= value * seconds;
}
return result;
}, []);
if (parts.length === 0) {
return (
<>
<p className={numberClassName}>0</p>{' '}
<p className={labelClassName}><Trans i18nKey="UNITS.MINUTES"/></p>
</>
);
}
return parts;
}
return (
<div className="global-stats">
<div className="stats-header">
<div>{props.heading}</div>
</div>
<div className="play-duration-stats" key={props.data.ItemId}>
<p className="stat-value"> {props.data.Plays || 0}</p>
<p className="stat-unit" ><Trans i18nKey="UNITS.PLAYS"/> /</p>
<>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')}</>
</div>
</div>
);
}
export default WatchTimeStats;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import ActivityTable from "../activity/activity-table";
import { Trans } from "react-i18next";
import { FormControl, FormSelect } from "react-bootstrap";
@@ -10,6 +10,7 @@ function ItemActivity(props) {
const token = localStorage.getItem("token");
const [itemCount, setItemCount] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const [streamTypeFilter, setStreamTypeFilter] = useState("All");
useEffect(() => {
const fetchData = async () => {
@@ -55,6 +56,8 @@ function ItemActivity(props) {
);
}
filteredData = filteredData.filter((item) => (streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter));
return (
<div className="Activity">
<div className="d-md-flex justify-content-between">
@@ -63,8 +66,31 @@ function ItemActivity(props) {
</h1>
<div className="d-flex flex-column flex-md-row">
<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">
<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" />
</div>
<FormSelect
onChange={(event) => {
setStreamTypeFilter(event.target.value);
}}
value={streamTypeFilter}
className="w-md-75 rounded-0 rounded-end"
>
<option value="All">
<Trans i18nKey="ALL" />
</option>
<option value="Transcode">
<Trans i18nKey="TRANSCODE" />
</option>
<option value="DirectPlay">
<Trans i18nKey="DIRECT" />
</option>
</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-col rounded-0 rounded-start align-items-center px-2 bg-primary-1 my-md-3">
<Trans i18nKey="UNITS.ITEMS" />
</div>
<FormSelect
@@ -82,7 +108,7 @@ function ItemActivity(props) {
</div>
<FormControl
type="text"
placeholder= {i18next.t("SEARCH")}
placeholder={i18next.t("SEARCH")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ms-md-3 my-3 w-sm-100 w-md-75"

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import "../../css/error.css";
import { Button } from "react-bootstrap";
import Loading from "../general/loading";

View File

@@ -1,5 +1,5 @@
import axios from "axios";
import axios from "../../../lib/axios_instance";
import i18next from "i18next";
import { useState } from "react";
import { Container, Row,Col, Modal } from "react-bootstrap";

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import MoreItemCards from "./more-items/more-items-card";
@@ -16,7 +16,7 @@ function MoreItems(props) {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);

View File

@@ -5,12 +5,31 @@ import { useParams } from "react-router-dom";
import ArchiveDrawerFillIcon from "remixicon-react/ArchiveDrawerFillIcon";
import "../../../css/lastplayed.css";
import { Trans } from "react-i18next";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import baseUrl from "../../../../lib/baseurl";
function MoreItemCards(props) {
const { Id } = useParams();
const [loaded, setLoaded] = useState(props.data.archived);
const [fallback, setFallback] = useState(false);
const SeriesIcon = <TvLineIcon size={"50%"} />;
const MovieIcon = <FilmLineIcon size={"50%"} />;
const MusicIcon = <FileMusicLineIcon size={"50%"} />;
const MixedIcon = <CheckboxMultipleBlankLineIcon size={"50%"} />;
const currentLibraryDefaultIcon =
props.data.Type === "Movie"
? MovieIcon
: props.data.Type === "Episode"
? SeriesIcon
: props.data.Type === "Audio"
? MusicIcon
: MixedIcon;
function formatFileSize(sizeInBytes) {
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
if (sizeInMB < 1000) {
@@ -27,10 +46,19 @@ function MoreItemCards(props) {
to={`/libraries/item/${props.data.Type === "Episode" ? props.data.EpisodeId : props.data.Id}`}
className="text-decoration-none"
>
<div className={props.data.Type === "Episode" ? "last-card-banner episode" : "last-card-banner"}>
<div
className={
(props.data.Type === "Episode"
? "last-card-banner episode"
: props.data.Type === "Audio"
? "last-card-banner audio"
: "last-card-banner") + " d-flex justify-content-center align-items-center"
}
>
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes != null) ||
(props.data.PrimaryImageHash && props.data.PrimaryImageHash != null)) &&
!loaded ? (
!loaded &&
!fallback ? (
<Blurhash
hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]}
width={"100%"}
@@ -41,18 +69,40 @@ function MoreItemCards(props) {
{!props.data.archived ? (
fallback ? (
<img
src={`${"/proxy/Items/Images/Primary?id=" + Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { display: "block" } : { display: "none" }}
/>
Id == undefined ? (
<div
className="d-flex flex-column justify-content-center align-items-center position-relative"
style={{ height: "100%", width: "200px" }}
>
{props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? (
<Blurhash
hash={props.data.PrimaryImageHash}
width={"100%"}
height={"100%"}
className="rounded-top-3 overflow-hidden position-absolute"
style={{ display: "block" }}
/>
) : null}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
{currentLibraryDefaultIcon}
</div>
</div>
) : (
<img
src={`${baseUrl+"/proxy/Items/Images/Primary?id=" + Id + "&fillHeight=320&fillWidth=213&quality=50"}`}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { display: "block" } : { display: "none" }}
/>
)
) : (
<img
src={`${
src={`${baseUrl+
"/proxy/Items/Images/Primary?id=" +
(props.data.Type === "Episode" ? props.data.EpisodeId : props.data.Id) +
"&fillHeight=320&fillWidth=213&quality=50"
(props.data.Type === "Audio"
? "&fillHeight=300&fillWidth=300&quality=50"
: "&fillHeight=320&fillWidth=213&quality=50")
}`}
alt=""
onLoad={() => setLoaded(true)}
@@ -63,15 +113,15 @@ function MoreItemCards(props) {
) : (
<div
className="d-flex flex-column justify-content-center align-items-center position-relative"
style={{ height: "100%" }}
style={{ height: "100%", width: "200px" }}
>
{(props.data.ImageBlurHashes && props.data.ImageBlurHashes != null) ||
(props.data.PrimaryImageHash && props.data.PrimaryImageHash != null) ? (
{props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? (
<Blurhash
hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]}
hash={props.data.PrimaryImageHash}
width={"100%"}
height={"100%"}
className="rounded-top-3 overflow-hidden position-absolute"
style={{ display: "block" }}
/>
) : null}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">

View File

@@ -1,12 +1,11 @@
import { useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../lib/axios_instance";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
// import LibraryDetails from './library/library-details';
import Loading from "./general/loading";
import LibraryGlobalStats from "./library/library-stats";
import LibraryLastWatched from "./library/last-watched";
import RecentlyAdded from "./library/recently-added";
import LibraryActivity from "./library/library-activity";
@@ -16,13 +15,21 @@ import ErrorBoundary from "./general/ErrorBoundary";
import { Tabs, Tab, Button, ButtonGroup } from "react-bootstrap";
import { Trans } from "react-i18next";
import LibraryOptions from "./library/library-options";
import GlobalStats from "./general/globalStats";
function LibraryInfo() {
const { LibraryId } = useParams();
const [activeTab, setActiveTab] = useState("tabOverview");
const [activeTab, setActiveTab] = useState(
localStorage.getItem(`PREF_LIBRARY_TAB_LAST_SELECTED_${LibraryId}`) ?? "tabOverview"
);
const [data, setData] = useState();
const token = localStorage.getItem("token");
function setTab(tabName) {
setActiveTab(tabName);
localStorage.setItem(`PREF_LIBRARY_TAB_LAST_SELECTED_${LibraryId}`, tabName);
}
useEffect(() => {
const fetchData = async () => {
try {
@@ -64,23 +71,18 @@ function LibraryInfo() {
<p className="user-name">{data.Name}</p>
<ButtonGroup>
<Button
onClick={() => setActiveTab("tabOverview")}
onClick={() => setTab("tabOverview")}
active={activeTab === "tabOverview"}
variant="outline-primary"
type="button"
>
<Trans i18nKey="TAB_CONTROLS.OVERVIEW" />
</Button>
<Button
onClick={() => setActiveTab("tabItems")}
active={activeTab === "tabItems"}
variant="outline-primary"
type="button"
>
<Button onClick={() => setTab("tabItems")} active={activeTab === "tabItems"} variant="outline-primary" type="button">
<Trans i18nKey="MEDIA" />
</Button>
<Button
onClick={() => setActiveTab("tabActivity")}
onClick={() => setTab("tabActivity")}
active={activeTab === "tabActivity"}
variant="outline-primary"
type="button"
@@ -89,7 +91,7 @@ function LibraryInfo() {
</Button>
<Button
onClick={() => setActiveTab("tabOptions")}
onClick={() => setTab("tabOptions")}
active={activeTab === "tabOptions"}
variant="outline-primary"
type="button"
@@ -99,9 +101,14 @@ function LibraryInfo() {
</ButtonGroup>
</div>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant="pills" className="hide-tab-titles">
<Tabs defaultActiveKey={activeTab} activeKey={activeTab} variant="pills" className="hide-tab-titles">
<Tab eventKey="tabOverview" title="Overview" className="bg-transparent">
<LibraryGlobalStats LibraryId={LibraryId} />
<GlobalStats
id={LibraryId}
param={"libraryid"}
endpoint={"getGlobalLibraryStats"}
title={<Trans i18nKey="LIBRARY_INFO.LIBRARY_STATS" />}
/>
{!data.archived && (
<ErrorBoundary>

View File

@@ -1,11 +1,18 @@
import React, { useState } from "react";
import { useState } from "react";
import { Blurhash } from "react-blurhash";
import { Link } from "react-router-dom";
import "../../../css/lastplayed.css";
import { Trans } from "react-i18next";
import TvLineIcon from "remixicon-react/TvLineIcon";
import FilmLineIcon from "remixicon-react/FilmLineIcon";
import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import baseUrl from "../../../../lib/baseurl";
function RecentlyAddedCard(props) {
const [loaded, setLoaded] = useState(false);
const [fallback, setFallback] = useState(false);
const twelve_hr = JSON.parse(localStorage.getItem("12hr"));
const localization = localStorage.getItem("i18nextLng");
@@ -19,21 +26,61 @@ function RecentlyAddedCard(props) {
hour12: twelve_hr,
};
const SeriesIcon = <TvLineIcon size={"75%"} />;
const MovieIcon = <FilmLineIcon size={"75%"} />;
const MusicIcon = <FileMusicLineIcon size={"75%"} />;
const MixedIcon = <CheckboxMultipleBlankLineIcon size={"75%"} />;
const currentLibraryDefaultIcon =
props.data.Type === "Movie"
? MovieIcon
: props.data.Type === "Episode"
? SeriesIcon
: props.data.Type === "Audio"
? MusicIcon
: MixedIcon;
return (
<div className="last-card">
<Link to={`/libraries/item/${props.data.EpisodeId ?? props.data.Id}`}>
<div className="last-card-banner">
{loaded ? null : props.data.PrimaryImageHash ? (
<Blurhash hash={props.data.PrimaryImageHash} width={"100%"} height={"100%"} className="rounded-3 overflow-hidden" />
<Link
to={`/libraries/item/${
(props.data.NewEpisodeCount != undefined ? props.data.SeasonId : props.data.EpisodeId) ?? props.data.Id
}`}
>
<div
className={
(props.data.Type === "Audio" ? "last-card-banner audio" : "last-card-banner") +
" d-flex justify-content-center align-items-center"
}
>
{fallback ? (
<div
className="d-flex flex-column justify-content-center align-items-center position-relative"
style={{ height: "100%", width: "200px" }}
>
{props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? (
<Blurhash
hash={props.data.PrimaryImageHash}
width={"100%"}
height={"100%"}
className="rounded-top-3 overflow-hidden position-absolute"
style={{ display: "block" }}
/>
) : null}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
{currentLibraryDefaultIcon}
</div>
</div>
) : null}
<img
src={`${
"/proxy/Items/Images/Primary?id=" +
baseUrl+"/proxy/Items/Images/Primary?id=" +
(props.data.Type === "Episode" ? props.data.SeriesId : props.data.Id) +
"&fillHeight=320&fillWidth=213&quality=50"
}`}
alt=""
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
style={loaded ? {} : { display: "none" }}
/>
</div>
@@ -47,19 +94,29 @@ function RecentlyAddedCard(props) {
<div className="last-item-name">
<Link to={`/libraries/item/${props.data.SeriesId ?? props.data.Id}`}>{props.data.SeriesName ?? props.data.Name}</Link>
</div>
{props.data.Type === "Episode" ? (
{props.data.Type === "Episode" && props.data.NewEpisodeCount == undefined && (
<div className="last-item-episode">
<Link to={`/libraries/item/${props.data.EpisodeId}`}>{props.data.Name}</Link>
</div>
) : null}
)}
</div>
{props.data.SeasonNumber ? (
{props.data.SeasonNumber && props.data.NewEpisodeCount == undefined && (
<div className="last-item-episode number">
S{props.data.SeasonNumber} - E{props.data.EpisodeNumber}
</div>
) : (
<></>
)}
{props.data.SeasonNumber && props.data.NewEpisodeCount != undefined && (
<div className="last-item-episode number pt-0 pb-1">
<Trans i18nKey="SEASON" /> {props.data.SeasonNumber}
</div>
)}
{props.data.NewEpisodeCount && (
<div className="last-item-episode number pt-0">
{props.data.NewEpisodeCount} <Trans i18nKey="EPISODES" />
</div>
)}
</div>
);

View File

@@ -1,64 +0,0 @@
import "../../../css/globalstats.css";
import i18next from "i18next";
import { Trans } from "react-i18next";
function WatchTimeStats(props) {
function formatTime(totalSeconds, numberClassName, labelClassName) {
const units = [
{ label: [i18next.t("UNITS.DAY"),i18next.t("UNITS.DAYS")], seconds: 86400 },
{ label: [i18next.t("UNITS.HOUR"),i18next.t("UNITS.HOURS")], seconds: 3600 },
{ label: [i18next.t("UNITS.MINUTE"),i18next.t("UNITS.MINUTES")], seconds: 60 },
];
const parts = units.reduce((result, { label, seconds }) => {
const value = Math.floor(totalSeconds / seconds);
if (value) {
const formattedValue = <p className={numberClassName}>{value}</p>;
const formattedLabel = (
<span className={labelClassName}>
{value === 1 ? label[0] : label[1] }
</span>
);
result.push(
<span key={label} className="time-part">
{formattedValue} {formattedLabel}
</span>
);
totalSeconds -= value * seconds;
}
return result;
}, []);
if (parts.length === 0) {
return (
<>
<p className={numberClassName}>0</p>{' '}
<p className={labelClassName}><Trans i18nKey="UNITS.MINUTES"/></p>
</>
);
}
return parts;
}
return (
<div className="global-stats">
<div className="stats-header">
<div>{props.heading}</div>
</div>
<div className="play-duration-stats" key={props.data.UserId}>
<p className="stat-value"> {props.data.Plays || 0}</p>
<p className="stat-unit" ><Trans i18nKey="UNITS.PLAYS"/> /</p>
<>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')}</>
</div>
</div>
);
}
export default WatchTimeStats;

View File

@@ -1,8 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
// import ItemCardInfo from "./LastWatched/last-watched-card";
import axios from "../../../lib/axios_instance";
import LastWatchedCard from "../general/last-watched-card";
@@ -19,7 +16,7 @@ function LibraryLastWatched(props) {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import ActivityTable from "../activity/activity-table";
import { Trans } from "react-i18next";
@@ -9,8 +9,21 @@ import i18next from "i18next";
function LibraryActivity(props) {
const [data, setData] = useState();
const token = localStorage.getItem("token");
const [itemCount, setItemCount] = useState(10);
const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_LIBRARY_ACTIVITY_ItemCount") ?? "10"));
const [searchQuery, setSearchQuery] = useState("");
const [streamTypeFilter, setStreamTypeFilter] = useState(
localStorage.getItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter") ?? "All"
);
function setItemLimit(limit) {
setItemCount(limit);
localStorage.setItem("PREF_LIBRARY_ACTIVITY_ItemCount", limit);
}
function setTypeFilter(filter) {
setStreamTypeFilter(filter);
localStorage.setItem("PREF_LIBRARY_ACTIVITY_StreamTypeFilter", filter);
}
useEffect(() => {
const fetchData = async () => {
@@ -55,6 +68,8 @@ function LibraryActivity(props) {
);
}
filteredData = filteredData.filter((item) => (streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter));
return (
<div className="Activity">
<div className="d-md-flex justify-content-between">
@@ -63,13 +78,36 @@ function LibraryActivity(props) {
</h1>
<div className="d-flex flex-column flex-md-row">
<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">
<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" />
</div>
<FormSelect
onChange={(event) => {
setTypeFilter(event.target.value);
}}
value={streamTypeFilter}
className="w-md-75 rounded-0 rounded-end"
>
<option value="All">
<Trans i18nKey="ALL" />
</option>
<option value="Transcode">
<Trans i18nKey="TRANSCODE" />
</option>
<option value="DirectPlay">
<Trans i18nKey="DIRECT" />
</option>
</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-col rounded-0 rounded-start align-items-center px-2 bg-primary-1 my-md-3">
<Trans i18nKey="UNITS.ITEMS" />
</div>
<FormSelect
onChange={(event) => {
setItemCount(event.target.value);
setItemLimit(event.target.value);
}}
value={itemCount}
className="my-md-3 w-md-75 rounded-0 rounded-end"
@@ -82,7 +120,7 @@ function LibraryActivity(props) {
</div>
<FormControl
type="text"
placeholder= {i18next.t("SEARCH")}
placeholder={i18next.t("SEARCH")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ms-md-3 my-3 w-sm-100 w-md-75"

View File

@@ -12,6 +12,7 @@ import FileMusicLineIcon from "remixicon-react/FileMusicLineIcon";
import CheckboxMultipleBlankLineIcon from "remixicon-react/CheckboxMultipleBlankLineIcon";
import { Trans } from "react-i18next";
import i18next from "i18next";
import baseUrl from "../../../lib/baseurl";
function LibraryCard(props) {
const [imageLoaded, setImageLoaded] = useState(true);
@@ -146,7 +147,7 @@ function LibraryCard(props) {
<Card.Img
variant="top"
className="library-card-banner library-card-banner-hover"
src={"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
src={baseUrl+"/proxy/Items/Images/Primary?id=" + props.data.Id + "&fillWidth=800&quality=50"}
onError={() =>setImageLoaded(false)}
/>
:

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
// import TableHead from "@mui/material/TableHead";
// import TableRow from "@mui/material/TableRow";
// import { Trans } from "react-i18next";
// import i18next from "i18next";
import Loading from "../general/loading";
import { Form } from "react-bootstrap";
function LibraryFilterModal(props) {
if (!props || !props.libraries) {
return <Loading />;
}
const handleLibrarySelection = (event) => {
const selectedOptions = props.selectedLibraries.find((library) => library === event.target.value)
? props.selectedLibraries.filter((library) => library !== event.target.value)
: [...props.selectedLibraries, event.target.value];
props.onSelectionChange(selectedOptions);
};
return (
<div className="px-5 py-2">
<Form>
{props.libraries.map((library) => (
<Form.Check
key={library.Id}
type="checkbox"
id={library.Id}
label={library.Name}
value={library.Id}
onChange={handleLibrarySelection}
checked={props.selectedLibraries.includes(library.Id)}
/>
))}
</Form>
</div>
);
}
export default LibraryFilterModal;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import axios from "axios";
import axios from "../../../lib/axios_instance";
import { FormControl, FormSelect, Button } from "react-bootstrap";
import SortAscIcon from "remixicon-react/SortAscIcon";
import SortDescIcon from "remixicon-react/SortDescIcon";
@@ -11,25 +11,30 @@ import "../../css/library/media-items.css";
import "../../css/width_breakpoint_css.css";
import "../../css/radius_breakpoint_css.css";
import { Trans } from "react-i18next";
import Loading from "../general/loading";
function LibraryItems(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
const [searchQuery, setSearchQuery] = useState("");
const [sortOrder, setSortOrder] = useState("Title");
const [sortAsc, setSortAsc] = useState("all");
const [sortOrder, setSortOrder] = useState(localStorage.getItem("PREF_sortOrder") ?? "Title");
const [sortAsc, setSortAsc] = useState(
localStorage.getItem("PREF_sortAsc") != undefined ? localStorage.getItem("PREF_sortAsc") == "true" : true
);
console.log(sortOrder);
const archive = {
all: "all",
archived: "true",
not_archived: "false",
};
const [showArchived, setShowArchived] = useState(archive.all);
const [showArchived, setShowArchived] = useState(localStorage.getItem("PREF_archiveFilterValue") ?? archive.all);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config();
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
@@ -68,11 +73,26 @@ function LibraryItems(props) {
function sortOrderLogic(_sortOrder) {
if (_sortOrder !== "Title") {
setSortAsc(false);
setSortDirection(false);
} else {
setSortAsc(true);
setSortDirection(true);
}
setSortOrder(_sortOrder);
setSortingOrder(_sortOrder);
}
function setSortDirection(asc) {
setSortAsc(asc);
localStorage.setItem("PREF_sortAsc", asc);
}
function setSortingOrder(order) {
setSortOrder(order);
localStorage.setItem("PREF_sortOrder", order);
}
function setArchivedFilter(value) {
setShowArchived(value);
localStorage.setItem("PREF_archiveFilterValue", value);
}
let filteredData = data;
@@ -92,7 +112,7 @@ function LibraryItems(props) {
}
if (!data || !config) {
return <></>;
return <Loading />;
}
return (
@@ -104,7 +124,11 @@ function LibraryItems(props) {
<div className="d-flex flex-column flex-md-row">
<div className="d-flex flex-row w-md-75">
<FormSelect onChange={(e) => setShowArchived(e.target.value)} className="my-md-3 w-100 rounded">
<FormSelect
value={showArchived}
onChange={(e) => setArchivedFilter(e.target.value)}
className="my-md-3 w-100 rounded"
>
<option value="all">
<Trans i18nKey="ALL" />
</option>
@@ -120,6 +144,7 @@ function LibraryItems(props) {
<FormSelect
onChange={(e) => sortOrderLogic(e.target.value)}
className="ms-md-3 my-md-3 w-100 rounded-0 rounded-start"
value={sortOrder}
>
<option value="Title">
<Trans i18nKey="TITLE" />
@@ -138,7 +163,7 @@ function LibraryItems(props) {
</option>
</FormSelect>
<Button className="my-md-3 rounded-0 rounded-end" onClick={() => setSortAsc(!sortAsc)}>
<Button className="my-md-3 rounded-0 rounded-end" onClick={() => setSortDirection(!sortAsc)}>
{sortAsc ? <SortAscIcon /> : <SortDescIcon />}
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import axios from "axios";
import axios from "../../../lib/axios_instance";
import i18next from "i18next";
import { useState } from "react";
import { Container, Row, Col, Modal } from "react-bootstrap";

Some files were not shown because too many files have changed in this diff Show More