Merge pull request #304 from GrimJu/activity-timeline-episode-number-fix

Activity timeline fixes and improvements
This commit is contained in:
Thegan Govender
2025-02-08 19:29:51 +02:00
committed by GitHub
5 changed files with 323 additions and 34 deletions

View File

@@ -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;
`);
};

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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,
};
}
}

View File

@@ -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 {