mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
Merge pull request #304 from GrimJu/activity-timeline-episode-number-fix
Activity timeline fixes and improvements
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.raw(`
|
||||
CREATE OR REPLACE FUNCTION public.fs_get_user_activity(user_id text, library_ids text[])
|
||||
RETURNS TABLE(
|
||||
"UserName" text,
|
||||
"Title" text,
|
||||
"EpisodeCount" bigint,
|
||||
"FirstActivityDate" timestamp with time zone,
|
||||
"LastActivityDate" timestamp with time zone,
|
||||
"TotalPlaybackDuration" bigint,
|
||||
"SeasonName" text,
|
||||
"MediaType" text,
|
||||
"NowPlayingItemId" text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH DateDifferences AS (
|
||||
SELECT
|
||||
jp."UserName" AS "UserNameCol",
|
||||
COALESCE(jp."SeriesName", jp."NowPlayingItemName") AS "TitleCol",
|
||||
jp."EpisodeId" AS "EpisodeIdCol",
|
||||
jp."ActivityDateInserted" AS "ActivityDateInsertedCol",
|
||||
jp."PlaybackDuration" AS "PlaybackDurationCol",
|
||||
ls."Name" AS "SeasonNameCol",
|
||||
jl."CollectionType" AS "MediaTypeCol",
|
||||
jp."NowPlayingItemId" AS "NowPlayingItemIdCol",
|
||||
LAG(jp."ActivityDateInserted") OVER (PARTITION BY jp."UserName" ORDER BY jp."ActivityDateInserted") AS prev_date,
|
||||
LAG(COALESCE(jp."SeriesName", jp."NowPlayingItemName")) OVER (PARTITION BY jp."UserName" ORDER BY jp."ActivityDateInserted") AS prev_title
|
||||
FROM
|
||||
public.jf_playback_activity AS jp
|
||||
JOIN
|
||||
public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id"
|
||||
JOIN
|
||||
public.jf_libraries AS jl ON jli."ParentId" = jl."Id"
|
||||
LEFT JOIN
|
||||
public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id"
|
||||
WHERE
|
||||
jp."UserId" = user_id
|
||||
AND jl."Id" = ANY(library_ids)
|
||||
),
|
||||
GroupedEntries AS (
|
||||
SELECT
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"EpisodeIdCol",
|
||||
"ActivityDateInsertedCol",
|
||||
"PlaybackDurationCol",
|
||||
"SeasonNameCol",
|
||||
"MediaTypeCol",
|
||||
"NowPlayingItemIdCol",
|
||||
prev_date,
|
||||
prev_title,
|
||||
CASE
|
||||
WHEN prev_title IS DISTINCT FROM "TitleCol" THEN 1 -- Start a new group when Title changes
|
||||
WHEN prev_date IS NULL OR "ActivityDateInsertedCol" > prev_date + INTERVAL '1 month' THEN 1
|
||||
ELSE 0
|
||||
END AS new_group
|
||||
FROM
|
||||
DateDifferences
|
||||
),
|
||||
FinalGroups AS (
|
||||
SELECT
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"EpisodeIdCol",
|
||||
"ActivityDateInsertedCol",
|
||||
"PlaybackDurationCol",
|
||||
"SeasonNameCol",
|
||||
"MediaTypeCol",
|
||||
"NowPlayingItemIdCol",
|
||||
SUM(new_group) OVER (PARTITION BY "UserNameCol" ORDER BY "ActivityDateInsertedCol") AS grp
|
||||
FROM
|
||||
GroupedEntries
|
||||
)
|
||||
SELECT
|
||||
"UserNameCol" AS "UserName",
|
||||
"TitleCol" AS "Title",
|
||||
COUNT(DISTINCT "EpisodeIdCol") AS "EpisodeCount",
|
||||
MIN("ActivityDateInsertedCol") AS "FirstActivityDate",
|
||||
MAX("ActivityDateInsertedCol") AS "LastActivityDate",
|
||||
SUM("PlaybackDurationCol")::bigint AS "TotalPlaybackDuration",
|
||||
"SeasonNameCol" AS "SeasonName",
|
||||
MAX("MediaTypeCol") AS "MediaType",
|
||||
"NowPlayingItemIdCol" AS "NowPlayingItemId"
|
||||
FROM
|
||||
FinalGroups
|
||||
GROUP BY
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"SeasonNameCol",
|
||||
"NowPlayingItemIdCol",
|
||||
grp
|
||||
HAVING
|
||||
NOT (MAX("MediaTypeCol") = 'Shows' AND "SeasonNameCol" IS NULL)
|
||||
AND SUM("PlaybackDurationCol") >= 20
|
||||
ORDER BY
|
||||
MAX("ActivityDateInsertedCol") DESC;
|
||||
END;
|
||||
$function$;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.raw(`
|
||||
DROP FUNCTION IF EXISTS fs_get_user_activity;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fs_get_user_activity(
|
||||
user_id TEXT,
|
||||
library_ids TEXT[]
|
||||
)
|
||||
RETURNS TABLE (
|
||||
"UserName" TEXT,
|
||||
"Title" TEXT,
|
||||
"EpisodeCount" BIGINT,
|
||||
"FirstActivityDate" TIMESTAMPTZ,
|
||||
"LastActivityDate" TIMESTAMPTZ,
|
||||
"TotalPlaybackDuration" BIGINT,
|
||||
"SeasonName" TEXT,
|
||||
"MediaType" TEXT,
|
||||
"NowPlayingItemId" TEXT
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH DateDifferences AS (
|
||||
SELECT
|
||||
jp."UserName" AS "UserNameCol",
|
||||
CASE
|
||||
WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName"
|
||||
ELSE jp."NowPlayingItemName"
|
||||
END AS "TitleCol",
|
||||
jp."EpisodeId" AS "EpisodeIdCol",
|
||||
jp."ActivityDateInserted" AS "ActivityDateInsertedCol",
|
||||
jp."PlaybackDuration" AS "PlaybackDurationCol",
|
||||
ls."Name" AS "SeasonNameCol",
|
||||
jl."CollectionType" AS "MediaTypeCol",
|
||||
jp."NowPlayingItemId" AS "NowPlayingItemIdCol",
|
||||
LAG(jp."ActivityDateInserted") OVER (PARTITION BY jp."UserName", CASE WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" ELSE jp."NowPlayingItemName" END ORDER BY jp."ActivityDateInserted") AS prev_date
|
||||
FROM
|
||||
public.jf_playback_activity AS jp
|
||||
JOIN
|
||||
public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id"
|
||||
JOIN
|
||||
public.jf_libraries AS jl ON jli."ParentId" = jl."Id"
|
||||
LEFT JOIN
|
||||
public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id"
|
||||
WHERE
|
||||
jp."UserId" = user_id
|
||||
AND jl."Id" = ANY(library_ids)
|
||||
),
|
||||
GroupedEntries AS (
|
||||
SELECT
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"EpisodeIdCol",
|
||||
"ActivityDateInsertedCol",
|
||||
"PlaybackDurationCol",
|
||||
"SeasonNameCol",
|
||||
"MediaTypeCol",
|
||||
"NowPlayingItemIdCol",
|
||||
prev_date,
|
||||
CASE
|
||||
WHEN prev_date IS NULL OR "ActivityDateInsertedCol" > prev_date + INTERVAL '1 month' THEN 1 -- Pick whatever interval you want here, I'm biased as I don't monitor music / never intended this feature to be used for music
|
||||
ELSE 0
|
||||
END AS new_group
|
||||
FROM
|
||||
DateDifferences
|
||||
),
|
||||
FinalGroups AS (
|
||||
SELECT
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"EpisodeIdCol",
|
||||
"ActivityDateInsertedCol",
|
||||
"PlaybackDurationCol",
|
||||
"SeasonNameCol",
|
||||
"MediaTypeCol",
|
||||
"NowPlayingItemIdCol",
|
||||
SUM(new_group) OVER (PARTITION BY "UserNameCol", "TitleCol" ORDER BY "ActivityDateInsertedCol") AS grp
|
||||
FROM
|
||||
GroupedEntries
|
||||
)
|
||||
SELECT
|
||||
"UserNameCol" AS "UserName",
|
||||
"TitleCol" AS "Title",
|
||||
COUNT(DISTINCT "EpisodeIdCol") AS "EpisodeCount",
|
||||
MIN("ActivityDateInsertedCol") AS "FirstActivityDate",
|
||||
MAX("ActivityDateInsertedCol") AS "LastActivityDate",
|
||||
SUM("PlaybackDurationCol")::bigint AS "TotalPlaybackDuration",
|
||||
"SeasonNameCol" AS "SeasonName",
|
||||
MAX("MediaTypeCol") AS "MediaType",
|
||||
"NowPlayingItemIdCol" AS "NowPlayingItemId"
|
||||
FROM
|
||||
FinalGroups
|
||||
GROUP BY
|
||||
"UserNameCol",
|
||||
"TitleCol",
|
||||
"SeasonNameCol",
|
||||
"NowPlayingItemIdCol",
|
||||
grp
|
||||
HAVING
|
||||
NOT (MAX("MediaTypeCol") = 'Shows' AND "SeasonNameCol" IS NULL)
|
||||
AND SUM("PlaybackDurationCol") >= 20
|
||||
ORDER BY
|
||||
MAX("ActivityDateInsertedCol") DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
`);
|
||||
};
|
||||
@@ -1,15 +1,13 @@
|
||||
import "./css/stats.css";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import ActivityTimelineComponent from "./components/activity-timeline/activity-timeline";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, FormSelect, Modal } from "react-bootstrap";
|
||||
import axios from "../lib/axios_instance.jsx";
|
||||
import Config from "../lib/config.jsx";
|
||||
import "./css/timeline/activity-timeline.css";
|
||||
import Loading from "./components/general/loading";
|
||||
import { Button, FormSelect, Modal } from "react-bootstrap";
|
||||
import LibraryFilterModal from "./components/library/library-filter-modal";
|
||||
import "./css/timeline/activity-timeline.css";
|
||||
|
||||
function ActivityTimeline(props) {
|
||||
const { preselectedUser } = props;
|
||||
@@ -42,8 +40,6 @@ function ActivityTimeline(props) {
|
||||
);
|
||||
};
|
||||
const handleUserSelection = (selectedUser) => {
|
||||
console.log(selectedUser);
|
||||
|
||||
setSelectedUser(selectedUser);
|
||||
localStorage.setItem("PREF_ACTIVITY_TIMELINE_selectedUser", selectedUser);
|
||||
};
|
||||
@@ -93,6 +89,9 @@ function ActivityTimeline(props) {
|
||||
})
|
||||
.then((users) => {
|
||||
setUsers(users.data);
|
||||
if (!selectedUser && users.data[0]) {
|
||||
setSelectedUser(users.data[0].UserId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -112,6 +111,13 @@ function ActivityTimeline(props) {
|
||||
})
|
||||
.then((libraries) => {
|
||||
setLibraries(libraries.data);
|
||||
if (
|
||||
selectedLibraries?.length === 0 &&
|
||||
!localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") &&
|
||||
libraries.data.length > 0
|
||||
) {
|
||||
setSelectedLibraries(libraries.data.map((library) => library.Id));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -126,11 +132,11 @@ function ActivityTimeline(props) {
|
||||
<Trans i18nKey={"TIMELINE_PAGE.TIMELINE"} />
|
||||
</h1>
|
||||
<div
|
||||
className="d-flex flex-column flex-md-row"
|
||||
className="d-flex flex-column flex-sm-row"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
<div className="user-selection">
|
||||
<div className="d-flex flex-row w-100 ms-md-3 w-sm-100 w-md-75 mb-3 my-md-3">
|
||||
<div className="d-flex flex-row w-100 ms-sm-3 w-sm-100 w-sm-75 mb-3 my-sm-1">
|
||||
{!preselectedUser && (
|
||||
<>
|
||||
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
|
||||
@@ -139,7 +145,7 @@ function ActivityTimeline(props) {
|
||||
<FormSelect
|
||||
onChange={(e) => handleUserSelection(e.target.value)}
|
||||
value={selectedUser}
|
||||
className="w-md-75 rounded-0 rounded-end"
|
||||
className="w-sm-75 rounded-0 rounded-end"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<option key={user.UserId} value={user.UserId}>
|
||||
@@ -151,10 +157,10 @@ function ActivityTimeline(props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="library-selection">
|
||||
<div className="library-selection d-flex flex-column flex-sm-row">
|
||||
<Button
|
||||
onClick={() => setShowLibraryFilters(true)}
|
||||
className="ms-md-3 mb-3 my-md-3"
|
||||
className="ms-sm-3 mb-3 my-sm-1"
|
||||
>
|
||||
<Trans i18nKey="MENU_TABS.LIBRARIES" />
|
||||
</Button>
|
||||
|
||||
@@ -5,12 +5,14 @@ import TimelineItem from "@mui/lab/TimelineItem";
|
||||
import TimelineSeparator from "@mui/lab/TimelineSeparator";
|
||||
import TimelineConnector from "@mui/lab/TimelineConnector";
|
||||
import TimelineContent from "@mui/lab/TimelineContent";
|
||||
import TimelineOppositeContent from "@mui/lab/TimelineOppositeContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Card from "react-bootstrap/Card";
|
||||
import baseUrl from "../../../lib/baseurl";
|
||||
|
||||
import "../../css/timeline/activity-timeline.css";
|
||||
|
||||
import { useMediaQuery, useTheme } from "@mui/material";
|
||||
import moment from "moment";
|
||||
import TvLineIcon from "remixicon-react/TvLineIcon.js";
|
||||
import FilmLineIcon from "remixicon-react/FilmLineIcon.js";
|
||||
@@ -18,32 +20,75 @@ import { MEDIA_TYPES } from "./helpers";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
function formatEntryDates(entry) {
|
||||
const { FirstActivityDate, LastActivityDate, MediaType } = entry;
|
||||
const localization = localStorage.getItem("i18nextLng");
|
||||
|
||||
const dateFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
function formatEntryDates(FirstActivityDate, LastActivityDate, MediaType) {
|
||||
const startDate = moment(FirstActivityDate);
|
||||
const endDate = moment(LastActivityDate);
|
||||
|
||||
if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) {
|
||||
return startDate.format("L");
|
||||
return Intl.DateTimeFormat(localization, dateFormatOptions).format(
|
||||
startDate.toDate()
|
||||
);
|
||||
} else {
|
||||
return `${startDate.format("L")} - ${endDate.format("L")}`;
|
||||
return `${Intl.DateTimeFormat(localization, dateFormatOptions).format(
|
||||
startDate.toDate()
|
||||
)} - ${Intl.DateTimeFormat(localization, dateFormatOptions).format(
|
||||
endDate.toDate()
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
const DefaultImage = (props) => {
|
||||
const { MediaType } = props;
|
||||
const SeriesIcon = <TvLineIcon size={"50%"} color="white" />;
|
||||
const MovieIcon = <FilmLineIcon size={"50%"} color="white" />;
|
||||
return (
|
||||
<div className="default_library_image default_library_image_hover d-flex justify-content-center align-items-center">
|
||||
{MediaType === MEDIA_TYPES.Shows ? SeriesIcon : MovieIcon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const SeriesIcon = <TvLineIcon size={"50%"} color="white" />;
|
||||
const MovieIcon = <FilmLineIcon size={"50%"} color="white" />;
|
||||
|
||||
export default function ActivityTimelineItem(entry) {
|
||||
const { Title, SeasonName, NowPlayingItemId, EpisodeCount, MediaType } =
|
||||
entry;
|
||||
const TimeLineTextContent = (props) => {
|
||||
const {
|
||||
Title,
|
||||
SeasonName,
|
||||
MediaType,
|
||||
EpisodeCount,
|
||||
FirstActivityDate,
|
||||
LastActivityDate,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="activity-description">
|
||||
<Typography variant="h6" component="span">
|
||||
{Title}
|
||||
</Typography>
|
||||
{SeasonName && <Typography>{SeasonName}</Typography>}
|
||||
<Typography>
|
||||
{formatEntryDates(FirstActivityDate, LastActivityDate, MediaType)}
|
||||
</Typography>
|
||||
{MediaType === MEDIA_TYPES.Shows && EpisodeCount && (
|
||||
<Typography>
|
||||
{EpisodeCount} <Trans i18nKey="TIMELINE_PAGE.EPISODES" />
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ActivityTimelineItem(props) {
|
||||
const { NowPlayingItemId } = props;
|
||||
const [useDefaultImage, setUseDefaultImage] = useState(false);
|
||||
const theme = useTheme();
|
||||
const shouldRenderVertically = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
return (
|
||||
<TimelineItem>
|
||||
<TimelineSeparator>
|
||||
@@ -63,24 +108,20 @@ export default function ActivityTimelineItem(entry) {
|
||||
onError={() => setUseDefaultImage(true)}
|
||||
/>
|
||||
) : (
|
||||
<DefaultImage {...entry} />
|
||||
<DefaultImage {...props} />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{shouldRenderVertically && <TimeLineTextContent {...props} />}
|
||||
<TimelineConnector />
|
||||
</TimelineSeparator>
|
||||
<TimelineContent>
|
||||
<Typography variant="h6" component="span">
|
||||
{Title}
|
||||
</Typography>
|
||||
{SeasonName && <Typography>{SeasonName}</Typography>}
|
||||
<Typography>{formatEntryDates(entry)}</Typography>
|
||||
{MediaType === MEDIA_TYPES.Shows && EpisodeCount && (
|
||||
<Typography>
|
||||
{EpisodeCount} <Trans i18nKey="TIMELINE_PAGE.EPISODES" />
|
||||
</Typography>
|
||||
)}
|
||||
</TimelineContent>
|
||||
{!shouldRenderVertically ? (
|
||||
<TimelineContent>
|
||||
<TimeLineTextContent {...props} />
|
||||
</TimelineContent>
|
||||
) : (
|
||||
<TimelineOppositeContent></TimelineOppositeContent>
|
||||
)}
|
||||
</TimelineItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ export function groupAdjacentSeasons(timelineEntries) {
|
||||
if (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
|
||||
let highestSeasonName = entry.SeasonName;
|
||||
let lastSeasonInSession;
|
||||
let totalEpisodeCount = +entry.EpisodeCount;
|
||||
//merge all further seasons as well
|
||||
while (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
|
||||
const potentialNextSeason = entryArray[potentialNextSeasonIndex];
|
||||
if (entry.Title === potentialNextSeason?.Title) {
|
||||
lastSeasonInSession = potentialNextSeason;
|
||||
totalEpisodeCount += +potentialNextSeason.EpisodeCount ?? 0;
|
||||
//remove season from list after usage
|
||||
entryArray[potentialNextSeasonIndex] = undefined;
|
||||
|
||||
@@ -47,6 +49,7 @@ export function groupAdjacentSeasons(timelineEntries) {
|
||||
...entry,
|
||||
SeasonName: newSeasonName,
|
||||
LastActivityDate: newLastActivityDate,
|
||||
EpisodeCount: totalEpisodeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
.Heading {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
@media (max-width: 576px) {
|
||||
h1 {
|
||||
width: 100%;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
* {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.activity-card {
|
||||
display: flex;
|
||||
@@ -21,6 +31,24 @@
|
||||
|
||||
.MuiTimelineItem-root {
|
||||
height: 20rem;
|
||||
@media (max-width: 576px) {
|
||||
height: 25rem;
|
||||
::before {
|
||||
padding: 0;
|
||||
flex: 0;
|
||||
}
|
||||
.MuiTimelineSeparator-root {
|
||||
flex: 1;
|
||||
}
|
||||
.MuiTimelineOppositeContent-root {
|
||||
display: none;
|
||||
}
|
||||
.activity-description {
|
||||
max-width: 50%;
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.MuiTimelineContent-root {
|
||||
|
||||
Reference in New Issue
Block a user