Merge branch 'CyferShepard:main' into main

This commit is contained in:
Félix MARQUET
2025-05-23 09:34:40 +02:00
committed by GitHub
22 changed files with 216 additions and 151 deletions

View File

@@ -104,8 +104,8 @@ class EmbyAPI {
//Functions
async getUsers() {
if (!this.configReady) {
async getUsers(refreshConfig = false) {
if (!this.configReady || refreshConfig) {
const success = await this.#fetchConfig();
if (!success) {
return [];
@@ -133,9 +133,9 @@ class EmbyAPI {
}
}
async getAdmins() {
async getAdmins(refreshConfig = false) {
try {
const users = await this.getUsers();
const users = await this.getUsers(refreshConfig);
return users?.filter((user) => user.Policy.IsAdministrator) || [];
} catch (error) {
this.#errorHandler(error);

View File

@@ -105,8 +105,8 @@ class JellyfinAPI {
//Functions
async getUsers() {
if (!this.configReady) {
async getUsers(refreshConfig = false) {
if (!this.configReady || refreshConfig) {
const success = await this.#fetchConfig();
if (!success) {
return [];
@@ -133,9 +133,9 @@ class JellyfinAPI {
}
}
async getAdmins() {
async getAdmins(refreshConfig = false) {
try {
const users = await this.getUsers();
const users = await this.getUsers(refreshConfig);
return users?.filter((user) => user.Policy.IsAdministrator) || [];
} catch (error) {
this.#errorHandler(error);

View File

@@ -45,7 +45,7 @@ class TaskManager {
if (code !== 0) {
console.error(`Worker ${task.name} stopped with exit code ${code}`);
}
if (onExit) {
if (code !== 0 && onExit) {
onExit();
}
delete this.tasks[task.name];

View File

@@ -50,7 +50,7 @@ const jf_library_items_mapping = (item) => ({
? item.ImageBlurHashes.Primary[item.ImageTags["Primary"]]
: null,
archived: false,
Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(filterInvalidGenres(item.Genres.map(titleCase))) : [],
Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(item.Genres.map(titleCase)) : [],
});
// Utility function to title-case a string
@@ -62,53 +62,6 @@ function titleCase(str) {
.join(" ");
}
function filterInvalidGenres(genres) {
const validGenres = [
"Action",
"Adventure",
"Animated",
"Biography",
"Comedy",
"Crime",
"Dance",
"Disaster",
"Documentary",
"Drama",
"Erotic",
"Family",
"Fantasy",
"Found Footage",
"Historical",
"Horror",
"Independent",
"Legal",
"Live Action",
"Martial Arts",
"Musical",
"Mystery",
"Noir",
"Performance",
"Political",
"Romance",
"Satire",
"Science Fiction",
"Short",
"Silent",
"Slasher",
"Sports",
"Spy",
"Superhero",
"Supernatural",
"Suspense",
"Teen",
"Thriller",
"War",
"Western",
];
return genres.filter((genre) => validGenres.map((g) => g.toLowerCase()).includes(genre.toLowerCase()));
}
module.exports = {
jf_library_items_columns,
jf_library_items_mapping,

View File

@@ -463,7 +463,24 @@ router.post("/setconfig", async (req, res) => {
settings.ServerID = systemInfo?.Id || null;
let query = 'UPDATE app_config SET settings=$1 where "ID"=1';
const query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}
}
const admins = await API.getAdmins(true);
const preferredAdmin = await new configClass().getPreferedAdmin();
if (admins && admins.length > 0 && preferredAdmin && !admins.map((item) => item.Id).includes(preferredAdmin)) {
const newAdmin = admins[0];
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: newAdmin.Id, username: newAdmin.Name };
const query = 'UPDATE app_config SET settings=$1 where "ID"=1';
await db.query(query, [settings]);
}

View File

@@ -20,16 +20,23 @@ router.post("/login", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password || password === CryptoJS.SHA3("").toString()) {
const query = "SELECT * FROM app_config";
const { rows: login } = await db.query(query);
if (
(!username || !password || password === CryptoJS.SHA3("").toString()) &&
login.length > 0 &&
login[0].REQUIRE_LOGIN == true
) {
res.sendStatus(401);
return;
}
const query = 'SELECT * FROM app_config WHERE ("APP_USER" = $1 AND "APP_PASSWORD" = $2) OR "REQUIRE_LOGIN" = false';
const values = [username, password];
const { rows: login } = await db.query(query, values);
const loginUser = login.filter(
(user) => (user.APP_USER === username && user.APP_PASSWORD === password) || user.REQUIRE_LOGIN == false
);
if (login.length > 0 || (username === JS_USER && password === CryptoJS.SHA3(JS_PASSWORD).toString())) {
if (loginUser.length > 0 || (username === JS_USER && password === CryptoJS.SHA3(JS_PASSWORD).toString())) {
const user = { id: 1, username: username };
jwt.sign({ user }, JWT_SECRET, (err, token) => {

View File

@@ -148,7 +148,7 @@ router.get("/getSessions", async (req, res) => {
router.get("/getAdminUsers", async (req, res) => {
try {
const adminUser = await API.getAdmins();
const adminUser = await API.getAdmins(true);
res.send(adminUser);
} catch (error) {
res.status(503);

View File

@@ -407,9 +407,9 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
}
});
router.post("/getViewsOverTime", async (req, res) => {
router.get("/getViewsOverTime", async (req, res) => {
try {
const { days } = req.body;
const { days } = req.query;
let _days = days;
if (days === undefined) {
_days = 30;
@@ -446,9 +446,9 @@ router.post("/getViewsOverTime", async (req, res) => {
}
});
router.post("/getViewsByDays", async (req, res) => {
router.get("/getViewsByDays", async (req, res) => {
try {
const { days } = req.body;
const { days } = req.query;
let _days = days;
if (days === undefined) {
_days = 30;
@@ -481,9 +481,9 @@ router.post("/getViewsByDays", async (req, res) => {
}
});
router.post("/getViewsByHour", async (req, res) => {
router.get("/getViewsByHour", async (req, res) => {
try {
const { days } = req.body;
const { days } = req.query;
let _days = days;
if (days === undefined) {
_days = 30;
@@ -516,6 +516,41 @@ router.post("/getViewsByHour", async (req, res) => {
}
});
router.get("/getViewsByLibraryType", async (req, res) => {
try {
const { days = 30 } = req.query;
const { rows } = await db.query(`
SELECT COALESCE(i."Type", 'Other') AS type, COUNT(a."NowPlayingItemId") AS count
FROM jf_playback_activity a LEFT JOIN jf_library_items i ON i."Id" = a."NowPlayingItemId"
WHERE a."ActivityDateInserted" BETWEEN NOW() - CAST($1 || ' days' as INTERVAL) AND NOW()
GROUP BY i."Type"
`, [days]);
const supportedTypes = new Set(["Audio", "Movie", "Series", "Other"]);
/** @type {Map<string, number>} */
const reorganizedData = new Map();
rows.forEach((item) => {
const { type, count } = item;
if (!supportedTypes.has(type)) return;
reorganizedData.set(type, count);
});
supportedTypes.forEach((type) => {
if (reorganizedData.has(type)) return;
reorganizedData.set(type, 0);
});
res.send(Object.fromEntries(reorganizedData));
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
router.get("/getGenreUserStats", async (req, res) => {
try {
const { size = 50, page = 1, userid } = req.query;

View File

@@ -395,12 +395,13 @@ async function removeOrphanedData() {
syncTask.loggedData.push({ color: "yellow", Message: "Removing Orphaned FileInfo/Episode/Season Records" });
await db.query("CALL jd_remove_orphaned_data()");
const archived_items = await db
.query(`select "Id" from jf_library_items where archived=true and "Type"='Series'`)
.then((res) => res.rows.map((row) => row.Id));
const archived_seasons = await db
.query(`select "Id" from jf_library_seasons where archived=true`)
.then((res) => res.rows.map((row) => row.Id));
const archived_items_query = `select i."Id" from jf_library_items i join jf_library_seasons s on s."SeriesId"=i."Id" and s.archived=false where i.archived=true and i."Type"='Series'
union
select i."Id" from jf_library_items i join jf_library_episodes e on e."SeriesId"=i."Id" and e.archived=false where i.archived=true and i."Type"='Series'
`;
const archived_items = await db.query(archived_items_query).then((res) => res.rows.map((row) => row.Id));
const archived_seasons_query = `select s."Id" from jf_library_seasons s join jf_library_episodes e on e."SeasonId"=s."Id" and e.archived=false where s.archived=true`;
const archived_seasons = await db.query(archived_seasons_query).then((res) => res.rows.map((row) => row.Id));
if (!(await _sync.updateSingleFieldOnDB("jf_library_seasons", archived_items, "archived", true, "SeriesId"))) {
syncTask.loggedData.push({ color: "red", Message: "Error archiving library seasons" });
await logging.updateLog(syncTask.uuid, syncTask.loggedData, taskstate.FAILED);

View File

@@ -3504,7 +3504,7 @@
}
},
"/stats/getViewsOverTime": {
"post": {
"get": {
"tags": [
"Stats"
],
@@ -3526,16 +3526,9 @@
"type": "string"
},
{
"name": "body",
"in": "body",
"schema": {
"type": "object",
"properties": {
"days": {
"example": "any"
}
}
}
"name": "days",
"in": "query",
"type": "string"
}
],
"responses": {
@@ -3558,7 +3551,7 @@
}
},
"/stats/getViewsByDays": {
"post": {
"get": {
"tags": [
"Stats"
],
@@ -3580,16 +3573,9 @@
"type": "string"
},
{
"name": "body",
"in": "body",
"schema": {
"type": "object",
"properties": {
"days": {
"example": "any"
}
}
}
"name": "days",
"in": "query",
"type": "string"
}
],
"responses": {
@@ -3612,7 +3598,7 @@
}
},
"/stats/getViewsByHour": {
"post": {
"get": {
"tags": [
"Stats"
],
@@ -3634,16 +3620,56 @@
"type": "string"
},
{
"name": "body",
"in": "body",
"schema": {
"type": "object",
"properties": {
"days": {
"example": "any"
}
}
}
"name": "days",
"in": "query",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/stats/getViewsByLibraryType": {
"get": {
"tags": [
"Stats"
],
"description": "",
"parameters": [
{
"name": "authorization",
"in": "header",
"type": "string"
},
{
"name": "x-api-token",
"in": "header",
"type": "string"
},
{
"name": "req",
"in": "query",
"type": "string"
},
{
"name": "days",
"in": "query",
"type": "string"
}
],
"responses": {

View File

@@ -42,8 +42,9 @@ async function runBackupTask(triggerType = triggertype.Automatic) {
}
}
parentPort.on("message", (message) => {
parentPort.on("message", async (message) => {
if (message.command === "start") {
runBackupTask(message.triggertype);
await runBackupTask(message.triggertype);
process.exit(0); // Exit the worker after the task is done
}
});

View File

@@ -28,8 +28,9 @@ async function runFullSyncTask(triggerType = triggertype.Automatic) {
}
}
parentPort.on("message", (message) => {
parentPort.on("message", async (message) => {
if (message.command === "start") {
runFullSyncTask(message.triggertype);
await runFullSyncTask(message.triggertype);
process.exit(0); // Exit the worker after the task is done
}
});

View File

@@ -27,8 +27,9 @@ async function runPlaybackReportingPluginSyncTask() {
}
}
parentPort.on("message", (message) => {
parentPort.on("message", async (message) => {
if (message.command === "start") {
runPlaybackReportingPluginSyncTask();
await runPlaybackReportingPluginSyncTask();
process.exit(0); // Exit the worker after the task is done
}
});

View File

@@ -28,8 +28,9 @@ async function runPartialSyncTask(triggerType = triggertype.Automatic) {
}
}
parentPort.on("message", (message) => {
parentPort.on("message", async (message) => {
if (message.command === "start") {
runPartialSyncTask(message.triggertype);
await runPartialSyncTask(message.triggertype);
process.exit(0); // Exit the worker after the task is done
}
});

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.5",
"version": "1.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "jfstat",
"version": "1.1.5",
"version": "1.1.6",
"private": true,
"main": "src/index.jsx",
"scripts": {

View File

@@ -23,8 +23,6 @@ function LibraryItems(props) {
localStorage.getItem("PREF_sortAsc") != undefined ? localStorage.getItem("PREF_sortAsc") == "true" : true
);
console.log(sortOrder);
const archive = {
all: "all",
archived: "true",
@@ -212,7 +210,11 @@ function LibraryItems(props) {
}
})
.map((item) => (
<MoreItemCards data={item} base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl} key={item.Id + item.SeasonNumber + item.EpisodeNumber} />
<MoreItemCards
data={item}
base_url={config.settings?.EXTERNAL_URL ?? config.hostUrl}
key={item.Id + item.SeasonNumber + item.EpisodeNumber}
/>
))}
</div>
</div>

View File

@@ -44,6 +44,20 @@ export default function SettingsConfig() {
set12hr(Boolean(storage_12hr));
}
const fetchAdmins = async () => {
try {
const adminData = await axios.get(`/proxy/getAdminUsers`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAdmins(adminData.data);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
Config.getConfig()
.then((config) => {
@@ -59,20 +73,6 @@ export default function SettingsConfig() {
setsubmissionMessage("Error Retrieving Configuration. Unable to contact Backend Server");
});
const fetchAdmins = async () => {
try {
const adminData = await axios.get(`/proxy/getAdminUsers`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
setAdmins(adminData.data);
} catch (error) {
console.log(error);
}
};
fetchAdmins();
}, [token]);
@@ -91,6 +91,8 @@ export default function SettingsConfig() {
console.log("Config updated successfully:", response.data);
setisSubmitted("Success");
setsubmissionMessage("Successfully updated configuration");
Config.setConfig();
fetchAdmins();
})
.catch((error) => {
let errorMessage = error.response.data.errorMessage;
@@ -98,7 +100,6 @@ export default function SettingsConfig() {
setisSubmitted("Failed");
setsubmissionMessage(`Error Updating Configuration: ${errorMessage}`);
});
Config.setConfig();
}
async function handleFormSubmitExternal(event) {
@@ -233,9 +234,13 @@ export default function SettingsConfig() {
</Form.Group>
{isSubmitted !== "" ? (
isSubmitted === "Failed" ? (
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">
{submissionMessage}
</Alert>
) : (
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessage}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">
{submissionMessage}
</Alert>
)
) : (
<></>
@@ -265,9 +270,13 @@ export default function SettingsConfig() {
{isSubmittedExternal !== "" ? (
isSubmittedExternal === "Failed" ? (
<Alert bg="dark" data-bs-theme="dark" variant="danger">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="danger">
{submissionMessageExternal}
</Alert>
) : (
<Alert bg="dark" data-bs-theme="dark" variant="success">{submissionMessageExternal}</Alert>
<Alert bg="dark" data-bs-theme="dark" variant="success">
{submissionMessageExternal}
</Alert>
)
) : (
<></>

View File

@@ -10,12 +10,26 @@ import i18next from "i18next";
function GenreStatCard(props) {
const [maxRange, setMaxRange] = useState(100);
const [data, setData] = useState(props.data);
useEffect(() => {
const maxDuration = props.data.reduce((max, item) => {
return Math.max(max, parseFloat((props.dataKey == "duration" ? item.duration : item.plays) || 0));
}, 0);
setMaxRange(maxDuration);
let sorted = [...props.data]
.sort((a, b) => {
const valueA = parseFloat(props.dataKey === "duration" ? a.duration : a.plays) || 0;
const valueB = parseFloat(props.dataKey === "duration" ? b.duration : b.plays) || 0;
return valueB - valueA; // Descending order
})
.slice(0, 15); // Take only the top 10
// Sort top 10 genres alphabetically
sorted = sorted.sort((a, b) => a.genre.localeCompare(b.genre));
setData(sorted);
}, [props.data, props.dataKey]);
const CustomTooltip = ({ active, payload }) => {
@@ -67,7 +81,7 @@ function GenreStatCard(props) {
</h1>
<ErrorBoundary>
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={props.data}>
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
<PolarGrid gridType="circle" />
<PolarAngleAxis dataKey="genre" />
<PolarRadiusAxis domain={[0, maxRange]} tick={false} axisLine={false} />

View File

@@ -17,12 +17,11 @@ function DailyPlayStats(props) {
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsOverTime`;
const url = `/stats/getViewsOverTime?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,

View File

@@ -13,12 +13,11 @@ function PlayStatsByDay(props) {
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsByDays`;
const url = `/stats/getViewsByDays?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,

View File

@@ -12,12 +12,11 @@ function PlayStatsByHour(props) {
useEffect(() => {
const fetchLibraries = () => {
const url = `/stats/getViewsByHour`;
const url = `/stats/getViewsByHour?days=${props.days}`;
axios
.post(
.get(
url,
{ days: props.days },
{
headers: {
Authorization: `Bearer ${token}`,