Playback Reporting Plugin

Added support for the Playback Reporting Plugin.

This will only import data from this table that is older then the first record captured by Jellystat to prevent duplication.

This means that it will not import any data going forward. its only meant to import old data to have parity with your old watch logs.

Due to the limited set of information logged by the plugin, you may notice some information that's missing when compared to data logged by Jellystat.

Other changes:
ServerId column added as the foundation for future updates that may or may not include multiple server support. This addition is the most crucial and is why it was added now and not held for future change sets.
This commit is contained in:
Thegan Govender
2023-07-09 01:23:57 +02:00
parent 923e5b9669
commit 1b67bd8482
9 changed files with 298 additions and 104 deletions

View File

@@ -0,0 +1,25 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_playback_activity');
if (hasTable) {
await knex.schema.alterTable('jf_playback_activity', function(table) {
table.text('ServerId');
table.boolean('imported').defaultTo(false);
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_playback_activity', function(table) {
table.dropColumn('ServerId');
table.dropColumn('imported');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,23 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_activity_watchdog');
if (hasTable) {
await knex.schema.alterTable('jf_activity_watchdog', function(table) {
table.text('ServerId');
});
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_activity_watchdog', function(table) {
table.dropColumn('ServerId');
});
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,59 @@
exports.up = function(knex) {
return knex.schema.raw(`
CREATE OR REPLACE PROCEDURE ji_insert_playback_plugin_data_to_activity_table() AS $$
BEGIN
insert into jf_playback_activity
SELECT
rowid,
false "IsPaused",
pb."UserId",
u."Name",
pb."ClientName",
pb."DeviceName",
null "DeviceId",
null "ApplicationVersion",
"ItemId" "NowPlayingItemId",
"ItemName" "NowPlayingItemName",
CASE WHEN e."EpisodeId"=pb."ItemId" THEN e."SeasonId" ELSE null END "SeasonId",
CASE WHEN i."Id"=e."SeriesId" THEN i."Name" ELSE null END "SeriesName",
CASE WHEN e."EpisodeId"=pb."ItemId" THEN e."Id" ELSE null END "EpisodeId",
"PlayDuration" "PlaybackDuration",
"DateCreated" "ActivityDateInserted",
"PlaybackMethod" "PlayMethod",
null "MediaStreams",
null "TranscodingInfo",
null "PlayState",
null "OriginalContainer",
null "RemoteEndPoint",
null "ServerId",
true "imported"
FROM public.jf_playback_reporting_plugin_data pb
LEFT JOIN public.jf_users u
on u."Id"=pb."UserId"
LEFT JOIN public.jf_library_episodes e
on e."EpisodeId"=pb."ItemId"
LEFT JOIN public.jf_library_items i
on i."Id"=pb."ItemId"
or i."Id"=e."SeriesId"
WHERE NOT EXISTS
(
SELECT "Id" "rowid"
FROM jf_playback_activity
WHERE imported=true
);
END;
$$ LANGUAGE plpgsql;
`).catch(function(error) {
console.error(error);
});
};
exports.down = function(knex) {
return knex.schema.raw(`
DROP PROCEDURE ji_insert_playback_plugin_data_to_activity_table;
`);
};

View File

@@ -24,6 +24,7 @@ const jf_activity_watchdog_columns = [
{ name: 'PlayState', mod: ':json' },
"OriginalContainer",
"RemoteEndPoint",
"ServerId",
];
@@ -49,6 +50,7 @@ const jf_activity_watchdog_columns = [
PlayState: item.PlayState? item.PlayState : null,
OriginalContainer: item.NowPlayingItem && item.NowPlayingItem.Container ? item.NowPlayingItem.Container : null,
RemoteEndPoint: item.RemoteEndPoint || null,
ServerId: item.ServerId || null,
});
module.exports = {

View File

@@ -19,7 +19,8 @@
{ name: 'TranscodingInfo', mod: ':json' },
{ name: 'PlayState', mod: ':json' },
"OriginalContainer",
"RemoteEndPoint"
"RemoteEndPoint",
"ServerId"
];
@@ -44,7 +45,8 @@
TranscodingInfo: item.TranscodingInfo? item.TranscodingInfo : null,
PlayState: item.PlayState? item.PlayState : null,
OriginalContainer: item.OriginalContainer ? item.OriginalContainer : null,
RemoteEndPoint: item.RemoteEndPoint ? item.RemoteEndPoint : null
RemoteEndPoint: item.RemoteEndPoint ? item.RemoteEndPoint : null,
ServerId: item.ServerId ? item.ServerId : null,
});
module.exports = {

View File

@@ -483,7 +483,7 @@ router.get("/dataValidator", async (req, res) => {
for (let i = 0; i < libraries.length; i++) {
const library = libraries[i];
let item_url = `${config[0].JF_HOST}/Users/${adminUser[0].Id}/Items?ParentID=${library.Id}`;
let item_url = `${config[0].JF_HOST}/Users/${userid}/Items?ParentID=${library.Id}`;
const response_data_item = await axios_instance.get(item_url, {
headers: {
"X-MediaBrowser-Token": config[0].JF_API_KEY,

View File

@@ -3,6 +3,8 @@ const pgp = require("pg-promise")();
const db = require("../db");
const axios = require("axios");
const https = require('https');
const moment = require('moment');
const { randomUUID } = require('crypto');
const logging=require("./logging");
@@ -19,9 +21,6 @@ const axios_instance = axios.create({
// const wss = require("./WebsocketHandler");
// const socket=wss;
const moment = require('moment');
const { randomUUID } = require('crypto');
const router = express.Router();
@@ -232,13 +231,7 @@ async function syncUserData(refLog)
try
{
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
refLog.loggedData.push({ Message: "Error: Config details not found!" });
refLog.result='Failed';
return;
}
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const data = await _sync.getUsers();
@@ -355,7 +348,7 @@ async function syncLibraryItems(refLog,data)
.query('SELECT "Id" FROM jf_libraries')
.then((res) => res.rows.map((row) => row.Id));
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/3" });
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/4" });
refLog.loggedData.push({color: "yellow",Message: "Beginning Library Item Sync",});
data=data.filter((row) => existingLibraryIds.includes(row.ParentId));
@@ -414,18 +407,9 @@ async function syncLibraryItems(refLog,data)
async function syncShowItems(refLog,data)
{
try{
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/3" });
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/4" });
refLog.loggedData.push({color: "yellow", Message: "Beginning Seasons and Episode sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
refLog.result='Failed';
return;
}
// const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
const { rows: shows } = await db.query(`SELECT * FROM public.jf_library_items where "Type"='Series'`);
let insertSeasonsCount = 0;
@@ -541,12 +525,6 @@ async function syncItemInfo(refLog)
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
if (config[0].JF_HOST === null || config[0].JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
refLog.result='Failed';
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
const { rows: Items } = await db.query(`SELECT * FROM public.jf_library_items where "Type" not in ('Series','Folder')`);
const { rows: Episodes } = await db.query(`SELECT * FROM public.jf_library_episodes`);
@@ -642,7 +620,7 @@ async function syncItemInfo(refLog)
refLog.loggedData.push({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
refLog.loggedData.push({color: "dodgerblue",Message: (insertEpisodeInfoCount > 0 ? insertEpisodeInfoCount:0) + " Episodes Info inserted. "+updateEpisodeInfoCount +" Episodes Info Updated"});
refLog.loggedData.push({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",});
refLog.loggedData.push({ color: "lawngreen", Message: "Info Sync Complete" });
refLog.loggedData.push({ color: "yellow", Message: "Info Sync Complete" });
}catch(error)
{
refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,});
@@ -650,10 +628,30 @@ async function syncItemInfo(refLog)
}
}
async function syncPlaybackPluginData()
async function removeOrphanedData(refLog)
{
// socket.sendMessageToClients({ color: "lawngreen", Message: "Syncing... 5/5" });
// socket.sendMessageToClients({color: "yellow", Message: "Beginning File Info Sync",});
try{
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/4" });
refLog.loggedData.push({color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records",});
await db.query('CALL jd_remove_orphaned_data()');
refLog.loggedData.push({color: "dodgerblue",Message: "Orphaned FileInfo/Episode/Season Removed.",});
refLog.loggedData.push({ color: "Yellow", Message: "Sync Complete" });
}catch(error)
{
refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ': Error:'+error,});
refLog.loggedData.push({ color: "red", Message: getErrorLineNumber(error)+ ": Cleanup Failed with errors" });
refLog.result='Failed';
}
}
async function syncPlaybackPluginData(refLog)
{
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing..." });
try {
const { rows: config } = await db.query(
@@ -665,27 +663,72 @@ async function syncPlaybackPluginData()
{
return;
}
const base_url = config[0].JF_HOST;
const apiKey = config[0].JF_API_KEY;
const base_url = config[0]?.JF_HOST;
const apiKey = config[0]?.JF_API_KEY;
if (base_url === null || config[0].JF_API_KEY === null) {
if (base_url === null || apiKey === null) {
return;
}
const { rows: pbData } = await db.query(
'SELECT * FROM jf_playback_reporting_plugin_data order by rowid desc limit 1'
);
//Playback Reporting Plugin Check
const pluginURL = `${base_url}/plugins`;
let query=`SELECT rowid, * FROM PlaybackActivity`;
const pluginResponse = await axios_instance.get(pluginURL,
{
headers: {
"X-MediaBrowser-Token": apiKey,
},
});
const hasPlaybackReportingPlugin=pluginResponse.data?.filter((plugins) => plugins?.ConfigurationFileName==='Jellyfin.Plugin.PlaybackReporting.xml');
if(pbData[0])
if(!hasPlaybackReportingPlugin || hasPlaybackReportingPlugin.length===0)
{
query+=' where rowid > '+pbData[0].rowid;
refLog.loggedData.push({color: "lawngreen", Message: "Playback Reporting Plugin not detected. Skipping step.",});
return;
}
//
refLog.loggedData.push({color: "dodgerblue", Message: "Determining query constraints.",});
const OldestPlaybackActivity = await db
.query('SELECT MIN("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);
//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(MaxPlaybackReportingPluginID)
{
query=query+` AND rowid > ${MaxPlaybackReportingPluginID}`;
}
}else if(MaxPlaybackReportingPluginID)
{
query=query+` WHERE rowid > ${MaxPlaybackReportingPluginID}`;
}
query+=' order by rowid';
refLog.loggedData.push({color: "dodgerblue", Message: "Query built. Executing.",});
//
const url = `${base_url}/user_usage_stats/submit_custom_query`;
const response = await axios_instance.post(url, {
@@ -702,34 +745,48 @@ async function syncPlaybackPluginData()
if (DataToInsert.length !== 0) {
refLog.loggedData.push({color: "dodgerblue", Message: `Inserting ${DataToInsert.length} Rows.`,});
let result=await db.insertBulk("jf_playback_reporting_plugin_data",DataToInsert,columnsPlaybackReporting);
console.log(result);
if (result.Result === "SUCCESS") {
refLog.loggedData.push({color: "dodgerblue", Message: `${DataToInsert.length} Rows have been inserted.`,});
} else {
refLog.loggedData.push({color: "red",Message: "Error: "+result.message,});
refLog.result='Failed';
}
}else
{
refLog.loggedData.push({color: "dodgerblue", Message: `No new data to insert.`,});
}
await importPlaybackDatatoActivityTable(refLog);
} catch (error) {
console.log(getErrorLineNumber(error)+ ": "+error);
return [];
refLog.loggedData.push({color: "red",Message: "Error: "+error,});
refLog.result='Failed';
}
}
async function removeOrphanedData(refLog)
async function importPlaybackDatatoActivityTable(refLog)
{
try{
refLog.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/4" });
refLog.loggedData.push({color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records",});
refLog.loggedData.push({ color: "yellow", Message: "Running process to format data to be inserted into the Activity Table" });
await db.query('CALL jd_remove_orphaned_data()');
await db.query('CALL ji_insert_playback_plugin_data_to_activity_table()');
refLog.loggedData.push({color: "dodgerblue",Message: "Orphaned FileInfo/Episode/Season Removed.",});
refLog.loggedData.push({color: "dodgerblue",Message: "Process complete. Data has been inserted.",});
refLog.loggedData.push({ color: "lawngreen", Message: "Sync Complete" });
}catch(error)
{
refLog.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ': Error:'+error,});
refLog.loggedData.push({ color: "red", Message: getErrorLineNumber(error)+ ": Cleanup Failed with errors" });
refLog.result='Failed';
}
refLog.loggedData.push({color: "lawngreen", Message: `Playback Reporting Plugin Sync Complete`,});
}
@@ -760,7 +817,7 @@ async function fullSync(taskType)
let refLog={loggedData:[],result:'Success'};
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
if (rows[0]?.JF_HOST === null || rows[0]?.JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
refLog.loggedData.push({ Message: "Error: Config details not found!" });
refLog.result='Failed';
@@ -772,7 +829,6 @@ async function fullSync(taskType)
const libraries = await _sync.getLibrariesFromApi();
const excluded_libraries= rows[0].settings.ExcludedLibraries||[];
console.log(excluded_libraries);
const filtered_libraries=libraries.filter((library)=> !excluded_libraries.includes(library.Id));
@@ -797,8 +853,10 @@ async function fullSync(taskType)
await syncLibraryItems(refLog,library_items);
await syncShowItems(refLog,seasons_and_episodes);
await syncItemInfo(refLog);
await updateLibraryStatsData(refLog);
await removeOrphanedData(refLog);
await updateLibraryStatsData(refLog);
const uuid = randomUUID();
let endTime = moment();
@@ -922,8 +980,38 @@ router.post("/fetchItem", async (req, res) => {
//////////////////////////////////////////////////////syncPlaybackPluginData
router.get("/syncPlaybackPluginData", async (req, res) => {
await syncPlaybackPluginData();
res.send();
let startTime = moment();
let refLog={loggedData:[],result:'Success'};
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0]?.JF_HOST === null || rows[0]?.JF_API_KEY === null) {
res.send({ error: "Config Details Not Found" });
refLog.loggedData.push({ Message: "Error: Config details not found!" });
refLog.result='Failed';
return;
}
await syncPlaybackPluginData(refLog);
const uuid = randomUUID();
let endTime = moment();
let diffInSeconds = endTime.diff(startTime, 'seconds');
const log=
{
"Id":uuid,
"Name":"Jellyfin Playback Reporting Plugin Sync",
"Type":"Task",
"ExecutionType":"Manual",
"Duration":diffInSeconds,
"TimeRun":startTime,
"Log":JSON.stringify(refLog.loggedData),
"Result":refLog.result
};
logging.insertLog(log);
res.send("syncPlaybackPluginData Complete");
});

23
src/lib/tasklist.js Normal file
View File

@@ -0,0 +1,23 @@
export const taskList = [
{
id: 0,
name: "Synchronize with Jellyfin",
type: "Import",
link: "/sync/beingSync"
},
{
id: 1,
name: "Import Playback Reporting Plugin Data",
type: "Import",
link: "/sync/syncPlaybackPluginData"
},
{
id: 2,
name: "Backup Jellystat",
type: "Process",
link: "/backup/beginBackup"
}
]

View File

@@ -10,64 +10,38 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import { taskList } from "../../../lib/tasklist";
import "../../css/settings/settings.css";
export default function Tasks() {
const [processing, setProcessing] = useState(false);
const token = localStorage.getItem('token');
async function beginSync() {
async function executeTask(url) {
setProcessing(true);
await axios
.get("/sync/beingSync", {
.get(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
}).catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
}
async function createBackup() {
const beginTask = (url) => {
setProcessing(true);
await axios
.get("/backup/beginBackup", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 200) {
// isValid = true;
}
})
.catch((error) => {
console.log(error);
});
setProcessing(false);
// return { isValid: isValid, errorMessage: errorMessage };
executeTask(url);
}
const handleClick = () => {
beginSync();
}
return (
<div className="tasks">
@@ -83,19 +57,17 @@ export default function Tasks() {
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Synchronize with Jellyfin</TableCell>
<TableCell>Import</TableCell>
<TableCell className="d-flex justify-content-center"> <Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={handleClick}>Start</Button></TableCell>
{taskList &&
taskList.map((task) => (
<TableRow key={task.id}>
<TableCell>{task.name}</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell className="d-flex justify-content-center"> <Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={() => beginTask(task.link)}>Start</Button></TableCell>
</TableRow>
<TableRow>
<TableCell>Backup Jellystat</TableCell>
<TableCell>Process</TableCell>
<TableCell className="d-flex justify-content-center"><Button variant={!processing ? "outline-primary" : "outline-light"} disabled={processing} onClick={createBackup}>Start</Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>