mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@@ -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
58
.vscode/launch.json
vendored
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -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"]
|
||||
|
||||
13
backend/classes/api-loader.js
Normal file
13
backend/classes/api-loader.js
Normal 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
161
backend/classes/backup.js
Normal 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;
|
||||
@@ -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
542
backend/classes/emby-api.js
Normal 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
31
backend/classes/env.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
60
backend/classes/logging.js
Normal file
60
backend/classes/logging.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
12
backend/global/backup_tables.js
Normal file
12
backend/global/backup_tables.js
Normal 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 };
|
||||
@@ -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 = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,4 +59,4 @@ exports.up = async function(knex) {
|
||||
exports.down = async function(knex) {
|
||||
await knex.raw(`DROP VIEW jf_all_user_activity;`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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)');
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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)');
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}";
|
||||
`);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
78
backend/migrations/076_fs_user_stats_fixed_hours_range.js
Normal file
78
backend/migrations/076_fs_user_stats_fixed_hours_range.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
3
backend/nodemon.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignore": ["backend/backup-data", "*.json"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
20
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
32
src/App.jsx
32
src/App.jsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
11
src/lib/baseurl.jsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
:
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
168
src/pages/components/general/globalStats.jsx
Normal file
168
src/pages/components/general/globalStats.jsx
Normal 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;
|
||||
77
src/pages/components/general/globalstats/watchtimestats.jsx
Normal file
77
src/pages/components/general/globalstats/watchtimestats.jsx
Normal 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;
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
:
|
||||
|
||||
42
src/pages/components/library/library-filter-modal.jsx
Normal file
42
src/pages/components/library/library-filter-modal.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user