Merge pull request #297 from GrimJu/activity-timeline-prototype

Prototype implementation of activity timeline feature
This commit is contained in:
Thegan Govender
2025-01-20 09:54:08 +02:00
committed by GitHub
12 changed files with 778 additions and 289 deletions

View File

@@ -0,0 +1,73 @@
exports.up = function (knex) {
return knex.schema.raw(`
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
select
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end as "Title",
COUNT(distinct jp."EpisodeId") as "EpisodeCount",
MIN(jp."ActivityDateInserted") as "FirstActivityDate",
MAX(jp."ActivityDateInserted") as "LastActivityDate",
SUM(jp."PlaybackDuration")::bigint as "TotalPlaybackDuration",
ls."Name" as "SeasonName",
MAX(jl."CollectionType") as "MediaType",
jp."NowPlayingItemId"
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)
group by
jp."UserName",
case
when jp."SeriesName" is not null then jp."SeriesName"
else jp."NowPlayingItemName"
end,
jp."SeriesName",
jp."SeasonId",
ls."Name",
jp."NowPlayingItemId"
having
not (MAX(jl."Name") = 'Shows'
and ls."Name" is null)
and SUM(jp."PlaybackDuration") >= 30
order by
MAX(jp."ActivityDateInserted") desc;
end;
$$ language plpgsql;
`);
};
exports.down = function (knex) {
return knex.schema.raw(`
DROP FUNCTION IF EXISTS fs_get_user_activity;
`);
};

View File

@@ -1432,6 +1432,31 @@ router.post("/deletePlaybackActivity", async (req, res) => {
}
});
router.post("/getActivityTimeLine", async (req, res) => {
try {
const { userId, libraries } = req.body;
if (libraries === undefined || !Array.isArray(libraries)) {
res.status(400);
res.send("A list of IDs is required. EG: [1,2,3]");
return;
}
if (userId === undefined) {
res.status(400);
res.send("A userId is required.");
return;
}
const {rows} = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]);
res.send(rows);
} 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" });

469
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.0",
"@mui/lab": "^6.0.0-beta.22",
"@mui/material": "^6.3.0",
"@mui/x-data-grid": "^7.23.3",
"@mui/x-date-pickers": "^7.23.3",
@@ -3257,6 +3258,44 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -4151,10 +4190,43 @@
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
},
"node_modules/@mui/base": {
"version": "5.0.0-beta.68",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz",
"integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@floating-ui/react-dom": "^2.1.1",
"@mui/types": "^7.2.20",
"@mui/utils": "^6.3.0",
"@popperjs/core": "^2.11.8",
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.0.tgz",
"integrity": "sha512-/d8NwSuC3rMwCjswmGB3oXC4sdDuhIUJ8inVQAxGrADJhf0eq/kmy+foFKvpYhHl2siOZR+MLdFttw6/Bzqtqg==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz",
"integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
@@ -4185,23 +4257,19 @@
}
}
},
"node_modules/@mui/material": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.0.tgz",
"integrity": "sha512-qhlTFyRMxfoVPxUtA5e8IvqxP0dWo2Ij7cvot7Orag+etUlZH+3UwD8gZGt+3irOoy7Ms3UNBflYjwEikUXtAQ==",
"node_modules/@mui/lab": {
"version": "6.0.0-beta.22",
"resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.22.tgz",
"integrity": "sha512-9nwUfBj+UzoQJOCbqV+JcCSJ74T+gGWrM1FMlXzkahtYUcMN+5Zmh2ArlttW3zv2dZyCzp7K5askcnKF0WzFQg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/core-downloads-tracker": "^6.3.0",
"@mui/system": "^6.3.0",
"@mui/types": "^7.2.20",
"@mui/utils": "^6.3.0",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"@mui/base": "5.0.0-beta.68",
"@mui/system": "^6.3.1",
"@mui/types": "^7.2.21",
"@mui/utils": "^6.3.1",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
"react-is": "^19.0.0",
"react-transition-group": "^4.4.5"
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
@@ -4213,7 +4281,8 @@
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^6.3.0",
"@mui/material": "^6.3.1",
"@mui/material-pigment-css": "^6.3.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4233,13 +4302,69 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/private-theming": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.0.tgz",
"integrity": "sha512-tdS8jvqMokltNTXg6ioRCCbVdDmZUJZa/T9VtTnX2Lwww3FTgCakst9tWLZSxm1fEE9Xp0m7onZJmgeUmWQYVw==",
"node_modules/@mui/material": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz",
"integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/utils": "^6.3.0",
"@mui/core-downloads-tracker": "^6.3.1",
"@mui/system": "^6.3.1",
"@mui/types": "^7.2.21",
"@mui/utils": "^6.3.1",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
"react-is": "^19.0.0",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^6.3.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@mui/material-pigment-css": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material/node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==",
"license": "MIT"
},
"node_modules/@mui/private-theming": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz",
"integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/utils": "^6.3.1",
"prop-types": "^15.8.1"
},
"engines": {
@@ -4259,10 +4384,11 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/styled-engine": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.0.tgz",
"integrity": "sha512-iWA6eyiPkO07AlHxRUvI7dwVRSc+84zV54kLmjUms67GJeOWVuXlu8ZO+UhCnwJxHacghxnabsMEqet5PYQmHg==",
"node_modules/@mui/styled-engine": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz",
"integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@emotion/cache": "^11.13.5",
@@ -4292,16 +4418,17 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/system": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.0.tgz",
"integrity": "sha512-L+8hUHMNlfReKSqcnVslFrVhoNfz/jw7Fe9NfDE85R3KarvZ4O3MU9daF/lZeqEAvnYxEilkkTfDwQ7qCgJdFg==",
"node_modules/@mui/system": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz",
"integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/private-theming": "^6.3.0",
"@mui/styled-engine": "^6.3.0",
"@mui/types": "^7.2.20",
"@mui/utils": "^6.3.0",
"@mui/private-theming": "^6.3.1",
"@mui/styled-engine": "^6.3.1",
"@mui/types": "^7.2.21",
"@mui/utils": "^6.3.1",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
@@ -4331,13 +4458,28 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/utils": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz",
"integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==",
"node_modules/@mui/types": {
"version": "7.2.21",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz",
"integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/utils": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz",
"integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.20",
"@mui/types": "^7.2.21",
"@types/prop-types": "^15.7.14",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
@@ -4360,150 +4502,11 @@
}
}
},
"node_modules/@mui/material/node_modules/react-is": {
"node_modules/@mui/utils/node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="
},
"node_modules/@mui/private-theming": {
"version": "5.15.14",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz",
"integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/utils": "^5.15.14",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/styled-engine": {
"version": "5.15.14",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz",
"integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.9",
"@emotion/cache": "^11.11.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/system": {
"version": "5.15.14",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.14.tgz",
"integrity": "sha512-auXLXzUaCSSOLqJXmsAaq7P96VPRXg2Rrz6OHNV7lr+kB8lobUF+/N84Vd9C4G/wvCXYPs5TYuuGBRhcGbiBGg==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/private-theming": "^5.15.14",
"@mui/styled-engine": "^5.15.14",
"@mui/types": "^7.2.14",
"@mui/utils": "^5.15.14",
"clsx": "^2.1.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/types": {
"version": "7.2.20",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz",
"integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==",
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/utils": {
"version": "5.15.14",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz",
"integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.9",
"@types/prop-types": "^15.7.11",
"prop-types": "^15.8.1",
"react-is": "^18.2.0"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==",
"license": "MIT"
},
"node_modules/@mui/x-data-grid": {
"version": "7.23.3",
@@ -4541,40 +4544,6 @@
}
}
},
"node_modules/@mui/x-data-grid/node_modules/@mui/utils": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz",
"integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.20",
"@types/prop-types": "^15.7.14",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-data-grid/node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="
},
"node_modules/@mui/x-date-pickers": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz",
@@ -4640,40 +4609,6 @@
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/@mui/utils": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz",
"integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.20",
"@types/prop-types": "^15.7.14",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="
},
"node_modules/@mui/x-internals": {
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz",
@@ -4693,40 +4628,6 @@
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-internals/node_modules/@mui/utils": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz",
"integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.20",
"@types/prop-types": "^15.7.14",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-internals/node_modules/react-is": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",

View File

@@ -18,6 +18,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.0",
"@mui/lab": "^6.0.0-beta.22",
"@mui/material": "^6.3.0",
"@mui/x-data-grid": "^7.23.3",
"@mui/x-date-pickers": "^7.23.3",

View File

@@ -8,7 +8,8 @@
"STATISTICS": "Statistics",
"SETTINGS": "Settings",
"ABOUT": "About",
"LOGOUT": "Logout"
"LOGOUT": "Logout",
"TIMELINE": "Timeline"
},
"HOME_PAGE": {
"SESSIONS": "Sessions",
@@ -77,7 +78,8 @@
"TAB_CONTROLS": {
"OVERVIEW": "Overview",
"ACTIVITY": "Activity",
"OPTIONS": "Options"
"OPTIONS": "Options",
"TIMELINE": "Timeline"
},
"ITEM_ACTIVITY": "Item Activity",
"ACTIVITY_TABLE": {
@@ -230,6 +232,10 @@
"GITHUB": "Github",
"Backup": "Backup Jellystat"
},
"TIMELINE_PAGE": {
"TIMELINE": "Timeline",
"EPISODES":"Episodes"
},
"SEARCH": "Search",
"TOTAL": "Total",
"LAST": "Last",
@@ -305,4 +311,4 @@
"POSTCODE": "Postcode",
"X_ROWS_SELECTED": "{ROWS} Rows Selected",
"SUBTITLES": "Subtitles"
}
}

View File

@@ -0,0 +1,205 @@
import "./css/stats.css";
import { Trans } from "react-i18next";
import ActivityTimelineComponent from "./components/activity-timeline/activity-timeline";
import { useEffect, useState } from "react";
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";
function ActivityTimeline(props) {
const { preselectedUser } = props;
const [users, setUsers] = useState();
const [selectedUser, setSelectedUser] = useState(
preselectedUser ??
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ??
""
);
const [libraries, setLibraries] = useState();
const [config, setConfig] = useState(null);
const [showLibraryFilters, setShowLibraryFilters] = useState(false);
const [selectedLibraries, setSelectedLibraries] = useState(
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") !=
undefined
? JSON.parse(
localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries")
)
: []
);
const timelineReady =
(users?.length > 0 || !!preselectedUser) && libraries?.length > 0;
const handleLibraryFilter = (selectedOptions) => {
setSelectedLibraries(selectedOptions);
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify(selectedOptions)
);
};
const handleUserSelection = (selectedUser) => {
console.log(selectedUser);
setSelectedUser(selectedUser);
localStorage.setItem("PREF_ACTIVITY_TIMELINE_selectedUser", selectedUser);
};
const toggleSelectAll = () => {
if (selectedLibraries.length > 0) {
setSelectedLibraries([]);
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify([])
);
} else {
setSelectedLibraries(libraries.map((library) => library.Id));
localStorage.setItem(
"PREF_ACTIVITY_TIMELINE_selectedLibraries",
JSON.stringify(libraries.map((library) => library.Id))
);
}
};
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
if (!config) {
fetchConfig();
}
}, [config]);
useEffect(() => {
if (config && !preselectedUser) {
const url = `/stats/getAllUserActivity`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((users) => {
setUsers(users.data);
})
.catch((error) => {
console.log(error);
});
}
}, [config, preselectedUser]);
useEffect(() => {
if (config) {
const url = `/stats/getLibraryMetadata`;
axios
.get(url, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
})
.then((libraries) => {
setLibraries(libraries.data);
})
.catch((error) => {
console.log(error);
});
}
}, [config]);
return timelineReady ? (
<div className="watch-stats">
<div className="Heading">
<h1>
<Trans i18nKey={"TIMELINE_PAGE.TIMELINE"} />
</h1>
<div
className="d-flex flex-column flex-md-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">
{!preselectedUser && (
<>
<div className="d-flex flex-col rounded-0 rounded-start align-items-center px-2 bg-primary-1">
<Trans i18nKey="USER" />
</div>
<FormSelect
onChange={(e) => handleUserSelection(e.target.value)}
value={selectedUser}
className="w-md-75 rounded-0 rounded-end"
>
{users.map((user) => (
<option key={user.UserId} value={user.UserId}>
{user.UserName}
</option>
))}
</FormSelect>
</>
)}
</div>
</div>
<div className="library-selection">
<Button
onClick={() => setShowLibraryFilters(true)}
className="ms-md-3 mb-3 my-md-3"
>
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Button>
<Modal
show={showLibraryFilters}
onHide={() => setShowLibraryFilters(false)}
>
<Modal.Header>
<Modal.Title>
<Trans i18nKey="MENU_TABS.LIBRARIES" />
</Modal.Title>
</Modal.Header>
<LibraryFilterModal
libraries={libraries}
selectedLibraries={selectedLibraries}
onSelectionChange={handleLibraryFilter}
/>
<Modal.Footer>
<Button variant="outline-primary" onClick={toggleSelectAll}>
<Trans i18nKey="ACTIVITY_TABLE.TOGGLE_SELECT_ALL" />
</Button>
<Button
variant="outline-primary"
onClick={() => setShowLibraryFilters(false)}
>
<Trans i18nKey="CLOSE" />
</Button>
</Modal.Footer>
</Modal>
</div>
</div>
</div>
<div>
{selectedUser && selectedLibraries?.length > 0 && (
<ActivityTimelineComponent
userId={selectedUser}
libraries={selectedLibraries}
/>
)}
</div>
</div>
) : (
<Loading />
);
}
export default ActivityTimeline;

View File

@@ -0,0 +1,86 @@
/* eslint-disable react/prop-types */
import { useState } from "react";
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 Typography from "@mui/material/Typography";
import Card from "react-bootstrap/Card";
import baseUrl from "../../../lib/baseurl";
import "../../css/timeline/activity-timeline.css";
import moment from "moment";
import TvLineIcon from "remixicon-react/TvLineIcon.js";
import FilmLineIcon from "remixicon-react/FilmLineIcon.js";
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 startDate = moment(FirstActivityDate);
const endDate = moment(LastActivityDate);
if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) {
return startDate.format("L");
} else {
return `${startDate.format("L")} - ${endDate.format("L")}`;
}
}
const DefaultImage = (props) => {
const { MediaType } = props;
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 [useDefaultImage, setUseDefaultImage] = useState(false);
return (
<TimelineItem>
<TimelineSeparator>
<TimelineConnector />
<div className="activity-card">
<Link to={`/libraries/item/${NowPlayingItemId}`}>
{!useDefaultImage ? (
<Card.Img
variant="top"
className="activity-card-img"
src={
baseUrl +
"/proxy/Items/Images/Primary?id=" +
NowPlayingItemId +
"&fillWidth=800&quality=50"
}
onError={() => setUseDefaultImage(true)}
/>
) : (
<DefaultImage {...entry} />
)}
</Link>
</div>
<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>
</TimelineItem>
);
}

View File

@@ -0,0 +1,80 @@
/* eslint-disable react/prop-types */
import { useEffect, useState } from "react";
import axios from "../../../lib/axios_instance";
import Timeline from "@mui/lab/Timeline";
import "../../css/timeline/activity-timeline.css";
import Config from "../../../lib/config.jsx";
import Loading from "../../../pages/components/general/loading.jsx";
import ActivityTimelineItem from "./activity-timeline-item.jsx";
import { groupAdjacentSeasons } from "./helpers.jsx";
export default function ActivityTimelineComponent(props) {
const { userId, libraries } = props;
const [timelineEntries, setTimelineEntries] = useState();
const [config, setConfig] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const newConfig = await Config.getConfig();
setConfig(newConfig);
} catch (error) {
if (error.code === "ERR_NETWORK") {
console.log(error);
}
}
};
const fetchLibraries = () => {
if (config) {
const url = `/api/getActivityTimeLine`;
axios
.post(
url,
{ userId: userId, libraries: libraries },
{
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
}
)
.then((timelineEntries) => {
const groupedAdjacentSeasons = groupAdjacentSeasons([
...timelineEntries.data,
]);
setTimelineEntries(groupedAdjacentSeasons);
})
.catch((error) => {
console.log(error);
});
}
};
if (!config) {
fetchConfig();
}
fetchLibraries();
}, [userId, libraries, config]);
return timelineEntries?.length > 0 ? (
<div>
<Timeline position="alternate">
{timelineEntries.map((entry) => (
<ActivityTimelineItem
key={`${entry.Title}-${entry.FirstActivityDate}-${entry.LastActivityDate}`}
{...entry}
/>
))}
</Timeline>
</div>
) : (
<Loading />
);
}

View File

@@ -0,0 +1,57 @@
export const MEDIA_TYPES = {
Movies: "movies",
Shows: "tvshows",
Music: "music",
};
/**
* groups subsequent seasons of shows into single entries with a combined label and timeframe
* @param {*} timelineEntries List of entries as returned by /api/getActivityTimeLine
* @returns Same list of entries, seasons of the same show that follow each other will be merged into one entry
*/
export function groupAdjacentSeasons(timelineEntries) {
return timelineEntries
.reverse()
.map((entry, index, entryArray) => {
if (entry?.MediaType === MEDIA_TYPES.Shows) {
let potentialNextSeasonIndex = index + 1;
//if the next entry is another season of the same show, merge them
if (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
let highestSeasonName = entry.SeasonName;
let lastSeasonInSession;
//merge all further seasons as well
while (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) {
const potentialNextSeason = entryArray[potentialNextSeasonIndex];
if (entry.Title === potentialNextSeason?.Title) {
lastSeasonInSession = potentialNextSeason;
//remove season from list after usage
entryArray[potentialNextSeasonIndex] = undefined;
//hack: in my db the seasons weren't always sorted correctly.
if (
highestSeasonName?.localeCompare(
lastSeasonInSession.SeasonName
) === -1
) {
highestSeasonName = lastSeasonInSession.SeasonName;
}
} else {
//all subsequent seasons have been merged into one entry and were removed from the list
break;
}
potentialNextSeasonIndex++;
}
const newSeasonName = `${entry.SeasonName} - ${highestSeasonName}`;
const newLastActivityDate = lastSeasonInSession.LastActivityDate;
return {
...entry,
SeasonName: newSeasonName,
LastActivityDate: newLastActivityDate,
};
}
}
return entry;
})
.filter((entry) => !!entry)
.reverse();
}

View File

@@ -11,6 +11,7 @@ import "../css/users/user-details.css";
import { Trans } from "react-i18next";
import baseUrl from "../../lib/baseurl";
import GlobalStats from "./general/globalStats";
import ActivityTimeline from "../activity_time_line";
function UserInfo() {
const { UserId } = useParams();
@@ -77,7 +78,12 @@ 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>
@@ -103,11 +109,23 @@ function UserInfo() {
>
<Trans i18nKey="TAB_CONTROLS.ACTIVITY" />
</Button>
<Button
onClick={() => setActiveTab("tabTimeline")}
active={activeTab === "tabTimeline"}
variant="outline-primary"
type="button"
>
<Trans i18nKey="TAB_CONTROLS.TIMELINE" />
</Button>
</ButtonGroup>
</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}
@@ -120,6 +138,9 @@ function UserInfo() {
<Tab eventKey="tabActivity" className="bg-transparent">
<UserActivity UserId={UserId} />
</Tab>
<Tab eventKey="tabTimeline" className="bg-transparent">
<ActivityTimeline preselectedUser={UserId} />
</Tab>
</Tabs>
</div>
);

View File

@@ -0,0 +1,28 @@
@import "../variables.module.css";
.Heading {
justify-content: space-between;
}
.activity-card {
display: flex;
width: 10rem;
* {
flex-grow: 1;
border-radius: var(--bs-border-radius-lg) !important;
}
.activity-card-img {
object-fit: cover;
background-color: black;
background-repeat: no-repeat;
background-size: cover;
transition: all 0.2s ease-in-out;
}
}
.MuiTimelineItem-root {
height: 20rem;
}
.MuiTimelineContent-root {
align-self: center;
}

View File

@@ -11,6 +11,7 @@ import About from "./pages/about";
import TestingRoutes from "./pages/testing";
import Activity from "./pages/activity";
import Statistics from "./pages/statistics";
import ActivityTimeline from "./pages/activity_time_line";
const routes = [
{
@@ -58,6 +59,11 @@ const routes = [
element: <Activity />,
exact: true,
},
{
path: "/timeline",
element: <ActivityTimeline />,
exact: true,
},
{
path: "/about",
element: <About />,