diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8a26d34..6312cb2 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json index b2b655f..46b5368 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}" - } - ] -} \ No newline at end of file + "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}" + } + ] +} diff --git a/Dockerfile b/Dockerfile index efc0c41..3abb606 100644 --- a/Dockerfile +++ b/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"] diff --git a/backend/classes/api-loader.js b/backend/classes/api-loader.js new file mode 100644 index 0000000..ef7135b --- /dev/null +++ b/backend/classes/api-loader.js @@ -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(); diff --git a/backend/classes/backup.js b/backend/classes/backup.js new file mode 100644 index 0000000..5282ca0 --- /dev/null +++ b/backend/classes/backup.js @@ -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; diff --git a/backend/classes/config.js b/backend/classes/config.js index ff5b4fe..ec335d0 100644 --- a/backend/classes/config.js +++ b/backend/classes/config.js @@ -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 { diff --git a/backend/classes/emby-api.js b/backend/classes/emby-api.js new file mode 100644 index 0000000..610992b --- /dev/null +++ b/backend/classes/emby-api.js @@ -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; diff --git a/backend/classes/env.js b/backend/classes/env.js new file mode 100644 index 0000000..afb76e7 --- /dev/null +++ b/backend/classes/env.js @@ -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; diff --git a/backend/classes/jellyfin-api.js b/backend/classes/jellyfin-api.js index 72470ab..bae976c 100644 --- a/backend/classes/jellyfin-api.js +++ b/backend/classes/jellyfin-api.js @@ -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; diff --git a/backend/classes/logging.js b/backend/classes/logging.js new file mode 100644 index 0000000..c75a864 --- /dev/null +++ b/backend/classes/logging.js @@ -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, +}; diff --git a/backend/db.js b/backend/db.js index 1c581ed..9eaa16c 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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, }; diff --git a/backend/global/backup_tables.js b/backend/global/backup_tables.js new file mode 100644 index 0000000..aaa9036 --- /dev/null +++ b/backend/global/backup_tables.js @@ -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 }; diff --git a/backend/migrations.js b/backend/migrations.js index b348ae1..0240694 100644 --- a/backend/migrations.js +++ b/backend/migrations.js @@ -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 = { } } }; - \ No newline at end of file + diff --git a/backend/migrations/001_app_config_table.js b/backend/migrations/001_app_config_table.js index e1a4185..8b03f72 100644 --- a/backend/migrations/001_app_config_table.js +++ b/backend/migrations/001_app_config_table.js @@ -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); diff --git a/backend/migrations/002_jf_activity_watchdog_table.js b/backend/migrations/002_jf_activity_watchdog_table.js index 9b55e87..6344a5c 100644 --- a/backend/migrations/002_jf_activity_watchdog_table.js +++ b/backend/migrations/002_jf_activity_watchdog_table.js @@ -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); diff --git a/backend/migrations/003_jf_libraries_table.js b/backend/migrations/003_jf_libraries_table.js index 970e0c3..b37a57a 100644 --- a/backend/migrations/003_jf_libraries_table.js +++ b/backend/migrations/003_jf_libraries_table.js @@ -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); } }; - \ No newline at end of file + diff --git a/backend/migrations/004_jf_library_items_table.js b/backend/migrations/004_jf_library_items_table.js index ff4cf0b..0d0c53c 100644 --- a/backend/migrations/004_jf_library_items_table.js +++ b/backend/migrations/004_jf_library_items_table.js @@ -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) { diff --git a/backend/migrations/005_jf_library_seasons_table.js b/backend/migrations/005_jf_library_seasons_table.js index c048404..a85e90f 100644 --- a/backend/migrations/005_jf_library_seasons_table.js +++ b/backend/migrations/005_jf_library_seasons_table.js @@ -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); diff --git a/backend/migrations/006_jf_library_episodes_table.js b/backend/migrations/006_jf_library_episodes_table.js index 64514fb..c8cd6c4 100644 --- a/backend/migrations/006_jf_library_episodes_table.js +++ b/backend/migrations/006_jf_library_episodes_table.js @@ -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); diff --git a/backend/migrations/007_jf_playback_activity_table.js b/backend/migrations/007_jf_playback_activity_table.js index 6d0bc9a..fe725d1 100644 --- a/backend/migrations/007_jf_playback_activity_table.js +++ b/backend/migrations/007_jf_playback_activity_table.js @@ -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); diff --git a/backend/migrations/008_js_users_table.js b/backend/migrations/008_js_users_table.js index 4609108..7964e84 100644 --- a/backend/migrations/008_js_users_table.js +++ b/backend/migrations/008_js_users_table.js @@ -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 @@ } }; - \ No newline at end of file + diff --git a/backend/migrations/009_jf_all_user_activity_view.js b/backend/migrations/009_jf_all_user_activity_view.js index a23dda9..320cf35 100644 --- a/backend/migrations/009_jf_all_user_activity_view.js +++ b/backend/migrations/009_jf_all_user_activity_view.js @@ -59,4 +59,4 @@ exports.up = async function(knex) { exports.down = async function(knex) { await knex.raw(`DROP VIEW jf_all_user_activity;`); }; - \ No newline at end of file + diff --git a/backend/migrations/013_fs_last_user_activity_function.js b/backend/migrations/013_fs_last_user_activity_function.js index 5df1384..8e17f1c 100644 --- a/backend/migrations/013_fs_last_user_activity_function.js +++ b/backend/migrations/013_fs_last_user_activity_function.js @@ -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); `); }; - \ No newline at end of file + diff --git a/backend/migrations/014_fs_library_stats_function.js b/backend/migrations/014_fs_library_stats_function.js index b4a9fbb..353286d 100644 --- a/backend/migrations/014_fs_library_stats_function.js +++ b/backend/migrations/014_fs_library_stats_function.js @@ -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); `); }; - \ No newline at end of file + diff --git a/backend/migrations/015_fs_most_active_user_function.js b/backend/migrations/015_fs_most_active_user_function.js index 3927a57..bbc3647 100644 --- a/backend/migrations/015_fs_most_active_user_function.js +++ b/backend/migrations/015_fs_most_active_user_function.js @@ -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)'); }; - \ No newline at end of file + diff --git a/backend/migrations/016_fs_most_played_items_function.js b/backend/migrations/016_fs_most_played_items_function.js index 5ac4b79..c15f404 100644 --- a/backend/migrations/016_fs_most_played_items_function.js +++ b/backend/migrations/016_fs_most_played_items_function.js @@ -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)'); }; - \ No newline at end of file + diff --git a/backend/migrations/017_fs_most_popular_items_function.js b/backend/migrations/017_fs_most_popular_items_function.js index 3e60d69..6379306 100644 --- a/backend/migrations/017_fs_most_popular_items_function.js +++ b/backend/migrations/017_fs_most_popular_items_function.js @@ -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);`); }; - \ No newline at end of file + diff --git a/backend/migrations/018_fs_most_used_clients_function.js b/backend/migrations/018_fs_most_used_clients_function.js index 31e90c6..d03ddb6 100644 --- a/backend/migrations/018_fs_most_used_clients_function.js +++ b/backend/migrations/018_fs_most_used_clients_function.js @@ -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);`); }; - \ No newline at end of file + diff --git a/backend/migrations/019_fs_most_viewed_libraries_function.js b/backend/migrations/019_fs_most_viewed_libraries_function.js index 9ab33a3..e3a43cf 100644 --- a/backend/migrations/019_fs_most_viewed_libraries_function.js +++ b/backend/migrations/019_fs_most_viewed_libraries_function.js @@ -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); `); }; - \ No newline at end of file + diff --git a/backend/migrations/020_fs_user_stats_function.js b/backend/migrations/020_fs_user_stats_function.js index 0ee0d28..6c1b34d 100644 --- a/backend/migrations/020_fs_user_stats_function.js +++ b/backend/migrations/020_fs_user_stats_function.js @@ -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); }); diff --git a/backend/migrations/021_fs_watch_stats_over_time_functions.js b/backend/migrations/021_fs_watch_stats_over_time_functions.js index 03f6e17..d95d6be 100644 --- a/backend/migrations/021_fs_watch_stats_over_time_functions.js +++ b/backend/migrations/021_fs_watch_stats_over_time_functions.js @@ -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); }); diff --git a/backend/migrations/022_fs_watch_stats_popular_days_of_week_function.js b/backend/migrations/022_fs_watch_stats_popular_days_of_week_function.js index 2801c0b..25ec192 100644 --- a/backend/migrations/022_fs_watch_stats_popular_days_of_week_function.js +++ b/backend/migrations/022_fs_watch_stats_popular_days_of_week_function.js @@ -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); }); diff --git a/backend/migrations/023_fs_watch_stats_popular_hour_of_day_function,js.js b/backend/migrations/023_fs_watch_stats_popular_hour_of_day_function,js.js index 9871c26..2b029ec 100644 --- a/backend/migrations/023_fs_watch_stats_popular_hour_of_day_function,js.js +++ b/backend/migrations/023_fs_watch_stats_popular_hour_of_day_function,js.js @@ -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); }); diff --git a/backend/migrations/024_jf_item_info_table.js b/backend/migrations/024_jf_item_info_table.js index 55ed620..be4c47f 100644 --- a/backend/migrations/024_jf_item_info_table.js +++ b/backend/migrations/024_jf_item_info_table.js @@ -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); diff --git a/backend/migrations/026_fs_last_user_activity_function.js b/backend/migrations/026_fs_last_user_activity_function.js index 66b862a..29f5016 100644 --- a/backend/migrations/026_fs_last_user_activity_function.js +++ b/backend/migrations/026_fs_last_user_activity_function.js @@ -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}"; `); }; \ No newline at end of file diff --git a/backend/migrations/028_jf_playback_reporting_plugin_data_table.js b/backend/migrations/028_jf_playback_reporting_plugin_data_table.js index b30e7b5..3be43a7 100644 --- a/backend/migrations/028_jf_playback_reporting_plugin_data_table.js +++ b/backend/migrations/028_jf_playback_reporting_plugin_data_table.js @@ -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); diff --git a/backend/migrations/030_jf_logging_table.js b/backend/migrations/030_jf_logging_table.js index 9765a16..0246f9a 100644 --- a/backend/migrations/030_jf_logging_table.js +++ b/backend/migrations/030_jf_logging_table.js @@ -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); diff --git a/backend/migrations/065_ji_insert_playback_plugin_data_to_activity_table_fixes.js b/backend/migrations/065_ji_insert_playback_plugin_data_to_activity_table_fixes.js index 28a3194..7bf9ef6 100644 --- a/backend/migrations/065_ji_insert_playback_plugin_data_to_activity_table_fixes.js +++ b/backend/migrations/065_ji_insert_playback_plugin_data_to_activity_table_fixes.js @@ -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); } diff --git a/backend/migrations/068_ji_insert_playback_plugin_data_to_activity_table_fixes_2.js b/backend/migrations/068_ji_insert_playback_plugin_data_to_activity_table_fixes_2.js index 9a8fbe8..4b5811d 100644 --- a/backend/migrations/068_ji_insert_playback_plugin_data_to_activity_table_fixes_2.js +++ b/backend/migrations/068_ji_insert_playback_plugin_data_to_activity_table_fixes_2.js @@ -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); } diff --git a/backend/migrations/073_fs_watch_stats_over_time_exclude_archived_libraries.js b/backend/migrations/073_fs_watch_stats_over_time_exclude_archived_libraries.js new file mode 100644 index 0000000..0847c5a --- /dev/null +++ b/backend/migrations/073_fs_watch_stats_over_time_exclude_archived_libraries.js @@ -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); + } +}; diff --git a/backend/migrations/074_fs_watch_stats_popular_days_of_week_exclude_archived_libraries.js b/backend/migrations/074_fs_watch_stats_popular_days_of_week_exclude_archived_libraries.js new file mode 100644 index 0000000..f17f11a --- /dev/null +++ b/backend/migrations/074_fs_watch_stats_popular_days_of_week_exclude_archived_libraries.js @@ -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); + } +}; diff --git a/backend/migrations/075_fs_watch_stats_popular_hour_of_day_exclude_archived_libraries.js b/backend/migrations/075_fs_watch_stats_popular_hour_of_day_exclude_archived_libraries.js new file mode 100644 index 0000000..26d4ef7 --- /dev/null +++ b/backend/migrations/075_fs_watch_stats_popular_hour_of_day_exclude_archived_libraries.js @@ -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); + } +}; diff --git a/backend/migrations/076_fs_user_stats_fixed_hours_range.js b/backend/migrations/076_fs_user_stats_fixed_hours_range.js new file mode 100644 index 0000000..094a4e2 --- /dev/null +++ b/backend/migrations/076_fs_user_stats_fixed_hours_range.js @@ -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); + } +}; diff --git a/backend/migrations/077_jf_recent_playback_activity_fix_devide_by_zero.js b/backend/migrations/077_jf_recent_playback_activity_fix_devide_by_zero.js new file mode 100644 index 0000000..1e4bc0c --- /dev/null +++ b/backend/migrations/077_jf_recent_playback_activity_fix_devide_by_zero.js @@ -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); + } +}; diff --git a/backend/models/jf_item_info.js b/backend/models/jf_item_info.js index 44a17d7..6ba16f0 100644 --- a/backend/models/jf_item_info.js +++ b/backend/models/jf_item_info.js @@ -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, - }; \ No newline at end of file +module.exports = { + jf_item_info_columns, + jf_item_info_mapping, +}; diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..fd998ed --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore": ["backend/backup-data", "*.json"] +} diff --git a/backend/routes/api.js b/backend/routes/api.js index c24e364..d2f5270 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -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; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index b31f329..24ad292 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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; diff --git a/backend/routes/backup.js b/backend/routes/backup.js index 0899c1a..d24ae44 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -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; diff --git a/backend/routes/logging.js b/backend/routes/logging.js index 19d5ef4..de9dd47 100644 --- a/backend/routes/logging.js +++ b/backend/routes/logging.js @@ -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; diff --git a/backend/routes/proxy.js b/backend/routes/proxy.js index 8cf756e..ed77751 100644 --- a/backend/routes/proxy.js +++ b/backend/routes/proxy.js @@ -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; diff --git a/backend/routes/stats.js b/backend/routes/stats.js index 9da6c6c..f213bfc 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -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; diff --git a/backend/routes/sync.js b/backend/routes/sync.js index 8a117f6..aa11b40 100644 --- a/backend/routes/sync.js +++ b/backend/routes/sync.js @@ -7,13 +7,12 @@ const { randomUUID } = require("crypto"); const { sendUpdate } = require("../ws"); -const logging = require("./logging"); +const logging = require("../classes/logging"); const taskName = require("../logging/taskName"); const triggertype = require("../logging/triggertype"); const configClass = require("../classes/config"); -const JellyfinAPI = require("../classes/jellyfin-api"); -const Jellyfin = new JellyfinAPI(); +const API = require("../classes/api-loader"); const router = express.Router(); @@ -47,7 +46,7 @@ class sync { async insertData(tablename, dataToInsert, column_mappings) { let result = await db.insertBulk(tablename, dataToInsert, column_mappings); if (result.Result === "SUCCESS") { - syncTask.loggedData.push({ color: "dodgerblue", Message: dataToInsert.length + " Rows Inserted." }); + // syncTask.loggedData.push({ color: "dodgerblue", Message: dataToInsert.length + " Rows Inserted." }); } else { syncTask.loggedData.push({ color: "red", @@ -81,12 +80,12 @@ class sync { async function syncUserData() { sendUpdate(syncTask.wsKey, { type: "Update", message: "Syncing User Data" }); - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/7" }); + syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/4" }); syncTask.loggedData.push({ color: "yellow", Message: "Beginning User Sync" }); const _sync = new sync(); - const data = await Jellyfin.getUsers(); + const data = await API.getUsers(); const existingIds = await _sync.getExistingIDsforTable("jf_users"); // get existing user Ids from the db @@ -110,7 +109,7 @@ async function syncUserData() { async function syncLibraryFolders(data, existing_excluded_libraries) { sendUpdate(syncTask.wsKey, { type: "Update", message: "Syncing Library Folders" }); - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/7" }); + syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/4" }); syncTask.loggedData.push({ color: "yellow", Message: "Beginning Library Sync" }); const _sync = new sync(); const existingIds = await db @@ -144,21 +143,16 @@ async function syncLibraryFolders(data, existing_excluded_libraries) { } await _sync.updateSingleFieldOnDB("jf_libraries", toArchiveLibraryIds, "archived", true); - - syncTask.loggedData.push({ color: "yellow", Message: "Library Sync Complete" }); } + syncTask.loggedData.push({ color: "yellow", Message: "Library Sync Complete" }); } async function syncLibraryItems(data) { const _sync = new sync(); const existingLibraryIds = await _sync.getExistingIDsforTable("jf_libraries"); // get existing library Ids from the db - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 3/7" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Beginning Library Item Sync (3/7)" }); - syncTask.loggedData.push({ color: "yellow", Message: "Beginning Library Item Sync" }); - data = data.filter((row) => existingLibraryIds.includes(row.ParentId)); - const existingIds = await _sync.getExistingIDsforTable("jf_library_items where archived=false"); + const existingIds = await _sync.getExistingIDsforTable("jf_library_items"); let dataToInsert = await data.map(jf_library_items_mapping); dataToInsert = dataToInsert.filter((item) => item.Id !== undefined); @@ -171,144 +165,130 @@ async function syncLibraryItems(data) { await _sync.insertData("jf_library_items", dataToInsert, jf_library_items_columns); } - syncTask.loggedData.push({ - color: "dodgerblue", - Message: `${ - syncTask.taskName === taskName.partialsync ? dataToInsert.length : Math.max(dataToInsert.length - existingIds.length, 0) - } Rows Inserted. ${syncTask.taskName === taskName.partialsync ? 0 : existingIds.length} Rows Updated.`, - }); + return { + insertedItemsCount: + syncTask.taskName === taskName.partialsync ? dataToInsert.length : Math.max(dataToInsert.length - existingIds.length, 0), + updatedItemsCount: syncTask.taskName === taskName.partialsync ? 0 : existingIds.length, + }; +} - if (syncTask.taskName === taskName.fullsync) { - let toArchiveIds = existingIds.filter((id) => !data.some((row) => row.Id === id)); - - if (toArchiveIds.length > 0) { - await _sync.updateSingleFieldOnDB("jf_library_items", toArchiveIds, "archived", true); - } +async function archiveLibraryItems(fetchedData) { + const _sync = new sync(); + const existingIds = await _sync.getExistingIDsforTable("jf_library_items where archived=false"); + let toArchiveIds = existingIds.filter((id) => !fetchedData.some((row) => row === id)); + if (toArchiveIds.length > 0) { + await _sync.updateSingleFieldOnDB("jf_library_items", toArchiveIds, "archived", true); syncTask.loggedData.push({ color: "orange", Message: toArchiveIds.length + " Library Items Archived." }); } - - syncTask.loggedData.push({ color: "yellow", Message: "Item Sync Complete" }); } -async function syncShowItems(data) { - const _sync = new sync(); - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/7" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Beginning Show Item Sync (4/7)" }); - syncTask.loggedData.push({ color: "yellow", Message: "Beginning Seasons and Episode sync" }); - - const { rows: shows } = await db.query(`SELECT * FROM public.jf_library_items where "Type"='Series'`); +async function syncSeasons(seasons) { + const shows = seasons.map((season) => season.SeriesId); let insertSeasonsCount = 0; - let insertEpisodeCount = 0; let updateSeasonsCount = 0; - let updateEpisodeCount = 0; - //loop for each show for (const show of shows) { - //get all seasons and episodes for this show from the data - const allSeasons = data.filter((item) => item.Type === "Season" && item.SeriesId === show.Id); - const allEpisodes = data.filter((item) => item.Type === "Episode" && item.SeriesId === show.Id); + const existingIdsSeasons = await db + .query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show}'`) + .then((res) => res.rows.map((row) => row.Id)); - if (allSeasons.length > 0 || allEpisodes.length > 0) { - const existingIdsSeasons = await db - .query(`SELECT * FROM public.jf_library_seasons where "SeriesId" = '${show.Id}'`) - .then((res) => res.rows.map((row) => row.Id)); - let existingIdsEpisodes = []; - if (existingIdsSeasons.length > 0) { - existingIdsEpisodes = await db - .query( - `SELECT * FROM public.jf_library_episodes WHERE "SeasonId" IN (${existingIdsSeasons - .filter((seasons) => seasons !== "") - .map((seasons) => pgp.as.value(seasons)) - .map((value) => "'" + value + "'") - .join(", ")})` - ) - .then((res) => res.rows.map((row) => row.EpisodeId)); - } + let seasonsToInsert = []; + seasonsToInsert = await seasons.filter((season) => season.SeriesId == show).map(jf_library_seasons_mapping); - let seasonsToInsert = []; - let episodesToInsert = []; + if (syncTask.taskName === taskName.partialsync) { + seasonsToInsert = seasonsToInsert.filter((season) => !existingIdsSeasons.some((id) => id === season.Id)); + } - seasonsToInsert = await allSeasons.map(jf_library_seasons_mapping); - episodesToInsert = await allEpisodes.map(jf_library_episodes_mapping); - - //for partial sync, dont overwrite existing data - if (syncTask.taskName === taskName.partialsync) { - seasonsToInsert = seasonsToInsert.filter((season) => !existingIdsSeasons.some((id) => id === season.Id)); - episodesToInsert = episodesToInsert.filter((episode) => !existingIdsEpisodes.some((id) => id === episode.EpisodeId)); - } - - //Bulkinsert new seasons not on db - if (seasonsToInsert.length !== 0) { - let result = await db.insertBulk("jf_library_seasons", seasonsToInsert, jf_library_seasons_columns); - if (result.Result === "SUCCESS") { - insertSeasonsCount += - syncTask.taskName === taskName.partialsync - ? seasonsToInsert.length - : Math.max(seasonsToInsert.length - existingIdsSeasons.length, 0); - updateSeasonsCount += syncTask.taskName === taskName.partialsync ? 0 : existingIdsSeasons.length; - } else { - syncTask.loggedData.push({ - color: "red", - Message: "Error performing bulk insert:" + result.message, - }); - await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); - } - } - - //Bulkinsert new episodes not on db - if (episodesToInsert.length !== 0) { - let result = await db.insertBulk("jf_library_episodes", episodesToInsert, jf_library_episodes_columns); - if (result.Result === "SUCCESS") { - insertEpisodeCount += - syncTask.taskName === taskName.partialsync - ? episodesToInsert.length - : Math.max(episodesToInsert.length - existingIdsEpisodes.length, 0); - updateEpisodeCount += syncTask.taskName === taskName.partialsync ? 0 : existingIdsEpisodes.length; - } else { - syncTask.loggedData.push({ - color: "red", - Message: "Error performing bulk insert:" + result.message, - }); - await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); - } - } - - if (syncTask.taskName === taskName.fullsync) { - let toArchiveSeasons = existingIdsSeasons.filter((id) => !seasonsToInsert.some((row) => row.Id === id)); - let toArchiveEpisodes = existingIdsEpisodes.filter( - (EpisodeId) => !episodesToInsert.some((row) => row.EpisodeId === EpisodeId) - ); - - if (toArchiveSeasons.length > 0) { - await _sync.updateSingleFieldOnDB("jf_library_seasons", toArchiveSeasons, "archived", true); - syncTask.loggedData.push({ color: "orange", Message: toArchiveSeasons.length + " Seasons Archived." }); - } - if (toArchiveEpisodes.length > 0) { - await _sync.updateSingleFieldOnDB("jf_library_episodes", toArchiveEpisodes, "archived", true, "EpisodeId"); - - syncTask.loggedData.push({ color: "orange", Message: toArchiveEpisodes.length + " Episodes Archived." }); - } + if (seasonsToInsert.length !== 0) { + let result = await db.insertBulk("jf_library_seasons", seasonsToInsert, jf_library_seasons_columns); + if (result.Result === "SUCCESS") { + insertSeasonsCount += + syncTask.taskName === taskName.partialsync + ? seasonsToInsert.length + : Math.max(seasonsToInsert.length - existingIdsSeasons.length, 0); + updateSeasonsCount += syncTask.taskName === taskName.partialsync ? 0 : existingIdsSeasons.length; + } else { + syncTask.loggedData.push({ + color: "red", + Message: "Error performing bulk insert:" + result.message, + }); + await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); } } } - syncTask.loggedData.push({ - color: "dodgerblue", - Message: `Seasons: ${insertSeasonsCount} Rows Inserted. ${updateSeasonsCount} Rows Updated.`, - }); - syncTask.loggedData.push({ - color: "dodgerblue", - Message: `Episodes: ${insertEpisodeCount} Rows Inserted. ${updateEpisodeCount} Rows Updated.`, - }); - syncTask.loggedData.push({ color: "yellow", Message: "Sync Complete" }); + return { insertSeasonsCount: insertSeasonsCount, updateSeasonsCount: updateSeasonsCount }; } -async function syncItemInfo(seasons_and_episodes, library_items) { - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 5/7" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Beginning Item Info Sync (5/7)" }); - syncTask.loggedData.push({ color: "yellow", Message: "Beginning File Info Sync" }); +async function syncEpisodes(episodes) { + const shows = episodes.map((episode) => episode.SeriesId); + let insertEpisodeCount = 0; + let updateEpisodeCount = 0; + + for (const show of shows) { + const existingIdsEpisodes = await db + .query(`SELECT "EpisodeId" FROM public.jf_library_episodes where "SeriesId" = '${show}'`) + .then((res) => res.rows.map((row) => row.EpisodeId)); + + let episodesToInsert = []; + episodesToInsert = await episodes.filter((episode) => episode.SeriesId == show).map(jf_library_episodes_mapping); + + if (syncTask.taskName === taskName.partialsync) { + episodesToInsert = episodesToInsert.filter( + (episode) => !existingIdsEpisodes.some((EpisodeId) => EpisodeId === episode.EpisodeId) + ); + } + + if (episodesToInsert.length !== 0) { + let result = await db.insertBulk("jf_library_episodes", episodesToInsert, jf_library_episodes_columns); + if (result.Result === "SUCCESS") { + insertEpisodeCount += + syncTask.taskName === taskName.partialsync + ? episodesToInsert.length + : Math.max(episodesToInsert.length - existingIdsEpisodes.length, 0); + updateEpisodeCount += syncTask.taskName === taskName.partialsync ? 0 : existingIdsEpisodes.length; + } else { + syncTask.loggedData.push({ + color: "red", + Message: "Error performing bulk insert:" + result.message, + }); + await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); + } + } + } + + return { insertEpisodeCount: insertEpisodeCount, updateEpisodeCount: updateEpisodeCount }; +} + +async function archiveSeasonsAndEpisodes(fetchedSeasons, fetchedEpisodes) { + const _sync = new sync(); + const existingIdsSeasons = await db + .query(`SELECT * FROM public.jf_library_seasons where archived=false`) + .then((res) => res.rows.map((row) => row.Id)); + + const existingIdsEpisodes = await db + .query(`SELECT * FROM public.jf_library_episodes where archived=false`) + .then((res) => res.rows.map((row) => row.EpisodeId)); + + // if (syncTask.taskName === taskName.fullsync) { + let toArchiveSeasons = existingIdsSeasons.filter((id) => !fetchedSeasons.some((row) => row === id)); + let toArchiveEpisodes = existingIdsEpisodes.filter((EpisodeId) => !fetchedEpisodes.some((row) => row === EpisodeId)); + + if (toArchiveSeasons.length > 0) { + await _sync.updateSingleFieldOnDB("jf_library_seasons", toArchiveSeasons, "archived", true); + syncTask.loggedData.push({ color: "orange", Message: toArchiveSeasons.length + " Seasons Archived." }); + } + if (toArchiveEpisodes.length > 0) { + await _sync.updateSingleFieldOnDB("jf_library_episodes", toArchiveEpisodes, "archived", true, "EpisodeId"); + + syncTask.loggedData.push({ color: "orange", Message: toArchiveEpisodes.length + " Episodes Archived." }); + } + // } +} + +async function syncItemInfo(seasons_and_episodes, library_items) { let Items = library_items.filter((item) => item.Type !== "Series" && item.Type !== "Folder" && item.Id !== undefined); let Episodes = seasons_and_episodes.filter((item) => item.Type === "Episode" && item.Id !== undefined); @@ -317,16 +297,9 @@ async function syncItemInfo(seasons_and_episodes, library_items) { let updateItemInfoCount = 0; let updateEpisodeInfoCount = 0; - let current_item = 0; - let all_items = Items.length; let data_to_insert = []; //loop for each Movie for (const Item of Items) { - current_item++; - sendUpdate(syncTask.wsKey, { - type: "Update", - message: `Syncing Item Info ${((current_item / all_items) * 100).toFixed(2)}%`, - }); const existingItemInfo = await db .query(`SELECT * FROM public.jf_item_info where "Id" = '${Item.Id}'`) .then((res) => res.rows.map((row) => row.Id)); @@ -343,16 +316,8 @@ async function syncItemInfo(seasons_and_episodes, library_items) { } } - let current_episode = 0; - let all_episodes = Episodes.length; //loop for each Episode for (const Episode of Episodes) { - current_episode++; - sendUpdate(syncTask.wsKey, { - type: "Update", - message: `Syncing Episode Info ${((current_episode / all_episodes) * 100).toFixed(2)}%`, - }); - const existingEpisodeItemInfo = await db .query(`SELECT * FROM public.jf_item_info where "Id" = '${Episode.Id}'`) .then((res) => res.rows.map((row) => row.Id)); @@ -384,27 +349,18 @@ async function syncItemInfo(seasons_and_episodes, library_items) { } } - syncTask.loggedData.push({ - color: "dodgerblue", - Message: - (insertItemInfoCount > 0 ? insertItemInfoCount : 0) + " Item Info inserted. " + updateItemInfoCount + " Item Info Updated", - }); - syncTask.loggedData.push({ - color: "dodgerblue", - Message: - (insertEpisodeInfoCount > 0 ? insertEpisodeInfoCount : 0) + - " Episodes Info inserted. " + - updateEpisodeInfoCount + - " Episodes Info Updated", - }); - syncTask.loggedData.push({ color: "yellow", Message: "Info Sync Complete" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Info Sync Complete" }); + return { + insertItemInfoCount: insertItemInfoCount, + updateItemInfoCount: updateItemInfoCount, + insertEpisodeInfoCount: insertEpisodeInfoCount, + updateEpisodeInfoCount: updateEpisodeInfoCount, + }; } async function removeOrphanedData() { const _sync = new sync(); - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 6/7" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Cleaning up FileInfo/Episode/Season Records (6/7)" }); + syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/4" }); + sendUpdate(syncTask.wsKey, { type: "Update", message: "Cleaning up FileInfo/Episode/Season Records (4/4)" }); syncTask.loggedData.push({ color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records" }); await db.query("CALL jd_remove_orphaned_data()"); @@ -423,14 +379,10 @@ async function removeOrphanedData() { and archived=false`); syncTask.loggedData.push({ color: "dodgerblue", Message: "Orphaned FileInfo/Episode/Season Removed." }); - - syncTask.loggedData.push({ color: "Yellow", Message: "Sync Complete" }); } async function migrateArchivedActivty() { - const _sync = new sync(); - syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 7/7" }); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Migrating Archived Activity to New Items (7/7)" }); + sendUpdate(syncTask.wsKey, { type: "Update", message: "Migrating Archived Activity to New Items" }); syncTask.loggedData.push({ color: "yellow", Message: "Migrating Archived Activity to New Items" }); //Movies @@ -486,18 +438,16 @@ async function migrateArchivedActivty() { } syncTask.loggedData.push({ color: "dodgerblue", Message: "Archived Activity Migrated to New Items Succesfully." }); - - syncTask.loggedData.push({ color: "Yellow", Message: "Sync Complete" }); } async function syncPlaybackPluginData() { PlaybacksyncTask.loggedData.push({ color: "lawngreen", Message: "Syncing..." }); //Playback Reporting Plugin Check - const installed_plugins = await Jellyfin.getInstalledPlugins(); + const installed_plugins = await API.getInstalledPlugins(); const hasPlaybackReportingPlugin = installed_plugins.filter( - (plugins) => plugins?.ConfigurationFileName === "Jellyfin.Plugin.PlaybackReporting.xml" + (plugins) => ["playback_reporting.xml", "Jellyfin.Plugin.PlaybackReporting.xml"].includes(plugins?.ConfigurationFileName) //TO-DO Change this to the correct plugin name ); if (!hasPlaybackReportingPlugin || hasPlaybackReportingPlugin.length === 0) { @@ -514,6 +464,10 @@ async function syncPlaybackPluginData() { .query('SELECT MIN("ActivityDateInserted") "OldestPlaybackActivity" FROM public.jf_playback_activity') .then((res) => res.rows[0]?.OldestPlaybackActivity); + const NewestPlaybackActivity = await db + .query('SELECT MAX("ActivityDateInserted") "OldestPlaybackActivity" FROM public.jf_playback_activity') + .then((res) => res.rows[0]?.OldestPlaybackActivity); + const MaxPlaybackReportingPluginID = await db .query('SELECT MAX(rowid) "MaxRowId" FROM jf_playback_reporting_plugin_data') .then((res) => res.rows[0]?.MaxRowId); @@ -521,15 +475,29 @@ async function syncPlaybackPluginData() { //Query Builder let query = `SELECT rowid, * FROM PlaybackActivity`; - if (OldestPlaybackActivity) { - const formattedDateTime = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); - - query = query + ` WHERE DateCreated < '${formattedDateTime}'`; + if (OldestPlaybackActivity && NewestPlaybackActivity) { + const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + query = query + ` WHERE (DateCreated < '${formattedDateTimeOld}' or DateCreated > '${formattedDateTimeNew}')`; + } + if (OldestPlaybackActivity && !NewestPlaybackActivity) { + const formattedDateTimeOld = moment(OldestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + query = query + ` WHERE DateCreated < '${formattedDateTimeOld}'`; if (MaxPlaybackReportingPluginID) { query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`; } - } else if (MaxPlaybackReportingPluginID) { + } + + if (!OldestPlaybackActivity && NewestPlaybackActivity) { + const formattedDateTimeNew = moment(NewestPlaybackActivity).format("YYYY-MM-DD HH:mm:ss"); + query = query + ` WHERE DateCreated > '${formattedDateTimeNew}'`; + if (MaxPlaybackReportingPluginID) { + query = query + ` AND rowid > ${MaxPlaybackReportingPluginID}`; + } + } + + if (!OldestPlaybackActivity && !NewestPlaybackActivity && MaxPlaybackReportingPluginID) { query = query + ` WHERE rowid > ${MaxPlaybackReportingPluginID}`; } @@ -538,7 +506,7 @@ async function syncPlaybackPluginData() { PlaybacksyncTask.loggedData.push({ color: "dodgerblue", Message: "Query built. Executing." }); // - const PlaybackData = await Jellyfin.StatsSubmitCustomQuery(query); + const PlaybackData = await API.StatsSubmitCustomQuery(query); let DataToInsert = await PlaybackData.map(mappingPlaybackReporting); @@ -589,7 +557,10 @@ async function fullSync(triggertype) { return; } - let libraries = await Jellyfin.getLibraries(); + //syncUserData + await syncUserData(); + + let libraries = await API.getLibraries(); if (libraries.length === 0) { syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." }); await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED); @@ -602,68 +573,172 @@ async function fullSync(triggertype) { let filtered_libraries = libraries.filter((library) => !excluded_libraries.includes(library.Id)); let existing_excluded_libraries = libraries.filter((library) => excluded_libraries.includes(library.Id)); + //syncLibraryFolders + await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); + + syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 3/4" }); + syncTask.loggedData.push({ color: "yellow", Message: "Beginning Media Sync" }); + //clear data from memory as its no longer needed libraries = null; - let data = []; + let fetchedItemIds = []; + let fetchedSeasonIds = []; + let fetchedEpisodeIds = []; + + //item sync counters + let insertedItemsCount = 0; + let updatedItemsCount = 0; + let insertedSeasonsCount = 0; + let updatedSeasonsCount = 0; + let insertedEpisodeCount = 0; + let updatedEpisodeCount = 0; + + //item info sync counters + + let insertItemInfoCount = 0; + let insertEpisodeInfoCount = 0; + let updateItemInfoCount = 0; + let updateEpisodeInfoCount = 0; //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) for (let i = 0; i < filtered_libraries.length; i++) { + let startIndex = 0; + let increment = 200; const item = filtered_libraries[i]; - const wsMessage = "Fetching Data for Library : " + item.Name + ` (${i + 1}/${filtered_libraries.length})`; + const wsMessage = "Syncing Library : " + item.Name + ` (${i + 1}/${filtered_libraries.length})`; sendUpdate(syncTask.wsKey, { type: "Update", message: wsMessage, }); - let libraryItems = await Jellyfin.getItemsFromParentId({ + let libraryItems = await API.getItemsFromParentId({ id: item.Id, ws: sendUpdate, syncTask: syncTask, wsMessage: wsMessage, + params: { + startIndex: startIndex, + increment: increment, + }, }); - if (libraryItems.length === 0) { - syncTask.loggedData.push({ Message: "Error: No Items found for Library : " + item.Name }); + + while (libraryItems.length != 0) { + if (libraryItems.length === 0 && startIndex === 0) { + syncTask.loggedData.push({ Message: "Error: No Items found for Library : " + item.Name }); + break; + } + + const libraryItemsWithParent = libraryItems.map((items) => ({ + ...items, + ...{ ParentId: item.Id }, + })); + + let library_items = libraryItemsWithParent.filter((item) => ["Movie", "Audio", "Series"].includes(item.Type)); + + let seasons = libraryItemsWithParent.filter((item) => ["Season"].includes(item.Type)); + let episodes = libraryItemsWithParent.filter((item) => ["Episode"].includes(item.Type)); + + if (library_items.length > 0) { + //syncLibraryItems + fetchedItemIds.push(...library_items.map((item) => item.Id)); + let counts = await syncLibraryItems(library_items); + insertedItemsCount += Number(counts.insertedItemsCount); + updatedItemsCount += Number(counts.updatedItemsCount); + } + + if (seasons.length > 0) { + //syncSeasons + fetchedSeasonIds.push(...seasons.map((item) => item.Id)); + let count = await syncSeasons(seasons); + insertedSeasonsCount += Number(count.insertSeasonsCount); + updatedSeasonsCount += Number(count.updateSeasonsCount); + } + if (episodes.length > 0) { + //syncEpisodes + fetchedEpisodeIds.push(...episodes.map((item) => item.Id)); + let count = await syncEpisodes(episodes); + insertedEpisodeCount += Number(count.insertEpisodeCount); + updatedEpisodeCount += Number(count.updateEpisodeCount); + } + + //syncItemInfo + let infoCount = await syncItemInfo([...seasons, ...episodes], library_items); + + insertItemInfoCount += Number(infoCount.insertItemInfoCount); + updateItemInfoCount += Number(infoCount.updateItemInfoCount); + insertEpisodeInfoCount += Number(infoCount.insertEpisodeInfoCount); + updateEpisodeInfoCount += Number(infoCount.updateEpisodeInfoCount); + + //clear data from memory as its no longer needed + library_items = null; + seasons = null; + episodes = null; + + startIndex += increment; + + libraryItems = await API.getItemsFromParentId({ + id: item.Id, + ws: sendUpdate, + syncTask: syncTask, + wsMessage: wsMessage, + params: { + startIndex: startIndex, + increment: increment, + }, + }); } - sendUpdate(syncTask.wsKey, { type: "Update", message: "Mapping Data for Library : " + item.Name }); - - const libraryItemsWithParent = libraryItems.map((items) => ({ - ...items, - ...{ ParentId: item.Id }, - })); - data.push(...libraryItemsWithParent); sendUpdate(syncTask.wsKey, { type: "Update", message: "Data Fetched for Library : " + item.Name }); } - let library_items = data.filter((item) => ["Movie", "Audio", "Series"].includes(item.Type)); - let seasons_and_episodes = data.filter((item) => ["Season", "Episode"].includes(item.Type)); - //clear data from memory as its no longer needed - data = null; + syncTask.loggedData.push({ + color: "dodgerblue", + Message: (insertedItemsCount > 0 ? insertedItemsCount : 0) + " Items inserted. " + updatedItemsCount + " Item Info Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertedSeasonsCount > 0 ? insertedSeasonsCount : 0) + " Seasons inserted. " + updatedSeasonsCount + " Seasons Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertedEpisodeCount > 0 ? insertedEpisodeCount : 0) + + " Episodes inserted. " + + updatedEpisodeCount + + " Episodes Updated", + }); - //syncUserData - await syncUserData(); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertItemInfoCount > 0 ? insertItemInfoCount : 0) + + " Item Info inserted. " + + updateItemInfoCount + + " Item Info Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertEpisodeInfoCount > 0 ? insertEpisodeInfoCount : 0) + + " Episodes Info inserted. " + + updateEpisodeInfoCount + + " Episodes Info Updated", + }); - //syncLibraryFolders - await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); + if (syncTask.taskName === taskName.fullsync) { + //archiveLibraryItems + await archiveLibraryItems(fetchedItemIds); + await archiveSeasonsAndEpisodes(fetchedSeasonIds, fetchedEpisodeIds); + } + + syncTask.loggedData.push({ color: "yellow", Message: "Media Sync Complete" }); //clear data from memory as its no longer needed filtered_libraries = null; existing_excluded_libraries = null; - //syncLibraryItems - await syncLibraryItems(library_items); - - //syncShowItems - await syncShowItems(seasons_and_episodes); - - //syncItemInfo - await syncItemInfo(seasons_and_episodes, library_items); - - //clear data from memory as its no longer needed - library_items = null; - seasons_and_episodes = null; - //removeOrphanedData await removeOrphanedData(); @@ -697,7 +772,7 @@ async function partialSync(triggertype) { return; } - const libraries = await Jellyfin.getLibraries(); + const libraries = await API.getLibraries(); if (libraries.length === 0) { syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." }); @@ -711,53 +786,168 @@ async function partialSync(triggertype) { const filtered_libraries = libraries.filter((library) => !excluded_libraries.includes(library.Id)); const existing_excluded_libraries = libraries.filter((library) => excluded_libraries.includes(library.Id)); - let data = []; - - //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) - for (let i = 0; i < filtered_libraries.length; i++) { - const library = filtered_libraries[i]; - sendUpdate(syncTask.wsKey, { - type: "Update", - message: "Fetching Data for Library : " + library.Name + ` (${i + 1}/${filtered_libraries.length})`, - }); - let recentlyAddedForLibrary = await Jellyfin.getRecentlyAdded({ libraryid: library.Id, limit: 10 }); - - sendUpdate(syncTask.wsKey, { type: "Update", message: "Mapping Data for Library : " + library.Name }); - const libraryItemsWithParent = recentlyAddedForLibrary.map((items) => ({ - ...items, - ...{ ParentId: library.Id }, - })); - data.push(...libraryItemsWithParent); - sendUpdate(syncTask.wsKey, { type: "Update", message: "Data Fetched for Library : " + library.Name }); - } - - const library_items = data.filter((item) => ["Movie", "Audio", "Series"].includes(item.Type)); - - for (const item of library_items.filter((item) => item.Type === "Series")) { - let dataForShow = await Jellyfin.getItemsFromParentId({ id: item.Id }); - const seasons_and_episodes_for_show = dataForShow.filter((item) => ["Season", "Episode"].includes(item.Type)); - data.push(...seasons_and_episodes_for_show); - } - - const seasons_and_episodes = data.filter((item) => ["Season", "Episode"].includes(item.Type)); - - //clear data from memory as its no longer needed - data = null; - // //syncUserData await syncUserData(); // //syncLibraryFolders await syncLibraryFolders(filtered_libraries, existing_excluded_libraries); - //syncLibraryItems - await syncLibraryItems(library_items); + //item sync counters + let insertedItemsCount = 0; + let updatedItemsCount = 0; + let insertedSeasonsCount = 0; + let updatedSeasonsCount = 0; + let insertedEpisodeCount = 0; + let updatedEpisodeCount = 0; - //syncShowItems - await syncShowItems(seasons_and_episodes); + //item info sync counters - //syncItemInfo - await syncItemInfo(seasons_and_episodes, library_items); + let insertItemInfoCount = 0; + let insertEpisodeInfoCount = 0; + let updateItemInfoCount = 0; + let updateEpisodeInfoCount = 0; + + let lastSyncDate = moment().subtract(24, "hours"); + + const last_execution = await db + .query( + `SELECT "DateCreated" + FROM public.jf_library_items + ORDER BY "DateCreated" DESC + LIMIT 1` + ) + .then((res) => res.rows); + if (last_execution.length !== 0) { + lastSyncDate = moment(last_execution[0].DateCreated); + } + + //for each item in library run get item using that id as the ParentId (This gets the children of the parent id) + for (let i = 0; i < filtered_libraries.length; i++) { + let startIndex = 0; + let increment = 200; + + const library = filtered_libraries[i]; + sendUpdate(syncTask.wsKey, { + type: "Update", + message: "Fetching Data for Library : " + library.Name + ` (${i + 1}/${filtered_libraries.length})`, + }); + + const wsMessage = "Syncing Library : " + library.Name + ` (${i + 1}/${filtered_libraries.length})`; + + let libraryItems = await API.getItemsFromParentId({ + id: library.Id, + ws: sendUpdate, + syncTask: syncTask, + wsMessage: wsMessage, + params: { + startIndex: startIndex, + increment: increment, + }, + }); + + libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate)); + + while (libraryItems.length != 0) { + if (libraryItems.length === 0 && startIndex === 0) { + syncTask.loggedData.push({ Message: "No New Items found for Library : " + library.Name }); + break; + } + + const libraryItemsWithParent = libraryItems.map((items) => ({ + ...items, + ...{ ParentId: library.Id }, + })); + + let library_items = libraryItemsWithParent.filter((item) => ["Movie", "Audio", "Series"].includes(item.Type)); + + let seasons = libraryItemsWithParent.filter((item) => ["Season"].includes(item.Type)); + let episodes = libraryItemsWithParent.filter((item) => ["Episode"].includes(item.Type)); + + if (library_items.length > 0) { + //syncLibraryItems + + let counts = await syncLibraryItems(library_items); + insertedItemsCount += Number(counts.insertedItemsCount); + updatedItemsCount += Number(counts.updatedItemsCount); + } + + if (seasons.length > 0) { + //syncSeasons + let count = await syncSeasons(seasons); + insertedSeasonsCount += Number(count.insertSeasonsCount); + updatedSeasonsCount += Number(count.updateSeasonsCount); + } + if (episodes.length > 0) { + //syncEpisodes + let count = await syncEpisodes(episodes); + insertedEpisodeCount += Number(count.insertEpisodeCount); + updatedEpisodeCount += Number(count.updateEpisodeCount); + } + + //syncItemInfo + let infoCount = await syncItemInfo([...seasons, ...episodes], library_items); + + insertItemInfoCount += Number(infoCount.insertItemInfoCount); + updateItemInfoCount += Number(infoCount.updateItemInfoCount); + insertEpisodeInfoCount += Number(infoCount.insertEpisodeInfoCount); + updateEpisodeInfoCount += Number(infoCount.updateEpisodeInfoCount); + + //clear data from memory as its no longer needed + library_items = null; + seasons = null; + episodes = null; + + startIndex += increment; + + libraryItems = await API.getItemsFromParentId({ + id: library.Id, + ws: sendUpdate, + syncTask: syncTask, + wsMessage: wsMessage, + params: { + startIndex: startIndex, + increment: increment, + }, + }); + + libraryItems = libraryItems.filter((item) => moment(item.DateCreated).isAfter(lastSyncDate)); + } + } + + syncTask.loggedData.push({ + color: "dodgerblue", + Message: (insertedItemsCount > 0 ? insertedItemsCount : 0) + " Items inserted. " + updatedItemsCount + " Item Info Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertedSeasonsCount > 0 ? insertedSeasonsCount : 0) + " Seasons inserted. " + updatedSeasonsCount + " Seasons Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertedEpisodeCount > 0 ? insertedEpisodeCount : 0) + + " Episodes inserted. " + + updatedEpisodeCount + + " Episodes Updated", + }); + + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertItemInfoCount > 0 ? insertItemInfoCount : 0) + + " Item Info inserted. " + + updateItemInfoCount + + " Item Info Updated", + }); + syncTask.loggedData.push({ + color: "dodgerblue", + Message: + (insertEpisodeInfoCount > 0 ? insertEpisodeInfoCount : 0) + + " Episodes Info inserted. " + + updateEpisodeInfoCount + + " Episodes Info Updated", + }); //removeOrphanedData await removeOrphanedData(); @@ -848,7 +1038,7 @@ router.post("/fetchItem", async (req, res) => { return; } - const { itemId } = req.body; + const { itemId, ParentId, insert = true } = req.body; if (itemId === undefined) { res.status(400); res.send("The itemId field is required."); @@ -860,14 +1050,18 @@ router.post("/fetchItem", async (req, res) => { return; } - const libraries = await Jellyfin.getLibraries(); + const libraries = ParentId ? [{ Id: ParentId }] : await API.getLibraries(); - const item = []; + let item = []; for (let i = 0; i < libraries.length; i++) { const library = libraries[i]; - let libraryItems = await Jellyfin.getItemsFromParentId({ id: library.Id, itemid: itemId }); + let libraryItems = await API.getItemsFromParentId({ + id: library.Id, + itemid: itemId, + params: { limit: itemId.length ?? 1 }, + }); if (libraryItems.length > 0) { const libraryItemsWithParent = libraryItems.map((items) => ({ @@ -877,55 +1071,66 @@ router.post("/fetchItem", async (req, res) => { item.push(...libraryItemsWithParent); } } + + item = item.filter((item) => (Array.isArray(itemId) ? itemId.includes(item.Id) : item.Id === itemId)); if (item.length === 0) { res.status(404); res.send({ error: "Error: Item not found in library" }); return; } - let insertTable = "jf_library_items"; - let itemToInsert = await item.map((item) => { - if (item.Type === "Episode") { - insertTable = "jf_library_episodes"; - return jf_library_episodes_mapping(item); - } else if (item.Type === "Season") { - insertTable = "jf_library_seasons"; - return jf_library_seasons_mapping(item); - } else { - return jf_library_items_mapping(item); - } - }); - let itemInfoToInsert = await item - .map((item) => - item.MediaSources.map((iteminfo) => jf_item_info_mapping(iteminfo, item.Type == "Episode" ? "Episode" : "Item")) - ) - .flat(); + if (insert) { + let insertTable = "jf_library_items"; + let itemToInsert = await item.map((item) => { + if (item.Type === "Episode") { + insertTable = "jf_library_episodes"; + return jf_library_episodes_mapping(item); + } else if (item.Type === "Season") { + insertTable = "jf_library_seasons"; + return jf_library_seasons_mapping(item); + } else { + return jf_library_items_mapping(item); + } + }); - if (itemToInsert.length !== 0) { - let result = await db.insertBulk( - insertTable, - itemToInsert, - insertTable == "jf_library_items" - ? jf_library_items_columns - : insertTable == "jf_library_seasons" - ? jf_library_seasons_columns - : jf_library_episodes_columns - ); - if (result.Result === "SUCCESS") { - let result_info = await db.insertBulk("jf_item_info", itemInfoToInsert, jf_item_info_columns); - if (result_info.Result === "SUCCESS") { - res.send("Item Synced"); + if (itemToInsert.length !== 0) { + let itemInfoToInsert = await item + .map((iteminfo) => + iteminfo.MediaSources ? jf_item_info_mapping(iteminfo, iteminfo.Type == "Episode" ? "Episode" : "Item") : [] + ) + + .flat(); + let result = await db.insertBulk( + insertTable, + itemToInsert, + insertTable == "jf_library_items" + ? jf_library_items_columns + : insertTable == "jf_library_seasons" + ? jf_library_seasons_columns + : jf_library_episodes_columns + ); + if (result.Result === "SUCCESS") { + if (itemInfoToInsert.length !== 0) { + let result_info = await db.insertBulk("jf_item_info", itemInfoToInsert, jf_item_info_columns); + if (result_info.Result === "SUCCESS") { + res.send("Item Synced"); + } else { + res.status(500); + res.send("Unable to insert Item Info: " + result_info.message); + } + } else { + res.send("Item Synced"); + } } else { res.status(500); - res.send("Unable to insert Item Info: " + result_info.message); + res.send("Unable to insert Item: " + result.message); } } else { - res.status(500); - res.send("Unable to insert Item: " + result.message); + res.status(404); + res.send("Unable to find Item"); } } else { - res.status(404); - res.send("Unable to find Item"); + res.send(item); } } catch (error) { console.log(error); @@ -972,6 +1177,11 @@ function sleep(ms) { }); } +// Handle other routes +router.use((req, res) => { + res.status(404).send({ error: "Not Found" }); +}); + ////////////////////////////////////// module.exports = { diff --git a/backend/routes/utils.js b/backend/routes/utils.js index 685e750..273cf18 100644 --- a/backend/routes/utils.js +++ b/backend/routes/utils.js @@ -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; \ No newline at end of file +// Handle other routes +router.use((req, res) => { + res.status(404).send({ error: "Not Found" }); +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 8ade570..baa7a2e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/swagger.json b/backend/swagger.json index 0fe7d3c..f8ec8c5 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -37,10 +37,7 @@ "description": "Jellystat Log Endpoints" } ], - "schemes": [ - "http", - "https" - ], + "schemes": ["http", "https"], "securityDefinitions": { "apiKey": { "type": "apiKey", @@ -51,9 +48,7 @@ "paths": { "/auth/login": { "post": { - "tags": [ - "Auth" - ], + "tags": ["Auth"], "description": "", "parameters": [ { @@ -87,9 +82,7 @@ }, "/auth/isConfigured": { "get": { - "tags": [ - "Auth" - ], + "tags": ["Auth"], "description": "", "responses": { "200": { @@ -103,9 +96,7 @@ }, "/auth/createuser": { "post": { - "tags": [ - "Auth" - ], + "tags": ["Auth"], "description": "", "parameters": [ { @@ -139,9 +130,7 @@ }, "/auth/configSetup": { "post": { - "tags": [ - "Auth" - ], + "tags": ["Auth"], "description": "", "parameters": [ { @@ -175,9 +164,7 @@ }, "/proxy/web/assets/img/devices/": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -198,9 +185,7 @@ }, "/proxy/Items/Images/Backdrop/": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -236,9 +221,7 @@ }, "/proxy/Items/Images/Primary/": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -269,9 +252,7 @@ }, "/proxy/Users/Images/Primary/": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -302,9 +283,7 @@ }, "/proxy/getSessions": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "responses": { "200": { @@ -318,9 +297,7 @@ }, "/proxy/getAdminUsers": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "responses": { "200": { @@ -334,9 +311,7 @@ }, "/proxy/getRecentlyAdded": { "get": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -357,9 +332,7 @@ }, "/proxy/validateSettings": { "post": { - "tags": [ - "Proxy" - ], + "tags": ["Proxy"], "description": "", "parameters": [ { @@ -390,9 +363,7 @@ }, "/api/getconfig": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -430,11 +401,46 @@ } } }, + "/api/getLibraries": { + "get": { + "tags": ["API"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/api/getRecentlyAdded": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -461,6 +467,11 @@ "name": "limit", "in": "query", "type": "string" + }, + { + "name": "GroupResults", + "in": "query", + "type": "string" } ], "responses": { @@ -484,9 +495,7 @@ }, "/api/setconfig": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -541,9 +550,7 @@ }, "/api/setPreferredAdmin": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -598,9 +605,7 @@ }, "/api/setRequireLogin": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -652,9 +657,7 @@ }, "/api/updateCredentials": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -715,9 +718,7 @@ }, "/api/updatePassword": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -769,9 +770,7 @@ }, "/api/TrackedLibraries": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -811,9 +810,7 @@ }, "/api/setExcludedLibraries": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -865,9 +862,7 @@ }, "/api/UntrackedUsers": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -907,9 +902,7 @@ }, "/api/setUntrackedUsers": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -961,9 +954,7 @@ }, "/api/keys": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -998,9 +989,7 @@ } }, "delete": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1050,9 +1039,7 @@ } }, "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1104,9 +1091,7 @@ }, "/api/getTaskSettings": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1146,9 +1131,7 @@ }, "/api/setTaskSettings": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1206,9 +1189,7 @@ }, "/api/CheckForUpdates": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1245,9 +1226,7 @@ }, "/api/getUserDetails": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1300,50 +1279,9 @@ } } }, - "/api/getLibraries": { - "get": { - "tags": [ - "API" - ], - "description": "", - "parameters": [ - { - "name": "authorization", - "in": "header", - "type": "string" - }, - { - "name": "x-api-token", - "in": "header", - "type": "string" - }, - { - "name": "req", - "in": "query", - "type": "string" - } - ], - "responses": { - "200": { - "description": "OK" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - } - } - } - }, "/api/getLibrary": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1398,9 +1336,7 @@ }, "/api/getLibraryItems": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1452,9 +1388,7 @@ }, "/api/getSeasons": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1506,9 +1440,7 @@ }, "/api/getEpisodes": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1560,9 +1492,7 @@ }, "/api/getItemDetails": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1614,9 +1544,7 @@ }, "/api/item/purge": { "delete": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1674,9 +1602,7 @@ }, "/api/library/purge": { "delete": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1734,9 +1660,7 @@ }, "/api/libraryItems/purge": { "delete": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1794,9 +1718,7 @@ }, "/api/getHistory": { "get": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1833,9 +1755,7 @@ }, "/api/getLibraryHistory": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1890,9 +1810,7 @@ }, "/api/getItemHistory": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -1947,9 +1865,7 @@ }, "/api/getUserHistory": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -2004,9 +1920,7 @@ }, "/api/deletePlaybackActivity": { "post": { - "tags": [ - "API" - ], + "tags": ["API"], "description": "", "parameters": [ { @@ -2059,11 +1973,178 @@ } } }, + "/sync/beginSync": { + "get": { + "tags": ["Sync"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/sync/beginPartialSync": { + "get": { + "tags": ["Sync"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/sync/fetchItem": { + "post": { + "tags": ["Sync"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "itemId": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/sync/syncPlaybackPluginData": { + "get": { + "tags": ["Sync"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/stats/getLibraryOverview": { "get": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2103,9 +2184,7 @@ }, "/stats/getMostViewedByType": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2160,9 +2239,7 @@ }, "/stats/getMostPopularByType": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2217,9 +2294,7 @@ }, "/stats/getMostViewedLibraries": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2271,9 +2346,7 @@ }, "/stats/getMostUsedClient": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2325,9 +2398,7 @@ }, "/stats/getMostActiveUsers": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2379,9 +2450,7 @@ }, "/stats/getPlaybackActivity": { "get": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2421,9 +2490,7 @@ }, "/stats/getAllUserActivity": { "get": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2460,9 +2527,7 @@ }, "/stats/getUserLastPlayed": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2514,9 +2579,7 @@ }, "/stats/getGlobalUserStats": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2571,9 +2634,7 @@ }, "/stats/getGlobalItemStats": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2628,9 +2689,7 @@ }, "/stats/getGlobalLibraryStats": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2685,9 +2744,7 @@ }, "/stats/getLibraryCardStats": { "get": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2725,9 +2782,7 @@ } }, "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2779,9 +2834,7 @@ }, "/stats/getLibraryMetadata": { "get": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2821,9 +2874,7 @@ }, "/stats/getLibraryItemsWithStats": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2870,11 +2921,122 @@ } } }, + "/stats/getLibraryItemsPlayMethodStats": { + "post": { + "tags": ["Stats"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "libraryid": { + "example": "any" + }, + "startDate": { + "example": "any" + }, + "endDate": { + "example": "any" + }, + "hours": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/stats/getPlaybackMethodStats": { + "post": { + "tags": ["Stats"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "days": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/stats/getLibraryLastPlayed": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2926,9 +3088,7 @@ }, "/stats/getViewsOverTime": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -2980,9 +3140,7 @@ }, "/stats/getViewsByDays": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -3034,9 +3192,7 @@ }, "/stats/getViewsByHour": { "post": { - "tags": [ - "Stats" - ], + "tags": ["Stats"], "description": "", "parameters": [ { @@ -3086,11 +3242,293 @@ } } }, + "/backup/beginBackup": { + "get": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/backup/restore/{filename}": { + "get": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/backup/files": { + "get": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/backup/files/{filename}": { + "get": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + }, + "delete": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/backup/upload": { + "post": { + "tags": ["Backup"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/logs/getLogs": { + "get": { + "tags": ["Logs"], + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "x-api-token", + "in": "header", + "type": "string" + }, + { + "name": "req", + "in": "query", + "type": "string" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + }, "/utils/geolocateIp": { "post": { - "tags": [ - "Utils" - ], + "tags": ["Utils"], "description": "", "parameters": [ { @@ -3152,4 +3590,4 @@ "apiKey": [] } ] -} \ No newline at end of file +} diff --git a/backend/tasks/ActivityMonitor.js b/backend/tasks/ActivityMonitor.js index 3e17e04..e04c299 100644 --- a/backend/tasks/ActivityMonitor.js +++ b/backend/tasks/ActivityMonitor.js @@ -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 { diff --git a/backend/tasks/BackupTask.js b/backend/tasks/BackupTask.js index dba6f93..fa9e7c2 100644 --- a/backend/tasks/BackupTask.js +++ b/backend/tasks/BackupTask.js @@ -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); diff --git a/backend/tasks/FullSyncTask.js b/backend/tasks/FullSyncTask.js index 06312cf..337bba5 100644 --- a/backend/tasks/FullSyncTask.js +++ b/backend/tasks/FullSyncTask.js @@ -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 = { diff --git a/backend/tasks/RecentlyAddedItemsSyncTask.js b/backend/tasks/RecentlyAddedItemsSyncTask.js index 6a626f5..4c83eee 100644 --- a/backend/tasks/RecentlyAddedItemsSyncTask.js +++ b/backend/tasks/RecentlyAddedItemsSyncTask.js @@ -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; } diff --git a/backend/ws.js b/backend/ws.js index c5a9f61..58afb10 100644 --- a/backend/ws.js +++ b/backend/ws.js @@ -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 }; diff --git a/index.html b/index.html index fb0fe71..948e3aa 100644 --- a/index.html +++ b/index.html @@ -2,22 +2,23 @@ - + - + + - + diff --git a/package-lock.json b/package-lock.json index 3321bbc..8615f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 79bbb08..c67bfff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index afc0c07..131c2f8 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -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", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index 31b19e7..8216983 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -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)" } diff --git a/src/App.jsx b/src/App.jsx index 8fd7eee..3b803b9 100644 --- a/src/App.jsx +++ b/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() {
- } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {routes.map((route, index) => ( + + ))}
diff --git a/src/index.jsx b/src/index.jsx index d7df81b..fb6fda5 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -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( } /> - + diff --git a/src/lib/axios_instance.jsx b/src/lib/axios_instance.jsx index 87a9afc..e27c7e2 100644 --- a/src/lib/axios_instance.jsx +++ b/src/lib/axios_instance.jsx @@ -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; diff --git a/src/lib/baseurl.jsx b/src/lib/baseurl.jsx new file mode 100644 index 0000000..e7212eb --- /dev/null +++ b/src/lib/baseurl.jsx @@ -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; diff --git a/src/lib/config.jsx b/src/lib/config.jsx index a809660..454a873 100644 --- a/src/lib/config.jsx +++ b/src/lib/config.jsx @@ -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(); diff --git a/src/pages/about.jsx b/src/pages/about.jsx index dc02c3c..8a0e8cf 100644 --- a/src/pages/about.jsx +++ b/src/pages/about.jsx @@ -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 ; + if (!data) { + return ; } - - return ( -
-

- - - - - : - - - {data.current_version} - - - - - : - - - {data.message} - - - - - - : - - - https://github.com/CyferShepard/Jellystat - - - - -
- ); - - + return ( +
+

+ +

+ + + + + : + + {data.current_version} + + + + : + + {data.message} + + + + + : + + + + {" "} + https://github.com/CyferShepard/Jellystat + + + + + +
+ ); } diff --git a/src/pages/activity.jsx b/src/pages/activity.jsx index 40f4d97..c537469 100644 --- a/src/pages/activity.jsx +++ b/src/pages/activity.jsx @@ -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 (
+ setShowLibraryFilters(false)}> + + + + + + + + + + +

-
-
+ + +
+
+ +
+ { + setTypeFilter(event.target.value); + }} + value={streamTypeFilter} + className="w-md-75 rounded-0 rounded-end" + > + + + + +
+ +
+
{ - 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" > @@ -116,10 +230,10 @@ function Activity() {
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" />
diff --git a/src/pages/components/HomeStatisticCards.jsx b/src/pages/components/HomeStatisticCards.jsx index cee2d28..7081685 100644 --- a/src/pages/components/HomeStatisticCards.jsx +++ b/src/pages/components/HomeStatisticCards.jsx @@ -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 (
-

+

+ +

-
+
+ +
-
+
+ +
- -
- - + + - - -
+ +
); } diff --git a/src/pages/components/LibrarySelector/SelectionCard.jsx b/src/pages/components/LibrarySelector/SelectionCard.jsx index 1e9f301..5af0798 100644 --- a/src/pages/components/LibrarySelector/SelectionCard.jsx +++ b/src/pages/components/LibrarySelector/SelectionCard.jsx @@ -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) { setImageLoaded(false)} /> : diff --git a/src/pages/components/activity/activity-table.jsx b/src/pages/components/activity/activity-table.jsx index 352036f..b67e39d 100644 --- a/src/pages/components/activity/activity-table.jsx +++ b/src/pages/components/activity/activity-table.jsx @@ -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 ( 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: () => { diff --git a/src/pages/components/activity/stream_info.jsx b/src/pages/components/activity/stream_info.jsx index 0f47a25..7ad9408 100644 --- a/src/pages/components/activity/stream_info.jsx +++ b/src/pages/components/activity/stream_info.jsx @@ -101,7 +101,7 @@ function Row(logs) { - + {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'} {data.MediaStreams ? data.MediaStreams.find(stream => stream.Type === 'Video')?.AspectRatio : '-'} diff --git a/src/pages/components/general/globalStats.jsx b/src/pages/components/general/globalStats.jsx new file mode 100644 index 0000000..cdcddd9 --- /dev/null +++ b/src/pages/components/general/globalStats.jsx @@ -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: , + data: dayStats, + setMethod: setDayStats, + }, + { + key: 7, + heading: , + data: weekStats, + setMethod: setWeekStats, + }, + { + key: 30, + heading: , + data: monthStats, + setMethod: setMonthStats, + }, + { + key: 180, + heading: , + data: d180Stats, + setMethod: setd180Stats, + }, + { + key: 365, + heading: , + data: d365Stats, + setMethod: setd365Stats, + }, + { + key: 9999, + heading: , + 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 ( +
+
+

{props.title}

+ + + + + + {stats.map((stat) => { + return ( +
+ toggleStat(stat)} name={stat.heading} />} + label={stat.heading} + /> +
+ ); + })} +
+
+
+ {stats.map((stat) => { + if (!prefs.includes(stat.key)) return ; + })} +
+
+ ); +} + +export default GlobalStats; diff --git a/src/pages/components/general/globalstats/watchtimestats.jsx b/src/pages/components/general/globalstats/watchtimestats.jsx new file mode 100644 index 0000000..30c1187 --- /dev/null +++ b/src/pages/components/general/globalstats/watchtimestats.jsx @@ -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 =

{value}

; + const formattedLabel = ( + + + + ); + result.push( + + {formattedValue} {formattedLabel} + + ); + 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 ( + <> +

0

{" "} +

+ +

+ + ); + } + + return filteredParts; + } + + return ( +
+
+
{props.heading}
+
+ +
+
+

{props.data.Plays || 0}

+

+ +

+
+ +
{formatTime(props.data.total_playback_duration || 0, "stat-value", "stat-unit")}
+
+
+ ); +} + +export default WatchTimeStats; diff --git a/src/pages/components/general/last-watched-card.jsx b/src/pages/components/general/last-watched-card.jsx index 27a535c..9aaed34 100644 --- a/src/pages/components/general/last-watched-card.jsx +++ b/src/pages/components/general/last-watched-card.jsx @@ -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 ? ( setLoaded(true)} style={loaded ? { display: "block" } : { display: "none" }} diff --git a/src/pages/components/general/navbar.jsx b/src/pages/components/general/navbar.jsx index fad69e8..12576ce 100644 --- a/src/pages/components/general/navbar.jsx +++ b/src/pages/components/general/navbar.jsx @@ -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 ( diff --git a/src/pages/components/general/version-card.jsx b/src/pages/components/general/version-card.jsx index 2e1ad71..7b45f14 100644 --- a/src/pages/components/general/version-card.jsx +++ b/src/pages/components/general/version-card.jsx @@ -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'; diff --git a/src/pages/components/ip-info.jsx b/src/pages/components/ip-info.jsx index b8729e7..05f0654 100644 --- a/src/pages/components/ip-info.jsx +++ b/src/pages/components/ip-info.jsx @@ -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) { diff --git a/src/pages/components/item-info.jsx b/src/pages/components/item-info.jsx index 89327d9..d88f229 100644 --- a/src/pages/components/item-info.jsx +++ b/src/pages/components/item-info.jsx @@ -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 = ; + const MovieIcon = ; + const MusicIcon = ; + const MixedIcon = ; + + 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() { {!data.archived && data.PrimaryImageHash && data.PrimaryImageHash != null && !loaded ? ( - +
+ {data.PrimaryImageHash && data.PrimaryImageHash != null ? ( + + ) : null} + + {fallback ? ( +
+ {currentLibraryDefaultIcon} +
+ ) : null} +
) : null} + {!data.archived ? ( setLoaded(true)} + onError={() => setFallback(true)} /> ) : (
@@ -313,7 +348,12 @@ function ItemInfo() { - + } + /> {["Series", "Season"].includes(data && data.Type) ? : <>} diff --git a/src/pages/components/item-info/globalStats.jsx b/src/pages/components/item-info/globalStats.jsx deleted file mode 100644 index e2751f8..0000000 --- a/src/pages/components/item-info/globalStats.jsx +++ /dev/null @@ -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 ( -
-

-
- } /> - } /> - } /> - } /> -
-
- ); -} - -export default GlobalStats; diff --git a/src/pages/components/item-info/globalstats/watchtimestats.jsx b/src/pages/components/item-info/globalstats/watchtimestats.jsx deleted file mode 100644 index 0329431..0000000 --- a/src/pages/components/item-info/globalstats/watchtimestats.jsx +++ /dev/null @@ -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 =

{value}

; - const formattedLabel = ( - - {value === 1 ? label : i18next.t(`UNITS.${label.toUpperCase()}S`) } - - ); - result.push( - - {formattedValue} {formattedLabel} - - ); - totalSeconds -= value * seconds; - } - return result; - }, []); - - if (parts.length === 0) { - return ( - <> -

0

{' '} -

- - ); - } - - return parts; - } - - - - return ( -
-
-
{props.heading}
-
- -
-

{props.data.Plays || 0}

-

/

- - <>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')} - -
-
- ); -} - -export default WatchTimeStats; diff --git a/src/pages/components/item-info/item-activity.jsx b/src/pages/components/item-info/item-activity.jsx index 623799f..613336f 100644 --- a/src/pages/components/item-info/item-activity.jsx +++ b/src/pages/components/item-info/item-activity.jsx @@ -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 (
@@ -63,8 +66,31 @@ function ItemActivity(props) {
-
-
+
+
+ +
+ { + setStreamTypeFilter(event.target.value); + }} + value={streamTypeFilter} + className="w-md-75 rounded-0 rounded-end" + > + + + + +
+ +
+
setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" diff --git a/src/pages/components/item-info/item-not-found.jsx b/src/pages/components/item-info/item-not-found.jsx index fcfffab..84bc8f8 100644 --- a/src/pages/components/item-info/item-not-found.jsx +++ b/src/pages/components/item-info/item-not-found.jsx @@ -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"; diff --git a/src/pages/components/item-info/item-options.jsx b/src/pages/components/item-info/item-options.jsx index 3c57f40..6220322 100644 --- a/src/pages/components/item-info/item-options.jsx +++ b/src/pages/components/item-info/item-options.jsx @@ -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"; diff --git a/src/pages/components/item-info/more-items.jsx b/src/pages/components/item-info/more-items.jsx index 26ec94e..13408f2 100644 --- a/src/pages/components/item-info/more-items.jsx +++ b/src/pages/components/item-info/more-items.jsx @@ -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); diff --git a/src/pages/components/item-info/more-items/more-items-card.jsx b/src/pages/components/item-info/more-items/more-items-card.jsx index ec13eff..f17a910 100644 --- a/src/pages/components/item-info/more-items/more-items-card.jsx +++ b/src/pages/components/item-info/more-items/more-items-card.jsx @@ -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 = ; + const MovieIcon = ; + const MusicIcon = ; + const MixedIcon = ; + + 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" > -
+
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes != null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash != null)) && - !loaded ? ( + !loaded && + !fallback ? ( setLoaded(true)} - style={loaded ? { display: "block" } : { display: "none" }} - /> + Id == undefined ? ( +
+ {props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? ( + + ) : null} +
+ {currentLibraryDefaultIcon} +
+
+ ) : ( + setLoaded(true)} + style={loaded ? { display: "block" } : { display: "none" }} + /> + ) ) : ( setLoaded(true)} @@ -63,15 +113,15 @@ function MoreItemCards(props) { ) : (
- {(props.data.ImageBlurHashes && props.data.ImageBlurHashes != null) || - (props.data.PrimaryImageHash && props.data.PrimaryImageHash != null) ? ( + {props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? ( ) : null}
diff --git a/src/pages/components/library-info.jsx b/src/pages/components/library-info.jsx index 738a3b6..9ac9d23 100644 --- a/src/pages/components/library-info.jsx +++ b/src/pages/components/library-info.jsx @@ -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() {

{data.Name}

-
- + - + } + /> {!data.archived && ( diff --git a/src/pages/components/library/RecentlyAdded/recently-added-card.jsx b/src/pages/components/library/RecentlyAdded/recently-added-card.jsx index 64a8f6e..a23e92c 100644 --- a/src/pages/components/library/RecentlyAdded/recently-added-card.jsx +++ b/src/pages/components/library/RecentlyAdded/recently-added-card.jsx @@ -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 = ; + const MovieIcon = ; + const MusicIcon = ; + const MixedIcon = ; + + const currentLibraryDefaultIcon = + props.data.Type === "Movie" + ? MovieIcon + : props.data.Type === "Episode" + ? SeriesIcon + : props.data.Type === "Audio" + ? MusicIcon + : MixedIcon; + return (
- -
- {loaded ? null : props.data.PrimaryImageHash ? ( - + +
+ {fallback ? ( +
+ {props.data.PrimaryImageHash && props.data.PrimaryImageHash != null ? ( + + ) : null} +
+ {currentLibraryDefaultIcon} +
+
) : null} setLoaded(true)} + onError={() => setFallback(true)} style={loaded ? {} : { display: "none" }} />
@@ -47,19 +94,29 @@ function RecentlyAddedCard(props) {
{props.data.SeriesName ?? props.data.Name}
- {props.data.Type === "Episode" ? ( + {props.data.Type === "Episode" && props.data.NewEpisodeCount == undefined && (
{props.data.Name}
- ) : null} + )}
- {props.data.SeasonNumber ? ( + {props.data.SeasonNumber && props.data.NewEpisodeCount == undefined && (
S{props.data.SeasonNumber} - E{props.data.EpisodeNumber}
- ) : ( - <> + )} + + {props.data.SeasonNumber && props.data.NewEpisodeCount != undefined && ( +
+ {props.data.SeasonNumber} +
+ )} + + {props.data.NewEpisodeCount && ( +
+ {props.data.NewEpisodeCount} +
)}
); diff --git a/src/pages/components/library/globalstats/watchtimestats.jsx b/src/pages/components/library/globalstats/watchtimestats.jsx deleted file mode 100644 index 075e509..0000000 --- a/src/pages/components/library/globalstats/watchtimestats.jsx +++ /dev/null @@ -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 =

{value}

; - const formattedLabel = ( - - {value === 1 ? label[0] : label[1] } - - ); - result.push( - - {formattedValue} {formattedLabel} - - ); - totalSeconds -= value * seconds; - } - return result; - }, []); - - if (parts.length === 0) { - return ( - <> -

0

{' '} -

- - ); - } - - return parts; - } - - - - return ( -
-
-
{props.heading}
-
- -
-

{props.data.Plays || 0}

-

/

- - <>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')} - -
-
- ); -} - -export default WatchTimeStats; diff --git a/src/pages/components/library/last-watched.jsx b/src/pages/components/library/last-watched.jsx index 10c1ca3..b0b9d7b 100644 --- a/src/pages/components/library/last-watched.jsx +++ b/src/pages/components/library/last-watched.jsx @@ -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); diff --git a/src/pages/components/library/library-activity.jsx b/src/pages/components/library/library-activity.jsx index 39e3ebe..76481f8 100644 --- a/src/pages/components/library/library-activity.jsx +++ b/src/pages/components/library/library-activity.jsx @@ -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 (
@@ -63,13 +78,36 @@ function LibraryActivity(props) {
-
-
+
+
+ +
+ { + setTypeFilter(event.target.value); + }} + value={streamTypeFilter} + className="w-md-75 rounded-0 rounded-end" + > + + + + +
+ +
+
{ - 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) {
setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" diff --git a/src/pages/components/library/library-card.jsx b/src/pages/components/library/library-card.jsx index 25960f4..0a861cb 100644 --- a/src/pages/components/library/library-card.jsx +++ b/src/pages/components/library/library-card.jsx @@ -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) { setImageLoaded(false)} /> : diff --git a/src/pages/components/library/library-filter-modal.jsx b/src/pages/components/library/library-filter-modal.jsx new file mode 100644 index 0000000..3bcb95b --- /dev/null +++ b/src/pages/components/library/library-filter-modal.jsx @@ -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 ; + } + + 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 ( +
+
+ {props.libraries.map((library) => ( + + ))} + +
+ ); +} + +export default LibraryFilterModal; diff --git a/src/pages/components/library/library-items.jsx b/src/pages/components/library/library-items.jsx index b7a73f5..8f90d6f 100644 --- a/src/pages/components/library/library-items.jsx +++ b/src/pages/components/library/library-items.jsx @@ -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 ; } return ( @@ -104,7 +124,11 @@ function LibraryItems(props) {
- setShowArchived(e.target.value)} className="my-md-3 w-100 rounded"> + setArchivedFilter(e.target.value)} + className="my-md-3 w-100 rounded" + > @@ -120,6 +144,7 @@ function LibraryItems(props) { sortOrderLogic(e.target.value)} className="ms-md-3 my-md-3 w-100 rounded-0 rounded-start" + value={sortOrder} > -
diff --git a/src/pages/components/library/library-options.jsx b/src/pages/components/library/library-options.jsx index 08389f9..84a776d 100644 --- a/src/pages/components/library/library-options.jsx +++ b/src/pages/components/library/library-options.jsx @@ -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"; diff --git a/src/pages/components/library/library-stats.jsx b/src/pages/components/library/library-stats.jsx deleted file mode 100644 index 400f958..0000000 --- a/src/pages/components/library/library-stats.jsx +++ /dev/null @@ -1,84 +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 LibraryGlobalStats(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/getGlobalLibraryStats`, { - hours: (24*1), - libraryid: props.LibraryId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setDayStats(dayData.data); - - const weekData = await axios.post(`/stats/getGlobalLibraryStats`, { - hours: (24*7), - libraryid: props.LibraryId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setWeekStats(weekData.data); - - const monthData = await axios.post(`/stats/getGlobalLibraryStats`, { - hours: (24*30), - libraryid: props.LibraryId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setMonthStats(monthData.data); - - const allData = await axios.post(`/stats/getGlobalLibraryStats`, { - hours: (24*999), - libraryid: props.LibraryId, - }, { - 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.LibraryId,token]); - - return ( -
-

-
- } /> - } /> - } /> - } /> -
-
- ); -} - -export default LibraryGlobalStats; diff --git a/src/pages/components/library/recently-added.jsx b/src/pages/components/library/recently-added.jsx index 7a36584..aaecf85 100644 --- a/src/pages/components/library/recently-added.jsx +++ b/src/pages/components/library/recently-added.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react"; -import axios from "axios"; +import { useState, useEffect } from "react"; +import axios from "../../../lib/axios_instance"; import RecentlyAddedCard from "./RecentlyAdded/recently-added-card"; @@ -10,13 +10,14 @@ import { Trans } from "react-i18next"; function RecentlyAdded(props) { const [data, setData] = useState(); const token = localStorage.getItem("token"); + const groupRecentlyAdded = localStorage.getItem("groupRecentlyAdded") ?? true; useEffect(() => { const fetchData = async () => { try { - let url = `/api/getRecentlyAdded`; + let url = `/api/getRecentlyAdded?GroupResults=${groupRecentlyAdded}`; if (props.LibraryId) { - url += `?libraryid=${props.LibraryId}`; + url += `&libraryid=${props.LibraryId}`; } const itemData = await axios.get(url, { diff --git a/src/pages/components/libraryOverview.jsx b/src/pages/components/libraryOverview.jsx index 1262a6e..4a37f62 100644 --- a/src/pages/components/libraryOverview.jsx +++ b/src/pages/components/libraryOverview.jsx @@ -1,6 +1,6 @@ import "../css/libraryOverview.css"; import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../lib/axios_instance"; import Loading from "./general/loading"; import LibraryStatComponent from "./libraryStatCard/library-stat-component"; diff --git a/src/pages/components/libraryStatCard/library-stat-component.jsx b/src/pages/components/libraryStatCard/library-stat-component.jsx index 7d62f9c..416a14d 100644 --- a/src/pages/components/libraryStatCard/library-stat-component.jsx +++ b/src/pages/components/libraryStatCard/library-stat-component.jsx @@ -42,7 +42,7 @@ function LibraryStatComponent(props) {
{props.data && - props.data.map((item, index) => ( + props.data.slice(0,5).map((item, index) => (
diff --git a/src/pages/components/playbackactivity.jsx b/src/pages/components/playbackactivity.jsx index 4f2781b..4240c90 100644 --- a/src/pages/components/playbackactivity.jsx +++ b/src/pages/components/playbackactivity.jsx @@ -1,8 +1,5 @@ -import React, { useState, useEffect } from "react"; -import axios from "axios"; -// import Config from "../lib/config"; - -// import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; +import { useState, useEffect } from "react"; +import axios from "../../lib/axios_instance"; import "../css/users/users.css"; diff --git a/src/pages/components/sessions/session-card.jsx b/src/pages/components/sessions/session-card.jsx index b77e14f..3158f76 100644 --- a/src/pages/components/sessions/session-card.jsx +++ b/src/pages/components/sessions/session-card.jsx @@ -1,20 +1,20 @@ /* eslint-disable react/prop-types */ -import React, {useState,useEffect} from "react"; -import { Link } from 'react-router-dom'; -import Card from 'react-bootstrap/Card'; -import Row from 'react-bootstrap/Row'; -import Col from 'react-bootstrap/Col'; -import Container from 'react-bootstrap/Container'; +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import Card from "react-bootstrap/Card"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import Container from "react-bootstrap/Container"; import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; import PlayFillIcon from "remixicon-react/PlayFillIcon"; import PauseFillIcon from "remixicon-react/PauseFillIcon"; import { clientData } from "../../../lib/devices"; -import Tooltip from "@mui/material/Tooltip"; -import IpInfoModal from '../ip-info'; - -import axios from 'axios'; +import Tooltip from "@mui/material/Tooltip"; +import IpInfoModal from "../ip-info"; +import { Trans } from "react-i18next"; +import baseUrl from "../../../lib/baseurl"; function ticksToTimeString(ticks) { // Convert ticks to seconds @@ -24,57 +24,59 @@ function ticksToTimeString(ticks) { const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; // Format the time string as hh:MM:ss - const timeString = `${hours.toString().padStart(2, "0")}:${minutes + const timeString = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds .toString() - .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; + .padStart(2, "0")}`; return timeString; } function getETAFromTicks(ticks) { - // Get current date const currentDate = Date.now(); // Calculate ETA - const etaMillis = currentDate + ticks/10000; + const etaMillis = currentDate + ticks / 10000; const eta = new Date(etaMillis); // Return formated string in user locale - return eta.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function convertBitrate(bitrate) { - if(!bitrate) - { - return 'N/A'; + if (!bitrate) { + return "N/A"; } const kbps = (bitrate / 1000).toFixed(1); const mbps = (bitrate / 1000000).toFixed(1); if (kbps >= 1000) { - return mbps+' Mbps'; + return mbps + " Mbps"; } else { - return kbps+' Kbps'; + return kbps + " Kbps"; } } function SessionCard(props) { const cardStyle = { - backgroundImage: `url(proxy/Items/Images/Backdrop?id=${(props.data.session.NowPlayingItem.SeriesId ? props.data.session.NowPlayingItem.SeriesId : props.data.session.NowPlayingItem.Id)}&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`, - height:'100%', - backgroundSize: 'cover', + backgroundImage: `url(proxy/Items/Images/Backdrop?id=${ + props.data.session.NowPlayingItem.SeriesId + ? props.data.session.NowPlayingItem.SeriesId + : props.data.session.NowPlayingItem.Id + }&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`, + height: "100%", + backgroundSize: "cover", }; const cardBgStyle = { - backdropFilter: 'blur(5px)', - backgroundColor: 'rgb(0, 0, 0, 0.6)', - height:'100%', + backdropFilter: "blur(5px)", + backgroundColor: "rgb(0, 0, 0, 0.6)", + height: "100%", }; - - - const ipv4Regex = 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 ipv4Regex = 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 [ipModalVisible, setIPModalVisible] = React.useState(false); const [ipAddressLookup, setIPAddressLookup] = React.useState(); @@ -87,7 +89,6 @@ function SessionCard(props) { return false; }; - function showIPDataModal(ipAddress) { ipv4Regex.lastIndex = 0; setIPAddressLookup(ipAddress); @@ -98,188 +99,210 @@ function SessionCard(props) { setIPModalVisible(true); } - - return ( setIPModalVisible(false)} ipAddress={ipAddressLookup} /> -
- - - - - - - - - - - - +
+ + + + + + + + - - {props.data.session.DeviceName +" - "+props.data.session.Client + " " + props.data.session.ApplicationVersion} + + + {props.data.session.DeviceName + + " - " + + props.data.session.Client + + " " + + props.data.session.ApplicationVersion} + - - {props.data.session.PlayState.PlayMethod +(props.data.session.NowPlayingItem.MediaStreams ? ' ( '+props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.Codec.toUpperCase()+(props.data.session.TranscodingInfo? ' - '+props.data.session.TranscodingInfo.VideoCodec.toUpperCase() : '')+' - '+convertBitrate(props.data.session.TranscodingInfo ? props.data.session.TranscodingInfo.Bitrate :props.data.session.NowPlayingItem.MediaStreams.find(stream => stream.Type==='Video')?.BitRate)+' )':'')} + + + {props.data.session.PlayState.PlayMethod + + (props.data.session.NowPlayingItem.MediaStreams + ? " ( " + + props.data.session.NowPlayingItem.MediaStreams.find( + (stream) => stream.Type === "Video" + )?.Codec.toUpperCase() + + (props.data.session.TranscodingInfo + ? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase() + : "") + + " - " + + convertBitrate( + props.data.session.TranscodingInfo + ? props.data.session.TranscodingInfo.Bitrate + : props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video") + ?.BitRate + ) + + " )" + : "")} + - - {props.data.session.NowPlayingItem.SubtitleStream} - + {props.data.session.NowPlayingItem.SubtitleStream} - + - - {isRemoteSession(props.data.session.RemoteEndPoint) && (import.meta.env.JS_GEOLITE_ACCOUNT_ID && import.meta.env.JS_GEOLITE_LICENSE_KEY) ? - - - : + {isRemoteSession(props.data.session.RemoteEndPoint) && + (window.env.JS_GEOLITE_ACCOUNT_ID ?? import.meta.env.JS_GEOLITE_ACCOUNT_ID) ? ( + showIPDataModal(props.data.session.RemoteEndPoint)} + > + : {props.data.session.RemoteEndPoint} + + ) : ( - IP Address: {props.data.session.RemoteEndPoint} + : {props.data.session.RemoteEndPoint} - } - + )} - props.data.session.DeviceName.toLowerCase().includes(item)) || "other") - : - ( clientData.find(item => props.data.session.Client.toLowerCase().includes(item)) || "other") - ) - } - alt="" - /> - - - - - - - {props.data.session.NowPlayingItem.Type==='Episode' ? - - - - - {props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)} - - - - - : - <> - } - - {props.data.session.NowPlayingItem.Type==='Episode' ? - - - {'S'+props.data.session.NowPlayingItem.ParentIndexNumber +' - E'+ props.data.session.NowPlayingItem.IndexNumber} - - - - : - - - - {props.data.session.NowPlayingItem.SeriesName ? (props.data.session.NowPlayingItem.SeriesName+" - "+ props.data.session.NowPlayingItem.Name) : (props.data.session.NowPlayingItem.Name)} - - - + className="card-device-image" + src={ + baseUrl+"/proxy/web/assets/img/devices/?devicename=" + + (props.data.session.Client.toLowerCase() === "jellyfin mobile (ios)" && + props.data.session.DeviceName.toLowerCase() === "iphone" + ? "apple" + : props.data.session.Client.toLowerCase().includes("web") + ? clientData.find((item) => props.data.session.DeviceName.toLowerCase().includes(item)) || "other" + : clientData.find((item) => props.data.session.Client.toLowerCase().includes(item)) || "other") } - - - - {props.data.session.UserPrimaryImageTag !== undefined ? ( - - ) : ( - - )} - - - {props.data.session.UserName} - - - + alt="" + /> - - + {props.data.session.NowPlayingItem.Type === "Episode" ? ( + + + + {props.data.session.NowPlayingItem.SeriesName + ? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name + : props.data.session.NowPlayingItem.Name} + + + + ) : ( + <> + )} + + {props.data.session.NowPlayingItem.Type === "Episode" ? ( + + + {"S" + + props.data.session.NowPlayingItem.ParentIndexNumber + + " - E" + + props.data.session.NowPlayingItem.IndexNumber} + + + ) : ( + + + + {props.data.session.NowPlayingItem.SeriesName + ? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name + : props.data.session.NowPlayingItem.Name} + + + + )} - {props.data.session.PlayState.IsPaused ? - - : - - } + + {props.data.session.UserPrimaryImageTag !== undefined ? ( + + ) : ( + + )} + + + + {props.data.session.UserName} + + + + + - + + + {props.data.session.PlayState.IsPaused ? : } + - - - - - - {ticksToTimeString(props.data.session.PlayState.PositionTicks)}/ - {ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)} - - - - - + + + + + {ticksToTimeString(props.data.session.PlayState.PositionTicks)}/ + {ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)} + + + + + - - - - - - -
-
+ + + + + +
+
+
+ +
- -
-
- + ); } diff --git a/src/pages/components/sessions/sessions.jsx b/src/pages/components/sessions/sessions.jsx index fac434d..ebe92aa 100644 --- a/src/pages/components/sessions/sessions.jsx +++ b/src/pages/components/sessions/sessions.jsx @@ -62,7 +62,7 @@ function Sessions() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { console.log(error); diff --git a/src/pages/components/settings/Tasks.jsx b/src/pages/components/settings/Tasks.jsx index f7b0f6c..a266464 100644 --- a/src/pages/components/settings/Tasks.jsx +++ b/src/pages/components/settings/Tasks.jsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Button from "react-bootstrap/Button"; import Table from "@mui/material/Table"; diff --git a/src/pages/components/settings/apiKeys.jsx b/src/pages/components/settings/apiKeys.jsx index a64735b..a71c5a8 100644 --- a/src/pages/components/settings/apiKeys.jsx +++ b/src/pages/components/settings/apiKeys.jsx @@ -1,5 +1,5 @@ import React, { useState,useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import {Form, Row, Col,ButtonGroup, Button } from 'react-bootstrap'; import Table from '@mui/material/Table'; diff --git a/src/pages/components/settings/backup_page.jsx b/src/pages/components/settings/backup_page.jsx new file mode 100644 index 0000000..d4fdcd3 --- /dev/null +++ b/src/pages/components/settings/backup_page.jsx @@ -0,0 +1,12 @@ +import { Col } from "react-bootstrap"; +import BackupTables from "./backup_tables"; +import BackupFiles from "./backupfiles"; + +export default function BackupPage() { + return ( + + + + + ); +} diff --git a/src/pages/components/settings/backup_tables.jsx b/src/pages/components/settings/backup_tables.jsx new file mode 100644 index 0000000..81a66a5 --- /dev/null +++ b/src/pages/components/settings/backup_tables.jsx @@ -0,0 +1,78 @@ +import { useState, useEffect } from "react"; +import axios from "../../../lib/axios_instance"; + +import "../../css/settings/backups.css"; +import { Trans } from "react-i18next"; +import { Button } from "react-bootstrap"; + +const token = localStorage.getItem("token"); + +export default function BackupTables() { + const [tables, setTables] = useState([]); + + const setTableExclusion = async (table) => { + const tableData = await axios.post( + `/api/setExcludedBackupTable`, + { + table: table, + }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ); + if (tableData.data) { + setTables(tableData.data ?? []); + } + return; + }; + + useEffect(() => { + const fetchData = async () => { + try { + const backupTables = await axios.get(`/api/getBackupTables`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + setTables(backupTables.data); + } catch (error) { + console.log(error); + } + }; + + fetchData(); + + const intervalId = setInterval(fetchData, 60000 * 5); + return () => clearInterval(intervalId); + }, []); + + function toggleTable(table) { + setTableExclusion(table); + } + + return ( +
+

+ +

+
+ {tables.length > 0 && + tables.map((table, index) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/components/settings/backupfiles.jsx b/src/pages/components/settings/backupfiles.jsx index e057a2e..a109afe 100644 --- a/src/pages/components/settings/backupfiles.jsx +++ b/src/pages/components/settings/backupfiles.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import { Form, DropdownButton, Dropdown, ButtonGroup, Button } from "react-bootstrap"; import Table from "@mui/material/Table"; diff --git a/src/pages/components/settings/logs.jsx b/src/pages/components/settings/logs.jsx index 0c63709..17707df 100644 --- a/src/pages/components/settings/logs.jsx +++ b/src/pages/components/settings/logs.jsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import {ButtonGroup, Button } from 'react-bootstrap'; import Table from '@mui/material/Table'; diff --git a/src/pages/components/settings/security.jsx b/src/pages/components/settings/security.jsx index cbd7481..4ff3e46 100644 --- a/src/pages/components/settings/security.jsx +++ b/src/pages/components/settings/security.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Form from "react-bootstrap/Form"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; @@ -31,7 +31,7 @@ export default function SettingsConfig() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setuse_password(newConfig.requireLogin); setFormValues({ JS_USERNAME: newConfig.username }); } catch (error) { @@ -84,6 +84,7 @@ export default function SettingsConfig() { } ) .then((data) => { + Config.setConfig(); setuse_password(requireLogin); }) .catch((error) => { @@ -94,7 +95,7 @@ export default function SettingsConfig() { async function handleFormSubmit(event) { event.preventDefault(); setisSubmitted(""); - if (formValues.JS_C_PASSWORD && formValues.JS_C_PASSWORD.length < 6) { + if (formValues.JS_C_PASSWORD) { setisSubmitted("Failed"); setsubmissionMessage(i18next.t("ERROR_MESSAGES.PASSWORD_LENGTH")); return; @@ -126,6 +127,7 @@ export default function SettingsConfig() { // let result = await updatePassword(hashedOldPassword, hashedNewPassword); let result = await updateUser(username, hashedOldPassword, hashedNewPassword); + Config.setConfig(); if (result.isValid) { setisSubmitted("Success"); setsubmissionMessage(i18next.t("PASSWORD_UPDATE_SUCCESS")); diff --git a/src/pages/components/settings/settingsConfig.jsx b/src/pages/components/settings/settingsConfig.jsx index 4b73e84..0840574 100644 --- a/src/pages/components/settings/settingsConfig.jsx +++ b/src/pages/components/settings/settingsConfig.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; import Loading from "../general/loading"; import Form from "react-bootstrap/Form"; @@ -42,7 +42,7 @@ export default function SettingsConfig() { } useEffect(() => { - Config() + Config.getConfig() .then((config) => { setFormValues({ JF_HOST: config.hostUrl }); setConfig(config); @@ -94,6 +94,7 @@ export default function SettingsConfig() { setisSubmitted("Failed"); setsubmissionMessage(`Error Updating Configuration: ${errorMessage}`); }); + Config.setConfig(); } function handleFormChange(event) { @@ -129,6 +130,7 @@ export default function SettingsConfig() { setisSubmitted("Failed"); setsubmissionMessage("Error Updating Configuration: ", error); }); + Config.setConfig(); } function updateLanguage(event) { @@ -158,7 +160,8 @@ export default function SettingsConfig() {
- + {config.settings?.IS_JELLYFIN ? : } + { setLoaded(true); - } + }; - const backgroundImage=`/proxy/Items/Images/Backdrop?id=${props.data[0].Id}&fillWidth=300&quality=10`; + const backgroundImage = `/proxy/Items/Images/Backdrop?id=${props.data[0].Id}&fillWidth=300&quality=10`; const cardStyle = { backgroundImage: `url(${backgroundImage}), linear-gradient(to right, #00A4DC, #AA5CC3)`, - height:'100%', - backgroundSize: 'cover', + height: "100%", + backgroundSize: "cover", }; const cardBgStyle = { - backdropFilter: props.base_url ? 'blur(5px)' : 'blur(0px)', - backgroundColor: 'rgb(0, 0, 0, 0.6)', - height:'100%', + backdropFilter: props.base_url ? "blur(5px)" : "blur(0px)", + backgroundColor: "rgb(0, 0, 0, 0.6)", + height: "100%", }; - if (props.data.length === 0) { return <>; } - return (
- {props.icon ? -
- {props.icon} -
- : + {props.icon ? ( +
{props.icon}
+ ) : ( <> - {!props.data[0].archived && props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && !loaded && ( -
- -
- )} - {!props.data[0].archived ? - + +
)} - src={"proxy/Items/Images/Primary?id=" + props.data[0].Id + "&fillWidth=400&quality=90"} - style={{ display: loaded ? 'block' : 'none' }} - onLoad={handleImageLoad} - onError={() => setLoaded(false)} - /> - : - -
- {props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && ( - - - - )} -
- - Archived + {!props.data[0].archived ? ( + setLoaded(false)} + /> + ) : ( +
+ {props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash != null && ( + + )} +
+ + Archived +
-
- - } + )} - } - + )} - - - + + +
{props.heading}
@@ -93,51 +91,49 @@ function ItemStatComponent(props) {
{props.data && - props.data.slice(0, 5).map((item, index) => ( -
- -
- {index + 1} - {item.UserId ? - - - {item.Name} - - - - : - (!item.Client && !props.icon) ? - - - - {item.Name} - - - : - (!item.Client && props.icon) ? - - - {item.Name} + props.data.slice(0, 5).map((item, index) => ( +
+
+ {index + 1} + {item.UserId ? ( + + + {item.Name} - : - + ) : !item.Client && !props.icon ? ( + + + {item.Name} + + + ) : !item.Client && props.icon ? ( + item.Id ? ( + + + {item.Name} + + + ) : ( + + {item.Name} + + ) + ) : ( + {item.Client} - - } + + )} +
+ + {item.Plays || item.unique_viewers || item.Count}
- - - {item.Plays || item.unique_viewers} - - -
- ))} + ))} -
-
+
+
); } diff --git a/src/pages/components/statCards/most_active_users.jsx b/src/pages/components/statCards/most_active_users.jsx index 3a3ff6f..0daa80f 100644 --- a/src/pages/components/statCards/most_active_users.jsx +++ b/src/pages/components/statCards/most_active_users.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; import ItemStatComponent from "./ItemStatComponent"; @@ -17,7 +17,7 @@ function MostActiveUsers(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/most_used_client.jsx b/src/pages/components/statCards/most_used_client.jsx index bc592f7..9ba74af 100644 --- a/src/pages/components/statCards/most_used_client.jsx +++ b/src/pages/components/statCards/most_used_client.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import ItemStatComponent from "./ItemStatComponent"; diff --git a/src/pages/components/statCards/mp_movies.jsx b/src/pages/components/statCards/mp_movies.jsx index c679be0..1da09ea 100644 --- a/src/pages/components/statCards/mp_movies.jsx +++ b/src/pages/components/statCards/mp_movies.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; @@ -18,7 +18,7 @@ function MPMovies(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/mp_music.jsx b/src/pages/components/statCards/mp_music.jsx index 5ad1630..d7fd589 100644 --- a/src/pages/components/statCards/mp_music.jsx +++ b/src/pages/components/statCards/mp_music.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; import ItemStatComponent from "./ItemStatComponent"; @@ -16,7 +16,7 @@ function MPMusic(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/mp_series.jsx b/src/pages/components/statCards/mp_series.jsx index 7d9e3ea..ca00833 100644 --- a/src/pages/components/statCards/mp_series.jsx +++ b/src/pages/components/statCards/mp_series.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; import ItemStatComponent from "./ItemStatComponent"; import { Trans } from "react-i18next"; @@ -14,7 +14,7 @@ function MPSeries(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/mv_libraries.jsx b/src/pages/components/statCards/mv_libraries.jsx index 0c6ee6b..fc63789 100644 --- a/src/pages/components/statCards/mv_libraries.jsx +++ b/src/pages/components/statCards/mv_libraries.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; - +import axios from "../../../lib/axios_instance"; import ItemStatComponent from "./ItemStatComponent"; diff --git a/src/pages/components/statCards/mv_movies.jsx b/src/pages/components/statCards/mv_movies.jsx index 5d00ff3..ddb3e8a 100644 --- a/src/pages/components/statCards/mv_movies.jsx +++ b/src/pages/components/statCards/mv_movies.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; @@ -19,7 +19,7 @@ function MVMusic(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/mv_music.jsx b/src/pages/components/statCards/mv_music.jsx index e70e173..8033818 100644 --- a/src/pages/components/statCards/mv_music.jsx +++ b/src/pages/components/statCards/mv_music.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; import ItemStatComponent from "./ItemStatComponent"; import { Trans } from "react-i18next"; @@ -14,7 +14,7 @@ function MVMovies(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/mv_series.jsx b/src/pages/components/statCards/mv_series.jsx index ddbf721..6a426f2 100644 --- a/src/pages/components/statCards/mv_series.jsx +++ b/src/pages/components/statCards/mv_series.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Config from "../../../lib/config"; @@ -17,7 +17,7 @@ function MVSeries(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/components/statCards/playback_method_stats.jsx b/src/pages/components/statCards/playback_method_stats.jsx new file mode 100644 index 0000000..94c021c --- /dev/null +++ b/src/pages/components/statCards/playback_method_stats.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from "react"; +import axios from "../../../lib/axios_instance"; +import Config from "../../../lib/config"; + +import ItemStatComponent from "./ItemStatComponent"; +import { Trans } from "react-i18next"; + +import BarChartGroupedLineIcon from "remixicon-react/BarChartGroupedLineIcon"; + +function PlaybackMethodStats(props) { + const translations = { + DirectPlay: , + Transocde: , + }; + const chartIcon = ; + + const [data, setData] = useState(); + const [days, setDays] = useState(30); + + const [config, setConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + if (error.code === "ERR_NETWORK") { + console.log(error); + } + } + }; + + const fetchStats = () => { + if (config) { + const url = `/stats/getPlaybackMethodStats`; + + axios + .post( + url, + { days: props.days }, + { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + } + ) + .then((data) => { + setData(data.data); + }) + .catch((error) => { + console.log(error); + }); + } + }; + + if (!config) { + fetchConfig(); + } + + if (!data) { + fetchStats(); + } + if (days !== props.days) { + setDays(props.days); + fetchStats(); + } + + const intervalId = setInterval(fetchStats, 60000 * 5); + return () => clearInterval(intervalId); + }, [data, config, days, props.days]); + + if (!data || data.length === 0) { + return <>; + } + + return ( + + stream.Name == "DirectPlay" ? { ...stream, Name: translations.DirectPlay } : { ...stream, Name: translations.Transocde } + )} + icon={chartIcon} + heading={} + units={} + /> + ); +} + +export default PlaybackMethodStats; diff --git a/src/pages/components/statistics/daily-play-count.jsx b/src/pages/components/statistics/daily-play-count.jsx index e1204af..d4ea99e 100644 --- a/src/pages/components/statistics/daily-play-count.jsx +++ b/src/pages/components/statistics/daily-play-count.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; - +import axios from "../../../lib/axios_instance"; import Chart from "./chart"; import "../../css/stats.css"; diff --git a/src/pages/components/statistics/play-method-chart.jsx b/src/pages/components/statistics/play-method-chart.jsx new file mode 100644 index 0000000..8d5bbac --- /dev/null +++ b/src/pages/components/statistics/play-method-chart.jsx @@ -0,0 +1,91 @@ +import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, Legend } from "recharts"; + +function PlayMethodChart({ stats, types }) { + console.log(stats); + console.log(types); + const colors = [ + "rgb(54, 162, 235)", // blue + "rgb(255, 99, 132)", // pink + "rgb(75, 192, 192)", // teal + "rgb(255, 159, 64)", // orange + "rgb(153, 102, 255)", // lavender + "rgb(255, 205, 86)", // yellow + "rgb(201, 203, 207)", // light grey + "rgb(101, 119, 134)", // blue-grey + "rgb(255, 87, 87)", // light red + "rgb(50, 205, 50)", // lime green + "rgb(0, 255, 255)", // light cyan + "rgb(255, 255, 0)", // light yellow + "rgb(30, 144, 255)", // dodger blue + "rgb(192, 192, 192)", // silver + "rgb(255, 20, 147)", // deep pink + "rgb(105, 105, 105)", // dim grey + "rgb(240, 248, 255)", // alice blue + "rgb(255, 182, 193)", // light pink + "rgb(245, 222, 179)", // wheat + "rgb(147, 112, 219)", // medium purple + ]; + + const CustomTooltip = ({ payload, label, active }) => { + if (active) { + return ( +
+

{label}

+ {types.map((type, index) => ( +

{`${type.Name} : ${payload[index].value} Views`}

+ ))} +
+ ); + } + + return null; + }; + + const getMaxValue = () => { + let max = 0; + if (stats) { + stats.forEach((datum) => { + Object.keys(datum).forEach((key) => { + if (key !== "Key") { + max = Math.max(max, parseInt(datum[key])); + } + }); + }); + } + + return max; + }; + + const max = getMaxValue() + 10; + + return ( + + + + {types.map((type, index) => ( + + + + + ))} + + + + } /> + + {types.map((type, index) => ( + + ))} + + + ); +} + +export default PlayMethodChart; diff --git a/src/pages/components/statistics/play-stats-by-day.jsx b/src/pages/components/statistics/play-stats-by-day.jsx index 3efebb6..8380060 100644 --- a/src/pages/components/statistics/play-stats-by-day.jsx +++ b/src/pages/components/statistics/play-stats-by-day.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Chart from "./chart"; import "../../css/stats.css"; diff --git a/src/pages/components/statistics/play-stats-by-hour.jsx b/src/pages/components/statistics/play-stats-by-hour.jsx index 2945794..b80b922 100644 --- a/src/pages/components/statistics/play-stats-by-hour.jsx +++ b/src/pages/components/statistics/play-stats-by-hour.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../../lib/axios_instance"; import Chart from "./chart"; import "../../css/stats.css"; import { Trans } from "react-i18next"; diff --git a/src/pages/components/statistics/playbackMethodStats.jsx b/src/pages/components/statistics/playbackMethodStats.jsx new file mode 100644 index 0000000..63046be --- /dev/null +++ b/src/pages/components/statistics/playbackMethodStats.jsx @@ -0,0 +1,84 @@ +import { useState, useEffect } from "react"; +import axios from "../../../lib/axios_instance"; + +import "../../css/stats.css"; +import { Trans } from "react-i18next"; +import PlayMethodChart from "./play-method-chart"; + +function PlayMethodStats(props) { + const [stats, setStats] = useState(); + const [types, setTypes] = useState(); + const [hours, setHours] = useState(999); + const token = localStorage.getItem("token"); + + useEffect(() => { + const fetchLibraries = () => { + const url = `/stats/getLibraryItemsPlayMethodStats`; + + axios + .post( + url, + { + hours: 999, + libraryid: props.libraryid ?? "a656b907eb3a73532e40e44b968d0225", + }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ) + .then((data) => { + setStats(data.data.stats); + setTypes(data.data.types); + }) + .catch((error) => { + console.log(error); + }); + }; + + if (!stats) { + fetchLibraries(); + } + if (hours !== props.hours) { + setHours(props.hours); + fetchLibraries(); + } + + const intervalId = setInterval(fetchLibraries, 60000 * 5); + return () => clearInterval(intervalId); + }, [hours, props.hours, token]); + + if (!stats) { + return <>; + } + + if (stats.length === 0) { + return ( +
+

+ - {hours} 1 ? "S" : ""}`} /> +

+ +

+ +

+
+ ); + } + return ( +
+

+ - {hours}{" "} + 1 ? "S" : ""}`} /> +

+ +
+ +
+
+ ); +} + +export default PlayMethodStats; diff --git a/src/pages/components/user-info.jsx b/src/pages/components/user-info.jsx index 4b4c6e6..0e4290c 100644 --- a/src/pages/components/user-info.jsx +++ b/src/pages/components/user-info.jsx @@ -1,60 +1,59 @@ -import { useParams } from 'react-router-dom'; +import { useParams } from "react-router-dom"; import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../../lib/axios_instance"; import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; import Config from "../../lib/config"; -import {Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap'; +import { Tabs, Tab, Button, ButtonGroup } from "react-bootstrap"; -import GlobalStats from './user-info/globalStats'; -import LastPlayed from './user-info/lastplayed'; -import UserActivity from './user-info/user-activity'; +import LastPlayed from "./user-info/lastplayed"; +import UserActivity from "./user-info/user-activity"; import "../css/users/user-details.css"; -import { Trans } from 'react-i18next'; - - - - +import { Trans } from "react-i18next"; +import baseUrl from "../../lib/baseurl"; +import GlobalStats from "./general/globalStats"; function UserInfo() { const { UserId } = useParams(); const [data, setData] = useState(); const [imgError, setImgError] = useState(false); const [config, setConfig] = useState(); - const [activeTab, setActiveTab] = useState('tabOverview'); + const [activeTab, setActiveTab] = useState("tabOverview"); useEffect(() => { - const fetchConfig = async () => { - try { - const newConfig = await Config(); - setConfig(newConfig); - } catch (error) { - console.log(error); - } - }; - - const fetchData = async () => { - if(config){ try { - const userData = await axios.post(`/api/getUserDetails`, { - userid: UserId, - }, { - headers: { - Authorization: `Bearer ${config.token}`, - "Content-Type": "application/json", - }, - }); - setData(userData.data); + const newConfig = await Config.getConfig(); + setConfig(newConfig); } catch (error) { console.log(error); } - } + }; + const fetchData = async () => { + if (config) { + try { + const userData = await axios.post( + `/api/getUserDetails`, + { + userid: UserId, + }, + { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + } + ); + setData(userData.data); + } catch (error) { + console.log(error); + } + } }; fetchData(); if (!config) { - fetchConfig(); + fetchConfig(); } const intervalId = setInterval(fetchData, 60000 * 5); @@ -69,48 +68,59 @@ function UserInfo() { return <>; } - - return (
-
- {imgError ? ( - - ) : ( - - )} -
+
+ {imgError ? ( + + ) : ( + + )} +
-
-

{data.Name}

+
+

{data.Name}

- - + + +
-
- - - - - - - - - - - + + + } + /> + + + + + +
); } diff --git a/src/pages/components/user-info/globalStats.jsx b/src/pages/components/user-info/globalStats.jsx deleted file mode 100644 index 1ffd727..0000000 --- a/src/pages/components/user-info/globalStats.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { 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/getGlobalUserStats`, { - hours: (24*1), - userid: props.UserId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setDayStats(dayData.data); - - const weekData = await axios.post(`/stats/getGlobalUserStats`, { - hours: (24*7), - userid: props.UserId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setWeekStats(weekData.data); - - const monthData = await axios.post(`/stats/getGlobalUserStats`, { - hours: (24*30), - userid: props.UserId, - }, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - setMonthStats(monthData.data); - - const allData = await axios.post(`/stats/getGlobalUserStats`, { - hours: (24*999), - userid: props.UserId, - }, { - 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.UserId,token]); - - return ( -
-

-
- } /> - } /> - } /> - } /> -
-
- ); -} - -export default GlobalStats; diff --git a/src/pages/components/user-info/globalstats/watchtimestats.jsx b/src/pages/components/user-info/globalstats/watchtimestats.jsx deleted file mode 100644 index 9a661c6..0000000 --- a/src/pages/components/user-info/globalstats/watchtimestats.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; - -import "../../../css/globalstats.css"; - -function WatchTimeStats(props) { - - function formatTime(totalSeconds, numberClassName, labelClassName) { - const units = [ - { label: 'Day', seconds: 86400 }, - { label: 'Hour', seconds: 3600 }, - { label: 'Minute', seconds: 60 }, - ]; - - const parts = units.reduce((result, { label, seconds }) => { - const value = Math.floor(totalSeconds / seconds); - if (value) { - const formattedValue =

{value}

; - const formattedLabel = ( - - {label} - {value === 1 ? '' : 's'} - - ); - result.push( - - {formattedValue} {formattedLabel} - - ); - totalSeconds -= value * seconds; - } - return result; - }, []); - - if (parts.length === 0) { - return ( - <> -

0

{' '} -

Minutes

- - ); - } - - return parts; - } - - - - return ( -
-
-
{props.heading}
-
- -
-

{props.data.Plays || 0}

-

Plays /

- - <>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')} - -
-
- ); -} - -export default WatchTimeStats; diff --git a/src/pages/components/user-info/lastplayed.jsx b/src/pages/components/user-info/lastplayed.jsx index e1b1c5d..0c4d553 100644 --- a/src/pages/components/user-info/lastplayed.jsx +++ b/src/pages/components/user-info/lastplayed.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; - +import axios from "../../../lib/axios_instance"; import LastWatchedCard from "../general/last-watched-card"; import ErrorBoundary from "../general/ErrorBoundary"; @@ -15,7 +14,7 @@ function LastPlayed(props) { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { console.log(error); diff --git a/src/pages/components/user-info/user-activity.jsx b/src/pages/components/user-info/user-activity.jsx index 6826ec4..acf131b 100644 --- a/src/pages/components/user-info/user-activity.jsx +++ b/src/pages/components/user-info/user-activity.jsx @@ -1,20 +1,38 @@ 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"; +import { Button, FormControl, FormSelect, Modal } from "react-bootstrap"; import "../../css/width_breakpoint_css.css"; import "../../css/radius_breakpoint_css.css"; import "../../css/users/user-activity.css"; +import i18next from "i18next"; +import LibraryFilterModal from "../library/library-filter-modal"; function UserActivity(props) { const [data, setData] = useState(); const token = localStorage.getItem("token"); const [itemCount, setItemCount] = useState(10); const [searchQuery, setSearchQuery] = useState(""); + const [streamTypeFilter, setStreamTypeFilter] = useState("All"); + const [libraryFilters, setLibraryFilters] = useState([]); + const [libraries, setLibraries] = useState([]); + const [showLibraryFilters, setShowLibraryFilters] = useState(false); + + const handleLibraryFilter = (selectedOptions) => { + setLibraryFilters(selectedOptions); + }; + + const toggleSelectAll = () => { + if (libraryFilters.length > 0) { + setLibraryFilters([]); + } else { + setLibraryFilters(libraries.map((library) => library.Id)); + } + }; useEffect(() => { - const fetchData = async () => { + const fetchHistory = async () => { try { const itemData = await axios.post( `/api/getUserHistory`, @@ -34,9 +52,35 @@ function UserActivity(props) { } }; - fetchData(); + const fetchLibraries = () => { + const url = `/api/getLibraries`; + axios + .get(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + .then((data) => { + const libraryFilters = data.data.map((library) => { + return { + Name: library.Name, + Id: library.Id, + Archived: library.archived, + }; + }); + setLibraries(libraryFilters); + setLibraryFilters(libraryFilters.map((library) => library.Id)); + }) + .catch((error) => { + console.log(error); + }); + }; - const intervalId = setInterval(fetchData, 60000 * 5); + fetchHistory(); + fetchLibraries(); + + const intervalId = setInterval(fetchHistory, 60000 * 5); return () => clearInterval(intervalId); }, [props.UserId, token]); @@ -54,16 +98,64 @@ function UserActivity(props) { ); } + filteredData = filteredData.filter( + (item) => + (libraryFilters.includes(item.ParentId) || item.ParentId == null) && + (streamTypeFilter == "All" ? true : item.PlayMethod === streamTypeFilter) + ); return (
+ setShowLibraryFilters(false)}> + + + + + + + + + + +

- +

-
-
+ + +
+
+ +
+ { + setStreamTypeFilter(event.target.value); + }} + value={streamTypeFilter} + className="w-md-75 rounded-0 rounded-end" + > + + + + +
+ +
+
setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" diff --git a/src/pages/css/globalstats.css b/src/pages/css/globalstats.css index e01ab74..670f9dc 100644 --- a/src/pages/css/globalstats.css +++ b/src/pages/css/globalstats.css @@ -1,52 +1,43 @@ - -@import './variables.module.css'; -.global-stats-container -{ - display: grid; - grid-template-columns: repeat(auto-fit, minmax(350px, 400px)); - grid-auto-rows: 120px; - background-color: var(--secondary-background-color); - padding: 20px; - border-radius: 8px; - font-size: 1.3em; +@import "./variables.module.css"; +.global-stats-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 400px)); + grid-auto-rows: 120px; + background-color: var(--secondary-background-color); + padding: 20px; + border-radius: 8px; + font-size: 1.3em; } -.global-stats -{ - color: white; - width: fit-content; - max-width: 400px; - min-width: 350px; - height: 100px; - padding: 5px; - +.global-stats { + color: white; + width: fit-content; + max-width: 400px; + min-width: 350px; + height: 100px; + padding: 5px; } -.play-duration-stats -{ - padding-top: 20px; - display: flex; - flex-direction: row; - align-items: center; +.play-duration-stats { + padding-top: 10px; + display: flex; + flex-direction: column; + align-items: start; } -.stat-value -{ - text-align: right; - color: var(--secondary-color); - font-weight: 500; - font-size: 1.1em; - margin: 0; +.stat-value { + text-align: right; + color: var(--secondary-color); + font-weight: 500; + font-size: 1.1em; + margin: 0; } -.stat-unit -{ - padding-inline: 5px; - margin: 0; +.stat-unit { + padding-inline: 5px; + margin: 0; } -.time-part -{ - display: flex; - flex-direction: row; - align-items: center; -} \ No newline at end of file +.time-part { + display: flex; + flex-direction: row; +} diff --git a/src/pages/css/lastplayed.css b/src/pages/css/lastplayed.css index f79f839..8025253 100644 --- a/src/pages/css/lastplayed.css +++ b/src/pages/css/lastplayed.css @@ -47,6 +47,10 @@ width: 220px !important; } +.audio { + height: 150px !important; +} + .last-card-banner { width: 150px; height: 220px; diff --git a/src/pages/css/library/media-items.css b/src/pages/css/library/media-items.css index 72e5be9..d6c0985 100644 --- a/src/pages/css/library/media-items.css +++ b/src/pages/css/library/media-items.css @@ -11,6 +11,7 @@ color: white; margin-bottom: 20px; min-height: 300px; + justify-content: space-between; } .media-items-container::-webkit-scrollbar { diff --git a/src/pages/debugTools/session-card.jsx b/src/pages/debugTools/session-card.jsx new file mode 100644 index 0000000..2ec97fd --- /dev/null +++ b/src/pages/debugTools/session-card.jsx @@ -0,0 +1,318 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import Card from "react-bootstrap/Card"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import Container from "react-bootstrap/Container"; + +import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; +import PlayFillIcon from "remixicon-react/PlayFillIcon"; +import PauseFillIcon from "remixicon-react/PauseFillIcon"; + +import { clientData } from "../../lib/devices"; +import Tooltip from "@mui/material/Tooltip"; +import IpInfoModal from "../components/ip-info"; +import "./sessionCard.css"; +import baseUrl from "../../lib/baseurl"; + +function ticksToTimeString(ticks) { + // Convert ticks to seconds + const seconds = Math.floor(ticks / 10000000); + // Calculate hours, minutes, and remaining seconds + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + // Format the time string as hh:MM:ss + const timeString = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds + .toString() + .padStart(2, "0")}`; + + return timeString; +} + +function getETAFromTicks(ticks) { + // Get current date + const currentDate = Date.now(); + + // Calculate ETA + const etaMillis = currentDate + ticks / 10000; + const eta = new Date(etaMillis); + + // Return formated string in user locale + return eta.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function convertBitrate(bitrate) { + if (!bitrate) { + return "N/A"; + } + const kbps = (bitrate / 1000).toFixed(1); + const mbps = (bitrate / 1000000).toFixed(1); + + if (kbps >= 1000) { + return mbps + " Mbps"; + } else { + return kbps + " Kbps"; + } +} + +function SessionCard(props) { + const cardStyle = { + backgroundImage: `url(proxy/Items/Images/Backdrop?id=${ + props.data.session.NowPlayingItem.SeriesId + ? props.data.session.NowPlayingItem.SeriesId + : props.data.session.NowPlayingItem.Id + }&fillHeight=320&fillWidth=213&quality=80), linear-gradient(to right, #00A4DC, #AA5CC3)`, + height: "100%", + backgroundSize: "cover", + }; + + const cardBgStyle = { + backdropFilter: "blur(5px)", + backgroundColor: "rgb(0, 0, 0, 0.6)", + height: "100%", + }; + + const ipv4Regex = 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 [ipModalVisible, setIPModalVisible] = React.useState(false); + const [ipAddressLookup, setIPAddressLookup] = React.useState(); + const [isJsonVisible, setIsJsonVisible] = useState(false); + + const toggleJsonVisibility = () => { + setIsJsonVisible(!isJsonVisible); + }; + + const isRemoteSession = (ipAddress) => { + ipv4Regex.lastIndex = 0; + if (ipv4Regex.test(ipAddress ?? ipAddressLookup)) { + return true; + } + return false; + }; + + function showIPDataModal(ipAddress) { + ipv4Regex.lastIndex = 0; + setIPAddressLookup(ipAddress); + if (!isRemoteSession) { + return; + } + + setIPModalVisible(true); + } + + return ( + + setIPModalVisible(false)} ipAddress={ipAddressLookup} /> +
+ + + + + + + + + + + + {props.data.session.DeviceName + + " - " + + props.data.session.Client + + " " + + props.data.session.ApplicationVersion} + + + + + {props.data.session.PlayState.PlayMethod + + (props.data.session.NowPlayingItem.MediaStreams + ? " ( " + + props.data.session.NowPlayingItem.MediaStreams.find( + (stream) => stream.Type === "Video" + )?.Codec.toUpperCase() + + (props.data.session.TranscodingInfo + ? " - " + props.data.session.TranscodingInfo.VideoCodec.toUpperCase() + : "") + + " - " + + convertBitrate( + props.data.session.TranscodingInfo + ? props.data.session.TranscodingInfo.Bitrate + : props.data.session.NowPlayingItem.MediaStreams.find((stream) => stream.Type === "Video") + ?.BitRate + ) + + " )" + : "")} + + + + {props.data.session.NowPlayingItem.SubtitleStream} + + + + + + {isRemoteSession(props.data.session.RemoteEndPoint) && + (window.env.JS_GEOLITE_ACCOUNT_ID ?? import.meta.env.JS_GEOLITE_ACCOUNT_ID) && + (window.env.JS_GEOLITE_LICENSE_KEY ?? import.meta.env.JS_GEOLITE_LICENSE_KEY) ? ( + + ) : ( + IP Address: {props.data.session.RemoteEndPoint} + )} + + + + + + props.data.session.DeviceName.toLowerCase().includes(item)) || "other" + : clientData.find((item) => props.data.session.Client.toLowerCase().includes(item)) || "other") + } + alt="" + /> + + + + + {props.data.session.NowPlayingItem.Type === "Episode" ? ( + + + + {props.data.session.NowPlayingItem.SeriesName + ? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name + : props.data.session.NowPlayingItem.Name} + + + + ) : ( + <> + )} + + {props.data.session.NowPlayingItem.Type === "Episode" ? ( + + + {"S" + + props.data.session.NowPlayingItem.ParentIndexNumber + + " - E" + + props.data.session.NowPlayingItem.IndexNumber} + + + ) : ( + + + + {props.data.session.NowPlayingItem.SeriesName + ? props.data.session.NowPlayingItem.SeriesName + " - " + props.data.session.NowPlayingItem.Name + : props.data.session.NowPlayingItem.Name} + + + + )} + + + {props.data.session.UserPrimaryImageTag !== undefined ? ( + + ) : ( + + )} + + + + {props.data.session.UserName} + + + + + + + + + {props.data.session.PlayState.IsPaused ? : } + + + + + + + {ticksToTimeString(props.data.session.PlayState.PositionTicks)}/ + {ticksToTimeString(props.data.session.NowPlayingItem.RunTimeTicks)} + + + + + + + + + + + + +
+
+
+ +
+
+
+ + {isJsonVisible && ( +
+
{JSON.stringify(props, null, 2)}
+
+ )} +
+
+ ); +} + +export default SessionCard; diff --git a/src/pages/debugTools/sessionCard.css b/src/pages/debugTools/sessionCard.css new file mode 100644 index 0000000..564a1aa --- /dev/null +++ b/src/pages/debugTools/sessionCard.css @@ -0,0 +1,6 @@ +.json-data-container { + max-height: 300px; + overflow-y: auto; + background-color: black; + border-radius: 0px 0px 8px 8px; +} diff --git a/src/pages/debugTools/sessions.jsx b/src/pages/debugTools/sessions.jsx new file mode 100644 index 0000000..d43e313 --- /dev/null +++ b/src/pages/debugTools/sessions.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from "react"; +import Config from "../../lib/config"; + +import "../css/sessions.css"; +import ErrorBoundary from "../components/general/ErrorBoundary"; +import SessionCard from "./session-card"; + +import Loading from "../components/general/loading"; +import { Trans } from "react-i18next"; +import socket from "../../socket"; + +function Sessions() { + const [data, setData] = useState(); + const [config, setConfig] = useState(); + + useEffect(() => { + socket.on("sessions", (data) => { + if (typeof data === "object" && Array.isArray(data)) { + let toSet = data.filter((row) => row.NowPlayingItem !== undefined); + toSet.forEach((s) => { + handleLiveTV(s); + s.NowPlayingItem.SubtitleStream = getSubtitleStream(s); + }); + setData(toSet); + } + }); + return () => { + socket.off("sessions"); + }; + }, [config]); + + const handleLiveTV = (row) => { + let nowPlaying = row.NowPlayingItem; + if (!nowPlaying.RunTimeTicks && nowPlaying?.CurrentProgram) { + nowPlaying.RunTimeTicks = 0; + nowPlaying.Name = `${nowPlaying.Name}: ${nowPlaying.CurrentProgram.Name}`; + } + }; + + const getSubtitleStream = (row) => { + let result = ""; + + if (!row.PlayState) { + return result; + } + + let subStreamIndex = row.PlayState.SubtitleStreamIndex; + + if (subStreamIndex === undefined || subStreamIndex === -1) { + return result; + } + + if (row.NowPlayingItem.MediaStreams && row.NowPlayingItem.MediaStreams.length) { + result = `Subtitles: ${row.NowPlayingItem.MediaStreams[subStreamIndex].DisplayTitle}`; + } + + return result; + }; + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + console.log(error); + } + }; + + if (!config) { + fetchConfig(); + } + }, [config]); + + if (!data && !config) { + return ; + } + + if ((!data && config) || data.length === 0) { + return ( +
+

+ +

+
+ +
+
+ ); + } + + return ( +
+

+ +

+
+ {data && + data.length > 0 && + data + .sort((a, b) => a.Id.padStart(12, "0").localeCompare(b.Id.padStart(12, "0"))) + .map((session) => ( + + + + ))} +
+
+ ); +} + +export default Sessions; diff --git a/src/pages/libraries.jsx b/src/pages/libraries.jsx index 66bec40..819de7c 100644 --- a/src/pages/libraries.jsx +++ b/src/pages/libraries.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import "./css/library/libraries.css"; @@ -21,7 +21,7 @@ function Libraries() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/library_selector.jsx b/src/pages/library_selector.jsx index c783a1f..6d5f276 100644 --- a/src/pages/library_selector.jsx +++ b/src/pages/library_selector.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import "./css/library/libraries.css"; @@ -11,7 +11,6 @@ import InformationLineIcon from "remixicon-react/InformationLineIcon"; import { Tooltip } from "@mui/material"; import { Trans } from "react-i18next"; - function LibrarySelector() { const [data, setData] = useState(); const [config, setConfig] = useState(null); @@ -19,7 +18,7 @@ function LibrarySelector() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { @@ -29,8 +28,7 @@ function LibrarySelector() { }; const fetchLibraries = () => { - if(config) - { + if (config) { const url = `/api/TrackedLibraries`; axios .get(url, { @@ -45,11 +43,9 @@ function LibrarySelector() { .catch((error) => { console.log(error); }); - } }; - if (!config) { fetchConfig(); } @@ -57,7 +53,7 @@ function LibrarySelector() { fetchLibraries(); const intervalId = setInterval(fetchLibraries, 60000 * 60); return () => clearInterval(intervalId); - }, [ config]); + }, [config]); if (!data) { return ; @@ -65,18 +61,24 @@ function LibrarySelector() { return (
-

}>

+

+ {" "} + }> + + {" "} + + + +

- {data && + {data && data.map((item) => ( - - - - - ))} + + + + ))}
-
); } diff --git a/src/pages/login.jsx b/src/pages/login.jsx index c51731e..be205ae 100644 --- a/src/pages/login.jsx +++ b/src/pages/login.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import CryptoJS from "crypto-js"; import "./css/setup.css"; @@ -54,7 +54,8 @@ function Login() { .then(async (response) => { localStorage.setItem("token", response.data.token); setProcessing(false); - if (JS_USERNAME) { + if (JS_USERNAME || response.data.token) { + await Config.setConfig(); setsubmitButtonText(i18next.t("SUCCESS")); window.location.reload(); return; @@ -80,7 +81,7 @@ function Login() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.setConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index 706043d..d96a224 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -1,54 +1,76 @@ -import {Tabs, Tab } from 'react-bootstrap'; +import { Tabs, Tab } from "react-bootstrap"; +import { useState } from "react"; import SettingsConfig from "./components/settings/settingsConfig"; import Tasks from "./components/settings/Tasks"; -import BackupFiles from "./components/settings/backupfiles"; import SecuritySettings from "./components/settings/security"; import ApiKeys from "./components/settings/apiKeys"; import LibrarySelector from "./library_selector"; import Logs from "./components/settings/logs"; - - - import "./css/settings/settings.css"; import { Trans } from "react-i18next"; +import BackupPage from "./components/settings/backup_page"; export default function Settings() { + const [activeTab, setActiveTab] = useState(localStorage.getItem(`PREF_SETTINGS_LAST_SELECTED_TAB`) ?? "tabGeneral"); + function setTab(tabName) { + setActiveTab(tabName); + localStorage.setItem(`PREF_SETTINGS_LAST_SELECTED_TAB`, tabName); + } return (
- + + } + style={{ minHeight: "500px" }} + > + + + + - } style={{minHeight:'500px'}}> - - - - + } + style={{ minHeight: "500px" }} + > + + - } style={{minHeight:'500px'}}> - - + } + style={{ minHeight: "500px" }} + > + + - } style={{minHeight:'500px'}}> - - + } + style={{ minHeight: "500px" }} + > + + - } style={{minHeight:'500px'}}> - - - - } style={{minHeight:'500px'}}> - - - - - - - - + } + style={{ minHeight: "500px" }} + > + + +
); } diff --git a/src/pages/setup.jsx b/src/pages/setup.jsx index df16737..363a7e1 100644 --- a/src/pages/setup.jsx +++ b/src/pages/setup.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import Form from "react-bootstrap/Form"; import Button from "react-bootstrap/Button"; @@ -12,7 +12,6 @@ import logo_dark from "./images/icon-b-512.png"; import "./css/setup.css"; import i18next from "i18next"; import { Trans } from "react-i18next"; -const token = localStorage.getItem("token"); function Setup() { const [config, setConfig] = useState(null); @@ -45,6 +44,7 @@ function Setup() { axios .post("/auth/configSetup/", formValues) .then(async () => { + await Config.setConfig(); setsubmitButtonText(i18next.t("SETTINGS_SAVED")); setProcessing(false); setTimeout(async () => { @@ -76,7 +76,7 @@ function Setup() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/signup.jsx b/src/pages/signup.jsx index 6c205c3..79db15c 100644 --- a/src/pages/signup.jsx +++ b/src/pages/signup.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import CryptoJS from "crypto-js"; import "./css/setup.css"; @@ -70,7 +70,7 @@ function Signup() { useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { diff --git a/src/pages/statistics.jsx b/src/pages/statistics.jsx index 30a7e86..ae1fb05 100644 --- a/src/pages/statistics.jsx +++ b/src/pages/statistics.jsx @@ -8,16 +8,29 @@ import PlayStatsByHour from "./components/statistics/play-stats-by-hour"; import { Trans } from "react-i18next"; function Statistics() { - const [days, setDays] = useState(20); - const [input, setInput] = useState(20); + const [days, setDays] = useState( + localStorage.getItem("PREF_STATISTICS_STAT_DAYS_INPUT") != undefined + ? localStorage.getItem("PREF_STATISTICS_STAT_DAYS_INPUT") + : localStorage.getItem("PREF_STATISTICS_STAT_DAYS") ?? 20 + ); + const [input, setInput] = useState(localStorage.getItem("PREF_STATISTICS_STAT_DAYS_INPUT") ?? 20); + + const handleOnChange = (event) => { + setInput(event.target.value); + localStorage.setItem("PREF_STATISTICS_STAT_DAYS_INPUT", event.target.value); + }; const handleKeyDown = (event) => { if (event.key === "Enter") { if (input < 1) { setInput(1); setDays(0); + localStorage.setItem("PREF_STATISTICS_STAT_DAYS", 0); + localStorage.setItem("PREF_STATISTICS_STAT_DAYS_INPUT", 1); } else { setDays(parseInt(input)); + localStorage.setItem("PREF_STATISTICS_STAT_DAYS", parseInt(input)); + localStorage.setItem("PREF_STATISTICS_STAT_DAYS_INPUT", input); } console.log(days); @@ -27,19 +40,19 @@ function Statistics() { return (
-

+

+ +

-
-
- setInput(event.target.value)} - onKeyDown={handleKeyDown} - /> +
+ +
+
+ +
+
+ 1 ? "S" : ""}`} />
-
1 ? 'S':''}`}/>
diff --git a/src/pages/testing.jsx b/src/pages/testing.jsx index 94e1aa5..22b8239 100644 --- a/src/pages/testing.jsx +++ b/src/pages/testing.jsx @@ -1,65 +1,16 @@ -import { toast } from 'react-toastify'; - -import './css/library/libraries.css'; - - - - -// import LibraryOverView from './components/libraryOverview'; -// import HomeStatisticCards from './components/HomeStatisticCards'; -// import Sessions from './components/sessions/sessions'; -import LibrarySelector from './library_selector'; -import { Button } from '@mui/material'; - - - -function Testing() { - - - - -// async function getToken(username,password) { -// const response = await fetch('http://localhost:3003/login', { -// method: 'POST', -// headers: { -// 'Content-Type': 'application/json', -// }, -// body: JSON.stringify({ -// username: username, -// password: password, -// }), -// }); - -// const data = await response.json(); -// return data.token; -// } - -// // Make a GET request with JWT authentication -// async function getDataWithAuth() { -// try { -// const token = await getToken('test','pass'); // a function to get the JWT token -// // console.log(token); -// localStorage.setItem('token', token); -// } catch (error) { -// console.error(error); -// } -// } -// getDataWithAuth(); - - +import { Routes, Route } from "react-router-dom"; +import Sessions from "./debugTools/sessions"; +import PlayMethodStats from "./components/statistics/playbackMethodStats"; +// import PlaybackMethodStats from "./components/statCards/playback_method_stats"; +const TestingRoutes = () => { return ( -
- - - - - - - -
- + + } /> + } /> + {/* } /> */} + ); -} +}; -export default Testing; +export default TestingRoutes; diff --git a/src/pages/users.jsx b/src/pages/users.jsx index 6a9d272..d9350dd 100644 --- a/src/pages/users.jsx +++ b/src/pages/users.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../lib/axios_instance"; import Config from "../lib/config"; import { Link } from "react-router-dom"; import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon"; @@ -206,7 +206,11 @@ function Row(row) { {data.TotalPlays} {formatTotalWatchTime(data.TotalWatchTime) || `0 ${i18next.t("UNITS.MINUTES")}`} - {data.LastSeen ? `${i18next.t("USERS_PAGE.AGO_ALT")} ${formatLastSeenTime(data.LastSeen)} ${i18next.t("USERS_PAGE.AGO").toLocaleLowerCase()}` : i18next.t("ERROR_MESSAGES.NEVER")} + {data.LastSeen + ? `${i18next.t("USERS_PAGE.AGO_ALT")} ${formatLastSeenTime(data.LastSeen)} ${i18next + .t("USERS_PAGE.AGO") + .toLocaleLowerCase()}` + : i18next.t("ERROR_MESSAGES.NEVER")} @@ -218,16 +222,22 @@ function Users() { const [config, setConfig] = useState(null); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [page, setPage] = React.useState(0); - const [itemCount, setItemCount] = useState(10); + + const [itemCount, setItemCount] = useState(parseInt(localStorage.getItem("PREF_USER_ACTIVITY_ItemCount") ?? "10")); const [searchQuery, setSearchQuery] = useState(""); const [order, setOrder] = React.useState("asc"); const [orderBy, setOrderBy] = React.useState("LastSeen"); + function setItemLimit(limit) { + setItemCount(limit); + localStorage.setItem("PREF_USER_ACTIVITY_ItemCount", limit); + } + useEffect(() => { const fetchConfig = async () => { try { - const newConfig = await Config(); + const newConfig = await Config.getConfig(); setConfig(newConfig); } catch (error) { if (error.code === "ERR_NETWORK") { @@ -418,7 +428,7 @@ function Users() { onChange={(event) => { setRowsPerPage(event.target.value); setPage(0); - setItemCount(event.target.value); + setItemLimit(event.target.value); }} value={itemCount} className="my-md-3 w-md-75 rounded-0 rounded-end" @@ -431,7 +441,7 @@ function Users() {
setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" diff --git a/src/routes.jsx b/src/routes.jsx new file mode 100644 index 0000000..8fb7873 --- /dev/null +++ b/src/routes.jsx @@ -0,0 +1,73 @@ +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 About from "./pages/about"; + +import TestingRoutes from "./pages/testing"; +import Activity from "./pages/activity"; +import Statistics from "./pages/statistics"; + +const routes = [ + { + path: "/", + element: , + exact: true, + }, + { + path: "/settings", + element: , + exact: true, + }, + { + path: "/users", + element: , + exact: true, + }, + { + path: "/users/:UserId", + element: , + exact: true, + }, + { + path: "/libraries", + element: , + exact: true, + }, + { + path: "/libraries/:LibraryId", + element: , + exact: true, + }, + { + path: "/libraries/item/:Id", + element: , + exact: true, + }, + { + path: "/statistics", + element: , + exact: true, + }, + { + path: "/activity", + element: , + exact: true, + }, + { + path: "/about", + element: , + exact: true, + }, + { + path: "/testing/*", + element: , + exact: true, + }, +]; + +export default routes; diff --git a/src/socket.js b/src/socket.js index 11c4fcb..5402bbd 100644 --- a/src/socket.js +++ b/src/socket.js @@ -1,5 +1,8 @@ -import { io } from 'socket.io-client'; +import { io } from "socket.io-client"; +import baseUrl from "./lib/baseurl"; -const socket = io(); +const socket = io({ + path: baseUrl + "/socket.io/", +}); -export default socket; \ No newline at end of file +export default socket; diff --git a/vite.config.js b/vite.config.js index 6cdaf0c..682d355 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react-swc"; // https://vitejs.dev/config/ export default defineConfig({ envPrefix: "JS_", + base: "", optimizeDeps: { include: ["react", "react-dom", "react-router-dom", "axios", "react-toastify"], esbuildOptions: {