Added Genre stats to users page #292

Added Genres to items table
enhanced db-helper to add group by
fixed bug in fb-helper where null rows triggered a not count prop error
fixed issue where no plays on user screen attempted to open the item page
updated translations for genres
This commit is contained in:
CyferShepard
2025-03-30 17:33:58 +02:00
parent 17fb7d6813
commit 607b21c542
18 changed files with 476 additions and 132 deletions

View File

@@ -104,6 +104,7 @@ async function query({
joins = [],
where = [],
values = [],
group_by = [],
order_by = "Id",
sort_order = "desc",
pageNumber = 1,
@@ -120,9 +121,9 @@ async function query({
const joinConditions = join.conditions
.map((condition, index) => {
const conjunction = index === 0 ? "" : condition.type ? condition.type.toUpperCase() : "AND";
return `${conjunction} ${wrapField(condition.first)} ${condition.operator} ${
condition.second ? wrapField(condition.second) : `${condition.value}`
}`;
return `${conjunction} ${condition.wrap == false ? condition.first : wrapField(condition.first)} ${
condition.operator
} ${condition.second ? wrapField(condition.second) : `${condition.value}`}`;
})
.join(" ");
const joinQuery = ` ${join.type.toUpperCase()} JOIN ${join.table} AS ${join.alias} ON ${joinConditions}`;
@@ -137,6 +138,12 @@ async function query({
countQuery += ` WHERE ${whereClause}`;
}
// Add group by
if (group_by.length > 0) {
query += ` GROUP BY ${group_by.map(wrapField).join(", ")}`;
countQuery += ` GROUP BY ${group_by.map(wrapField).join(", ")}`;
}
// Add order by and pagination
query += ` ORDER BY ${wrapField(order_by)} ${sort_order}`;
query += ` LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}`;
@@ -146,7 +153,7 @@ async function query({
// Count total rows
const countResult = await client.query(countQuery, values);
const totalRows = parseInt(countResult.rows[0].count, 10);
const totalRows = parseInt(countResult.rows.length > 0 ? countResult.rows[0].count : 0, 10);
// Return the structured response
return {

View File

@@ -162,7 +162,7 @@ class EmbyAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
startIndex: startIndex,
recursive: recursive,
limit: limit,
@@ -230,7 +230,7 @@ class EmbyAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
startIndex: startIndex,
recursive: recursive,
limit: limit,
@@ -413,7 +413,7 @@ class EmbyAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
},
});

View File

@@ -162,7 +162,7 @@ class JellyfinAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
startIndex: startIndex,
recursive: recursive,
limit: limit,
@@ -228,7 +228,7 @@ class JellyfinAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
startIndex: startIndex,
recursive: recursive,
limit: limit,
@@ -411,7 +411,7 @@ class JellyfinAPI {
"X-MediaBrowser-Token": this.config.JF_API_KEY,
},
params: {
fields: "MediaSources,DateCreated",
fields: "MediaSources,DateCreated,Genres",
},
});

View File

@@ -0,0 +1,25 @@
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.jsonb("Genres").defaultTo(JSON.stringify([]));
});
}
} catch (error) {
console.error(error);
}
};
exports.down = 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.dropColumn("Genres"); // Drop the column during rollback
});
}
} catch (error) {
console.error(error);
}
};

View File

@@ -1,17 +1,50 @@
const update_query = [
{table:'jf_activity_watchdog',query:' ON CONFLICT ("Id") DO UPDATE SET "TranscodingInfo" = EXCLUDED."TranscodingInfo", "MediaStreams" = EXCLUDED."MediaStreams", "PlayMethod" = EXCLUDED."PlayMethod","ActivityDateInserted" = EXCLUDED."ActivityDateInserted","PlaybackDuration" = EXCLUDED."PlaybackDuration","IsPaused"= EXCLUDED."IsPaused"'},
{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", archived=false'},
{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", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash", "DateCreated" = EXCLUDED."DateCreated" , archived=false'},
{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", "DateCreated" = EXCLUDED."DateCreated", 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" , archived=false'},
{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 ("Id") DO UPDATE SET "PlaybackDuration" = EXCLUDED."PlaybackDuration", "ActivityDateInserted" = EXCLUDED."ActivityDateInserted"'},
{table:'jf_playback_reporting_plugin_data',query:' ON CONFLICT DO NOTHING'},
{table:'jf_users',query:' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PrimaryImageTag" = EXCLUDED."PrimaryImageTag", "LastLoginDate" = EXCLUDED."LastLoginDate", "LastActivityDate" = EXCLUDED."LastActivityDate"'}
];
module.exports = {
update_query
};
const update_query = [
{
table: "jf_activity_watchdog",
query:
' ON CONFLICT ("Id") DO UPDATE SET "TranscodingInfo" = EXCLUDED."TranscodingInfo", "MediaStreams" = EXCLUDED."MediaStreams", "PlayMethod" = EXCLUDED."PlayMethod","ActivityDateInserted" = EXCLUDED."ActivityDateInserted","PlaybackDuration" = EXCLUDED."PlaybackDuration","IsPaused"= EXCLUDED."IsPaused"',
},
{
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", archived=false',
},
{
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", "PrimaryImageHash" = EXCLUDED."PrimaryImageHash", "DateCreated" = EXCLUDED."DateCreated" , archived=false',
},
{
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", "DateCreated" = EXCLUDED."DateCreated", archived=false, "Genres" = EXCLUDED."Genres"',
},
{
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" , archived=false',
},
{
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 ("Id") DO UPDATE SET "PlaybackDuration" = EXCLUDED."PlaybackDuration", "ActivityDateInserted" = EXCLUDED."ActivityDateInserted"',
},
{ table: "jf_playback_reporting_plugin_data", query: " ON CONFLICT DO NOTHING" },
{
table: "jf_users",
query:
' ON CONFLICT ("Id") DO UPDATE SET "Name" = EXCLUDED."Name", "PrimaryImageTag" = EXCLUDED."PrimaryImageTag", "LastLoginDate" = EXCLUDED."LastLoginDate", "LastActivityDate" = EXCLUDED."LastActivityDate"',
},
];
module.exports = {
update_query,
};

View File

@@ -1,55 +1,59 @@
const jf_library_items_columns = [
"Id",
"Name",
"ServerId",
"PremiereDate",
"DateCreated",
"EndDate",
"CommunityRating",
"RunTimeTicks",
"ProductionYear",
"IsFolder",
"Type",
"Status",
"ImageTagsPrimary",
"ImageTagsBanner",
"ImageTagsLogo",
"ImageTagsThumb",
"BackdropImageTags",
"ParentId",
"PrimaryImageHash",
"archived",
"Genres",
];
const jf_library_items_columns = [
"Id",
"Name",
"ServerId",
"PremiereDate",
"DateCreated",
"EndDate",
"CommunityRating",
"RunTimeTicks",
"ProductionYear",
"IsFolder",
"Type",
"Status",
"ImageTagsPrimary",
"ImageTagsBanner",
"ImageTagsLogo",
"ImageTagsThumb",
"BackdropImageTags",
"ParentId",
"PrimaryImageHash",
"archived",
];
const jf_library_items_mapping = (item) => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
DateCreated: item.DateCreated,
EndDate: item.EndDate,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IsFolder: item.IsFolder,
Type: item.Type,
Status: item.Status,
ImageTagsPrimary: item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
ImageTagsBanner: item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null,
ImageTagsLogo: item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null,
ImageTagsThumb: item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
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,
Genres: item.Genres && Array.isArray(item.Genres) ? JSON.stringify(item.Genres) : [],
});
const jf_library_items_mapping = (item) => ({
Id: item.Id,
Name: item.Name,
ServerId: item.ServerId,
PremiereDate: item.PremiereDate,
DateCreated: item.DateCreated,
EndDate: item.EndDate,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
IsFolder: item.IsFolder,
Type: item.Type,
Status: item.Status,
ImageTagsPrimary:
item.ImageTags && item.ImageTags.Primary ? item.ImageTags.Primary : null,
ImageTagsBanner:
item.ImageTags && item.ImageTags.Banner ? item.ImageTags.Banner : null,
ImageTagsLogo:
item.ImageTags && item.ImageTags.Logo ? item.ImageTags.Logo : null,
ImageTagsThumb:
item.ImageTags && item.ImageTags.Thumb ? item.ImageTags.Thumb : null,
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 = {
jf_library_items_columns,
jf_library_items_mapping,
};
module.exports = {
jf_library_items_columns,
jf_library_items_mapping,
};

View File

@@ -1,6 +1,7 @@
// api.js
const express = require("express");
const db = require("../db");
const dbHelper = require("../classes/db-helper");
const moment = require("moment");
const router = express.Router();
@@ -515,6 +516,69 @@ router.post("/getViewsByHour", async (req, res) => {
}
});
router.get("/getGenreStats", async (req, res) => {
try {
const { size = 50, page = 1, userid } = req.query;
if (userid === undefined) {
res.status(400);
res.send("No User ID provided");
return;
}
const values = [];
const query = {
select: ["COALESCE(g.genre, 'No Genre') AS genre", `SUM(a."PlaybackDuration") AS duration`, "COUNT(*) AS plays"],
table: "jf_playback_activity_with_metadata",
alias: "a",
joins: [
{
type: "inner",
table: "jf_library_items",
alias: "i",
conditions: [{ first: "a.NowPlayingItemId", operator: "=", second: "i.Id" }],
},
{
type: "left",
table: `
LATERAL (
SELECT
jsonb_array_elements_text(
CASE
WHEN jsonb_array_length(COALESCE(i."Genres", '[]'::jsonb)) = 0 THEN '["No Genre"]'::jsonb
ELSE i."Genres"
END
) AS genre
)
`,
alias: "g",
conditions: [{ first: 1, operator: "=", value: 1, wrap: false }],
},
],
where: [[{ column: "a.UserId", operator: "=", value: `$${values.length + 1}` }]],
group_by: [`COALESCE(g.genre, 'No Genre')`],
order_by: "genre",
pageNumber: page,
pageSize: size,
};
values.push(userid);
query.values = values;
const result = await dbHelper.query(query);
const response = { current_page: page, pages: result.pages, size: size, results: result.results };
res.send(response);
} catch (error) {
console.log(error);
res.status(503);
res.send(error);
}
});
// Handle other routes
router.use((req, res) => {
res.status(404).send({ error: "Not Found" });

View File

@@ -313,5 +313,6 @@
"POSTCODE": "Codi postal",
"X_ROWS_SELECTED": "{ROWS} files seleccionades",
"TRANSCODE_REASONS": "",
"SUBTITLES": "Subtítols"
"SUBTITLES": "Subtítols",
"GENRES": "Gèneres"
}

View File

@@ -314,5 +314,6 @@
"POSTCODE": "Postcode",
"X_ROWS_SELECTED": "{ROWS} Rows Selected",
"TRANSCODE_REASONS": "Transcode Reasons",
"SUBTITLES": "Subtitles"
"SUBTITLES": "Subtitles",
"GENRES": "Genres"
}

View File

@@ -313,6 +313,7 @@
"TIMEZONE": "Fuseau horaire",
"POSTCODE": "Code postal",
"X_ROWS_SELECTED": "{ROWS} Ligne(s) sélectionnée(s)",
"TRANSCODE_REASONS": "",
"SUBTITLES": ""
"TRANSCODE_REASONS": "Raisons du transcodage",
"SUBTITLES": "Sous-titres",
"GENRES": "Genres"
}

View File

@@ -309,6 +309,7 @@
"TIMEZONE": "Fuso orario",
"POSTCODE": "CAP",
"X_ROWS_SELECTED": "{ROWS} righe selezionate",
"TRANSCODE_REASONS": "",
"SUBTITLES": ""
"TRANSCODE_REASONS": "Motivo della transcodifica",
"SUBTITLES": "Sottotitoli",
"GENRES": "Generi"
}

View File

@@ -313,5 +313,6 @@
"POSTCODE": "邮编",
"X_ROWS_SELECTED": "已选中 {ROWS} 行",
"TRANSCODE_REASONS": "转码原因",
"SUBTITLES": "字幕"
"SUBTITLES": "字幕",
"GENRES": "类型"
}

View File

@@ -12,6 +12,7 @@ import { Trans } from "react-i18next";
import baseUrl from "../../lib/baseurl";
import GlobalStats from "./general/globalStats";
import ActivityTimeline from "../activity_time_line";
import GenreStats from "./user-info/genre-stats.jsx";
function UserInfo() {
const { UserId } = useParams();
@@ -78,12 +79,7 @@ function UserInfo() {
) : (
<img
className="user-image"
src={
baseUrl +
"/proxy/Users/Images/Primary?id=" +
UserId +
"&quality=100"
}
src={baseUrl + "/proxy/Users/Images/Primary?id=" + UserId + "&quality=100"}
onError={handleImageError}
alt=""
></img>
@@ -121,11 +117,7 @@ function UserInfo() {
</div>
</div>
<Tabs
defaultActiveKey="tabOverview"
activeKey={activeTab}
variant="pills"
>
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant="pills">
<Tab eventKey="tabOverview" className="bg-transparent">
<GlobalStats
id={UserId}
@@ -133,6 +125,7 @@ function UserInfo() {
endpoint={"getGlobalUserStats"}
title={<Trans i18nKey="USERS_PAGE.USER_STATS" />}
/>
<GenreStats UserId={UserId} />
<LastPlayed UserId={UserId} />
</Tab>
<Tab eventKey="tabActivity" className="bg-transparent">

View File

@@ -0,0 +1,87 @@
import { useState, useEffect } from "react";
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, Tooltip } from "recharts";
import ErrorBoundary from "../general/ErrorBoundary.jsx";
import "../../css/genres.css";
import { Trans } from "react-i18next";
import i18next from "i18next";
function GenreStatCard(props) {
const [maxRange, setMaxRange] = useState(100);
useEffect(() => {
const maxDuration = props.data.reduce((max, item) => {
return Math.max(max, parseFloat((props.dataKey == "duration" ? item.duration : item.plays) || 0));
}, 0);
setMaxRange(maxDuration);
}, [props.data, props.dataKey]);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="radial-tooltip">
<p className="tooltip-header">{payload[0].payload.genre}</p>
<p>
{props.dataKey == "duration" ? formatTotalWatchTime(payload[0].value) : payload[0].value} {i18next.t("UNITS.PLAYS")}
</p>
</div>
);
}
return null;
};
function formatTotalWatchTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
let timeString = "";
if (hours > 0) {
timeString += `${hours} ${hours === 1 ? i18next.t("UNITS.HOUR").toLowerCase() : i18next.t("UNITS.HOURS").toLowerCase()} `;
}
if (minutes > 0) {
timeString += `${minutes} ${
minutes === 1 ? i18next.t("UNITS.MINUTE").toLowerCase() : i18next.t("UNITS.MINUTES").toLowerCase()
} `;
}
if (remainingSeconds > 0) {
timeString += `${remainingSeconds} ${
remainingSeconds === 1 ? i18next.t("UNITS.SECOND").toLowerCase() : i18next.t("UNITS.SECONDS").toLowerCase()
}`;
}
return timeString.trim();
}
return (
<div className="genre-stats">
<h1 className="my-3 text-center">
<Trans i18nKey={props.dataKey == "duration" ? "SETTINGS_PAGE.DURATION" : "TOTAL_PLAYS"} />
</h1>
<ErrorBoundary>
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={props.data}>
<PolarGrid gridType="circle" />
<PolarAngleAxis dataKey="genre" />
<PolarRadiusAxis domain={[0, maxRange]} tick={false} />
<Radar
name="Duration"
dataKey={props.dataKey}
stroke={`var(--tertiary-background-color)`}
fill={`var(--secondary-color)`}
fillOpacity={0.6}
/>
<Tooltip content={<CustomTooltip />} />
</RadarChart>
</ResponsiveContainer>
</ErrorBoundary>
</div>
);
}
export default GenreStatCard;

View File

@@ -0,0 +1,77 @@
import { useState, useEffect } from "react";
import { Row, Col } from "react-bootstrap";
import GenreStatCard from "./genre-stat-card.jsx";
import { Trans } from "react-i18next";
import "../../css/genres.css";
import Config from "../../../lib/config";
import axios from "../../../lib/axios_instance";
function GenreStats(props) {
const [data, setData] = useState();
const [config, setConfig] = useState();
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
console.log(error);
}
};
const fetchData = async () => {
if (config) {
try {
const itemData = await axios.get(`/stats/getGenreStats`, {
params: {
userid: props.UserId,
},
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
const results = itemData.data.results || [];
setData(results);
} catch (error) {
console.log(error);
}
}
};
if (!data) {
fetchData();
}
if (!config) {
fetchConfig();
}
const intervalId = setInterval(fetchData, 60000 * 5);
return () => clearInterval(intervalId);
}, [data, config, props.UserId]);
if (!data || data.length == 0 || !config) {
return <></>;
}
return (
<div className="genre">
<h1 className="my-3">
<Trans i18nKey="GENRES" />
</h1>
<div className="genre-container d-flex flex-row justify-content-between">
<Row className="w-100 pb-5">
<Col className="p-0 auto">
<GenreStatCard data={data} dataKey="duration" />
</Col>
<Col className="p-0 auto">
<GenreStatCard data={data} dataKey="plays" />
</Col>
</Row>
</div>
</div>
);
}
export default GenreStats;

50
src/pages/css/genres.css Normal file
View File

@@ -0,0 +1,50 @@
@import "./variables.module.css";
.genre-container {
display: flex;
overflow-x: none;
background-color: var(--secondary-background-color);
padding: 20px;
border-radius: 8px;
color: white;
margin-bottom: 20px;
width: 100%;
height: 100%;
}
.radial-tooltip {
background-color: black;
border-radius: 8px;
padding: 10px;
}
.radial-tooltip > p {
padding: 0px !important;
margin: 0px !important;
}
.radial-tooltip > .tooltip-header {
color: var(--secondary-color);
}
.genre-stats {
width: 90%;
height: 400px;
}
.genre-container::-webkit-scrollbar {
width: 5px; /* set scrollbar width */
}
.genre-container::-webkit-scrollbar-track {
background-color: transparent; /* set track color */
}
.genre-container::-webkit-scrollbar-thumb {
background-color: #8888884d; /* set thumb color */
border-radius: 5px; /* round corners */
width: 5px;
}
.genre-container::-webkit-scrollbar-thumb:hover {
background-color: #88888883; /* set thumb color */
}

View File

@@ -1,38 +1,31 @@
@import '../variables.module.css';
@import "../variables.module.css";
.user-detail-container
{
color:white;
background-color: var(--secondary-background-color);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
.user-detail-container {
color: white;
background-color: var(--secondary-background-color);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.user-name
{
font-size: 2.5em;
font-weight: 500;
margin: 0;
.user-name {
font-size: 2.5em;
font-weight: 500;
margin: 0;
}
.user-image
{
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 10px 5px var(--secondary-background-color);
.user-image {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 10px 5px var(--secondary-background-color);
}
.user-image-container
{
width: 100px;
height: 100px;
margin-right: 20px;
}
.user-image-container {
width: 100px;
height: 100px;
margin-right: 20px;
}

View File

@@ -196,9 +196,15 @@ function Row(row) {
)}
</TableCell>
<TableCell style={{ textTransform: data.LastWatched ? "none" : "lowercase" }}>
<Link to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">
{data.LastWatched || i18next.t("ERROR_MESSAGES.NEVER")}
</Link>
{data.NowPlayingItemId ? (
<Link to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">
{data.LastWatched || i18next.t("ERROR_MESSAGES.NEVER")}
</Link>
) : (
<span to={`/libraries/item/${data.NowPlayingItemId}`} className="text-decoration-none">
{data.LastWatched || i18next.t("ERROR_MESSAGES.NEVER")}
</span>
)}
</TableCell>
<TableCell style={{ textTransform: data.LastClient ? "none" : "lowercase" }}>
{data.LastClient || i18next.t("ERROR_MESSAGES.N/A")}