Sync Changes

Added Sync feature to only sync Recently Added Items that don't exist in the database (this will not update existing data), default interval now set to 15 Minutes

Renamed existing sync to Full sync (should function exactly the same), default interval now set to 1 day

Reworked handling of items no longer on jellyfin. Items are no longer deleted but are now marked as archived so that we can still view their items when looking at Playback Activity.

Added options to purge Archived data. This will either purge just the item (including seasons and episodes if its a show) or the item plus all related watch activity
This commit is contained in:
Thegan Govender
2023-11-19 22:17:36 +02:00
parent a9f9b07552
commit 837dd18014
28 changed files with 1534 additions and 319 deletions

View File

@@ -59,6 +59,33 @@ async function deleteBulk(table_name, data) {
return { Result: result, message: '' + message };
}
async function updateSingleFieldBulk(table_name, data,field_name, new_value) {
const client = await pool.connect();
let result = 'SUCCESS';
let message = '';
try {
await client.query('BEGIN');
if (data && data.length !== 0) {
const updateQuery = {
text: `UPDATE ${table_name} SET "${field_name}"='${new_value}' WHERE "Id" IN (${pgp.as.csv(data)})`,
};
// console.log(deleteQuery);
await client.query(updateQuery);
}
await client.query('COMMIT');
message = data.length + ' Rows updated.';
} catch (error) {
await client.query('ROLLBACK');
message = 'Bulk update error: ' + error;
result = 'ERROR';
} finally {
client.release();
}
return { Result: result, message: '' + message };
}
async function insertBulk(table_name, data, columns) {
//dedupe data
@@ -136,5 +163,6 @@ module.exports = {
query: query,
deleteBulk: deleteBulk,
insertBulk: insertBulk,
updateSingleFieldBulk:updateSingleFieldBulk,
// initDB: initDB,
};

View File

@@ -1,8 +1,9 @@
const task = {
sync: 'Jellyfin Sync',
fullsync: 'Full Jellyfin Sync',
partialsync: 'Recently Added Sync',
backup: 'Backup',
restore: 'Restore',
import: 'Jellyfin Playback Reporting Plugin Sync',
};
module.exports = task;

View File

@@ -0,0 +1,109 @@
exports.up = async function(knex) {
try
{
const hasTable = await knex.schema.hasTable('jf_library_items');
if (hasTable) {
await knex.schema.alterTable('jf_library_items', function(table) {
table.boolean('archived').defaultTo(false);
});
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_library_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_library_activity(
libraryid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, archived boolean, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
i.archived,
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE l."Id" = libraryid
ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC
) AS latest_distinct_rows
ORDER BY "LastPlayed"
LIMIT 15;
END;
$BODY$;`);
}
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.alterTable('jf_library_items', function(table) {
table.dropColumn('archived');
});
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_library_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_library_activity(
libraryid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE l."Id" = libraryid
ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC
) AS latest_distinct_rows
ORDER BY "LastPlayed"
LIMIT 15;
END;
$BODY$;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,76 @@
exports.up = async function(knex) {
try
{
await knex.schema.raw(`
DROP VIEW public.jf_library_items_with_playcount_playtime;
CREATE OR REPLACE VIEW public.jf_library_items_with_playcount_playtime
AS
SELECT i."Id",
i."Name",
i."ServerId",
i."PremiereDate",
i."EndDate",
i."CommunityRating",
i."RunTimeTicks",
i."ProductionYear",
i."IsFolder",
i."Type",
i."Status",
i."ImageTagsPrimary",
i."ImageTagsBanner",
i."ImageTagsLogo",
i."ImageTagsThumb",
i."BackdropImageTags",
i."ParentId",
i."PrimaryImageHash",
i.archived,
count(a."NowPlayingItemId") AS times_played,
COALESCE(sum(a."PlaybackDuration"), 0::numeric) AS total_play_time
FROM jf_library_items i
LEFT JOIN jf_playback_activity a ON i."Id" = a."NowPlayingItemId"
GROUP BY i."Id"
ORDER BY (count(a."NowPlayingItemId")) DESC;`);
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.raw(`
DROP VIEW public.jf_library_items_with_playcount_playtime;
CREATE OR REPLACE VIEW public.jf_library_items_with_playcount_playtime
AS
SELECT i."Id",
i."Name",
i."ServerId",
i."PremiereDate",
i."EndDate",
i."CommunityRating",
i."RunTimeTicks",
i."ProductionYear",
i."IsFolder",
i."Type",
i."Status",
i."ImageTagsPrimary",
i."ImageTagsBanner",
i."ImageTagsLogo",
i."ImageTagsThumb",
i."BackdropImageTags",
i."ParentId",
i."PrimaryImageHash",
count(a."NowPlayingItemId") AS times_played,
COALESCE(sum(a."PlaybackDuration"), 0::numeric) AS total_play_time
FROM jf_library_items i
LEFT JOIN jf_playback_activity a ON i."Id" = a."NowPlayingItemId"
GROUP BY i."Id"
ORDER BY (count(a."NowPlayingItemId")) DESC;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,93 @@
exports.up = async function(knex) {
try
{
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_user_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_user_activity(
userid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, archived boolean, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
i.archived,
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE a."UserId" = userid
) AS latest_distinct_rows
ORDER BY "LastPlayed";
END;
$BODY$;`);
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_user_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_user_activity(
userid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE a."UserId" = userid
) AS latest_distinct_rows
ORDER BY "LastPlayed";
END;
$BODY$;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,98 @@
exports.up = async function(knex) {
try
{
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_library_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_library_activity(
libraryid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, archived boolean, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
i.archived,
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE l."Id" = libraryid
ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC
) AS latest_distinct_rows
ORDER BY "LastPlayed"
LIMIT 15;
END;
$BODY$;`);
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_last_library_activity(text);
CREATE OR REPLACE FUNCTION public.fs_last_library_activity(
libraryid text)
RETURNS TABLE("Id" text, "EpisodeId" text, "Name" text, "EpisodeName" text, "SeasonNumber" integer, "EpisodeNumber" integer, "PrimaryImageHash" text, "UserId" text, "UserName" text, "LastPlayed" interval)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT *
FROM (
SELECT DISTINCT ON (i."Name", e."Name")
i."Id",
a."EpisodeId",
i."Name",
e."Name" AS "EpisodeName",
CASE WHEN a."SeasonId" IS NOT NULL THEN s."IndexNumber" ELSE NULL END AS "SeasonNumber",
CASE WHEN a."SeasonId" IS NOT NULL THEN e."IndexNumber" ELSE NULL END AS "EpisodeNumber",
i."PrimaryImageHash",
a."UserId",
a."UserName",
(NOW() - a."ActivityDateInserted") as "LastPlayed"
FROM jf_playback_activity a
JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
JOIN jf_libraries l ON i."ParentId" = l."Id"
LEFT JOIN jf_library_seasons s ON s."Id" = a."SeasonId"
LEFT JOIN jf_library_episodes e ON e."EpisodeId" = a."EpisodeId"
WHERE l."Id" = libraryid
ORDER BY i."Name", e."Name", a."ActivityDateInserted" DESC
) AS latest_distinct_rows
ORDER BY "LastPlayed"
LIMIT 15;
END;
$BODY$;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,102 @@
exports.up = async function(knex) {
try
{
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_most_played_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_played_items(
days integer,
itemtype text)
RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "Name" text, "Id" text, "PrimaryImageHash" text, archived boolean)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.plays,
t.total_playback_duration,
i."Name",
i."Id",
i."PrimaryImageHash",
i.archived
FROM (
SELECT
count(*) AS plays,
sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration,
jf_playback_activity."NowPlayingItemId"
FROM
jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId"
ORDER BY
count(*) DESC
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemtype
ORDER BY
t.plays DESC;
END;
$BODY$;`);
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_most_played_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_played_items(
days integer,
itemtype text)
RETURNS TABLE("Plays" bigint, total_playback_duration numeric, "Name" text, "Id" text, "PrimaryImageHash" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.plays,
t.total_playback_duration,
i."Name",
i."Id",
i."PrimaryImageHash"
FROM (
SELECT
count(*) AS plays,
sum(jf_playback_activity."PlaybackDuration") AS total_playback_duration,
jf_playback_activity."NowPlayingItemId"
FROM
jf_playback_activity
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId"
ORDER BY
count(*) DESC
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemtype
ORDER BY
t.plays DESC;
END;
$BODY$;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1,116 @@
exports.up = async function(knex) {
try
{
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_most_popular_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_popular_items(
days integer,
itemtype text)
RETURNS TABLE(unique_viewers bigint, latest_activity_date timestamp with time zone, "Name" text, "Id" text, "PrimaryImageHash" text, archived boolean)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.unique_viewers,
t.latest_activity_date,
i."Name",
i."Id",
i."PrimaryImageHash",
i.archived
FROM (
SELECT
jf_playback_activity."NowPlayingItemId",
count(DISTINCT jf_playback_activity."UserId") AS unique_viewers,
latest_activity_date.latest_date AS latest_activity_date
FROM
jf_playback_activity
JOIN (
SELECT
jf_playback_activity_1."NowPlayingItemId",
max(jf_playback_activity_1."ActivityDateInserted") AS latest_date
FROM
jf_playback_activity jf_playback_activity_1
GROUP BY jf_playback_activity_1."NowPlayingItemId"
) latest_activity_date
ON jf_playback_activity."NowPlayingItemId" = latest_activity_date."NowPlayingItemId"
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId", latest_activity_date.latest_date
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemtype
ORDER BY
t.unique_viewers DESC, t.latest_activity_date DESC;
END;
$BODY$;`);
}catch (error) {
console.error(error);
}
};
exports.down = async function(knex) {
try {
await knex.schema.raw(`
DROP FUNCTION IF EXISTS public.fs_most_popular_items(integer, text);
CREATE OR REPLACE FUNCTION public.fs_most_popular_items(
days integer,
itemtype text)
RETURNS TABLE(unique_viewers bigint, latest_activity_date timestamp with time zone, "Name" text, "Id" text, "PrimaryImageHash" text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
BEGIN
RETURN QUERY
SELECT
t.unique_viewers,
t.latest_activity_date,
i."Name",
i."Id",
i."PrimaryImageHash"
FROM (
SELECT
jf_playback_activity."NowPlayingItemId",
count(DISTINCT jf_playback_activity."UserId") AS unique_viewers,
latest_activity_date.latest_date AS latest_activity_date
FROM
jf_playback_activity
JOIN (
SELECT
jf_playback_activity_1."NowPlayingItemId",
max(jf_playback_activity_1."ActivityDateInserted") AS latest_date
FROM
jf_playback_activity jf_playback_activity_1
GROUP BY jf_playback_activity_1."NowPlayingItemId"
) latest_activity_date
ON jf_playback_activity."NowPlayingItemId" = latest_activity_date."NowPlayingItemId"
WHERE
jf_playback_activity."ActivityDateInserted" BETWEEN CURRENT_DATE - MAKE_INTERVAL(days => days) and NOW()
GROUP BY
jf_playback_activity."NowPlayingItemId", latest_activity_date.latest_date
) t
JOIN jf_library_items i
ON t."NowPlayingItemId" = i."Id"
AND i."Type" = itemtype
ORDER BY
t.unique_viewers DESC, t.latest_activity_date DESC;
END;
$BODY$;`);
} catch (error) {
console.error(error);
}
};

View File

@@ -5,7 +5,7 @@
{table:'jf_item_info',query:' ON CONFLICT ("Id") DO UPDATE SET "Path" = EXCLUDED."Path", "Name" = EXCLUDED."Name", "Size" = EXCLUDED."Size", "Bitrate" = EXCLUDED."Bitrate", "MediaStreams" = EXCLUDED."MediaStreams"'},
{table:'jf_libraries',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "Type" = EXCLUDED."Type", "CollectionType" = EXCLUDED."CollectionType", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary"'},
{table:'jf_library_episodes',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "OfficialRating" = EXCLUDED."OfficialRating", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "IndexNumber" = EXCLUDED."IndexNumber", "ParentIndexNumber" = EXCLUDED."ParentIndexNumber", "Type" = EXCLUDED."Type", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesId" = EXCLUDED."SeriesId", "SeasonId" = EXCLUDED."SeasonId", "SeasonName" = EXCLUDED."SeasonName", "SeriesName" = EXCLUDED."SeriesName"'},
{table:'jf_library_items',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "EndDate" = EXCLUDED."EndDate", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "Type" = EXCLUDED."Type", "Status" = EXCLUDED."Status", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary", "ImageTagsBanner" = EXCLUDED."ImageTagsBanner", "ImageTagsLogo" = EXCLUDED."ImageTagsLogo", "ImageTagsThumb" = EXCLUDED."ImageTagsThumb", "BackdropImageTags" = EXCLUDED."BackdropImageTags", "ParentId" = EXCLUDED."ParentId", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash"'},
{table:'jf_library_items',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PremiereDate" = EXCLUDED."PremiereDate", "EndDate" = EXCLUDED."EndDate", "CommunityRating" = EXCLUDED."CommunityRating", "RunTimeTicks" = EXCLUDED."RunTimeTicks", "ProductionYear" = EXCLUDED."ProductionYear", "Type" = EXCLUDED."Type", "Status" = EXCLUDED."Status", "ImageTagsPrimary" = EXCLUDED."ImageTagsPrimary", "ImageTagsBanner" = EXCLUDED."ImageTagsBanner", "ImageTagsLogo" = EXCLUDED."ImageTagsLogo", "ImageTagsThumb" = EXCLUDED."ImageTagsThumb", "BackdropImageTags" = EXCLUDED."BackdropImageTags", "ParentId" = EXCLUDED."ParentId", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash", archived=false'},
{table:'jf_library_seasons',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "ParentLogoItemId" = EXCLUDED."ParentLogoItemId", "ParentBackdropItemId" = EXCLUDED."ParentBackdropItemId", "ParentBackdropImageTags" = EXCLUDED."ParentBackdropImageTags", "SeriesPrimaryImageTag" = EXCLUDED."SeriesPrimaryImageTag"'},
{table:'jf_logging',query:` ON CONFLICT ("Id") DO UPDATE SET "Duration" = EXCLUDED."Duration", "Log"=EXCLUDED."Log", "Result"=EXCLUDED."Result" WHERE "jf_logging"."Result"='Running'`},
{table:'jf_playback_activity',query:' ON CONFLICT DO NOTHING'},

View File

@@ -18,6 +18,7 @@
"BackdropImageTags",
"ParentId",
"PrimaryImageHash",
"archived",
];
const jf_library_items_mapping = (item) => ({
@@ -43,6 +44,7 @@
BackdropImageTags: item.BackdropImageTags[0],
ParentId: item.ParentId,
PrimaryImageHash: item.ImageTags && item.ImageTags.Primary && item.ImageBlurHashes && item.ImageBlurHashes.Primary && item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] ? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]] : null,
archived: false,
});
module.exports = {

View File

@@ -5,6 +5,9 @@ const db = require("../db");
const https = require("https");
const { checkForUpdates } = require("../version-control");
const { randomUUID } = require('crypto');
const { sendUpdate } = require("../ws");
const pgp = require('pg-promise')();
const agent = new https.Agent({
rejectUnauthorized:
@@ -67,29 +70,29 @@ router.post("/setPreferredAdmin", async (req, res) => {
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
config[0].JF_API_KEY === null
) {
res.status(404);
res.send({ error: "Config Details Not Found" });
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 || {};
settings.preferred_admin = {userid:userid,username:username};
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
const { rows } = await db.query(query, [settings]);
res.send("Settings updated succesfully");
}else
{
@@ -174,17 +177,17 @@ router.get("/TrackedLibraries", async (req, res) => {
"X-MediaBrowser-Token": config[0].JF_API_KEY,
},
});
const filtered_items = response_data.data.Items.filter(
(type) => !["boxsets", "playlists"].includes(type.CollectionType)
);
const excluded_libraries = await db
.query('SELECT settings FROM app_config where "ID"=1')
.then((res) => res.rows);
if (excluded_libraries.length > 0) {
const libraries = excluded_libraries[0].settings?.ExcludedLibraries || [];
const librariesWithTrackedStatus = filtered_items.map((items) => ({
...items,
...{ Tracked: !libraries.includes(items.Id) },
@@ -222,7 +225,7 @@ router.post("/setExcludedLibraries", async (req, res) => {
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 || {};
@@ -278,7 +281,7 @@ router.get("/keys", async (req,res) => {
router.delete("/keys", async (req,res) => {
const { key } = req.body;
if(!key)
{
res.status(400);
@@ -304,7 +307,7 @@ router.delete("/keys", async (req,res) => {
.query('SELECT api_keys FROM app_config where "ID"=1')
.then((res) => res.rows[0].api_keys);
if (keysjson) {
const keys = keysjson || [];
const keyExists = keys.some(obj => obj.key === key);
@@ -312,7 +315,7 @@ router.delete("/keys", async (req,res) => {
{
const new_keys_array=keys.filter(obj => obj.key !== key);
let query = 'UPDATE app_config SET api_keys=$1 where "ID"=1';
await db.query(query, [JSON.stringify(new_keys_array)]);
return res.send('Key removed: '+key);
@@ -321,7 +324,7 @@ router.delete("/keys", async (req,res) => {
res.status(404);
return res.send('API key does not exist');
}
}else
{
@@ -361,7 +364,7 @@ router.post("/keys", async (req, res) => {
let keys=[];
const uuid = randomUUID()
const new_key={name:name, key:uuid};
if (keysjson) {
keys = keysjson || [];
keys.push(new_key);
@@ -388,16 +391,16 @@ router.get("/getTaskSettings", async (req, res) => {
if (settingsjson.length > 0) {
const settings = settingsjson[0].settings || {};
let tasksettings = settings.Tasks || {};
res.send(tasksettings);
}else {
res.status(404);
res.send({ error: "Task Settings Not Found" });
}
}catch(error)
{
res.status(503);
@@ -421,7 +424,7 @@ router.post("/setTaskSettings", async (req, res) => {
{
settings.Tasks = {};
}
let tasksettings = settings.Tasks;
if(!tasksettings[taskname])
{
@@ -436,13 +439,13 @@ router.post("/setTaskSettings", async (req, res) => {
await db.query(query, [settings]);
res.status(200);
res.send(tasksettings);
}else {
res.status(404);
res.send({ error: "Task Settings Not Found" });
}
}catch(error)
{
res.status(503);
@@ -701,7 +704,7 @@ router.get("/dataValidator", async (req, res) => {
}
});
//DB Queries
//DB Queries
router.post("/getUserDetails", async (req, res) => {
try {
const { userid } = req.body;
@@ -743,7 +746,6 @@ router.post("/getLibrary", async (req, res) => {
router.post("/getLibraryItems", async (req, res) => {
try {
const { libraryid } = req.body;
console.log(`ENDPOINT CALLED: /getLibraryItems: ` + libraryid);
const { rows } = await db.query(
`SELECT * FROM jf_library_items where "ParentId"=$1`, [libraryid]
);
@@ -758,30 +760,25 @@ router.post("/getSeasons", async (req, res) => {
const { Id } = req.body;
const { rows } = await db.query(
`SELECT * FROM jf_library_seasons where "SeriesId"=$1`, [Id]
`SELECT s.*,i.archived, i."PrimaryImageHash" FROM jf_library_seasons s left join jf_library_items i on i."Id"=s."SeriesId" where "SeriesId"=$1`, [Id]
);
console.log({ Id: Id });
res.send(rows);
} catch (error) {
console.log(error);
}
console.log(`ENDPOINT CALLED: /getSeasons: `);
});
router.post("/getEpisodes", async (req, res) => {
try {
const { Id } = req.body;
const { rows } = await db.query(
`SELECT * FROM jf_library_episodes where "SeasonId"=$1`, [Id]
`SELECT e.*,i.archived, i."PrimaryImageHash" FROM jf_library_episodes e left join jf_library_items i on i."Id"=e."SeriesId" where "SeasonId"=$1`, [Id]
);
console.log({ Id: Id });
res.send(rows);
} catch (error) {
console.log(error);
}
console.log(`ENDPOINT CALLED: /getEpisodes: `);
});
router.post("/getItemDetails", async (req, res) => {
@@ -792,11 +789,11 @@ router.post("/getItemDetails", async (req, res) => {
const { rows: items } = await db.query(query, [Id]);
if (items.length === 0) {
query = `SELECT im."Name" "FileName",im.*,s.* FROM jf_library_seasons s left join jf_item_info im on s."Id" = im."Id" where s."Id"=$1`;
query = `SELECT im."Name" "FileName",im.*,s.*, i.archived, i."PrimaryImageHash" FROM jf_library_seasons s left join jf_item_info im on s."Id" = im."Id" left join jf_library_items i on i."Id"=s."SeriesId" where s."Id"=$1`;
const { rows: seasons } = await db.query(query, [Id]);
if (seasons.length === 0) {
query = `SELECT im."Name" "FileName",im.*,e.* FROM jf_library_episodes e join jf_item_info im on e."EpisodeId" = im."Id" where e."EpisodeId"=$1`;
query = `SELECT im."Name" "FileName",im.*,e.*, i.archived , i."PrimaryImageHash" FROM jf_library_episodes e join jf_item_info im on e."EpisodeId" = im."Id" left join jf_library_items i on i."Id"=e."SeriesId" where e."EpisodeId"=$1`;
const { rows: episodes } = await db.query(query, [Id]);
if (episodes.length !== 0) {
@@ -814,7 +811,50 @@ router.post("/getItemDetails", async (req, res) => {
console.log(error);
}
console.log(`ENDPOINT CALLED: /getLibraryItems: `);
});
router.delete("/item/purge", async (req, res) => {
try {
const { id, withActivity } = req.body;
const { rows: episodes } = await db.query(`select * from jf_library_episodes where "SeriesId"=$1`, [id]);
if(episodes.length>0)
{
await db.query(`delete from jf_library_episodes where "SeriesId"=$1`, [id]);
}
const { rows: seasons } = await db.query(`select * from jf_library_seasons where "SeriesId"=$1`, [id]);
if(seasons.length>0)
{
await db.query(`delete from jf_library_seasons where "SeriesId"=$1`, [id]);
}
await db.query(`delete from jf_library_items where "Id"=$1`, [id]);
if(withActivity)
{
const deleteQuery = {
text: `DELETE FROM jf_playback_activity WHERE${episodes.length>0 ? `" EpisodeId" IN (${pgp.as.csv(episodes.map((item)=>item.EpisodeId))}) OR`:"" }${seasons.length>0 ? `" SeasonId" IN (${pgp.as.csv(seasons.map((item)=>item.SeasonId))}) OR` :""} "NowPlayingItemId"='${id}'`,
};
await db.query(deleteQuery);
}
sendUpdate("GeneralAlert",{type:"Success",message:`Item ${withActivity ? "with Playback Activity":""} has been Purged`});
res.send("Item purged succesfully");
} catch (error) {
console.log(error);
sendUpdate("GeneralAlert",{type:"Error",message:`There was an error Purging the Data`});
res.status(503);
res.send(error);
}
});
//DB Queries - History
@@ -887,7 +927,7 @@ router.post("/getItemHistory", async (req, res) => {
const { rows } = await db.query(
`select jf_playback_activity.*
from jf_playback_activity jf_playback_activity
where
where
("EpisodeId"=$1 OR "SeasonId"=$1 OR "NowPlayingItemId"=$1);`, [itemid]
);
@@ -976,7 +1016,7 @@ router.post("/validateSettings", async (req, res) => {
} catch (error) {
isValid = false;
errorMessage = `Error: ${error}`;
}
console.log({ isValid: isValid, errorMessage: errorMessage });

View File

@@ -13,6 +13,7 @@ const taskstate = require('../logging/taskstate');
const taskName = require('../logging/taskName');
const { sendUpdate } = require('../ws');
const db = require("../db");
const router = express.Router();
@@ -49,8 +50,8 @@ async function backup(refLog) {
});
// Get data from each table and append it to the backup file
try{
let now = moment();
@@ -69,11 +70,11 @@ async function backup(refLog) {
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 });
@@ -81,7 +82,7 @@ async function backup(refLog) {
return;
});
const backup_data=[];
refLog.logData.push({ color: "yellow", Message: "Begin Backup "+directoryPath });
for (let table of tables) {
const query = `SELECT * FROM ${table}`;
@@ -90,7 +91,7 @@ async function backup(refLog) {
refLog.logData.push({color: "dodgerblue",Message: `Saving ${rows.length} rows for table ${table}`});
backup_data.push({[table]:rows});
}
@@ -102,7 +103,7 @@ async function backup(refLog) {
//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) {
@@ -151,11 +152,11 @@ async function backup(refLog) {
refLog.logData.push({ color: "red", Message: "Backup Failed: "+error });
logging.updateLog(refLog.uuid,refLog.loggedData,taskstate.FAILED);
}
await pool.end();
}
// Restore function
@@ -230,14 +231,14 @@ async function restore(file,refLog) {
});
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();
@@ -255,8 +256,8 @@ router.get('/beginBackup', async (req, res) => {
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");
@@ -264,7 +265,7 @@ router.get('/beginBackup', async (req, res) => {
return;
}
}
const uuid = randomUUID();
let refLog={logData:[],uuid:uuid};
@@ -280,14 +281,14 @@ router.get('/beginBackup', 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);
const filePath = path.join(__dirname, '..', backupfolder, req.params.filename);
await restore(filePath,refLog);
Logging.updateLog(uuid,refLog.logData,taskstate.SUCCESS);
@@ -302,10 +303,10 @@ router.get('/restore/:filename', async (req, res) => {
router.get('/files', (req, res) => {
try
{
{
const directoryPath = path.join(__dirname, '..', backupfolder);
fs.readdir(directoryPath, (err, files) => {
if (err) {
@@ -329,7 +330,7 @@ router.get('/restore/:filename', async (req, res) => {
{
console.log(error);
}
});
@@ -344,14 +345,14 @@ router.get('/restore/:filename', async (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.`);
});
@@ -363,7 +364,7 @@ router.get('/restore/:filename', async (req, res) => {
});
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '..', backupfolder)); // Set the destination folder for uploaded files
@@ -372,10 +373,10 @@ router.get('/restore/:filename', async (req, res) => {
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({
@@ -383,13 +384,13 @@ router.get('/restore/:filename', async (req, res) => {
filePath: req.file.path,
});
});
module.exports =
module.exports =
{
router,
backup

View File

@@ -81,11 +81,11 @@ class sync {
if(!response || typeof response.data !== 'object' || !Array.isArray(response.data))
{
console.log("Invalid Response from Users API Call: "+response);
return [];
}
const adminUser = response.data.filter(
(user) => user.Policy.IsAdministrator === true
);
@@ -113,11 +113,11 @@ class sync {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
},
params:{
startIndex:startIndex,
recursive:recursive,
limit:increment
limit:increment,
},
});
@@ -148,11 +148,11 @@ class sync {
"X-MediaBrowser-Token": this.apiKey,
},
});
const filtered_libraries = response_data.data.Items.filter(
(type) => !["boxsets", "playlists"].includes(type.CollectionType)
);
return filtered_libraries;
@@ -162,7 +162,8 @@ class sync {
}
}
async getItems(key,id,params) {
async getItemsFromParent(key,id,params) {
try {
@@ -178,7 +179,7 @@ class sync {
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
},
params:{
startIndex:startIndex,
recursive:recursive,
@@ -227,11 +228,69 @@ class sync {
}
}
async getSeasons(SeriesId) {
try {
let url = `${this.hostUrl}/Shows/${SeriesId}/Seasons`;
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
const results = response.data.Items.filter((item) => item.LocationType !== "Virtual");
return results;
} catch (error) {
console.log(error);
return [];
}
}
async getEpisodes(SeriesId,SeasonId) {
try {
let url = `${this.hostUrl}/Shows/${SeriesId}/Episodes?seasonId=${SeasonId}`;
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
const results = response.data.Items.filter((item) => item.LocationType !== "Virtual");
return results;
} catch (error) {
console.log(error);
return [];
}
}
async getRecentlyAdded(userid,limit = 20, parentId) {
try {
let url = `${this.hostUrl}/Users/${userid}/Items/Latest?Limit=${limit}`;
if(parentId && parentId!=null)
{
url+=`&ParentId=${parentId}`;
}
const response = await axios_instance.get(url, {
headers: {
"X-MediaBrowser-Token": this.apiKey,
},
});
const results = response.data.filter((item) => item.LocationType !== "Virtual");
return results;
} catch (error) {
console.log(error);
return [];
}
}
async getExistingIDsforTable(tablename)
{
return await db
.query(`SELECT "Id" FROM ${tablename}`)
.then((res) => res.rows.map((row) => row.Id));
.then((res) => res.rows.map((row) => row.Id));
}
async insertData(tablename,dataToInsert,column_mappings)
@@ -258,12 +317,23 @@ class sync {
throw new Error("Error :" + result.message);
}
}
async updateSingleFieldOnDB(tablename,dataToUpdate,field_name,field_value)
{
let result = await db.updateSingleFieldBulk(tablename,dataToUpdate,field_name,field_value);
if (result.Result === "SUCCESS") {
syncTask.loggedData.push(dataToUpdate.length + " Rows updated.");
} else {
syncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
throw new Error("Error :" + result.message);
}
}
}
////////////////////////////////////////API Methods
async function syncUserData()
{
sendUpdate("SyncTask",{type:"Update",message:"Syncing User Data"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Syncing User Data"});
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
@@ -278,10 +348,10 @@ async function syncUserData()
if (dataToInsert.length > 0) {
await _sync.insertData("jf_users",dataToInsert,jf_users_columns);
}
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
await _sync.removeData("jf_users",toDeleteIds);
await _sync.removeData("jf_users",toDeleteIds);
}
//update usernames on log table where username does not match the user table
@@ -291,7 +361,7 @@ async function syncUserData()
async function syncLibraryFolders(data)
{
sendUpdate("SyncTask",{type:"Update",message:"Syncing Library Folders"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Syncing Library Folders"});
const _sync = new sync();
const existingIds = await _sync.getExistingIDsforTable('jf_libraries');// get existing library Ids from the db
@@ -301,7 +371,7 @@ async function syncLibraryFolders(data)
if (dataToInsert.length !== 0) {
await _sync.insertData("jf_libraries",dataToInsert,jf_libraries_columns);
}
//----------------------DELETE FUNCTION
//GET EPISODES IN SEASONS
//GET SEASONS IN SHOWS
@@ -309,7 +379,7 @@ async function syncLibraryFolders(data)
//FINALY DELETE LIBRARY
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
sendUpdate("SyncTask",{type:"Update",message:"Cleaning Up Old Library Data"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Cleaning Up Old Library Data"});
const ItemsToDelete=await db.query(`SELECT "Id" FROM jf_library_items where "ParentId" in (${toDeleteIds.map(id => `'${id}'`).join(',')})`).then((res) => res.rows.map((row) => row.Id));
if (ItemsToDelete.length > 0) {
@@ -317,9 +387,9 @@ async function syncLibraryFolders(data)
}
await _sync.removeData("jf_libraries",toDeleteIds);
}
}
}
async function syncLibraryItems(data)
{
@@ -327,56 +397,82 @@ async function syncLibraryItems(data)
const existingLibraryIds = await _sync.getExistingIDsforTable('jf_libraries');// get existing library Ids from the db
syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 1/4" });
sendUpdate("SyncTask",{type:"Update",message:"Beginning Library Item Sync (1/4)"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Beginning Library Item Sync (1/4)"});
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');
const existingIds = await _sync.getExistingIDsforTable('jf_library_items where archived=false');
let dataToInsert = [];
//filter fix if jf_libraries is empty
dataToInsert = await data.map(jf_library_items_mapping);
dataToInsert=dataToInsert.filter((item)=>item.Id !== undefined);
if(syncTask.taskName===taskName.partialsync)
{
dataToInsert=dataToInsert.filter((item)=>!existingIds.includes(item.Id));
}
if (dataToInsert.length > 0) {
await _sync.insertData("jf_library_items",dataToInsert,jf_library_items_columns);
}
const toDeleteIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if (toDeleteIds.length > 0) {
await _sync.removeData("jf_library_items",toDeleteIds);
}
syncTask.loggedData.push({color: "dodgerblue",Message: `${dataToInsert.length-existingIds.length >0 ? dataToInsert.length-existingIds.length : 0} Rows Inserted. ${existingIds.length} Rows Updated.`,});
syncTask.loggedData.push({color: "orange",Message: toDeleteIds.length + " Library Items Removed.",});
if(syncTask.taskName===taskName.fullsync)
{
let toArchiveIds = existingIds.filter((id) =>!data.some((row) => row.Id === id ));
if(syncTask.taskName===taskName.partialsync)
{
toArchiveIds=toArchiveIds.filter((id)=>!existingIds.includes(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)
async function syncShowItems(data,library_items)
{
syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 2/4" });
sendUpdate("SyncTask",{type:"Update",message:"Beginning Show Item Sync (2/4)"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Beginning Show Item Sync (2/4)"});
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'`);
//reduce list to only loop for shows that are in the library which match the shows in the data
//this should exist in the db as syncShowItems is usually called after syncLibraryItems
const _shows=shows.filter((item) => item.Id !== undefined && library_items.some((row) => row.Id === item.Id));
let insertSeasonsCount = 0;
let insertEpisodeCount = 0;
let updateSeasonsCount = 0;
let updateEpisodeCount = 0;
let deleteSeasonsCount = 0;
let deleteEpisodeCount = 0;
//loop for each show
for (const show of shows) {
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.Id}'`).then((res) => res.rows.map((row) => row.Id));
let existingIdsEpisodes = [];
if (existingIdsSeasons.length > 0) {
@@ -398,6 +494,14 @@ async function syncShowItems(data)
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 data not on db
if (seasonsToInsert.length !== 0) {
let result = await db.insertBulk("jf_library_seasons",seasonsToInsert,jf_library_seasons_columns);
@@ -411,20 +515,9 @@ async function syncShowItems(data)
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
const toDeleteIds = existingIdsSeasons.filter((id) =>!allSeasons.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteIds.length > 0) {
let result = await db.deleteBulk("jf_library_seasons",toDeleteIds);
if (result.Result === "SUCCESS") {
deleteSeasonsCount +=toDeleteIds.length;
} else {
syncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
//insert delete episodes
}
//Bulkinsert new data not on db
if (episodesToInsert.length !== 0) {
let result = await db.insertBulk("jf_library_episodes",episodesToInsert,jf_library_episodes_columns);
@@ -438,72 +531,78 @@ async function syncShowItems(data)
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
}
const toDeleteEpisodeIds = existingIdsEpisodes.filter((id) =>!allEpisodes.some((row) => row.Id=== id ));
//Bulk delete from db thats no longer on api
if (toDeleteEpisodeIds.length > 0) {
let result = await db.deleteBulk("jf_library_episodes",toDeleteEpisodeIds);
if (result.Result === "SUCCESS") {
deleteEpisodeCount +=toDeleteEpisodeIds.length;
} else {
syncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
}
syncTask.loggedData.push({color: "dodgerblue",Message: `Seasons: ${insertSeasonsCount > 0 ? insertSeasonsCount : 0} Rows Inserted. ${updateSeasonsCount} Rows Updated.`});
syncTask.loggedData.push({color: "orange",Message: deleteSeasonsCount + " Seasons Removed.",});
syncTask.loggedData.push({color: "dodgerblue",Message: `Episodes: ${insertEpisodeCount > 0 ? insertEpisodeCount : 0} Rows Inserted. ${updateEpisodeCount} Rows Updated.`});
syncTask.loggedData.push({color: "orange",Message: deleteEpisodeCount + " Episodes Removed.",});
syncTask.loggedData.push({color: "dodgerblue",Message: `Episodes: ${insertEpisodeCount > 0 ? insertEpisodeCount : 0} Rows Inserted. ${updateEpisodeCount} Rows Updated.`});
syncTask.loggedData.push({ color: "yellow", Message: "Sync Complete" });
}
async function syncItemInfo()
async function syncItemInfo(seasons_and_episodes,library_items)
{
syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 3/4" });
sendUpdate("SyncTask",{type:"Update",message:"Beginning Item Info Sync (3/4)"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Beginning Item Info Sync (3/4)"});
syncTask.loggedData.push({color: "yellow", Message: "Beginning File Info Sync",});
const { rows: config } = await db.query('SELECT * FROM app_config where "ID"=1');
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`);
let Items=library_items.filter((item) => item.Type !== 'Series' && item.Type !== 'Folder' && item.id !== undefined).map(jf_library_items_mapping);
let Episodes=seasons_and_episodes.filter((item) => item.Type === 'Episode' && item.LocationType !== 'Virtual' && item.id !== undefined).map(jf_library_episodes_mapping);
if(syncTask.taskName===taskName.fullsync)
{
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 e join jf_library_items i on i."Id"=e."SeriesId" where i.archived=false`);
Items=_Items;
Episodes=_Episodes;
}
let insertItemInfoCount = 0;
let insertEpisodeInfoCount = 0;
let updateItemInfoCount = 0;
let updateEpisodeInfoCount = 0;
let deleteItemInfoCount = 0;
let deleteEpisodeInfoCount = 0;
const admins = await _sync.getAdminUser();
if(admins.length===0)
let userid=config[0].settings?.preferred_admin?.userid;
if(!userid)
{
syncTask.loggedData.push({
color: "red",
Message: "Error fetching Admin ID (syncItemInfo)",
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
throw new Error('Error fetching Admin ID (syncItemInfo)');
const admins = await _sync.getAdminUser();
if(admins.length===0)
{
syncTask.loggedData.push({
color: "red",
Message: "Error fetching Admin ID (syncItemInfo)",
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
throw new Error('Error fetching Admin ID (syncItemInfo)');
}
userid = admins[0].Id;
}
const userid = admins[0].Id;
let current_item=0;
let all_items=Items.length;
//loop for each Movie
for (const Item of Items) {
current_item++;
sendUpdate("SyncTask",{type:"Update",message:`Syncing Item Info ${((current_item/all_items)*100).toFixed(2)}%`});
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));
if(existingItemInfo.length>0 && syncTask.taskName===taskName.partialsync)
{
//dont update item info if it already exists and running a partial sync
return;
}
const data = await _sync.getItemInfo(Item.Id,userid);
const existingItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Item.Id}'`).then((res) => res.rows.map((row) => row.Id));
let ItemInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Item'));
@@ -521,19 +620,8 @@ async function syncItemInfo()
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
const toDeleteItemInfoIds = existingItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteItemInfoIds.length > 0) {
let result = await db.deleteBulk("jf_item_info",toDeleteItemInfoIds);
if (result.Result === "SUCCESS") {
deleteItemInfoCount +=toDeleteItemInfoIds.length;
} else {
syncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
}
}
let current_episode=0;
@@ -541,14 +629,19 @@ async function syncItemInfo()
//loop for each Episode
for (const Episode of Episodes) {
current_episode++;
sendUpdate("SyncTask",{type:"Update",message:`Syncing Episode Info ${((current_episode/all_episodes)*100).toFixed(2)}%`});
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.EpisodeId}'`).then((res) => res.rows.map((row) => row.Id));
if(existingEpisodeItemInfo.length>0 && syncTask.taskName===taskName.partialsync)
{
//dont update item info if it already exists and running a partial sync
return;
}
const data = await _sync.getItemInfo(Episode.EpisodeId,userid);
const existingEpisodeItemInfo = await db.query(`SELECT * FROM public.jf_item_info where "Id" = '${Episode.EpisodeId}'`).then((res) => res.rows.map((row) => row.Id));
let EpisodeInfoToInsert = await data.map(item => jf_item_info_mapping(item, 'Episode'));
//filter fix if jf_libraries is empty
@@ -564,34 +657,20 @@ async function syncItemInfo()
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
const toDeleteEpisodeInfoIds = existingEpisodeItemInfo.filter((id) =>!data.some((row) => row.Id === id ));
//Bulk delete from db thats no longer on api
if (toDeleteEpisodeInfoIds.length > 0) {
let result = await db.deleteBulk("jf_item_info",toDeleteEpisodeInfoIds);
if (result.Result === "SUCCESS") {
deleteEpisodeInfoCount +=toDeleteEpisodeInfoIds.length;
} else {
syncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
}
}
// console.log(Episode.Name)
}
syncTask.loggedData.push({color: "dodgerblue",Message: (insertItemInfoCount >0 ? insertItemInfoCount : 0) + " Item Info inserted. "+updateItemInfoCount +" Item Info Updated"});
syncTask.loggedData.push({color: "orange",Message: deleteItemInfoCount + " Item Info Removed.",});
syncTask.loggedData.push({color: "dodgerblue",Message: (insertEpisodeInfoCount > 0 ? insertEpisodeInfoCount:0) + " Episodes Info inserted. "+updateEpisodeInfoCount +" Episodes Info Updated"});
syncTask.loggedData.push({color: "orange",Message: deleteEpisodeInfoCount + " Episodes Info Removed.",});
syncTask.loggedData.push({ color: "yellow", Message: "Info Sync Complete" });
sendUpdate("SyncTask",{type:"Update",message:"Info Sync Complete"});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Info Sync Complete"});
}
async function removeOrphanedData()
{
syncTask.loggedData.push({ color: "lawngreen", Message: "Syncing... 4/4" });
sendUpdate("SyncTask",{type:"Update",message:"Cleaning up FileInfo/Episode/Season Records (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()');
@@ -609,7 +688,7 @@ async function syncPlaybackPluginData()
'SELECT * FROM app_config where "ID"=1'
);
if(config.length===0)
{
PlaybacksyncTask.loggedData.push({ Message: "Error: Config details not found!" });
@@ -636,7 +715,7 @@ async function syncPlaybackPluginData()
},
});
const hasPlaybackReportingPlugin=pluginResponse.data?.filter((plugins) => plugins?.ConfigurationFileName==='Jellyfin.Plugin.PlaybackReporting.xml');
if(!hasPlaybackReportingPlugin || hasPlaybackReportingPlugin.length===0)
@@ -710,21 +789,21 @@ async function syncPlaybackPluginData()
PlaybacksyncTask.loggedData.push({color: "dodgerblue",Message: "Process complete. Data has been inserted.",});
} else {
PlaybacksyncTask.loggedData.push({color: "red",Message: "Error: "+result.message,});
logging.updateLog(PlaybacksyncTask.uuid,PlaybacksyncTask.loggedData,taskstate.FAILED);
}
}else
{
PlaybacksyncTask.loggedData.push({color: "dodgerblue", Message: `No new data to insert.`,});
}
}
PlaybacksyncTask.loggedData.push({color: "lawngreen", Message: `Playback Reporting Plugin Sync Complete`,});
}
async function updateLibraryStatsData()
@@ -742,14 +821,13 @@ async function fullSync(triggertype)
{
const uuid = randomUUID();
syncTask={loggedData:[],uuid:uuid};
syncTask={loggedData:[],uuid:uuid, wsKey:"FullSyncTask", taskName:taskName.fullsync};
try
{
sendUpdate("SyncTask",{type:"Start",message:triggertype+" Sync Started"});
logging.insertLog(uuid,triggertype,taskName.sync);
sendUpdate(syncTask.wsKey,{type:"Start",message:triggertype+" "+taskName.fullsync+" Started"});
logging.insertLog(uuid,triggertype,taskName.fullsync);
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" });
syncTask.loggedData.push({ Message: "Error: Config details not found!" });
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
return;
@@ -757,12 +835,12 @@ async function fullSync(triggertype)
const _sync = new sync(rows[0].JF_HOST, rows[0].JF_API_KEY);
const libraries = await _sync.getLibrariesFromApi();
const libraries = await _sync.getLibrariesFromApi();
if(libraries.length===0)
{
syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." });
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
sendUpdate("SyncTask",{type:"Success",message:triggertype+" Sync Completed"});
sendUpdate(syncTask.wsKey,{type:"Success",message:triggertype+" "+taskName.fullsync+" Completed"});
return;
}
@@ -775,15 +853,15 @@ async function fullSync(triggertype)
//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 item = filtered_libraries[i];
sendUpdate("SyncTask",{type:"Update",message:"Fetching Data for Library : "+item.Name + ` (${(i+1)}/${filtered_libraries.length})`});
let libraryItems = await _sync.getItems('parentId',item.Id);
sendUpdate("SyncTask",{type:"Update",message:"Mapping Data for Library : "+item.Name});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Fetching Data for Library : "+item.Name + ` (${(i+1)}/${filtered_libraries.length})`});
let libraryItems = await _sync.getItemsFromParent('parentId',item.Id);
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",{type:"Update",message:"Data Fetched for Library : "+item.Name});
sendUpdate(syncTask.wsKey,{type:"Update",message:"Data Fetched for Library : "+item.Name});
}
const library_items=data.filter((item) => ['Movie','Audio','Series'].includes(item.Type));
@@ -799,10 +877,10 @@ async function fullSync(triggertype)
await syncLibraryItems(library_items);
//syncShowItems
await syncShowItems(seasons_and_episodes);
await syncShowItems(seasons_and_episodes,library_items);
//syncItemInfo
await syncItemInfo();
await syncItemInfo(seasons_and_episodes,library_items);
//removeOrphanedData
await removeOrphanedData();
@@ -811,16 +889,132 @@ async function fullSync(triggertype)
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.SUCCESS);
sendUpdate("SyncTask",{type:"Success",message:triggertype+" Sync Completed"});
sendUpdate(syncTask.wsKey,{type:"Success",message:triggertype+" Sync Completed"});
}catch(error)
{
syncTask.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
sendUpdate("SyncTask",{type:"Error",message:triggertype+" Sync Halted with Errors"});
sendUpdate(syncTask.wsKey,{type:"Error",message:triggertype+" Sync Halted with Errors"});
}
}
async function partialSync(triggertype)
{
const uuid = randomUUID();
syncTask={loggedData:[],uuid:uuid, wsKey:"PartialSyncTask", taskName:taskName.partialsync};
try
{
sendUpdate(syncTask.wsKey,{type:"Start",message:triggertype+" "+taskName.partialsync+" Started"});
logging.insertLog(uuid,triggertype,taskName.partialsync);
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) {
syncTask.loggedData.push({ Message: "Error: Config details not found!" });
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
let userid=config[0].settings?.preferred_admin?.userid;
if(!userid)
{
const admins = await _sync.getAdminUser();
if(admins.length===0)
{
syncTask.loggedData.push({
color: "red",
Message: "Error fetching Admin ID (syncItemInfo)",
});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
throw new Error('Error fetching Admin ID (syncItemInfo)');
}
userid = admins[0].Id;
}
const libraries = await _sync.getLibrariesFromApi();
if(libraries.length===0)
{
syncTask.loggedData.push({ Message: "Error: No Libararies found to sync." });
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
sendUpdate(syncTask.wsKey,{type:"Success",message:triggertype+" "+taskName.fullsync+" Completed"});
return;
}
const excluded_libraries= config[0].settings.ExcludedLibraries||[];
const filtered_libraries=libraries.filter((library)=> !excluded_libraries.includes(library.Id));
const 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 item = filtered_libraries[i];
sendUpdate(syncTask.wsKey,{type:"Update",message:"Fetching Data for Library : "+item.Name + ` (${(i+1)}/${filtered_libraries.length})`});
let recentlyAddedForLibrary = await _sync.getRecentlyAdded(userid,10,item.Id);
sendUpdate(syncTask.wsKey,{type:"Update",message:"Mapping Data for Library : "+item.Name});
const libraryItemsWithParent = recentlyAddedForLibrary.map((items) => ({
...items,
...{ ParentId: item.Id },
}));
data.push(...libraryItemsWithParent);
sendUpdate(syncTask.wsKey,{type:"Update",message:"Data Fetched for Library : "+item.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 _sync.getItemsFromParent('ParentId',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));
// //syncUserData
await syncUserData();
// //syncLibraryFolders
await syncLibraryFolders(filtered_libraries);
//syncLibraryItems
await syncLibraryItems(library_items);
//syncShowItems
await syncShowItems(seasons_and_episodes,library_items);
//syncItemInfo
await syncItemInfo(seasons_and_episodes,library_items);
//removeOrphanedData
await removeOrphanedData();
await updateLibraryStatsData();
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.SUCCESS);
sendUpdate(syncTask.wsKey,{type:"Success",message:triggertype+" Sync Completed"});
}catch(error)
{
syncTask.loggedData.push({color: "red",Message: getErrorLineNumber(error)+ ": Error: "+error,});
logging.updateLog(syncTask.uuid,syncTask.loggedData,taskstate.FAILED);
sendUpdate(syncTask.wsKey,{type:"Error",message:triggertype+" Sync Halted with Errors"});
}
}
@@ -828,7 +1022,7 @@ async function fullSync(triggertype)
////////////////////////////////////////API Calls
///////////////////////////////////////Sync All
router.get("/beingSync", async (req, res) => {
router.get("/beginSync", async (req, res) => {
const { rows } = await db.query('SELECT * FROM app_config where "ID"=1');
if (rows[0].JF_HOST === null || rows[0].JF_API_KEY === null) {
@@ -838,12 +1032,12 @@ router.get("/beingSync", async (req, res) => {
const last_execution=await db.query( `SELECT "Result"
FROM public.jf_logging
WHERE "Name"='${taskName.sync}'
WHERE "Name"='${taskName.fullsync}'
ORDER BY "TimeRun" DESC
LIMIT 1`).then((res) => res.rows);
if(last_execution.length!==0)
{
{
if(last_execution[0].Result ===taskstate.RUNNING)
{
@@ -859,6 +1053,38 @@ router.get("/beingSync", async (req, res) => {
});
router.get("/beginPartialSync", async (req, res) => {
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" });
return;
}
const last_execution=await db.query( `SELECT "Result"
FROM public.jf_logging
WHERE "Name"='${taskName.partialsync}'
ORDER BY "TimeRun" DESC
LIMIT 1`).then((res) => res.rows);
if(last_execution.length!==0)
{
if(last_execution[0].Result ===taskstate.RUNNING)
{
sendUpdate("TaskError","Error: Sync is already running");
res.send();
return;
}
}
await partialSync(triggertype.Manual);
res.send();
});
///////////////////////////////////////Write Users
router.post("/fetchItem", async (req, res) => {
try{
@@ -877,9 +1103,9 @@ router.post("/fetchItem", async (req, res) => {
res.send({ error: "Config Details Not Found" });
return;
}
const _sync = new sync(config[0].JF_HOST, config[0].JF_API_KEY);
let userid=config[0].settings?.preferred_admin?.userid;
if(!userid)
@@ -927,7 +1153,7 @@ router.post("/fetchItem", async (req, res) => {
res.status(500);
res.send(error);
}
});
@@ -942,7 +1168,7 @@ router.get("/syncPlaybackPluginData", async (req, res) => {
{
logging.insertLog(uuid,triggertype.Manual,taskName.import);
sendUpdate("PlaybackSyncTask",{type:"Start",message:"Playback Plugin Sync Started"});
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" });
@@ -950,10 +1176,10 @@ router.get("/syncPlaybackPluginData", async (req, res) => {
logging.updateLog(uuid,PlaybacksyncTask.loggedData,taskstate.FAILED);
return;
}
await sleep(5000);
await syncPlaybackPluginData();
logging.updateLog(PlaybacksyncTask.uuid,PlaybacksyncTask.loggedData,taskstate.SUCCESS);
sendUpdate("PlaybackSyncTask",{type:"Success",message:"Playback Plugin Sync Completed"});
res.send("syncPlaybackPluginData Complete");
@@ -963,7 +1189,7 @@ router.get("/syncPlaybackPluginData", async (req, res) => {
logging.updateLog(PlaybacksyncTask.uuid,PlaybacksyncTask.loggedData,taskstate.FAILED);
res.send("syncPlaybackPluginData Halted with Errors");
}
});
@@ -977,5 +1203,9 @@ function sleep(ms) {
module.exports =
{router,fullSync};
module.exports =
{
router,
fullSync,
partialSync,
};

View File

@@ -27,8 +27,7 @@ const utilsRouter = require('./routes/utils');
// tasks
const ActivityMonitor = require('./tasks/ActivityMonitor');
const SyncTask = require('./tasks/SyncTask');
const BackupTask = require('./tasks/BackupTask');
const tasks = require('./tasks/tasks');
// websocket
const { setupWebSocketServer } = require('./ws');
@@ -155,8 +154,9 @@ try {
`[JELLYSTAT] Server listening on http://${LISTEN_IP}:${PORT}`
);
ActivityMonitor.ActivityMonitor(1000);
SyncTask.SyncTask();
BackupTask.BackupTask();
tasks.FullSyncTask();
tasks.RecentlyAddedItemsSyncTask();
tasks.BackupTask();
});
});
});

View File

@@ -5,11 +5,11 @@ const taskName=require('../logging/taskName');
const taskstate = require("../logging/taskstate");
const triggertype = require("../logging/triggertype");
async function SyncTask() {
async function FullSyncTask() {
try{
await db.query(
`UPDATE jf_logging SET "Result"='${taskstate.FAILED}' WHERE "Name"='${taskName.sync}' AND "Result"='${taskstate.RUNNING}'`
`UPDATE jf_logging SET "Result"='${taskstate.FAILED}' WHERE "Name"='${taskName.fullsync}' AND "Result"='${taskstate.RUNNING}'`
);
}
catch(error)
@@ -19,7 +19,7 @@ async function SyncTask() {
let interval=10000;
let taskDelay=15; //in minutes
let taskDelay=1440; //in minutes
@@ -86,7 +86,7 @@ async function intervalCallback() {
const last_execution=await db.query( `SELECT "TimeRun","Result"
FROM public.jf_logging
WHERE "Name"='${taskName.sync}'
WHERE "Name"='${taskName.fullsync}'
ORDER BY "TimeRun" DESC
LIMIT 1`).then((res) => res.rows);
if(last_execution.length!==0)
@@ -121,5 +121,5 @@ let intervalTask = setInterval(intervalCallback, interval);
}
module.exports = {
SyncTask,
FullSyncTask,
};

View File

@@ -0,0 +1,125 @@
const db = require("../db");
const moment = require('moment');
const sync = require("../routes/sync");
const taskName=require('../logging/taskName');
const taskstate = require("../logging/taskstate");
const triggertype = require("../logging/triggertype");
async function RecentlyAddedItemsSyncTask() {
try{
await db.query(
`UPDATE jf_logging SET "Result"='${taskstate.FAILED}' WHERE "Name"='${taskName.partialsync}' AND "Result"='${taskstate.RUNNING}'`
);
}
catch(error)
{
console.log('Error Cleaning up Sync Tasks: '+error);
}
let interval=10000;
let taskDelay=15; //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 || {};
let synctasksettings = settings.Tasks?.PartialJellyfinSync || {};
if (synctasksettings.Interval) {
taskDelay=synctasksettings.Interval;
} else {
synctasksettings.Interval=taskDelay;
if(!settings.Tasks)
{
settings.Tasks = {};
}
if(!settings.Tasks.PartialJellyfinSync)
{
settings.Tasks.PartialJellyfinSync = {};
}
settings.Tasks.PartialJellyfinSync = 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);
}
}
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)
{
return;
}
const last_execution=await db.query( `SELECT "TimeRun","Result"
FROM public.jf_logging
WHERE "Name"='${taskName.partialsync}'
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)
{
intervalTask = setInterval(intervalCallback, interval);
return;
}
}
console.log('Running Recently Added Scheduled Sync');
await sync.partialSync(triggertype.Automatic);
console.log('Scheduled Recently Added Sync Complete');
} catch (error)
{
console.log(error);
return [];
}
intervalTask = setInterval(intervalCallback, interval);
}
let intervalTask = setInterval(intervalCallback, interval);
}
module.exports = {
RecentlyAddedItemsSyncTask,
};

10
backend/tasks/tasks.js Normal file
View File

@@ -0,0 +1,10 @@
const { BackupTask } = require("./BackupTask");
const { RecentlyAddedItemsSyncTask } = require("./RecentlyAddedItemsSyncTask");
const { FullSyncTask } = require("./FullSyncTask");
const tasks = {
FullSyncTask:FullSyncTask,
RecentlyAddedItemsSyncTask:RecentlyAddedItemsSyncTask,
BackupTask:BackupTask,
};
module.exports = tasks;

View File

@@ -41,18 +41,29 @@ function App() {
const wsListeners = [
{ task: 'PlaybackSyncTask', ref: React.useRef(null) },
{ task: 'SyncTask', ref: React.useRef(null) },
{ task: 'PartialSyncTask', ref: React.useRef(null) },
{ task: 'FullSyncTask', ref: React.useRef(null) },
{ task: 'BackupTask', ref: React.useRef(null) },
{ task: 'TaskError', ref: React.useRef(null) },
{ task: 'GeneralAlert', ref: React.useRef(null) },
];
useEffect(() => {
wsListeners.forEach((listener) => {
socket.on(listener.task, (message) => {
if (message && (message.type === 'Start' || !listener.ref.current)) {
if (message && (message.type === 'Start')) {
listener.ref.current = toast.info(message?.message || message, {
autoClose: 15000,
});
} else
if (message && (message.type === 'Success' && !listener.ref.current)) {
listener.ref.current = toast.success(message?.message || message, {
autoClose: 15000,
});
} else if (message && (message.type === 'Error' && !listener.ref.current)) {
listener.ref.current = toast.error(message?.message || message, {
autoClose: 15000,
});
} else if (message && message.type === 'Update') {
toast.update(listener.ref.current, {
render: message?.message || message,

View File

@@ -2,25 +2,33 @@
export const taskList = [
{
id: 0,
name: "JellyfinSync",
description: "Synchronize with Jellyfin",
name: "PartialJellyfinSync",
description: "Recently Added Items Sync",
type: "Job",
link: "/sync/beingSync"
link: "/sync/beginPartialSync"
},
{
id: 1,
name: "JellyfinSync",
description: "Complete Sync with Jellyfin",
type: "Job",
link: "/sync/beginSync"
},
{
id: 2,
name: "Jellyfin Playback Reporting Plugin Sync",
description: "Import Playback Reporting Plugin Data",
type: "Import",
link: "/sync/syncPlaybackPluginData"
},
{
id: 2,
id: 3,
name: "Backup",
description: "Backup Jellystat",
type: "Job",
link: "/backup/beginBackup"
}
},
]

View File

@@ -1,20 +1,21 @@
import React, {useState} from "react";
import { Link } from "react-router-dom";
import { Blurhash } from 'react-blurhash';
import ArchiveDrawerFillIcon from 'remixicon-react/ArchiveDrawerFillIcon';
import "../../css/lastplayed.css";
function formatTime(time) {
const units = {
days: ['Day', 'Days'],
hours: ['Hour', 'Hours'],
minutes: ['Minute', 'Minutes'],
seconds: ['Second', 'Seconds']
};
let formattedTime = '';
if (time.days) {
formattedTime = `${time.days} ${units.days[time.days > 1 ? 1 : 0]}`;
} else if (time.hours) {
@@ -24,18 +25,20 @@ function formatTime(time) {
} else {
formattedTime = `${time.seconds} ${units.seconds[time.seconds > 1 ? 1 : 0]}`;
}
return `${formattedTime} ago`;
}
function LastWatchedCard(props) {
const [loaded, setLoaded] = useState(false);
return (
<div className="last-card">
<Link to={`/libraries/item/${props.data.EpisodeId||props.data.Id}`}>
<div className="last-card-banner">
{!loaded && props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null ? <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
{props.data.archived && loaded && props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null ? <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
{!props.data.archived ?
<img
src={
`${
@@ -47,12 +50,25 @@ function LastWatchedCard(props) {
onLoad={() => setLoaded(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
:
<div className="d-flex flex-column justify-content-center align-items-center position-relative" style={{height: '100%'}}>
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) )?
<Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-top-3 overflow-hidden position-absolute"/>
:
null
}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
<ArchiveDrawerFillIcon className="w-100 h-100 mb-2"/>
<span>Archived</span>
</div>
</div>
}
</div>
</Link>
<div className="last-item-details">
<div className="last-last-played">
{formatTime(props.data.LastPlayed)}
{formatTime(props.data.LastPlayed)}
</div>
<div className="pb-2">
@@ -71,7 +87,7 @@ function LastWatchedCard(props) {
<div className="last-item-episode number"> S{props.data.SeasonNumber} - E{props.data.EpisodeNumber}</div>:
<></>
}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
/* eslint-disable react/prop-types */
import { useState, useEffect } from "react";
import axios from "axios";
import { useParams } from 'react-router-dom';
import { Link } from "react-router-dom";
@@ -6,6 +7,8 @@ import { Blurhash } from 'react-blurhash';
import {Row, Col, Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon";
import ArchiveDrawerFillIcon from 'remixicon-react/ArchiveDrawerFillIcon';
import GlobalStats from './item-info/globalStats';
import "../css/items/item-details.css";
@@ -17,6 +20,7 @@ import ItemNotFound from "./item-info/item-not-found";
import Config from "../../lib/config";
import Loading from "./general/loading";
import ItemOptions from "./item-info/item-options";
@@ -26,7 +30,7 @@ function ItemInfo() {
const [config, setConfig] = useState();
const [refresh, setRefresh] = useState(true);
const [activeTab, setActiveTab] = useState('tabOverview');
const [loaded, setLoaded] = useState(false);
@@ -48,7 +52,7 @@ function ItemInfo() {
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
return timeString;
}
@@ -64,8 +68,7 @@ function ItemInfo() {
"Content-Type": "application/json",
},
});
console.log(itemData.data[0]);
setData(itemData.data[0]);
} catch (error) {
@@ -78,7 +81,7 @@ function ItemInfo() {
};
useEffect(() => {
@@ -101,7 +104,7 @@ useEffect(() => {
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
// eslint-disable-next-line
// eslint-disable-next-line
}, [config, Id]);
@@ -128,17 +131,18 @@ const cardStyle = {
const cardBgStyle = {
backgroundColor: 'rgb(0, 0, 0, 0.8)',
};
return (
<div>
<div className="item-detail-container rounded-3" style={cardStyle}>
<Row className="justify-content-center justify-content-md-start rounded-3 g-0 p-4" style={cardBgStyle}>
<Col className="col-auto my-4 my-md-0 item-banner-image" >
{data.PrimaryImageHash && data.PrimaryImageHash!=null && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'} className="rounded-3 overflow-hidden" style={{display:'block'}}/> : null}
{!data.archived && data.PrimaryImageHash && data.PrimaryImageHash!=null && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'} className="rounded-3 overflow-hidden" style={{display:'block'}}/> : null}
{!data.archived ?
<img
className="item-image"
src={
@@ -152,6 +156,19 @@ const cardBgStyle = {
}}
onLoad={() => setLoaded(true)}
/>
:
<div className="d-flex flex-column justify-content-center align-items-center position-relative" style={{height: '300px', width:'200px'}}>
{((data.PrimaryImageHash && data.PrimaryImageHash!=null) )?
<Blurhash hash={data.PrimaryImageHash } width={'200px'} height={'300px'} className="rounded-3 overflow-hidden position-absolute" style={{display:'block'}}/>
:
null
}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
<ArchiveDrawerFillIcon className="w-100 h-100 mb-2"/>
<span>Archived</span>
</div>
</div>
}
</Col>
<Col >
@@ -171,7 +188,7 @@ const cardBgStyle = {
<div className="my-3">
{data.Type==="Episode"? <p><Link to={`/libraries/item/${data.SeasonId}`} className="fw-bold">{data.SeasonName}</Link> Episode {data.IndexNumber} - {data.Name}</p> : <></> }
{data.Type==="Season"? <p>{data.Name}</p> : <></> }
{data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {data.FileName}</p> :<></>}
{data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {data.FileName}</p> :<></>}
{data.Path ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Path: {data.Path}</p> :<></>}
{data.RunTimeTicks ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">{data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(data.RunTimeTicks)}</p> :<></>}
{data.Size ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Size: {formatFileSize(data.Size)}</p> :<></>}
@@ -180,18 +197,20 @@ const cardBgStyle = {
<ButtonGroup>
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
</ButtonGroup>
{data.archived && (<Button onClick={() => setActiveTab('tabOptions')} active={activeTab==='tabOptions'} variant='outline-primary' type='button'>Options</Button>)}
</ButtonGroup>
</div>
</Col>
</Row>
</div>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills' className="hide-tab-titles">
<Tab eventKey="tabOverview" title='' className='bg-transparent'>
<GlobalStats ItemId={Id}/>
@@ -204,6 +223,9 @@ const cardBgStyle = {
<Tab eventKey="tabActivity" title='' className='bg-transparent'>
<ItemActivity itemid={Id}/>
</Tab>
<Tab eventKey="tabOptions" title='' className='bg-transparent'>
<ItemOptions itemid={Id}/>
</Tab>
</Tabs>
</div>
);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import axios from "axios";
import ActivityTable from "../activity/activity-table";

View File

@@ -0,0 +1,87 @@
import axios from "axios";
import { useState } from "react";
import { Container, Row,Col, Modal } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
function ItemOptions(props) {
const token = localStorage.getItem('token');
const [show, setShow] = useState(false);
const options=[{description:"Purge Cached Item",withActivity:false},{description:"Purge Cached Item and Playback Activity",withActivity:true}];
const [selectedOption, setSelectedOption] = useState(options[0]);
const navigate = useNavigate();
async function execPurge(withActivity) {
const url=`/api/item/purge`;
return await axios.delete(url,
{
data:{
id: props.itemid,
withActivity:withActivity,
},
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).then((response) => {
console.log(response);
setShow(false);
// navigate(-1);
}).catch((error) => {
console.log({error:error,token:token});
});
}
return (
<div className="Activity">
<div className="Heading mb-3">
<h1>Archived Data Options</h1>
</div>
<Container className="p-0 m-0">
{options.map((option, index) => (
<Row key={index} className="mb-2 me-0">
<Col>
<span>{option.description}</span>
</Col>
<Col>
<button className="btn btn-danger w-25" onClick={()=>{setSelectedOption(option);setShow(true);}}>Purge</button>
</Col>
</Row>
))}
<Modal show={show} onHide={() =>{setShow(false);}}>
<Modal.Header closeButton>
<Modal.Title>Confirm Action</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{"Are you sure you want to Purge this item"+(selectedOption.withActivity ? " and Associated Playback Activity?" : "?")}</p>
</Modal.Body>
<Modal.Footer>
<button className="btn btn-danger" onClick={() => {execPurge(selectedOption.withActivity);}}>
Purge
</button>
<button className="btn btn-primary" onClick={()=>{setShow(false);}}>
Close
</button>
</Modal.Footer>
</Modal>
</Container>
</div>
);
}
export default ItemOptions;

View File

@@ -1,49 +1,64 @@
import React, {useState} from "react";
import {useState} from "react";
import { Blurhash } from 'react-blurhash';
import { Link } from "react-router-dom";
import { useParams } from 'react-router-dom';
import ArchiveDrawerFillIcon from 'remixicon-react/ArchiveDrawerFillIcon';
import "../../../css/lastplayed.css";
function MoreItemCards(props) {
const { Id } = useParams();
const [loaded, setLoaded] = useState(false);
const [loaded, setLoaded] = useState(props.data.archived);
const [fallback, setFallback] = useState(false);
return (
<div className={props.data.Type==="Episode" ? "last-card episode-card" : "last-card"}>
<Link to={`/libraries/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
<Link to={`/libraries/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`} className="text-decoration-none">
<div className={props.data.Type==="Episode" ? "last-card-banner episode" : "last-card-banner"}>
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) ) && !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-3 overflow-hidden"/> : null}
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) ) && !loaded ? <Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-top-3 overflow-hidden"/> : null}
{fallback ?
{!props.data.archived ?
(fallback ?
<img
src={
`${
"/proxy/Items/Images/Primary?id=" +
Id +
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
:
src={
`${
"/proxy/Items/Images/Primary?id=" +
Id +
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
:
<img
src={
`${
"/proxy/Items/Images/Primary?id=" +
(props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) +
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
}
src={
`${
"/proxy/Items/Images/Primary?id=" +
(props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) +
"&fillHeight=320&fillWidth=213&quality=50"}`
}
alt=""
onLoad={() => setLoaded(true)}
onError={() => setFallback(true)}
style={loaded ? { backgroundImage: `url(path/to/image.jpg)` } : { display: 'none' }}
/>
)
:
<div className="d-flex flex-column justify-content-center align-items-center position-relative" style={{height: '100%'}}>
{((props.data.ImageBlurHashes && props.data.ImageBlurHashes!=null) || (props.data.PrimaryImageHash && props.data.PrimaryImageHash!=null) )?
<Blurhash hash={props.data.PrimaryImageHash || props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary] } width={'100%'} height={'100%'} className="rounded-top-3 overflow-hidden position-absolute"/>
:
null
}
<div className="d-flex flex-column justify-content-center align-items-center position-absolute">
<ArchiveDrawerFillIcon className="w-100 h-100 mb-2"/>
<span>Archived</span>
</div>
</div>
}
</div>
</Link>
@@ -55,10 +70,10 @@ function MoreItemCards(props) {
:
<></>
}
</div>
</div>
);
}

View File

@@ -54,8 +54,8 @@ function LibraryItems(props) {
}else{
fetchData();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [config, props.LibraryId]);
@@ -110,14 +110,14 @@ function LibraryItems(props) {
</Button>
</div>
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" />
<FormControl type="text" placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="ms-md-3 my-3 w-sm-100 w-md-75" />
</div>
</div>
<div className="media-items-container">
{filteredData.sort((a, b) =>
{
@@ -147,8 +147,8 @@ function LibraryItems(props) {
}
return b.total_play_time-a.total_play_time;
}
}
).map((item) => (
<MoreItemCards data={item} base_url={config.hostUrl} key={item.Id+item.SeasonNumber+item.EpisodeNumber}/>

View File

@@ -1,10 +1,12 @@
import React, {useState} from "react";
/* eslint-disable react/prop-types */
import {useState} from "react";
import { Blurhash } from 'react-blurhash';
import { Link } from "react-router-dom";
import Card from 'react-bootstrap/Card';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Tooltip from "@mui/material/Tooltip";
import ArchiveDrawerFillIcon from 'remixicon-react/ArchiveDrawerFillIcon';
function ItemStatComponent(props) {
const [loaded, setLoaded] = useState(false);
@@ -40,15 +42,16 @@ function ItemStatComponent(props) {
<Col className="d-none d-lg-block stat-card-banner">
{props.icon ?
<div className="stat-card-icon">
{props.icon}
{props.icon}
</div>
:
<>
{props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && !loaded && (
{!props.data[0].archived && props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && !loaded && (
<div className="position-absolute w-100 h-100">
<Blurhash hash={props.data[0].PrimaryImageHash} height={'100%'} className="rounded-3 overflow-hidden"/>
</div>
)}
{!props.data[0].archived ?
<Card.Img
className="stat-card-image"
src={"proxy/Items/Images/Primary?id=" + props.data[0].Id + "&fillWidth=400&quality=90"}
@@ -56,6 +59,21 @@ function ItemStatComponent(props) {
onLoad={handleImageLoad}
onError={() => setLoaded(false)}
/>
:
<div>
{props.data && props.data[0] && props.data[0].PrimaryImageHash && props.data[0].PrimaryImageHash!=null && (
<Blurhash hash={props.data[0].PrimaryImageHash} height={'180px'} className="rounded-3 overflow-hidden position-absolute"/>
)}
<div className="d-flex flex-column justify-content-center align-items-center stat-card-image position-absolute">
<ArchiveDrawerFillIcon className="w-100 h-100"/>
<span>Archived</span>
</div>
</div>
}
</>
}
@@ -73,26 +91,26 @@ function ItemStatComponent(props) {
{props.data &&
props.data.map((item, index) => (
<div className="d-flex justify-content-between stat-items" key={item.Id || index}>
<div className="d-flex justify-content-between" key={item.Id || index}>
<Card.Text className="stat-item-index m-0">{index + 1}</Card.Text>
{item.UserId ?
{item.UserId ?
<Link to={`/users/${item.UserId}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
</Tooltip>
</Link>
:
!item.Client && !props.icon ?
(!item.Client && !props.icon) ?
<Link to={`/libraries/item/${item.Id}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
</Tooltip>
</Link>
:
!item.Client && props.icon ?
(!item.Client && props.icon) ?
<Link to={`/libraries/${item.Id}`} className="item-name">
<Tooltip title={item.Name} >
<Card.Text>{item.Name}</Card.Text>
@@ -104,11 +122,11 @@ function ItemStatComponent(props) {
</Tooltip>
}
</div>
<Card.Text className="stat-item-count">
{item.Plays || item.unique_viewers}
</Card.Text>
</div>
))}
</Card.Body>

View File

@@ -40,7 +40,14 @@
border-color: var(--secondary-background-color) !important;
}
.library-items > div>div> .form-control::placeholder
{
color: white !important;
}
.library-items > div> div>.form-control:focus
{
box-shadow: none !important;

View File

@@ -28,7 +28,7 @@ function Setup() {
setProcessing(true);
await axios
.get("/sync/beingSync", {
.get("/sync/beginSync", {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
@@ -58,7 +58,7 @@ function Setup() {
},
})
.catch((error) => {
});
let data=result.data;
@@ -143,10 +143,10 @@ function Setup() {
<Form onSubmit={handleFormSubmit} className="mt-5">
<Form.Group as={Row} className="inputbox" >
<Form.Control id="JF_HOST" name="JF_HOST" value={formValues.JF_HOST || ""} onChange={handleFormChange} placeholder=" "/>
<Form.Label column>
URL
</Form.Label>
@@ -161,7 +161,7 @@ function Setup() {
API Key
</Form.Label>
</InputGroup>
</Form.Group>