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() {{value}
; + const formattedLabel = ( + +0
{" "} +
+
{props.data.Plays || 0}
+
+
{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
{' '} -{props.data.Plays || 0}
-{data.Name}
{value}
; - const formattedLabel = ( - - {value === 1 ? label[0] : label[1] } - - ); - result.push( - - {formattedValue} {formattedLabel} - - ); - totalSeconds -= value * seconds; - } - return result; - }, []); - - if (parts.length === 0) { - return ( - <> -0
{' '} -{props.data.Plays || 0}
-{label}
+ {types.map((type, index) => ( +{`${type.Name} : ${payload[index].value} Views`}
+ ))} +{data.Name}
+{data.Name}
{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.data.Plays || 0}
-Plays /
- - <>{formatTime(props.data.total_playback_duration || 0,'stat-value','stat-unit')}> - -{JSON.stringify(props, null, 2)}
+