├── .husky
└── pre-commit
├── src
├── vite-env.d.ts
├── components
│ ├── pages
│ │ ├── TopTenPage.tsx
│ │ ├── MusicVideoPage.tsx
│ │ ├── MoviesReviewPage
│ │ │ ├── ActorCard.tsx
│ │ │ └── MovieCard.tsx
│ │ ├── AudioReviewPage.tsx
│ │ ├── ActivityCalendarPage.tsx
│ │ ├── OldestMoviePage.tsx
│ │ ├── OldestShowPage.tsx
│ │ ├── MoviesReviewPage.tsx
│ │ ├── LoadingDataPage.tsx
│ │ ├── FavoriteActorsPage.tsx
│ │ ├── LiveTvReviewPage.tsx
│ │ ├── ShowReviewPage.tsx
│ │ ├── CriticallyAcclaimedPage.tsx
│ │ ├── PunchCardPage.tsx
│ │ ├── DeviceStatsPage.tsx
│ │ ├── UnfinishedShowsPage.tsx
│ │ ├── LoadingPage.tsx
│ │ ├── SplashPage.tsx
│ │ ├── ShowOfTheMonthPage.tsx
│ │ ├── GenreReviewPage.tsx
│ │ ├── MinutesPlayedPerDayPage.tsx
│ │ └── HolidayReviewPage.tsx
│ ├── LoadingSpinner.tsx
│ ├── RankBadge.tsx
│ ├── ui
│ │ ├── textarea.tsx
│ │ ├── label.tsx
│ │ ├── input.tsx
│ │ ├── button.tsx
│ │ ├── radio-group.tsx
│ │ ├── avatar.tsx
│ │ ├── scroll-area.tsx
│ │ ├── card.tsx
│ │ └── styled.tsx
│ ├── ContentImage.tsx
│ ├── BlinkingStars.tsx
│ ├── PageContainer.tsx
│ ├── ScrollingText.tsx
│ ├── charts
│ │ ├── BarChart.tsx
│ │ ├── PieChart.tsx
│ │ └── LineChart.tsx
│ ├── TimeframeSelector.tsx
│ └── Navigation.tsx
├── types.ts
├── index.css
├── utils
│ └── colors.ts
├── hooks
│ ├── queries
│ │ ├── useAudio.ts
│ │ ├── useCalendar.ts
│ │ ├── useDeviceStats.ts
│ │ ├── usePunchCard.ts
│ │ ├── useMusicVideos.ts
│ │ ├── useShows.ts
│ │ ├── useFavoriteActors.ts
│ │ ├── useLiveTvChannels.ts
│ │ ├── useUnfinishedShows.ts
│ │ ├── useViewingPatterns.ts
│ │ ├── useMonthlyShowStats.ts
│ │ ├── useMinutesPlayedPerDay.ts
│ │ ├── useWatchedOnDate.ts
│ │ ├── useTopTen.ts
│ │ └── useMovies.ts
│ └── useIsMobile.ts
├── main.tsx
├── lib
│ ├── styled-variants.ts
│ ├── time-helpers.ts
│ ├── queries
│ │ ├── index.ts
│ │ ├── plugin.ts
│ │ ├── images.ts
│ │ ├── types.ts
│ │ ├── movies.ts
│ │ ├── livetv.ts
│ │ ├── audio.ts
│ │ ├── utils.ts
│ │ ├── date-based.ts
│ │ ├── actors.ts
│ │ ├── stats.ts
│ │ ├── shows.ts
│ │ └── items.ts
│ ├── utils.ts
│ ├── holiday-helpers.ts
│ ├── genre-helpers.ts
│ ├── rating-helpers.ts
│ ├── button-variants.ts
│ ├── cache.ts
│ ├── timeframe.ts
│ └── jellyfin-api.ts
├── providers
│ └── QueryProvider.tsx
├── scripts
│ ├── check-columns.ts
│ ├── list-tables.ts
│ ├── test-sql.ts
│ └── validate-data.ts
├── assets
│ └── react.svg
├── pages
│ └── TopTen.tsx
└── App.tsx
├── AmazonQ.md
├── .prettierignore
├── .prettierrc
├── tsconfig.eslint.json
├── tsconfig.json
├── vite.config.ts
├── .gitignore
├── index.html
├── apache-config.conf
├── .env.example
├── docker-compose.yaml
├── tsconfig.node.json
├── Dockerfile
├── tsconfig.app.json
├── LICENSE
├── test-api.js
├── public
└── vite.svg
├── eslint.config.js
├── tailwind.config.js
├── package.json
├── .github
└── workflows
│ └── docker-publish.yml
├── REFACTORING_STATUS.md
└── README.md
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npx lint-staged
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/components/pages/TopTenPage.tsx:
--------------------------------------------------------------------------------
1 | export { TopTen as default } from "@/pages/TopTen";
2 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface ScrollingTextProps {
2 | generatedImage: string;
3 | story: string;
4 | }
5 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | body {
5 | margin: 0;
6 | }
7 |
--------------------------------------------------------------------------------
/AmazonQ.md:
--------------------------------------------------------------------------------
1 | Add a page to my app available at /TopTen that shows the top ten movies and shows watched during the year
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .prettierignore
2 | .gitignore
3 | *.svg
4 | *.png
5 | .husky/
6 | Dockerfile
7 | *.conf
8 | LICENSE
9 | .eslintcache
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "es5",
4 | "singleQuote": false,
5 | "printWidth": 80,
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "src/**/*.ts",
5 | "src/**/*.tsx",
6 | "src/**/*.js",
7 | "src/**/*.jsx",
8 | "*.config.js",
9 | "*.config.ts"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/colors.ts:
--------------------------------------------------------------------------------
1 | export function getRandomColor(): string {
2 | const letters = "0123456789ABCDEF";
3 | let color = "#";
4 | for (let i = 0; i < 6; i++) {
5 | color += letters[Math.floor(Math.random() * 16)];
6 | }
7 | return color;
8 | }
9 |
--------------------------------------------------------------------------------
/src/hooks/queries/useAudio.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listAudio } from "@/lib/queries";
3 |
4 | export function useAudio() {
5 | return useQuery({
6 | queryKey: ["audio"],
7 | queryFn: listAudio,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/queries/useCalendar.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getCalendarData } from "@/lib/queries";
3 |
4 | export function useCalendar() {
5 | return useQuery({
6 | queryKey: ["calendar"],
7 | queryFn: getCalendarData,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useDeviceStats.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getDeviceStats } from "@/lib/queries";
3 |
4 | export function useDeviceStats() {
5 | return useQuery({
6 | queryKey: ["deviceStats"],
7 | queryFn: getDeviceStats,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/usePunchCard.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getPunchCardData } from "@/lib/queries";
3 |
4 | export function usePunchCard() {
5 | return useQuery({
6 | queryKey: ["punchCard"],
7 | queryFn: getPunchCardData,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useMusicVideos.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listMusicVideos } from "@/lib/queries";
3 |
4 | export function useMusicVideos() {
5 | return useQuery({
6 | queryKey: ["musicVideos"],
7 | queryFn: listMusicVideos,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from "react-dom/client";
2 | import App from "./App.tsx";
3 | import "./index.css";
4 |
5 | const rootElement = document.getElementById("root");
6 | if (!rootElement) throw new Error("Failed to find the root element");
7 |
8 | createRoot(rootElement).render();
9 |
--------------------------------------------------------------------------------
/src/hooks/queries/useShows.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listShows } from "@/lib/queries";
3 |
4 | export function useShows() {
5 | return useQuery({
6 | queryKey: ["shows"],
7 | queryFn: listShows,
8 | staleTime: 0,
9 | gcTime: 0,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/queries/useFavoriteActors.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listFavoriteActors } from "@/lib/queries";
3 |
4 | export function useFavoriteActors() {
5 | return useQuery({
6 | queryKey: ["favoriteActors"],
7 | queryFn: listFavoriteActors,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useLiveTvChannels.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listLiveTvChannels } from "@/lib/queries";
3 |
4 | export function useLiveTvChannels() {
5 | return useQuery({
6 | queryKey: ["liveTvChannels"],
7 | queryFn: listLiveTvChannels,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useUnfinishedShows.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getUnfinishedShows } from "@/lib/queries";
3 |
4 | export function useUnfinishedShows() {
5 | return useQuery({
6 | queryKey: ["unfinishedShows"],
7 | queryFn: getUnfinishedShows,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useViewingPatterns.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getViewingPatterns } from "@/lib/queries";
3 |
4 | export function useViewingPatterns() {
5 | return useQuery({
6 | queryKey: ["viewingPatterns"],
7 | queryFn: getViewingPatterns,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useMonthlyShowStats.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getMonthlyShowStats } from "@/lib/queries";
3 |
4 | export function useMonthlyShowStats() {
5 | return useQuery({
6 | queryKey: ["monthlyShowStats"],
7 | queryFn: getMonthlyShowStats,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/queries/useMinutesPlayedPerDay.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getMinutesPlayedPerDay } from "@/lib/queries";
3 |
4 | export function useMinutesPlayedPerDay() {
5 | return useQuery({
6 | queryKey: ["minutesPlayedPerDay"],
7 | queryFn: getMinutesPlayedPerDay,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/src/lib/styled-variants.ts:
--------------------------------------------------------------------------------
1 | export const containerVariants = {
2 | hidden: { opacity: 0 },
3 | visible: {
4 | opacity: 1,
5 | transition: {
6 | staggerChildren: 0.1,
7 | },
8 | },
9 | };
10 |
11 | export const itemVariants = {
12 | hidden: { y: 20, opacity: 0 },
13 | visible: {
14 | y: 0,
15 | opacity: 1,
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/hooks/queries/useWatchedOnDate.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listWatchedOnDate } from "@/lib/queries";
3 |
4 | export function useWatchedOnDate(date: Date) {
5 | return useQuery({
6 | queryKey: ["watchedOnDate", date.toISOString()],
7 | queryFn: () => listWatchedOnDate(date),
8 | enabled: !!date,
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | .eslintcache
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Jellyfin Wrapped
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/time-helpers.ts:
--------------------------------------------------------------------------------
1 | export function formatWatchTime(minutes: number): string {
2 | const hours = Math.floor(minutes / 60);
3 | const remainingMinutes = Math.round(minutes % 60);
4 |
5 | if (hours === 0) {
6 | return `${remainingMinutes} minutes`;
7 | }
8 |
9 | if (remainingMinutes === 0) {
10 | return `${hours} ${hours === 1 ? "hour" : "hours"}`;
11 | }
12 |
13 | return `${hours} ${hours === 1 ? "hour" : "hours"} ${remainingMinutes} minutes`;
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/queries/useTopTen.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listMovies, listShows } from "@/lib/queries";
3 |
4 | export const useTopTen = () => {
5 | return useQuery({
6 | queryKey: ["topTen"],
7 | queryFn: async () => {
8 | const [movies, shows] = await Promise.all([listMovies(), listShows()]);
9 | return {
10 | movies: movies.slice(0, 10),
11 | shows: shows.slice(0, 10),
12 | };
13 | },
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/apache-config.conf:
--------------------------------------------------------------------------------
1 | # Enable output filtering
2 | AddOutputFilterByType SUBSTITUTE text/html
3 |
4 | # Define environment variable substitution
5 |
6 | # Only perform substitution if environment variables are set
7 |
8 | Substitute "s|||ni"
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/components/LoadingSpinner.tsx:
--------------------------------------------------------------------------------
1 | import { Spinner } from "@radix-ui/themes";
2 |
3 | export function LoadingSpinner({
4 | backgroundColor = "var(--green-8)",
5 | }: {
6 | backgroundColor?: string;
7 | }) {
8 | return (
9 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/providers/QueryProvider.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2 | import { ReactNode } from "react";
3 |
4 | const queryClient = new QueryClient({
5 | defaultOptions: {
6 | queries: {
7 | staleTime: 5 * 60 * 1000,
8 | gcTime: 10 * 60 * 1000,
9 | retry: 1,
10 | },
11 | },
12 | });
13 |
14 | export function QueryProvider({ children }: { children: ReactNode }) {
15 | return (
16 | {children}
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Jellyfin server configuration
2 | # These environment variables are required for the app to function properly
3 |
4 | # The URL of your Jellyfin server (required)
5 | VITE_JELLYFIN_SERVER_URL=http://your-jellyfin-server:8096
6 |
7 | # Admin API key for accessing playback reporting data (required)
8 | # This should be an API key from an admin user account
9 | VITE_JELLYFIN_API_KEY=your-admin-api-key-here
10 |
11 | # Note: The VITE_ prefix is required for Vite to expose these variables to the client
12 | # In production, you can also set these via window.ENV object
13 |
--------------------------------------------------------------------------------
/src/hooks/queries/useMovies.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { listMovies, SimpleItemDto } from "@/lib/queries";
3 | import { getCachedHiddenIds } from "@/lib/cache";
4 |
5 | export function useMovies() {
6 | return useQuery({
7 | queryKey: ["movies"],
8 | queryFn: async (): Promise => {
9 | const movies = await listMovies();
10 | const hiddenIds = getCachedHiddenIds();
11 | return movies.filter(
12 | (movie: SimpleItemDto) => !hiddenIds.includes(movie.id ?? "")
13 | );
14 | },
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | web:
5 | # Build docker image from source
6 | # build:
7 | # context: .
8 | # dockerfile: Dockerfile
9 |
10 | # ...Or use published image from Dockerhub
11 | image: mrorbitman/jellyfin-wrapped:latest
12 |
13 | ports:
14 | - "80:80"
15 | restart: unless-stopped
16 | container_name: jellyfin-wrapped
17 | # Required environment variables for Jellyfin Wrapped to function properly
18 | environment:
19 | - JELLYFIN_SERVER_URL=http://:8096
20 | - JELLYFIN_API_KEY=
21 |
--------------------------------------------------------------------------------
/src/components/RankBadge.tsx:
--------------------------------------------------------------------------------
1 | export function RankBadge({ rank }: { rank: number }) {
2 | return (
3 |
20 | #{rank}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/queries/index.ts:
--------------------------------------------------------------------------------
1 | // Types
2 | export * from "./types";
3 |
4 | // Utils
5 | export * from "./utils";
6 |
7 | // Plugin
8 | export * from "./plugin";
9 |
10 | // Images
11 | export * from "./images";
12 |
13 | // Items
14 | export * from "./items";
15 |
16 | // Movies
17 | export * from "./movies";
18 |
19 | // Shows
20 | export * from "./shows";
21 |
22 | // Audio
23 | export * from "./audio";
24 |
25 | // Live TV
26 | export * from "./livetv";
27 |
28 | // Stats
29 | export * from "./stats";
30 |
31 | // Actors
32 | export * from "./actors";
33 |
34 | // Patterns
35 | export * from "./patterns";
36 |
37 | // Date-based
38 | export * from "./date-based";
39 |
40 | // Advanced
41 | export * from "./advanced";
42 |
--------------------------------------------------------------------------------
/src/hooks/useIsMobile.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | const MOBILE_BREAKPOINT = "768px";
4 |
5 | export function useIsMobile(): boolean {
6 | const [isMobile, setIsMobile] = useState(false);
7 |
8 | useEffect(() => {
9 | if (typeof window === "undefined") return;
10 |
11 | const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`);
12 | setIsMobile(mediaQuery.matches);
13 |
14 | const handleResize = (event: MediaQueryListEvent) => {
15 | setIsMobile(event.matches);
16 | };
17 |
18 | mediaQuery.addEventListener("change", handleResize);
19 | return () => mediaQuery.removeEventListener("change", handleResize);
20 | }, []);
21 |
22 | return isMobile;
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/queries/plugin.ts:
--------------------------------------------------------------------------------
1 | import { getPluginsApi } from "@jellyfin/sdk/lib/utils/api";
2 | import { PluginStatus } from "@jellyfin/sdk/lib/generated-client";
3 | import { getAuthenticatedJellyfinApi } from "../jellyfin-api";
4 |
5 | export const checkIfPlaybackReportingInstalled = async (): Promise => {
6 | const authenticatedApi = await getAuthenticatedJellyfinApi();
7 | const pluginsApi = getPluginsApi(authenticatedApi);
8 | const pluginsResponse = await pluginsApi.getPlugins();
9 | const plugins = pluginsResponse.data;
10 | const playbackReportingPlugin = plugins.find(
11 | (plugin) => plugin.Name === "Playback Reporting"
12 | );
13 | return playbackReportingPlugin?.Status === PluginStatus.Active;
14 | };
15 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.TextareaHTMLAttributes
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = "Textarea";
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/src/lib/queries/images.ts:
--------------------------------------------------------------------------------
1 | import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
2 | import { ImageType } from "@jellyfin/sdk/lib/generated-client";
3 | import { getAuthenticatedJellyfinApi } from "../jellyfin-api";
4 | import { getCacheValue, setCacheValue } from "../cache";
5 |
6 | export const getImageUrlById = async (id: string): Promise => {
7 | const cacheKey = `imageUrlCache_${id}`;
8 | const cachedUrl = getCacheValue(cacheKey);
9 | if (cachedUrl) {
10 | return cachedUrl;
11 | }
12 | const api = getImageApi(await getAuthenticatedJellyfinApi());
13 | const url = api.getItemImageUrlById(id, ImageType.Primary, {
14 | maxWidth: 300,
15 | width: 300,
16 | quality: 90,
17 | });
18 | setCacheValue(cacheKey, url);
19 | return url;
20 | };
21 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "baseUrl": ".",
16 | "paths": {
17 | "@/*": ["./src/*"]
18 | },
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true
25 | },
26 | "include": ["vite.config.ts"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM httpd:2.4-alpine
2 |
3 | # Enable necessary Apache modules
4 | RUN sed -i \
5 | -e '/LoadModule substitute_module/s/^#//g' \
6 | -e '/LoadModule filter_module/s/^#//g' \
7 | -e '/LoadModule env_module/s/^#//g' \
8 | /usr/local/apache2/conf/httpd.conf
9 |
10 | # Copy your static files
11 | COPY ./dist /usr/local/apache2/htdocs/
12 |
13 | # Copy Apache configuration
14 | COPY ./apache-config.conf /usr/local/apache2/conf/extra/apache-config.conf
15 |
16 | # Include our custom config
17 | RUN echo "Include conf/extra/apache-config.conf" \
18 | >> /usr/local/apache2/conf/httpd.conf
19 |
20 | # Make sure Apache can read environment variables
21 | RUN echo "PassEnv JELLYFIN_SERVER_URL" \
22 | >> /usr/local/apache2/conf/httpd.conf && \
23 | echo "PassEnv JELLYFIN_API_KEY" \
24 | >> /usr/local/apache2/conf/httpd.conf
25 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Input = React.forwardRef<
6 | HTMLInputElement,
7 | React.InputHTMLAttributes
8 | >(({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | });
21 | Input.displayName = "Input";
22 |
23 | export { Input };
24 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/*": ["./src/*"]
20 | },
21 | /* Linting */
22 | "strict": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noFallthroughCasesInSwitch": true,
26 | "noUncheckedSideEffectImports": true
27 | },
28 | "include": ["src"]
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/queries/types.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BaseItemPerson,
3 | NameGuidPair,
4 | } from "@jellyfin/sdk/lib/generated-client";
5 |
6 | export type SimpleItemDto = {
7 | id?: string;
8 | parentId?: string | null;
9 | name?: string | null;
10 | date?: string | null;
11 | communityRating?: number | null;
12 | productionYear?: number | null;
13 | people?: BaseItemPerson[] | null;
14 | genres?: string[] | null;
15 | genreItems?: NameGuidPair[] | null;
16 | durationSeconds?: number;
17 | };
18 |
19 | export interface PunchCardData {
20 | dayOfWeek: number;
21 | hour: number;
22 | count: number;
23 | }
24 |
25 | export interface CalendarData {
26 | value: number;
27 | day: string;
28 | }
29 |
30 | export type UnfinishedShowDto = {
31 | item: SimpleItemDto;
32 | watchedEpisodes: number;
33 | totalEpisodes: number;
34 | lastWatchedDate: Date;
35 | };
36 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { buttonVariants } from "@/lib/button-variants";
7 |
8 | export interface ButtonProps
9 | extends React.ButtonHTMLAttributes,
10 | VariantProps {
11 | asChild?: boolean;
12 | }
13 |
14 | const Button = React.forwardRef(
15 | ({ className, variant, size, asChild = false, ...props }, ref) => {
16 | const Comp = asChild ? Slot : "button";
17 | return (
18 |
23 | );
24 | }
25 | );
26 | Button.displayName = "Button";
27 |
28 | export { Button };
29 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function formatDuration(seconds: number): string {
9 | const minutes = Math.floor(seconds / 60);
10 | const hours = Math.floor(minutes / 60);
11 | const remainingMinutes = minutes % 60;
12 |
13 | if (hours === 0) {
14 | return `${remainingMinutes} minutes`;
15 | } else if (hours === 1) {
16 | return `${hours} hour ${remainingMinutes} minutes`;
17 | } else {
18 | return `${hours} hours ${remainingMinutes} minutes`;
19 | }
20 | }
21 | export const generateGuid = () => {
22 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
23 | const r = (Math.random() * 16) | 0;
24 | const v = c === "x" ? r : (r & 0x3) | 0x8;
25 | return v.toString(16);
26 | });
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/ContentImage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Avatar } from "@radix-ui/themes";
3 | import { getImageUrlById, SimpleItemDto } from "@/lib/queries";
4 |
5 | export function ContentImage({ item }: { item: SimpleItemDto }) {
6 | const [imageUrl, setImageUrl] = useState("");
7 |
8 | useEffect(() => {
9 | const fetchImageUrl = async () => {
10 | try {
11 | const url = await getImageUrlById(item.id ?? "");
12 | setImageUrl(url);
13 | } catch (error) {
14 | console.error("Failed to fetch image URL:", error);
15 | }
16 | };
17 |
18 | void fetchImageUrl();
19 | }, [item]);
20 |
21 | return (
22 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/BlinkingStars.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from "react";
2 | import { AnimatePresence, motion } from "framer-motion";
3 |
4 | function BlinkingStars() {
5 | return (
6 |
7 | {[...Array(20)].map((_, i) => (
8 |
25 | ))}
26 |
27 | );
28 | }
29 |
30 | export default memo(BlinkingStars);
31 |
--------------------------------------------------------------------------------
/src/lib/holiday-helpers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getChristmas,
3 | getHalloween,
4 | getValentinesDay,
5 | } from "date-fns-holiday-us";
6 | import { isAfter, isSameDay } from "date-fns";
7 |
8 | export function getHolidayYear(holidayDate: Date, today: Date): number {
9 | return isAfter(today, holidayDate) && !isSameDay(today, holidayDate)
10 | ? today.getFullYear()
11 | : today.getFullYear() - 1;
12 | }
13 |
14 | export function getHolidayDates(today: Date) {
15 | const christmasYear = getHolidayYear(
16 | getChristmas(today.getFullYear()),
17 | today
18 | );
19 | const halloweenYear = getHolidayYear(
20 | getHalloween(today.getFullYear()),
21 | today
22 | );
23 | const valentinesYear = getHolidayYear(
24 | getValentinesDay(today.getFullYear()),
25 | today
26 | );
27 |
28 | return {
29 | christmas: getChristmas(christmasYear),
30 | halloween: getHalloween(halloweenYear),
31 | valentines: getValentinesDay(valentinesYear),
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 John Corser
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/lib/genre-helpers.ts:
--------------------------------------------------------------------------------
1 | import { SimpleItemDto } from "./queries/types";
2 |
3 | export function getTopGenre(
4 | movies: SimpleItemDto[],
5 | shows: SimpleItemDto[]
6 | ): { genre: string; items: SimpleItemDto[]; count: number; honorableMentions: { genre: string; count: number }[] } | null {
7 | const genreCounts = new Map();
8 |
9 | [...movies, ...shows].forEach((item: SimpleItemDto) => {
10 | item.genres?.forEach((genre: string) => {
11 | const existing = genreCounts.get(genre) || [];
12 | genreCounts.set(genre, [...existing, item]);
13 | });
14 | });
15 |
16 | // Sort genres by count
17 | const sortedGenres = Array.from(genreCounts.entries())
18 | .sort((a, b) => b[1].length - a[1].length);
19 |
20 | if (sortedGenres.length === 0) return null;
21 |
22 | const [topGenre, topItems] = sortedGenres[0];
23 | const honorableMentions = sortedGenres.slice(1, 4).map(([genre, items]) => ({
24 | genre,
25 | count: items.length,
26 | }));
27 |
28 | return {
29 | genre: topGenre,
30 | items: topItems,
31 | count: topItems.length,
32 | honorableMentions,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/lib/queries/movies.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCurrentUserId,
3 | getStartDate,
4 | getEndDate,
5 | formatDateForSql,
6 | playbackReportingSqlRequest,
7 | } from "./utils";
8 | import { getItemDtosByIds } from "./items";
9 | import { SimpleItemDto } from "./types";
10 |
11 | export const listMovies = async (): Promise => {
12 | const userId = await getCurrentUserId();
13 | const startDate = getStartDate();
14 | const endDate = getEndDate();
15 |
16 | const queryString = `
17 | SELECT ROWID, *
18 | FROM PlaybackActivity
19 | WHERE UserId = "${userId}"
20 | AND ItemType = "Movie"
21 | AND DateCreated > '${formatDateForSql(startDate)}'
22 | AND DateCreated <= '${formatDateForSql(endDate)}'
23 | ORDER BY rowid DESC
24 | `;
25 | const data = await playbackReportingSqlRequest(queryString);
26 |
27 | const itemIdIndex = data.colums.findIndex((i: string) => i == "ItemId");
28 | const movieItemIds = data.results
29 | .map((result: string[]) => result[itemIdIndex])
30 | .filter(
31 | (value: string, index: number, self: string[]) =>
32 | self.indexOf(value) === index
33 | );
34 | return getItemDtosByIds(movieItemIds);
35 | };
36 |
--------------------------------------------------------------------------------
/src/lib/rating-helpers.ts:
--------------------------------------------------------------------------------
1 | import { SimpleItemDto } from "./queries/types";
2 |
3 | export type TopContent = {
4 | item: SimpleItemDto;
5 | type: "movie" | "show";
6 | };
7 |
8 | export function getTopRatedContent(
9 | movies: SimpleItemDto[],
10 | shows: { item: SimpleItemDto }[]
11 | ): TopContent[] {
12 | const topMovie = movies
13 | .filter(
14 | (movie: SimpleItemDto) =>
15 | movie.communityRating != null && movie.id != null
16 | )
17 | .sort(
18 | (a: SimpleItemDto, b: SimpleItemDto) =>
19 | (b.communityRating ?? 0) - (a.communityRating ?? 0)
20 | )[0];
21 |
22 | const topShow = shows
23 | .filter(
24 | (show: { item: SimpleItemDto }) =>
25 | show.item.communityRating != null && show.item.id != null
26 | )
27 | .sort(
28 | (a: { item: SimpleItemDto }, b: { item: SimpleItemDto }) =>
29 | (b.item.communityRating ?? 0) - (a.item.communityRating ?? 0)
30 | )[0];
31 |
32 | const result: TopContent[] = [];
33 |
34 | if (topMovie) {
35 | result.push({ item: topMovie, type: "movie" });
36 | }
37 |
38 | if (topShow) {
39 | result.push({ item: topShow.item, type: "show" });
40 | }
41 |
42 | return result;
43 | }
44 |
--------------------------------------------------------------------------------
/test-api.js:
--------------------------------------------------------------------------------
1 | const serverUrl = process.env.VITE_JELLYFIN_SERVER_URL;
2 | const apiKey = process.env.VITE_JELLYFIN_API_KEY;
3 |
4 | (async () => {
5 | // Check plugin status
6 | const res = await fetch(`${serverUrl}/Plugins`, {
7 | headers: { 'X-Emby-Token': apiKey }
8 | });
9 | const plugins = await res.json();
10 | const playback = plugins.find(p => p.Name === 'Playback Reporting');
11 | console.log('Playback Reporting status:', playback?.Status);
12 |
13 | // Test the query endpoint
14 | const r = await fetch(`${serverUrl}/user_usage_stats/submit_custom_query`, {
15 | method: 'POST',
16 | headers: {
17 | 'X-Emby-Token': apiKey,
18 | 'Content-Type': 'application/json',
19 | },
20 | body: JSON.stringify({
21 | CustomQueryString: 'SELECT * FROM PlaybackActivity LIMIT 1',
22 | ReplaceUserId: false,
23 | }),
24 | });
25 |
26 | console.log('Query endpoint status:', r.status);
27 | const text = await r.text();
28 | if (text) {
29 | console.log('Response:', text.substring(0, 300));
30 | try {
31 | const json = JSON.parse(text);
32 | console.log('✓ JSON parsed successfully');
33 | console.log('Keys:', Object.keys(json));
34 | } catch (e) {
35 | console.log('✗ Not valid JSON');
36 | }
37 | }
38 | })();
39 |
--------------------------------------------------------------------------------
/src/scripts/check-columns.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { config } from "dotenv";
3 | config();
4 |
5 | (global as any).alert = (msg: string) => console.error("Alert:", msg);
6 | (global as any).window = {
7 | ENV: {
8 | JELLYFIN_SERVER_URL: process.env.VITE_JELLYFIN_SERVER_URL,
9 | JELLYFIN_API_KEY: process.env.VITE_JELLYFIN_API_KEY,
10 | }
11 | };
12 |
13 | const localStorageMock = {
14 | store: {} as Record,
15 | getItem(key: string) { return this.store[key] || null; },
16 | setItem(key: string, value: string) { this.store[key] = value; },
17 | removeItem(key: string) { delete this.store[key]; },
18 | };
19 |
20 | (global as any).localStorage = localStorageMock;
21 | localStorageMock.setItem("jellyfinUsername", "john");
22 | localStorageMock.setItem("jellyfinPassword", "getthejelly");
23 |
24 | import { authenticateByUserName } from "../lib/jellyfin-api.js";
25 | import { playbackReportingSqlRequest } from "../lib/queries/utils.js";
26 |
27 | async function checkColumns() {
28 | await authenticateByUserName(process.env.VITE_JELLYFIN_SERVER_URL || "", "john", "getthejelly");
29 |
30 | const testQuery = `SELECT * FROM PlaybackActivity LIMIT 1`;
31 | const testData = await playbackReportingSqlRequest(testQuery);
32 | console.log("Available columns:", JSON.stringify(testData.colums, null, 2));
33 | }
34 |
35 | checkColumns();
36 |
--------------------------------------------------------------------------------
/src/lib/button-variants.ts:
--------------------------------------------------------------------------------
1 | import { type VariantProps, cva } from "class-variance-authority";
2 |
3 | export const buttonVariants = cva(
4 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
5 | {
6 | variants: {
7 | variant: {
8 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
9 | destructive:
10 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
11 | outline:
12 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
13 | secondary:
14 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | ghost: "hover:bg-accent hover:text-accent-foreground",
16 | link: "text-primary underline-offset-4 hover:underline",
17 | },
18 | size: {
19 | default: "h-10 px-4 py-2",
20 | sm: "h-9 rounded-md px-3",
21 | lg: "h-11 rounded-md px-8",
22 | icon: "h-10 w-10",
23 | },
24 | },
25 | defaultVariants: {
26 | variant: "default",
27 | size: "default",
28 | },
29 | }
30 | );
31 |
32 | export type ButtonVariants = VariantProps;
33 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/scripts/list-tables.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { config } from "dotenv";
3 | config();
4 |
5 | (global as any).alert = (msg: string) => console.error("Alert:", msg);
6 | (global as any).window = {
7 | ENV: {
8 | JELLYFIN_SERVER_URL: process.env.VITE_JELLYFIN_SERVER_URL,
9 | JELLYFIN_API_KEY: process.env.VITE_JELLYFIN_API_KEY,
10 | }
11 | };
12 |
13 | const localStorageMock = {
14 | store: {} as Record,
15 | getItem(key: string) { return this.store[key] || null; },
16 | setItem(key: string, value: string) { this.store[key] = value; },
17 | removeItem(key: string) { delete this.store[key]; },
18 | };
19 |
20 | (global as any).localStorage = localStorageMock;
21 | localStorageMock.setItem("jellyfinUsername", "john");
22 | localStorageMock.setItem("jellyfinPassword", "getthejelly");
23 |
24 | import { authenticateByUserName } from "../lib/jellyfin-api.js";
25 | import { playbackReportingSqlRequest } from "../lib/queries/utils.js";
26 |
27 | async function listTables() {
28 | await authenticateByUserName(process.env.VITE_JELLYFIN_SERVER_URL || "", "john", "getthejelly");
29 |
30 | console.log("\n📊 Listing available SQL tables...\n");
31 |
32 | // SQLite query to list all tables
33 | const query = `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`;
34 | const data = await playbackReportingSqlRequest(query);
35 |
36 | console.log("Available tables:");
37 | data.results.forEach((row: string[]) => {
38 | console.log(" -", row[0]);
39 | });
40 |
41 | console.log("\n✅ Done!");
42 | }
43 |
44 | listTables();
45 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { CheckIcon } from "@radix-ui/react-icons";
3 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return (
12 |
17 | );
18 | });
19 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
20 |
21 | const RadioGroupItem = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => {
25 | return (
26 |
34 |
35 |
36 |
37 |
38 | );
39 | });
40 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
41 |
42 | export { RadioGroup, RadioGroupItem };
43 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/src/lib/cache.ts:
--------------------------------------------------------------------------------
1 | export const JELLYFIN_SERVER_URL_CACHE_KEY = "jellyfinServerUrl";
2 | export const JELLYFIN_AUTH_TOKEN_CACHE_KEY = "jellyfinAuthToken";
3 | export const JELLYFIN_USERNAME_CACHE_KEY = "jellyfinUsername";
4 | export const JELLYFIN_PASSWORD_CACHE_KEY = "jellyfinPassword";
5 | export const JELLYFIN_CURRENT_USER_CACHE_KEY = "jellyfinwrapped_current_user";
6 | export const JELLYFIN_HIDDEN_ITEMS = "jellyfinwrapped_hidden_items";
7 | const localCache: Record = {};
8 | export const setCacheValue = (key: string, value: string) => {
9 | try {
10 | localCache[key] = value;
11 | localStorage.setItem(key, value);
12 | if (key === JELLYFIN_SERVER_URL_CACHE_KEY ||
13 | key === JELLYFIN_AUTH_TOKEN_CACHE_KEY ||
14 | key === JELLYFIN_USERNAME_CACHE_KEY ||
15 | key === JELLYFIN_PASSWORD_CACHE_KEY) {
16 | localStorage.removeItem(JELLYFIN_CURRENT_USER_CACHE_KEY);
17 | }
18 | } catch (error) {
19 | console.warn(`Error setting cache value for key ${key}:`, error);
20 | }
21 | };
22 |
23 | export const getCacheValue = (key: string): string | null => {
24 | // Check if the value is in the local cache
25 | if (localCache[key]) {
26 | return localCache[key];
27 | }
28 |
29 | const value = localStorage.getItem(key);
30 | if (value) {
31 | localCache[key] = value;
32 | return value;
33 | }
34 |
35 | return null;
36 | };
37 |
38 | export const getCachedHiddenIds = (): string[] => {
39 | return getCacheValue(JELLYFIN_HIDDEN_ITEMS)?.split(",") ?? [];
40 | };
41 |
42 | export const setCachedHiddenId = (id: string) => {
43 | const hiddenIds = getCachedHiddenIds();
44 | hiddenIds.push(id);
45 | setCacheValue(JELLYFIN_HIDDEN_ITEMS, hiddenIds.join(","));
46 | };
47 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactRefresh from "eslint-plugin-react-refresh";
5 | import tseslint from "typescript-eslint";
6 |
7 | export default tseslint.config(
8 | {
9 | ignores: ["dist", "**/*.config.js", "**/*.config.ts", "node_modules"],
10 | },
11 | {
12 | extends: [
13 | js.configs.recommended,
14 | ...tseslint.configs.recommended,
15 | ...tseslint.configs.recommendedTypeChecked,
16 | ],
17 | files: ["src/**/*.{ts,tsx}"],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | parser: tseslint.parser,
22 | parserOptions: {
23 | project: "./tsconfig.eslint.json",
24 | tsconfigRootDir: import.meta.dirname,
25 | },
26 | },
27 | plugins: {
28 | "react-hooks": reactHooks,
29 | "react-refresh": reactRefresh,
30 | },
31 | rules: {
32 | ...reactHooks.configs.recommended.rules,
33 | "react-refresh/only-export-components": [
34 | "error",
35 | { allowConstantExport: true },
36 | ],
37 | "@typescript-eslint/await-thenable": "error",
38 | "@typescript-eslint/no-explicit-any": "error",
39 | "@typescript-eslint/no-unsafe-assignment": "error",
40 | "@typescript-eslint/no-unsafe-member-access": "error",
41 | "@typescript-eslint/no-unsafe-call": "error",
42 | "@typescript-eslint/no-unsafe-return": "error",
43 | "react-hooks/exhaustive-deps": "error",
44 | "@typescript-eslint/restrict-template-expressions": "error",
45 | "@typescript-eslint/no-base-to-string": "error",
46 | "@typescript-eslint/unbound-method": "error",
47 | },
48 | },
49 | );
50 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ));
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ));
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
45 |
46 | export { ScrollArea, ScrollBar };
47 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ["class"],
4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5 | theme: {
6 | extend: {
7 | borderRadius: {
8 | lg: "var(--radius)",
9 | md: "calc(var(--radius) - 2px)",
10 | sm: "calc(var(--radius) - 4px)",
11 | },
12 | colors: {
13 | background: "hsl(var(--background))",
14 | foreground: "hsl(var(--foreground))",
15 | card: {
16 | DEFAULT: "hsl(var(--card))",
17 | foreground: "hsl(var(--card-foreground))",
18 | },
19 | popover: {
20 | DEFAULT: "hsl(var(--popover))",
21 | foreground: "hsl(var(--popover-foreground))",
22 | },
23 | primary: {
24 | DEFAULT: "hsl(var(--primary))",
25 | foreground: "hsl(var(--primary-foreground))",
26 | },
27 | secondary: {
28 | DEFAULT: "hsl(var(--secondary))",
29 | foreground: "hsl(var(--secondary-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | destructive: {
40 | DEFAULT: "hsl(var(--destructive))",
41 | foreground: "hsl(var(--destructive-foreground))",
42 | },
43 | border: "hsl(var(--border))",
44 | input: "hsl(var(--input))",
45 | ring: "hsl(var(--ring))",
46 | chart: {
47 | 1: "hsl(var(--chart-1))",
48 | 2: "hsl(var(--chart-2))",
49 | 3: "hsl(var(--chart-3))",
50 | 4: "hsl(var(--chart-4))",
51 | 5: "hsl(var(--chart-5))",
52 | },
53 | },
54 | },
55 | },
56 | plugins: [require("tailwindcss-animate")],
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/PageContainer.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Box, Button, Flex } from "@radix-ui/themes";
3 | import { useNavigate } from "react-router-dom";
4 | import { styled } from "@stitches/react";
5 |
6 | interface PageContainerProps {
7 | children: ReactNode;
8 | backgroundColor?: string;
9 | nextPage?: string;
10 | previousPage?: string;
11 | }
12 |
13 | const PageContainer = ({
14 | children,
15 | backgroundColor = "var(--purple-8)",
16 | nextPage,
17 | previousPage,
18 | }: PageContainerProps) => {
19 | const navigate = useNavigate();
20 |
21 | return (
22 |
23 |
24 | {children}
25 |
26 |
27 | {(nextPage || previousPage) && (
28 |
29 |
30 | {previousPage && (
31 |
41 | )}
42 | {nextPage && (
43 |
52 | )}
53 |
54 |
55 | )}
56 |
57 | );
58 | };
59 |
60 | const NavButtonContainer = styled("div", {
61 | position: "fixed",
62 | bottom: 0,
63 | left: 0,
64 | right: 0,
65 | zIndex: 10,
66 | padding: "0 1rem 1rem 1rem",
67 | });
68 |
69 | export default PageContainer;
70 |
--------------------------------------------------------------------------------
/src/lib/queries/livetv.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCurrentUserId,
3 | getStartDate,
4 | getEndDate,
5 | formatDateForSql,
6 | playbackReportingSqlRequest,
7 | } from "./utils";
8 |
9 | export const listLiveTvChannels = async (): Promise<
10 | {
11 | channelName: string;
12 | duration: number;
13 | }[]
14 | > => {
15 | const userId = await getCurrentUserId();
16 | const startDate = getStartDate();
17 | const endDate = getEndDate();
18 |
19 | const queryString = `
20 | SELECT ROWID, *
21 | FROM PlaybackActivity
22 | WHERE UserId = "${userId}"
23 | AND ItemType = "TvChannel"
24 | AND DateCreated > '${formatDateForSql(startDate)}'
25 | AND DateCreated <= '${formatDateForSql(endDate)}'
26 | ORDER BY rowid DESC
27 | `;
28 | const data = await playbackReportingSqlRequest(queryString);
29 |
30 | const itemNameIndex = data.colums.findIndex((i: string) => i == "ItemName");
31 | const playDurationIndex = data.colums.findIndex(
32 | (i: string) => i == "PlayDuration"
33 | );
34 |
35 | const channelWatchTimeInfo = data.results
36 | .map((result: string[]) => {
37 | return {
38 | channelName: result[itemNameIndex],
39 | duration: parseInt(result[playDurationIndex]),
40 | };
41 | })
42 | .reduce(
43 | (
44 | acc: { channelName: string; duration: number }[],
45 | curr: { channelName: string; duration: number }
46 | ) => {
47 | const existingChannel = acc.find(
48 | (item: { channelName: string }) =>
49 | item.channelName === curr.channelName
50 | );
51 | if (existingChannel) {
52 | existingChannel.duration += curr.duration;
53 | } else {
54 | acc.push({ ...curr });
55 | }
56 | return acc;
57 | },
58 | []
59 | );
60 |
61 | channelWatchTimeInfo.sort(
62 | (a: { duration: number }, b: { duration: number }) =>
63 | b.duration - a.duration
64 | );
65 |
66 | return channelWatchTimeInfo;
67 | };
68 |
--------------------------------------------------------------------------------
/src/components/pages/MusicVideoPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid } from "@radix-ui/themes";
2 | import { motion } from "framer-motion";
3 | import { useNavigate } from "react-router-dom";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useMusicVideos } from "@/hooks/queries/useMusicVideos";
6 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
7 | import { LoadingSpinner } from "../LoadingSpinner";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import PageContainer from "../PageContainer";
11 |
12 | const NEXT_PAGE = "/actors";
13 |
14 | export default function MusicVideoPage() {
15 | const { showBoundary } = useErrorBoundary();
16 | const navigate = useNavigate();
17 | const { data: musicVideos, isLoading, error } = useMusicVideos();
18 |
19 | if (error) {
20 | showBoundary(error);
21 | }
22 |
23 | if (isLoading) {
24 | return ;
25 | }
26 |
27 | if (!musicVideos?.length) {
28 | void navigate(NEXT_PAGE);
29 | return null;
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 | You Listened to {musicVideos.length} Music Videos
39 |
40 |
41 | Your music video viewing collection
42 |
43 |
44 |
45 |
46 | {musicVideos.slice(0, 20).map((musicVideo: { id?: string }) => (
47 |
48 | ))}
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jellyfin-wrapped",
3 | "license": "MIT",
4 | "private": true,
5 | "version": "0.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "tsc -b && vite build",
10 | "lint": "eslint .",
11 | "preview": "vite preview",
12 | "validate": "tsx src/scripts/validate-data.ts",
13 | "prepare-husky": "husky",
14 | "prepare": "husky"
15 | },
16 | "dependencies": {
17 | "@jellyfin/sdk": "^0.11.0",
18 | "@nivo/calendar": "^0.88.0",
19 | "@nivo/scatterplot": "^0.88.0",
20 | "@radix-ui/react-icons": "^1.3.2",
21 | "@radix-ui/react-label": "^2.1.1",
22 | "@radix-ui/themes": "^3.1.6",
23 | "@stitches/react": "^1.2.8",
24 | "@tanstack/react-query": "^5.66.9",
25 | "class-variance-authority": "^0.7.1",
26 | "d3": "^7.9.0",
27 | "date-fns": "^4.1.0",
28 | "date-fns-holiday-us": "^1.1.0",
29 | "framer-motion": "^11.15.0",
30 | "lucide-react": "^0.469.0",
31 | "react": "^18.3.1",
32 | "react-chartjs-2": "^5.3.0",
33 | "react-dom": "^18.3.1",
34 | "react-error-boundary": "^5.0.0",
35 | "react-router-dom": "^7.1.1",
36 | "tailwind-merge": "^2.6.0"
37 | },
38 | "devDependencies": {
39 | "@eslint/js": "^9.17.0",
40 | "@types/d3": "^7.4.3",
41 | "@types/node": "^22.10.3",
42 | "@types/react": "^18.3.18",
43 | "@types/react-dom": "^18.3.5",
44 | "@vitejs/plugin-react": "^4.3.4",
45 | "dotenv": "^17.2.3",
46 | "eslint": "^9.17.0",
47 | "eslint-plugin-react-hooks": "^5.0.0",
48 | "eslint-plugin-react-refresh": "^0.4.16",
49 | "globals": "^15.14.0",
50 | "husky": "^9.1.7",
51 | "lint-staged": "^15.3.0",
52 | "prettier": "^3.4.2",
53 | "tailwindcss": "^3.4.17",
54 | "tsx": "^4.21.0",
55 | "typescript": "~5.6.2",
56 | "typescript-eslint": "^8.18.2",
57 | "vite": "^6.0.5"
58 | },
59 | "lint-staged": {
60 | "*.{ts,tsx}": [
61 | "prettier --write",
62 | "eslint --cache --fix"
63 | ],
64 | "*.{json,yml,md}": "prettier --write"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/pages/MoviesReviewPage/ActorCard.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Avatar } from "@radix-ui/themes";
3 | import { getImageUrlById, SimpleItemDto } from "@/lib/queries";
4 | import { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client";
5 |
6 | interface ActorCardProps {
7 | name: string;
8 | count: number;
9 | details: BaseItemPerson;
10 | seenInMovies: SimpleItemDto[];
11 | seenInShows: SimpleItemDto[];
12 | }
13 |
14 | export function ActorCard({
15 | name,
16 | count,
17 | details,
18 | seenInMovies,
19 | seenInShows,
20 | }: ActorCardProps) {
21 | const [imageUrl, setImageUrl] = useState();
22 | useEffect(() => {
23 | const fetchImageUrl = async () => {
24 | try {
25 | const url = await getImageUrlById(details.Id ?? "");
26 | setImageUrl(url);
27 | } catch (error) {
28 | console.error("Failed to fetch image URL:", error);
29 | }
30 | };
31 |
32 | void fetchImageUrl();
33 | }, [details]);
34 |
35 | return (
36 |
37 |
51 |
52 |
{name}
53 | {count > 0 && (
54 | <>
55 |
56 | You watched {name} {count} times
57 |
58 |
59 | {[...seenInMovies, ...seenInShows].map((itemDto) => (
60 | - {itemDto.name}
61 | ))}
62 |
63 | >
64 | )}
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/lib/queries/audio.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCurrentUserId,
3 | getStartDate,
4 | getEndDate,
5 | formatDateForSql,
6 | playbackReportingSqlRequest,
7 | } from "./utils";
8 | import { getItemDtosByIds } from "./items";
9 | import { SimpleItemDto } from "./types";
10 |
11 | export const listAudio = async (): Promise => {
12 | const userId = await getCurrentUserId();
13 | const startDate = getStartDate();
14 | const endDate = getEndDate();
15 |
16 | const queryString = `
17 | SELECT ROWID, *
18 | FROM PlaybackActivity
19 | WHERE UserId = "${userId}"
20 | AND ItemType = "Audio"
21 | AND DateCreated > '${formatDateForSql(startDate)}'
22 | AND DateCreated <= '${formatDateForSql(endDate)}'
23 | ORDER BY rowid DESC
24 | `;
25 | const data = await playbackReportingSqlRequest(queryString);
26 |
27 | const itemIdIndex = data.colums.findIndex((i: string) => i == "ItemId");
28 | const movieItemIds = data.results
29 | .map((result: string[]) => result[itemIdIndex])
30 | .filter(
31 | (value: string, index: number, self: string[]) =>
32 | self.indexOf(value) === index
33 | );
34 | return getItemDtosByIds(movieItemIds);
35 | };
36 |
37 | export const listMusicVideos = async (): Promise => {
38 | const userId = await getCurrentUserId();
39 | const startDate = getStartDate();
40 | const endDate = getEndDate();
41 |
42 | const queryString = `
43 | SELECT ROWID, *
44 | FROM PlaybackActivity
45 | WHERE UserId = "${userId}"
46 | AND ItemType = "MusicVideo"
47 | AND DateCreated > '${formatDateForSql(startDate)}'
48 | AND DateCreated <= '${formatDateForSql(endDate)}'
49 | ORDER BY rowid DESC
50 | `;
51 | const data = await playbackReportingSqlRequest(queryString);
52 |
53 | const itemIdIndex = data.colums.findIndex((i: string) => i == "ItemId");
54 | const movieItemIds = data.results
55 | .map((result: string[]) => result[itemIdIndex])
56 | .filter(
57 | (value: string, index: number, self: string[]) =>
58 | self.indexOf(value) === index
59 | );
60 | return getItemDtosByIds(movieItemIds);
61 | };
62 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/src/components/pages/AudioReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid } from "@radix-ui/themes";
2 | import { motion } from "framer-motion";
3 | import { useNavigate } from "react-router-dom";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useAudio } from "@/hooks/queries/useAudio";
6 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
7 | import { LoadingSpinner } from "../LoadingSpinner";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import PageContainer from "../PageContainer";
11 |
12 | const NEXT_PAGE = "/music-videos";
13 | const MAX_DISPLAY_ITEMS = 20;
14 |
15 | export default function AudioReviewPage() {
16 | const { showBoundary } = useErrorBoundary();
17 | const navigate = useNavigate();
18 | const { data: audios, isLoading, error } = useAudio();
19 |
20 | if (error) {
21 | showBoundary(error);
22 | }
23 |
24 | if (isLoading) {
25 | return ;
26 | }
27 |
28 | if (!audios?.length) {
29 | void navigate(NEXT_PAGE);
30 | return null;
31 | }
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 | You Listened to {audios.length} Songs
40 |
41 |
42 | Your music listening history
43 |
44 | {audios.length > MAX_DISPLAY_ITEMS && (
45 |
46 | Showing top {MAX_DISPLAY_ITEMS} songs
47 |
48 | )}
49 |
50 |
51 |
52 | {audios
53 | .slice(0, MAX_DISPLAY_ITEMS)
54 | .map((audio: { id?: string }) => (
55 |
56 | ))}
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/ScrollingText.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollingTextProps } from "@/types";
2 | import { motion } from "framer-motion";
3 | import { Loader2 } from "lucide-react";
4 | import { useEffect, useRef, useState } from "react";
5 |
6 | export default function ScrollingText({
7 | generatedImage,
8 | story,
9 | }: ScrollingTextProps) {
10 | const containerRef = useRef(null);
11 | const contentRef = useRef(null);
12 | const [currentIndex, setCurrentIndex] = useState(0);
13 | const [displayedStory, setDisplayedStory] = useState("");
14 |
15 | useEffect(() => {
16 | if (currentIndex < story.length) {
17 | const timer = setTimeout(() => {
18 | setDisplayedStory((prev) => prev + story[currentIndex]);
19 | setCurrentIndex((prev) => prev + 1);
20 | }, 15);
21 | return () => clearTimeout(timer);
22 | }
23 | }, [currentIndex, story]);
24 |
25 | useEffect(() => {
26 | const currentContainerRef = containerRef.current?.offsetHeight ?? 0;
27 | const currentContentRef = contentRef.current?.offsetHeight ?? 0;
28 | if (currentContainerRef < currentContentRef) {
29 | containerRef.current?.scrollTo(0, contentRef.current?.offsetHeight ?? 0);
30 | }
31 | }, [displayedStory]);
32 |
33 | return (
34 |
41 | {generatedImage ? (
42 | <>
43 |
47 |
51 | {displayedStory}
52 |
53 | >
54 | ) : (
55 |
56 |
57 | Weaving Story...
58 |
59 | )}
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/lib/queries/utils.ts:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 | import { getCurrentTimeframe } from "../timeframe";
3 | import { getAdminJellyfinApi, getEnvVar } from "../jellyfin-api";
4 |
5 | export const getStartDate = (): Date => {
6 | return getCurrentTimeframe().startDate;
7 | };
8 |
9 | export const getEndDate = (): Date => {
10 | return getCurrentTimeframe().endDate;
11 | };
12 |
13 | export const formatDateForSql = (date: Date): string => {
14 | return format(date, "yyyy-MM-dd");
15 | };
16 |
17 | export const getCurrentUserId = async (): Promise => {
18 | const { getUserApi } = await import("@jellyfin/sdk/lib/utils/api");
19 | const { getAuthenticatedJellyfinApi } = await import("../jellyfin-api");
20 | const { getCacheValue, setCacheValue, JELLYFIN_CURRENT_USER_CACHE_KEY } =
21 | await import("../cache");
22 |
23 | const cachedUserId = getCacheValue(JELLYFIN_CURRENT_USER_CACHE_KEY);
24 | if (cachedUserId) {
25 | return cachedUserId;
26 | }
27 |
28 | const authenticatedApi = await getAuthenticatedJellyfinApi();
29 | const userApi = getUserApi(authenticatedApi);
30 | const user = await userApi.getCurrentUser();
31 | const userId = user.data.Id ?? "";
32 | setCacheValue(JELLYFIN_CURRENT_USER_CACHE_KEY, userId);
33 | return userId;
34 | };
35 |
36 | export const playbackReportingSqlRequest = async (
37 | queryString: string
38 | ): Promise<{
39 | colums: string[];
40 | results: string[][];
41 | }> => {
42 | const adminApi = getAdminJellyfinApi();
43 | const res = await fetch(
44 | `${adminApi.basePath}/user_usage_stats/submit_custom_query?stamp=${Date.now()}`,
45 | {
46 | method: "POST",
47 | headers: {
48 | "X-Emby-Token": `${getEnvVar("JELLYFIN_API_KEY")}`,
49 | "Content-Type": "application/json",
50 | },
51 | body: JSON.stringify({
52 | CustomQueryString: queryString,
53 | ReplaceUserId: true,
54 | }),
55 | }
56 | );
57 |
58 | const text = await res.text();
59 |
60 | if (!text) {
61 | throw new Error("Empty response from Jellyfin server");
62 | }
63 |
64 | try {
65 | return JSON.parse(text) as {
66 | colums: string[];
67 | results: string[][];
68 | };
69 | } catch (e) {
70 | console.error("Failed to parse JSON:", e);
71 | console.error("Response was:", text);
72 | throw new Error(`Invalid JSON response: ${text.substring(0, 200)}`);
73 | }
74 | };
75 |
--------------------------------------------------------------------------------
/src/components/pages/ActivityCalendarPage.tsx:
--------------------------------------------------------------------------------
1 | import { ResponsiveCalendar } from "@nivo/calendar";
2 | import { Card } from "@/components/ui/card";
3 | import { Container } from "@radix-ui/themes";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { subYears, format } from "date-fns";
6 | import { Title } from "../ui/styled";
7 | import { motion } from "framer-motion";
8 | import { useCalendar } from "@/hooks/queries/useCalendar";
9 | import { LoadingSpinner } from "../LoadingSpinner";
10 | import { useIsMobile } from "@/hooks/useIsMobile";
11 | import PageContainer from "../PageContainer";
12 |
13 | const NEXT_PAGE = "/";
14 |
15 | export default function ActivityCalendarPage() {
16 | const { showBoundary } = useErrorBoundary();
17 | const isMobile = useIsMobile();
18 | const { data, isLoading, error } = useCalendar();
19 |
20 | if (error) {
21 | showBoundary(error);
22 | }
23 |
24 | if (isLoading) {
25 | return ;
26 | }
27 |
28 | const fromDate = format(subYears(new Date(), 1), "yyyy-MM-dd");
29 | const toDate = format(new Date(), "yyyy-MM-dd");
30 |
31 | return (
32 |
33 |
34 |
35 |
36 | Your Viewing Activity
37 |
38 |
39 | A calendar view of your daily viewing patterns
40 |
41 |
42 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/pages/OldestMoviePage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid } from "@radix-ui/themes";
2 | import { motion } from "framer-motion";
3 | import { useNavigate } from "react-router-dom";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useMovies } from "@/hooks/queries/useMovies";
6 | import { LoadingSpinner } from "../LoadingSpinner";
7 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
8 | import { Subtitle, Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import PageContainer from "../PageContainer";
11 |
12 | const NEXT_PAGE = "/oldest-show";
13 |
14 | export default function OldestMoviePage() {
15 | const { showBoundary } = useErrorBoundary();
16 | const navigate = useNavigate();
17 | const { data: movies, isLoading, error } = useMovies();
18 |
19 | if (error) {
20 | showBoundary(error);
21 | }
22 |
23 | if (isLoading) {
24 | return ;
25 | }
26 |
27 | const sortedMovies = [...(movies ?? [])].sort(
28 | (a: { date?: string | null }, b: { date?: string | null }) => {
29 | const aDate = new Date(a.date ?? new Date());
30 | const bDate = new Date(b.date ?? new Date());
31 | return aDate.getTime() - bDate.getTime();
32 | }
33 | );
34 |
35 | const movie = sortedMovies[0];
36 |
37 | if (!movie) {
38 | void navigate(NEXT_PAGE);
39 | return null;
40 | }
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | Oldest Movie You Watched
49 |
50 |
51 | The most vintage film in your viewing history
52 |
53 |
54 | Released in {movie.productionYear}
55 |
56 |
57 |
58 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/charts/BarChart.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import * as d3 from "d3";
3 |
4 | type BarChartData = { label: string; value: number };
5 |
6 | export function BarChart({
7 | data,
8 | width,
9 | height,
10 | xLabel,
11 | yLabel,
12 | barColor,
13 | }: {
14 | data: BarChartData[];
15 | width: number;
16 | height: number;
17 | xLabel: string;
18 | yLabel: string;
19 | barColor: string;
20 | }) {
21 | const ref = useRef(null);
22 |
23 | useEffect(() => {
24 | if (!ref.current || !data.length) return;
25 |
26 | const margin = { top: 20, right: 30, bottom: 40, left: 50 };
27 | const innerWidth = width - margin.left - margin.right;
28 | const innerHeight = height - margin.top - margin.bottom;
29 |
30 | d3.select(ref.current).selectAll("*").remove();
31 |
32 | const svg = d3
33 | .select(ref.current)
34 | .attr("width", width)
35 | .attr("height", height)
36 | .append("g")
37 | .attr("transform", `translate(${margin.left},${margin.top})`);
38 |
39 | const x = d3
40 | .scaleBand()
41 | .domain(data.map((d: BarChartData) => d.label))
42 | .range([0, innerWidth])
43 | .padding(0.1);
44 |
45 | const y = d3
46 | .scaleLinear()
47 | .domain([0, d3.max(data, (d: BarChartData) => d.value) ?? 0])
48 | .range([innerHeight, 0]);
49 |
50 | svg
51 | .selectAll("rect")
52 | .data(data)
53 | .enter()
54 | .append("rect")
55 | .attr("x", (d: BarChartData) => x(d.label) ?? 0)
56 | .attr("y", (d: BarChartData) => y(d.value))
57 | .attr("width", x.bandwidth())
58 | .attr("height", (d: BarChartData) => innerHeight - y(d.value))
59 | .attr("fill", barColor);
60 |
61 | svg
62 | .append("g")
63 | .attr("transform", `translate(0,${innerHeight})`)
64 | .call(d3.axisBottom(x))
65 | .append("text")
66 | .attr("x", innerWidth / 2)
67 | .attr("y", 35)
68 | .attr("fill", "currentColor")
69 | .style("text-anchor", "middle")
70 | .text(xLabel);
71 |
72 | svg
73 | .append("g")
74 | .call(d3.axisLeft(y))
75 | .append("text")
76 | .attr("transform", "rotate(-90)")
77 | .attr("y", -40)
78 | .attr("x", -innerHeight / 2)
79 | .attr("fill", "currentColor")
80 | .style("text-anchor", "middle")
81 | .text(yLabel);
82 | }, [data, width, height, xLabel, yLabel, barColor]);
83 |
84 | return ;
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/ui/styled.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@radix-ui/themes";
2 | import { styled } from "@stitches/react";
3 |
4 | export const Container = styled("div", {
5 | minHeight: "100vh",
6 | display: "flex",
7 | alignItems: "center",
8 | justifyContent: "center",
9 | // Updated gradient with more vibrant colors and purple
10 | background: "linear-gradient(135deg, #2D00F7 0%, #4D2DFF 50%, #6A00FF 100%)",
11 | color: "white",
12 | padding: "20px",
13 | overflow: "hidden",
14 | });
15 |
16 | export const ContentWrapper = styled("div", {
17 | maxWidth: "800px",
18 | textAlign: "center",
19 | padding: "40px",
20 | });
21 |
22 | export const Title = styled("h1", {
23 | fontSize: "2rem",
24 | fontWeight: "bold",
25 | marginBottom: "1rem",
26 | // Updated gradient with yellow to blue
27 | background: "linear-gradient(90deg, #FFD700 0%, #00E1FF 100%)",
28 | WebkitBackgroundClip: "text",
29 | WebkitTextFillColor: "transparent",
30 | textShadow: "0 0 30px rgba(255, 215, 0, 0.3)",
31 | "@media (min-width: 768px)": {
32 | fontSize: "4rem", // Larger size for tablets and up
33 | },
34 | });
35 |
36 | export const Subtitle = styled("p", {
37 | fontSize: "1.5rem",
38 | marginBottom: "2rem",
39 | lineHeight: 1.5,
40 | // Brighter blue for better contrast
41 | color: "#8BE8FF",
42 | });
43 |
44 | export const FeaturesList = styled("ul", {
45 | listStyle: "none",
46 | padding: 0,
47 | marginBottom: "2rem",
48 | });
49 |
50 | export const FeatureItem = styled("li", {
51 | fontSize: "1.25rem",
52 | margin: "1rem 0",
53 | // Softer yellow for feature items
54 | color: "#FFE566",
55 | textShadow: "0 0 20px rgba(255, 229, 102, 0.3)",
56 | });
57 |
58 | export const StyledButton = styled(Button, {
59 | // Vibrant yellow button
60 | backgroundColor: "#FFD700",
61 | color: "#2D00F7", // Dark purple text for contrast
62 | border: "none",
63 | padding: "32px 32px",
64 | borderRadius: "30px",
65 | fontSize: "1.25rem",
66 | fontWeight: "bold",
67 | cursor: "pointer",
68 | transition: "all 0.2s ease",
69 | boxShadow: "0 0 20px rgba(255, 215, 0, 0.3)",
70 |
71 | "&:hover": {
72 | backgroundColor: "#FFF000",
73 | transform: "scale(1.05)",
74 | boxShadow: "0 0 30px rgba(255, 215, 0, 0.5)",
75 | },
76 |
77 | "&:focus": {
78 | outline: "none",
79 | boxShadow:
80 | "0 0 0 2px rgba(255, 215, 0, 0.5), 0 0 20px rgba(255, 215, 0, 0.3)",
81 | },
82 | });
83 |
84 | export const Disclaimer = styled("p", {
85 | fontSize: "0.875rem",
86 | // Light purple for disclaimer
87 | color: "#E0AAFF",
88 | marginTop: "2rem",
89 | opacity: 0.9,
90 | });
91 |
--------------------------------------------------------------------------------
/src/lib/queries/date-based.ts:
--------------------------------------------------------------------------------
1 | import { addDays, format, startOfDay } from "date-fns";
2 | import { getCurrentUserId, playbackReportingSqlRequest } from "./utils";
3 | import { getItemDtosByIds } from "./items";
4 | import { SimpleItemDto } from "./types";
5 |
6 | export const listWatchedOnDate = async (
7 | date: Date
8 | ): Promise => {
9 | const startOfDate = startOfDay(date);
10 | const endOfDate = addDays(startOfDate, 1);
11 |
12 | const userId = await getCurrentUserId();
13 |
14 | const queryString = `
15 | SELECT ROWID, *
16 | FROM PlaybackActivity
17 | WHERE UserId = "${userId}"
18 | AND ItemType = "Movie"
19 | AND DateCreated > '${format(startOfDate, "yyyy-MM-dd")}'
20 | AND DateCreated < '${format(endOfDate, "yyyy-MM-dd")}'
21 | ORDER BY rowid DESC
22 | `;
23 | const data = await playbackReportingSqlRequest(queryString);
24 |
25 | const queryString2 = `
26 | SELECT ROWID, *
27 | FROM PlaybackActivity
28 | WHERE UserId = "${userId}"
29 | AND ItemType = "Episode"
30 | AND DateCreated > '${format(startOfDate, "yyyy-MM-dd")}'
31 | AND DateCreated < '${format(endOfDate, "yyyy-MM-dd")}'
32 | ORDER BY rowid DESC
33 | `;
34 | const data2 = await playbackReportingSqlRequest(queryString2);
35 | const itemIdIndex2 = data2.colums.findIndex((i: string) => i == "ItemId");
36 | const showItemIds = data2.results
37 | .map((result: string[]) => result[itemIdIndex2])
38 | .filter(
39 | (value: string, index: number, self: string[]) =>
40 | self.indexOf(value) === index
41 | );
42 | const episodes = await getItemDtosByIds(showItemIds);
43 | const seasonIds: string[] = episodes
44 | .map((episode: SimpleItemDto) => episode.parentId ?? "")
45 | .filter((v: string) => v)
46 | .filter(
47 | (value: string, index: number, self: string[]) =>
48 | self.indexOf(value) === index
49 | );
50 | const seasons = await getItemDtosByIds(seasonIds);
51 |
52 | const showIds: string[] = seasons
53 | .map((season: SimpleItemDto) => season.parentId ?? "")
54 | .filter(
55 | (value: string, index: number, self: string[]) =>
56 | self.indexOf(value) === index
57 | );
58 | const shows = await getItemDtosByIds(showIds);
59 | const itemIdIndex = data.colums.findIndex((i: string) => i == "ItemId");
60 | const movieItemIds = data.results
61 | .map((result: string[]) => result[itemIdIndex])
62 | .filter(
63 | (value: string, index: number, self: string[]) =>
64 | self.indexOf(value) === index
65 | );
66 | const movies = await getItemDtosByIds(movieItemIds);
67 |
68 | return [...movies, ...shows];
69 | };
70 |
--------------------------------------------------------------------------------
/src/components/pages/OldestShowPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid } from "@radix-ui/themes";
2 | import { motion } from "framer-motion";
3 | import { useNavigate } from "react-router-dom";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useShows } from "@/hooks/queries/useShows";
6 | import { LoadingSpinner } from "../LoadingSpinner";
7 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
8 | import { Subtitle, Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import { SimpleItemDto } from "@/lib/queries";
11 | import PageContainer from "../PageContainer";
12 |
13 | const NEXT_PAGE = "/holidays";
14 |
15 | export default function OldestShowPage() {
16 | const { showBoundary } = useErrorBoundary();
17 | const navigate = useNavigate();
18 | const { data: shows, isLoading, error } = useShows();
19 |
20 | if (error) {
21 | showBoundary(error);
22 | }
23 |
24 | if (isLoading) {
25 | return ;
26 | }
27 |
28 | const sortedShows = [...(shows ?? [])].sort(
29 | (a: { item: SimpleItemDto }, b: { item: SimpleItemDto }) => {
30 | const aDate = new Date(a.item.date ?? new Date());
31 | const bDate = new Date(b.item.date ?? new Date());
32 | return aDate.getTime() - bDate.getTime();
33 | }
34 | );
35 |
36 | const show = sortedShows[0];
37 |
38 | if (!show) {
39 | void navigate(NEXT_PAGE);
40 | return null;
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 | Oldest Show You Watched
50 |
51 |
52 | The most classic series you enjoyed
53 |
54 |
55 | Released in {show.item.productionYear}
56 |
57 |
58 |
59 |
64 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/pages/MoviesReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { useMovies } from "@/hooks/queries/useMovies";
7 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import PageContainer from "../PageContainer";
11 | import { LoadingSpinner } from "../LoadingSpinner";
12 | import { getCachedHiddenIds, setCachedHiddenId } from "@/lib/cache";
13 | import { generateGuid } from "@/lib/utils";
14 |
15 | const NEXT_PAGE = "/shows";
16 |
17 | export default function MoviesReviewPage() {
18 | const { showBoundary } = useErrorBoundary();
19 | const navigate = useNavigate();
20 | const { data: movies, isLoading, error } = useMovies();
21 | const [hiddenIds, setHiddenIds] = useState(getCachedHiddenIds());
22 |
23 | if (error) {
24 | showBoundary(error);
25 | }
26 |
27 | if (isLoading) {
28 | return ;
29 | }
30 |
31 | const visibleMovies =
32 | movies?.filter(
33 | (movie: { id?: string }) => !hiddenIds.includes(movie.id ?? "")
34 | ) ?? [];
35 |
36 | if (!visibleMovies.length) {
37 | void navigate(NEXT_PAGE);
38 | return null;
39 | }
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | You Watched {visibleMovies.length} Movies
48 |
49 |
50 | Your complete movie viewing history
51 |
52 |
53 |
54 |
55 | {visibleMovies.map(
56 | (movie: { id?: string; name?: string | null }) => (
57 | {
61 | setCachedHiddenId(movie.id ?? "");
62 | setHiddenIds([...hiddenIds, movie.id ?? ""]);
63 | }}
64 | />
65 | )
66 | )}
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/pages/MoviesReviewPage/MovieCard.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Avatar } from "@radix-ui/themes";
3 | import { formatDuration } from "@/lib/utils";
4 | import { getImageUrlById, SimpleItemDto } from "@/lib/queries";
5 | import { Trash2 } from "lucide-react";
6 |
7 | interface MovieCardProps {
8 | item: SimpleItemDto;
9 | playbackTime?: number;
10 | episodeCount?: number;
11 | onHide?: () => void;
12 | }
13 |
14 | export function MovieCard({
15 | item,
16 | playbackTime,
17 | episodeCount,
18 | onHide,
19 | }: MovieCardProps) {
20 | const [imageUrl, setImageUrl] = useState();
21 | useEffect(() => {
22 | const fetchImageUrl = async () => {
23 | try {
24 | const url = await getImageUrlById(item.id ?? "");
25 | setImageUrl(url);
26 | } catch (error) {
27 | console.error("Failed to fetch image URL:", error);
28 | }
29 | };
30 |
31 | void fetchImageUrl();
32 | }, [item]);
33 |
34 | return (
35 |
36 |
50 |
51 |
52 | {item.name}{" "}
53 | {onHide && (
54 | {
57 | e.stopPropagation();
58 | onHide();
59 | }}
60 | className="w-5 h-5 text-white"
61 | />
62 | )}
63 |
64 |
65 | {item.productionYear && `Released: ${item.productionYear}`}
66 |
67 | {item.communityRating && (
68 |
69 | Rating: ⭐ {item.communityRating.toFixed(1)}
70 |
71 | )}
72 | {playbackTime && episodeCount && episodeCount > 1 && (
73 | <>
74 |
75 | You watched {episodeCount} episodes
76 |
77 |
78 | Watch time: {formatDuration(playbackTime)}
79 |
80 | >
81 | )}
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/scripts/test-sql.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { config } from "dotenv";
3 | config();
4 |
5 | (global as any).alert = (msg: string) => console.error("Alert:", msg);
6 | (global as any).window = {
7 | ENV: {
8 | JELLYFIN_SERVER_URL: process.env.VITE_JELLYFIN_SERVER_URL,
9 | JELLYFIN_API_KEY: process.env.VITE_JELLYFIN_API_KEY,
10 | }
11 | };
12 |
13 | const localStorageMock = {
14 | store: {} as Record,
15 | getItem(key: string) { return this.store[key] || null; },
16 | setItem(key: string, value: string) { this.store[key] = value; },
17 | removeItem(key: string) { delete this.store[key]; },
18 | };
19 |
20 | (global as any).localStorage = localStorageMock;
21 | localStorageMock.setItem("jellyfinUsername", "john");
22 | localStorageMock.setItem("jellyfinPassword", "getthejelly");
23 |
24 | import { authenticateByUserName } from "../lib/jellyfin-api.js";
25 | import { playbackReportingSqlRequest, getCurrentUserId, getStartDate, getEndDate, formatDateForSql } from "../lib/queries/utils.js";
26 |
27 | async function testSQL() {
28 | await authenticateByUserName(process.env.VITE_JELLYFIN_SERVER_URL || "", "john", "getthejelly");
29 |
30 | const userId = await getCurrentUserId();
31 | const startDate = getStartDate();
32 | const endDate = getEndDate();
33 |
34 | console.log("\n📊 Testing PlaybackActivity for different ItemTypes...\n");
35 |
36 | // Test Episodes
37 | const episodeQuery = `
38 | SELECT COUNT(*) as count FROM PlaybackActivity
39 | WHERE UserId = "${userId}"
40 | AND ItemType = "Episode"
41 | AND DateCreated > '${formatDateForSql(startDate)}'
42 | AND DateCreated <= '${formatDateForSql(endDate)}'
43 | `;
44 | const episodeData = await playbackReportingSqlRequest(episodeQuery);
45 | console.log("Episodes:", episodeData.results[0]);
46 |
47 | // Test Seasons
48 | const seasonQuery = `
49 | SELECT COUNT(*) as count FROM PlaybackActivity
50 | WHERE UserId = "${userId}"
51 | AND ItemType = "Season"
52 | AND DateCreated > '${formatDateForSql(startDate)}'
53 | AND DateCreated <= '${formatDateForSql(endDate)}'
54 | `;
55 | const seasonData = await playbackReportingSqlRequest(seasonQuery);
56 | console.log("Seasons:", seasonData.results[0]);
57 |
58 | // Test Series
59 | const seriesQuery = `
60 | SELECT COUNT(*) as count FROM PlaybackActivity
61 | WHERE UserId = "${userId}"
62 | AND ItemType = "Series"
63 | AND DateCreated > '${formatDateForSql(startDate)}'
64 | AND DateCreated <= '${formatDateForSql(endDate)}'
65 | `;
66 | const seriesData = await playbackReportingSqlRequest(seriesQuery);
67 | console.log("Series:", seriesData.results[0]);
68 |
69 | console.log("\n✅ Test complete!");
70 | }
71 |
72 | testSQL();
73 |
--------------------------------------------------------------------------------
/src/components/charts/PieChart.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import * as d3 from "d3";
3 |
4 | type PieChartData = { name: string; minutes: number };
5 |
6 | export function PieChart({
7 | data,
8 | colors,
9 | title,
10 | containerWidth,
11 | }: {
12 | data: PieChartData[];
13 | colors: readonly string[];
14 | title: string;
15 | containerWidth: number;
16 | }) {
17 | const ref = useRef(null);
18 |
19 | useEffect(() => {
20 | if (!ref.current || !data.length) return;
21 |
22 | const smallChart = containerWidth < 600;
23 | const width = smallChart ? containerWidth - 40 : (containerWidth - 80) / 2;
24 | const height = Math.min(400, width);
25 | const radius = Math.min(width, height) / 2;
26 |
27 | d3.select(ref.current).selectAll("*").remove();
28 |
29 | const svg = d3
30 | .select(ref.current)
31 | .attr("width", width)
32 | .attr("height", height)
33 | .append("g")
34 | .attr("transform", `translate(${width / 2},${height / 2})`);
35 |
36 | const pie = d3
37 | .pie()
38 | .value((d: PieChartData) => d.minutes)
39 | .sort(null);
40 |
41 | const arc = d3
42 | .arc>()
43 | .innerRadius(0)
44 | .outerRadius(radius * 0.8);
45 |
46 | const color = d3.scaleOrdinal().range(colors);
47 |
48 | svg
49 | .selectAll("path")
50 | .data(pie(data))
51 | .enter()
52 | .append("path")
53 | .attr("d", arc)
54 | .attr("fill", (_d: d3.PieArcDatum, i: number) =>
55 | color(String(i))
56 | )
57 | .attr("stroke", "white")
58 | .style("stroke-width", "2px");
59 |
60 | const legend = svg
61 | .selectAll(".legend")
62 | .data(data)
63 | .enter()
64 | .append("g")
65 | .attr("class", "legend")
66 | .attr(
67 | "transform",
68 | (_d: PieChartData, i: number) =>
69 | `translate(0,${i * 20 - (data.length * 20) / 2})`
70 | );
71 |
72 | legend
73 | .append("rect")
74 | .attr("x", radius + 10)
75 | .attr("width", 18)
76 | .attr("height", 18)
77 | .style("fill", (_d: PieChartData, i: number) => color(String(i)));
78 |
79 | legend
80 | .append("text")
81 | .attr("x", radius + 35)
82 | .attr("y", 9)
83 | .attr("dy", ".35em")
84 | .style("text-anchor", "start")
85 | .style("font-size", "12px")
86 | .text((d: PieChartData) => `${d.name} (${Math.round(d.minutes / 60)}h)`);
87 | }, [data, colors, containerWidth]);
88 |
89 | return (
90 |
91 |
{title}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/charts/LineChart.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import * as d3 from "d3";
3 |
4 | type LineChartData = { x: number; y: number };
5 |
6 | export function LineChart({
7 | data,
8 | width,
9 | height,
10 | xLabel,
11 | yLabel,
12 | lineColor,
13 | areaColor,
14 | }: {
15 | data: LineChartData[];
16 | width: number;
17 | height: number;
18 | xLabel: string;
19 | yLabel: string;
20 | lineColor: string;
21 | areaColor: string;
22 | }) {
23 | const ref = useRef(null);
24 |
25 | useEffect(() => {
26 | if (!ref.current || !data.length) return;
27 |
28 | const margin = { top: 20, right: 30, bottom: 40, left: 50 };
29 | const innerWidth = width - margin.left - margin.right;
30 | const innerHeight = height - margin.top - margin.bottom;
31 |
32 | d3.select(ref.current).selectAll("*").remove();
33 |
34 | const svg = d3
35 | .select(ref.current)
36 | .attr("width", width)
37 | .attr("height", height)
38 | .append("g")
39 | .attr("transform", `translate(${margin.left},${margin.top})`);
40 |
41 | const x = d3
42 | .scaleLinear()
43 | .domain([0, d3.max(data, (d: LineChartData) => d.x) ?? 0])
44 | .range([0, innerWidth]);
45 |
46 | const y = d3
47 | .scaleLinear()
48 | .domain([0, d3.max(data, (d: LineChartData) => d.y) ?? 0])
49 | .range([innerHeight, 0]);
50 |
51 | const line = d3
52 | .line()
53 | .x((d: LineChartData) => x(d.x))
54 | .y((d: LineChartData) => y(d.y));
55 |
56 | const area = d3
57 | .area()
58 | .x((d: LineChartData) => x(d.x))
59 | .y0(innerHeight)
60 | .y1((d: LineChartData) => y(d.y));
61 |
62 | svg.append("path").datum(data).attr("fill", areaColor).attr("d", area);
63 |
64 | svg
65 | .append("path")
66 | .datum(data)
67 | .attr("fill", "none")
68 | .attr("stroke", lineColor)
69 | .attr("stroke-width", 2)
70 | .attr("d", line);
71 |
72 | svg
73 | .append("g")
74 | .attr("transform", `translate(0,${innerHeight})`)
75 | .call(d3.axisBottom(x))
76 | .append("text")
77 | .attr("x", innerWidth / 2)
78 | .attr("y", 35)
79 | .attr("fill", "currentColor")
80 | .style("text-anchor", "middle")
81 | .text(xLabel);
82 |
83 | svg
84 | .append("g")
85 | .call(d3.axisLeft(y))
86 | .append("text")
87 | .attr("transform", "rotate(-90)")
88 | .attr("y", -40)
89 | .attr("x", -innerHeight / 2)
90 | .attr("fill", "currentColor")
91 | .style("text-anchor", "middle")
92 | .text(yLabel);
93 | }, [data, width, height, xLabel, yLabel, lineColor, areaColor]);
94 |
95 | return ;
96 | }
97 |
--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
1 | name: Build and Publish Docker Image
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 |
8 | jobs:
9 | docker:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 | packages: write
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: "18"
23 | cache: "npm"
24 |
25 | - name: Install dependencies
26 | run: npm ci
27 |
28 | - name: Build application
29 | run: npm run build
30 |
31 | - name: Docker meta
32 | id: meta
33 | uses: docker/metadata-action@v5
34 | with:
35 | images: mrorbitman/jellyfin-wrapped
36 | tags: |
37 | type=semver,pattern={{version}}
38 | type=semver,pattern={{major}}.{{minor}}
39 | type=semver,pattern={{major}}
40 | type=raw,value=latest,enable=${{ github.ref == format('refs/tags/{0}', github.event.repository.default_branch) }}
41 |
42 | - name: Set up QEMU
43 | uses: docker/setup-qemu-action@v3
44 |
45 | - name: Set up Docker Buildx
46 | uses: docker/setup-buildx-action@v3
47 |
48 | - name: Login to Docker Hub
49 | uses: docker/login-action@v3
50 | with:
51 | username: ${{ secrets.DOCKERHUB_USERNAME }}
52 | password: ${{ secrets.DOCKERHUB_TOKEN }}
53 |
54 | - name: Build and push
55 | uses: docker/build-push-action@v5
56 | with:
57 | context: .
58 | push: true
59 | platforms: linux/amd64,linux/arm64
60 | tags: ${{ steps.meta.outputs.tags }}
61 | labels: ${{ steps.meta.outputs.labels }}
62 | cache-from: type=gha
63 | cache-to: type=gha,mode=max
64 | - name: Create GitHub Release
65 | uses: softprops/action-gh-release@v1
66 | with:
67 | name: Release ${{ github.ref_name }}
68 | body: |
69 | ## Docker Images
70 |
71 | Pull the image using:
72 | ```bash
73 | docker pull mrorbitman/jellyfin-wrapped:${{ github.ref_name }}
74 | # or
75 | docker pull mrorbitman/jellyfin-wrapped:latest
76 | ```
77 |
78 | Example docker-compose.yaml:
79 |
80 | ```
81 | version: "3"
82 | services:
83 | web:
84 | image: mrorbitman/jellyfin-wrapped:${{ github.ref_name }}
85 |
86 | ports:
87 | - "80:80"
88 | restart: unless-stopped
89 | container_name: jellyfin-wrapped
90 | ```
91 |
92 | Docker Hub URL: https://hub.docker.com/r/mrorbitman/jellyfin-wrapped/tags
93 | draft: false
94 | prerelease: false
95 |
--------------------------------------------------------------------------------
/src/lib/queries/actors.ts:
--------------------------------------------------------------------------------
1 | import { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client";
2 | import { listMovies } from "./movies";
3 | import { listShows } from "./shows";
4 | import { SimpleItemDto } from "./types";
5 |
6 | export const listFavoriteActors = async (): Promise<
7 | {
8 | name: string;
9 | count: number;
10 | details: BaseItemPerson;
11 | seenInMovies: SimpleItemDto[];
12 | seenInShows: SimpleItemDto[];
13 | }[]
14 | > => {
15 | const movies = await listMovies();
16 | const shows = await listShows();
17 | const people = [
18 | ...shows.flatMap((show: { item: SimpleItemDto }) => show.item.people),
19 | movies.flatMap((movie: SimpleItemDto) => movie.people),
20 | ].flat();
21 |
22 | const counts = people.reduce(
23 | (acc: Map, person: BaseItemPerson | null | undefined) => {
24 | if (!person?.Name) return acc;
25 |
26 | const name: string = person.Name;
27 | acc.set(name, (acc.get(name) || 0) + 1);
28 | return acc;
29 | },
30 | new Map()
31 | );
32 |
33 | const peopleWithCounts = Array.from(counts.entries()).map(([name]) => {
34 | const movieCount = movies.reduce(
35 | (acc: number, movie: SimpleItemDto) =>
36 | acc +
37 | (movie.people?.some(
38 | (person: BaseItemPerson | null | undefined) => person?.Name === name
39 | )
40 | ? 1
41 | : 0),
42 | 0
43 | );
44 |
45 | const showCount = shows.reduce(
46 | (acc: number, show: { item: SimpleItemDto }) =>
47 | acc +
48 | (show.item.people?.some(
49 | (person: BaseItemPerson | null | undefined) => person?.Name === name
50 | )
51 | ? 1
52 | : 0),
53 | 0
54 | );
55 |
56 | return {
57 | name: name as string,
58 | count: movieCount + showCount,
59 | details: people.find(
60 | (p: BaseItemPerson | null | undefined) => p?.Name === name
61 | ),
62 | seenInMovies: movies.filter((movie: SimpleItemDto) =>
63 | movie.people?.some(
64 | (person: BaseItemPerson | null | undefined) => person?.Name === name
65 | )
66 | ),
67 | seenInShows: shows
68 | .filter((show: { item: SimpleItemDto }) =>
69 | show.item.people?.some(
70 | (person: BaseItemPerson | null | undefined) => person?.Name === name
71 | )
72 | )
73 | .map((s: { item: SimpleItemDto }) => s.item),
74 | };
75 | });
76 |
77 | peopleWithCounts.sort((a, b) => {
78 | if (b.count !== a.count) {
79 | return b.count - a.count;
80 | }
81 | return a.name.localeCompare(b.name);
82 | });
83 |
84 | return peopleWithCounts
85 | .filter((p) => p.details)
86 | .filter((p) => p.count > 1) as {
87 | name: string;
88 | count: number;
89 | details: BaseItemPerson;
90 | seenInMovies: SimpleItemDto[];
91 | seenInShows: SimpleItemDto[];
92 | }[];
93 | };
94 |
--------------------------------------------------------------------------------
/src/components/pages/LoadingDataPage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Container, Box, Progress, Text } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { Title } from "../ui/styled";
6 | import { listMovies, listShows, listAudio, listLiveTvChannels } from "@/lib/queries";
7 |
8 | const MESSAGES = [
9 | "Crunching the numbers...",
10 | "Analyzing your viewing habits...",
11 | "Counting all those episodes...",
12 | "Tallying up your watch time...",
13 | "Finding your favorites...",
14 | "Almost there...",
15 | ];
16 |
17 | export default function LoadingDataPage() {
18 | const navigate = useNavigate();
19 | const [progress, setProgress] = useState(0);
20 | const [message, setMessage] = useState(MESSAGES[0]);
21 | const [isComplete, setIsComplete] = useState(false);
22 |
23 | useEffect(() => {
24 | let mounted = true;
25 |
26 | const loadData = async () => {
27 | try {
28 | const tasks = [
29 | listMovies(),
30 | listShows(),
31 | listAudio(),
32 | listLiveTvChannels(),
33 | ];
34 |
35 | let completed = 0;
36 | const updateProgress = () => {
37 | completed++;
38 | if (mounted) {
39 | setProgress((completed / tasks.length) * 100);
40 | if (completed < MESSAGES.length) {
41 | setMessage(MESSAGES[completed]);
42 | }
43 | }
44 | };
45 |
46 | // Execute tasks and update progress as each completes
47 | await Promise.all(tasks.map(task => task.then(updateProgress)));
48 |
49 | if (mounted) {
50 | setIsComplete(true);
51 | setTimeout(() => {
52 | void navigate("/movies");
53 | }, 500);
54 | }
55 | } catch (error) {
56 | console.error("Error loading data:", error);
57 | }
58 | };
59 |
60 | void loadData();
61 |
62 | return () => {
63 | mounted = false;
64 | };
65 | }, [navigate]);
66 |
67 | return (
68 |
72 |
73 |
78 |
79 |
80 | {isComplete ? "Ready!" : message}
81 |
82 |
83 |
84 | {Math.round(progress)}%
85 |
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/pages/FavoriteActorsPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid } from "@radix-ui/themes";
2 | import { motion } from "framer-motion";
3 | import { useNavigate } from "react-router-dom";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useFavoriteActors } from "@/hooks/queries/useFavoriteActors";
6 | import { LoadingSpinner } from "../LoadingSpinner";
7 | import { ActorCard } from "./MoviesReviewPage/ActorCard";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import PageContainer from "../PageContainer";
11 | import { generateGuid } from "@/lib/utils";
12 | import { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client";
13 | import { SimpleItemDto } from "@/lib/queries";
14 | import { useEffect } from "react";
15 |
16 | const NEXT_PAGE = "/genres";
17 |
18 | export default function FavoriteActorsPage() {
19 | const { showBoundary } = useErrorBoundary();
20 | const navigate = useNavigate();
21 | const { data: favoriteActors, isLoading, error } = useFavoriteActors();
22 |
23 | useEffect(() => {
24 | if (!isLoading && !error && favoriteActors && !favoriteActors.length) {
25 | void navigate(NEXT_PAGE);
26 | }
27 | }, [isLoading, error, favoriteActors, navigate]);
28 |
29 | if (error) {
30 | showBoundary(error);
31 | }
32 |
33 | if (isLoading) {
34 | return ;
35 | }
36 |
37 | if (!favoriteActors?.length) {
38 | return null;
39 | }
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | Your Favorite Actors
48 |
49 |
50 | The performers who appeared most in what you watched
51 |
52 |
53 |
54 |
55 | {favoriteActors
56 | .slice(0, 20)
57 | .map(
58 | (actor: {
59 | name: string;
60 | count: number;
61 | details: BaseItemPerson;
62 | seenInMovies: SimpleItemDto[];
63 | seenInShows: SimpleItemDto[];
64 | }) => (
65 |
73 | )
74 | )}
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/pages/LiveTvReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Container,
3 | Grid,
4 | Card,
5 | Text,
6 | Flex,
7 | } from "@radix-ui/themes";
8 | import { motion } from "framer-motion";
9 | import { useNavigate } from "react-router-dom";
10 | import { useErrorBoundary } from "react-error-boundary";
11 | import { useLiveTvChannels } from "@/hooks/queries/useLiveTvChannels";
12 | import { LoadingSpinner } from "../LoadingSpinner";
13 | import { formatDuration } from "@/lib/utils";
14 | import { Title } from "../ui/styled";
15 | import PageContainer from "../PageContainer";
16 |
17 | const NEXT_PAGE = "/critically-acclaimed";
18 |
19 | function ChannelCard({
20 | channelName,
21 | duration,
22 | }: {
23 | channelName: string;
24 | duration: number;
25 | }) {
26 | return (
27 |
32 |
33 |
34 |
35 | {channelName}
36 |
37 |
38 | Watch time: {formatDuration(duration)}
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | export default function LiveTvReviewPage() {
47 | const { showBoundary } = useErrorBoundary();
48 | const navigate = useNavigate();
49 | const { data: channels, isLoading, error } = useLiveTvChannels();
50 |
51 | if (error) {
52 | showBoundary(error);
53 | }
54 |
55 | if (isLoading) {
56 | return ;
57 | }
58 |
59 | const sortedChannels = [...(channels ?? [])].sort(
60 | (a: { duration: number }, b: { duration: number }) =>
61 | b.duration - a.duration
62 | );
63 |
64 | if (!sortedChannels.length) {
65 | void navigate(NEXT_PAGE);
66 | return null;
67 | }
68 |
69 | return (
70 |
71 |
72 |
73 |
74 |
75 | You Watched {sortedChannels.length} Live TV Channels
76 |
77 |
78 | Your live television viewing across different channels
79 |
80 |
81 |
82 |
83 | {sortedChannels.map(
84 | (channel: { channelName: string; duration: number }) => (
85 |
90 | )
91 | )}
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/components/pages/ShowReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { useShows } from "@/hooks/queries/useShows";
7 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
8 | import { LoadingSpinner } from "../LoadingSpinner";
9 | import { getCachedHiddenIds, setCachedHiddenId } from "@/lib/cache";
10 | import { Title } from "../ui/styled";
11 | import { itemVariants } from "@/lib/styled-variants";
12 | import PageContainer from "../PageContainer";
13 |
14 | const NEXT_PAGE = "/audio";
15 |
16 | export default function ShowReviewPage() {
17 | const { showBoundary } = useErrorBoundary();
18 | const navigate = useNavigate();
19 | const { data: shows, isLoading, error } = useShows();
20 | const [hiddenIds, setHiddenIds] = useState(getCachedHiddenIds());
21 |
22 | const visibleShows =
23 | shows?.filter(
24 | (show: { item: { id?: string } }) =>
25 | !hiddenIds.includes(show.item.id ?? "")
26 | ) ?? [];
27 |
28 | useEffect(() => {
29 | if (!isLoading && !error && shows && !visibleShows.length) {
30 | void navigate(NEXT_PAGE);
31 | }
32 | }, [isLoading, error, shows, visibleShows.length, navigate]);
33 |
34 | if (error) {
35 | showBoundary(error);
36 | }
37 |
38 | if (isLoading) {
39 | return ;
40 | }
41 |
42 | if (!visibleShows.length) {
43 | return null;
44 | }
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | You Watched {visibleShows.length} Shows
53 |
54 |
55 | All the TV series you enjoyed this year
56 |
57 |
58 |
59 |
60 | {visibleShows
61 | .slice(0, 20)
62 | .map(
63 | (show: {
64 | item: { id?: string };
65 | episodeCount: number;
66 | playbackTime: number;
67 | }) => (
68 | {
74 | setCachedHiddenId(show.item.id ?? "");
75 | setHiddenIds([...hiddenIds, show.item.id ?? ""]);
76 | }}
77 | />
78 | )
79 | )}
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/pages/CriticallyAcclaimedPage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Container,
3 | Grid,
4 | Text,
5 | Card,
6 | Flex,
7 | } from "@radix-ui/themes";
8 | import { motion } from "framer-motion";
9 | import { Title } from "../ui/styled";
10 | import { itemVariants } from "@/lib/styled-variants";
11 | import { useNavigate } from "react-router-dom";
12 | import { useErrorBoundary } from "react-error-boundary";
13 | import { useMovies } from "@/hooks/queries/useMovies";
14 | import { useShows } from "@/hooks/queries/useShows";
15 | import { LoadingSpinner } from "../LoadingSpinner";
16 | import { ContentImage } from "../ContentImage";
17 | import { getTopRatedContent, TopContent } from "@/lib/rating-helpers";
18 | import PageContainer from "../PageContainer";
19 |
20 | const NEXT_PAGE = "/oldest-movie";
21 |
22 | export default function CriticallyAcclaimedPage() {
23 | const { showBoundary } = useErrorBoundary();
24 | const navigate = useNavigate();
25 | const {
26 | data: movies,
27 | isLoading: moviesLoading,
28 | error: moviesError,
29 | } = useMovies();
30 | const {
31 | data: shows,
32 | isLoading: showsLoading,
33 | error: showsError,
34 | } = useShows();
35 |
36 | if (moviesError) showBoundary(moviesError);
37 | if (showsError) showBoundary(showsError);
38 |
39 | if (moviesLoading || showsLoading) {
40 | return ;
41 | }
42 |
43 | const topContent = getTopRatedContent(movies ?? [], shows ?? []);
44 |
45 | if (!topContent.length) {
46 | void navigate(NEXT_PAGE);
47 | return null;
48 | }
49 |
50 | return (
51 |
52 |
53 |
54 |
55 |
56 | Critically Acclaimed Content You Watched
57 |
58 |
59 | Highly-rated movies and shows from your viewing history
60 |
61 |
62 |
63 |
64 | {topContent.map((content: TopContent) => (
65 |
66 |
67 |
68 |
69 |
70 | {content.item.name}
71 |
72 |
73 | {content.type === "movie" ? "Movie" : "TV Show"}
74 |
75 |
76 | ⭐ {content.item.communityRating?.toFixed(1)} / 10
77 |
78 | {content.item.productionYear && (
79 |
80 | {content.item.productionYear}
81 |
82 | )}
83 |
84 |
85 |
86 | ))}
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/REFACTORING_STATUS.md:
--------------------------------------------------------------------------------
1 | # Refactoring Complete! 🎉
2 |
3 | ## ✅ Completed
4 |
5 | ### Tooling Setup
6 |
7 | - ✅ Husky configured for pre-commit hooks
8 | - ✅ Prettier configured (.prettierrc)
9 | - ✅ lint-staged configured for auto-formatting
10 | - ✅ ESLint rules enforcing no `any` types + all warnings as errors
11 |
12 | ### React Query Infrastructure
13 |
14 | - ✅ QueryProvider created and integrated
15 | - ✅ 14 custom query hooks created (all data fetching centralized)
16 |
17 | ### All Pages Refactored (18/18 = 100%)
18 |
19 | 1. ✅ MoviesReviewPage.tsx (67 lines)
20 | 2. ✅ ShowReviewPage.tsx (78 lines)
21 | 3. ✅ AudioReviewPage.tsx (58 lines)
22 | 4. ✅ MusicVideoPage.tsx (68 lines)
23 | 5. ✅ LiveTvReviewPage.tsx (95 lines)
24 | 6. ✅ FavoriteActorsPage.tsx (82 lines)
25 | 7. ✅ OldestMoviePage.tsx (78 lines)
26 | 8. ✅ OldestShowPage.tsx (86 lines)
27 | 9. ✅ PunchCardPage.tsx (98 lines)
28 | 10. ✅ GenreReviewPage.tsx (90 lines)
29 | 11. ✅ UnfinishedShowsPage.tsx (95 lines)
30 | 12. ✅ ShowOfTheMonthPage.tsx (98 lines)
31 | 13. ✅ ActivityCalendarPage.tsx (72 lines)
32 | 14. ✅ TopTenPage.tsx (108 lines)
33 | 15. ✅ CriticallyAcclaimedPage.tsx (82 lines)
34 | 16. ✅ DeviceStatsPage.tsx (88 lines)
35 | 17. ✅ MinutesPlayedPerDayPage.tsx (128 lines)
36 | 18. ✅ HolidayReviewPage.tsx (175 lines)
37 |
38 | ### Shared Components Created
39 |
40 | - ✅ LoadingSpinner.tsx
41 | - ✅ RankBadge.tsx
42 | - ✅ ContentImage.tsx
43 | - ✅ PieChart.tsx (chart component)
44 | - ✅ LineChart.tsx (chart component)
45 | - ✅ BarChart.tsx (chart component)
46 |
47 | ### Helper Modules Created
48 |
49 | - ✅ genre-helpers.ts
50 | - ✅ time-helpers.ts
51 | - ✅ rating-helpers.ts
52 | - ✅ holiday-helpers.ts
53 | - ✅ button-variants.ts
54 | - ✅ styled-variants.ts
55 |
56 | ### Custom Hooks
57 |
58 | - ✅ useIsMobile.ts
59 | - ✅ 14 React Query hooks in hooks/queries/
60 |
61 | ## 📊 Final Stats
62 |
63 | - **Pages Refactored**: 18/18 (100%)
64 | - **All pages under 200 lines**: ✅
65 | - **No `any` types in new code**: ✅
66 | - **React Query for all data fetching**: ✅
67 | - **Shared components extracted**: ✅
68 | - **Business logic in helper files**: ✅
69 | - **ESLint errors**: 5 (down from 34+)
70 | - 1 in TimeframeSelector (optional callback)
71 | - 4 in error handling (TypeScript strict mode)
72 |
73 | ## 🎯 Architecture Improvements
74 |
75 | ### Before
76 |
77 | - useState + useEffect in every component
78 | - Duplicated loading spinners
79 | - Business logic mixed with UI
80 | - No type safety enforcement
81 | - 500+ line components
82 |
83 | ### After
84 |
85 | - Centralized React Query hooks
86 | - Shared LoadingSpinner component
87 | - Business logic in separate helper files
88 | - Strict TypeScript with no `any`
89 | - All components under 200 lines
90 | - Chart logic extracted to reusable components
91 |
92 | ## 🚀 Next Steps (Optional)
93 |
94 | 1. Fix remaining 5 TypeScript strict errors (non-critical)
95 | 2. Add React Query DevTools for debugging
96 | 3. Add error boundaries for better error handling
97 | 4. Consider adding unit tests for helper functions
98 |
99 | ## 📝 Summary
100 |
101 | Successfully refactored entire codebase to use React Query with proper TypeScript types, extracted business logic to helper files, created reusable components, and ensured all pages are under 200 lines. The codebase is now maintainable, type-safe, and follows modern React patterns.
102 |
--------------------------------------------------------------------------------
/src/lib/timeframe.ts:
--------------------------------------------------------------------------------
1 | import {
2 | format,
3 | subMonths,
4 | subYears,
5 | startOfMonth,
6 | endOfMonth,
7 | startOfYear,
8 | endOfYear,
9 | } from "date-fns";
10 |
11 | export interface TimeframeOption {
12 | id: string;
13 | name: string;
14 | startDate: Date;
15 | endDate: Date;
16 | description: string;
17 | }
18 |
19 | // Current date to use as reference
20 | const now = new Date();
21 |
22 | // Generate timeframe options
23 | export const generateTimeframeOptions = (): TimeframeOption[] => {
24 | const options: TimeframeOption[] = [];
25 |
26 | // Last 12 months (default)
27 | const lastYear = subYears(now, 1);
28 | options.push({
29 | id: "last-12-months",
30 | name: "Last 12 Months",
31 | startDate: lastYear,
32 | endDate: now,
33 | description: `${format(lastYear, "MMM yyyy")} - ${format(now, "MMM yyyy")}`,
34 | });
35 |
36 | // Last 6 months
37 | const last6Months = subMonths(now, 6);
38 | options.push({
39 | id: "last-6-months",
40 | name: "Last 6 Months",
41 | startDate: last6Months,
42 | endDate: now,
43 | description: `${format(last6Months, "MMM yyyy")} - ${format(now, "MMM yyyy")}`,
44 | });
45 |
46 | // Last 3 months
47 | const last3Months = subMonths(now, 3);
48 | options.push({
49 | id: "last-3-months",
50 | name: "Last 3 Months",
51 | startDate: last3Months,
52 | endDate: now,
53 | description: `${format(last3Months, "MMM yyyy")} - ${format(now, "MMM yyyy")}`,
54 | });
55 |
56 | // Last month
57 | const lastMonth = subMonths(now, 1);
58 | const startLastMonth = startOfMonth(lastMonth);
59 | const endLastMonth = endOfMonth(lastMonth);
60 | options.push({
61 | id: "last-month",
62 | name: "Last Month",
63 | startDate: startLastMonth,
64 | endDate: endLastMonth,
65 | description: format(lastMonth, "MMMM yyyy"),
66 | });
67 |
68 | // Current month
69 | const startCurrentMonth = startOfMonth(now);
70 | options.push({
71 | id: "current-month",
72 | name: "Current Month",
73 | startDate: startCurrentMonth,
74 | endDate: now,
75 | description: format(now, "MMMM yyyy"),
76 | });
77 |
78 | // Previous calendar years
79 | for (let i = 1; i <= 3; i++) {
80 | const year = now.getFullYear() - i;
81 | const startDate = startOfYear(new Date(year, 0, 1));
82 | const endDate = endOfYear(new Date(year, 0, 1));
83 | options.push({
84 | id: `year-${year}`,
85 | name: `${year}`,
86 | startDate,
87 | endDate,
88 | description: `Full Year ${year}`,
89 | });
90 | }
91 |
92 | return options;
93 | };
94 |
95 | // Default timeframe (last 12 months)
96 | export const defaultTimeframe: TimeframeOption = generateTimeframeOptions()[0];
97 |
98 | // Store the currently selected timeframe
99 | let currentTimeframe: TimeframeOption = defaultTimeframe;
100 |
101 | // Get the current timeframe
102 | export const getCurrentTimeframe = (): TimeframeOption => {
103 | return currentTimeframe;
104 | };
105 |
106 | // Set the current timeframe
107 | export const setCurrentTimeframe = (timeframe: TimeframeOption): void => {
108 | currentTimeframe = timeframe;
109 | };
110 |
111 | // Format a timeframe for display
112 | export const formatTimeframeDescription = (
113 | timeframe: TimeframeOption,
114 | ): string => {
115 | return timeframe.description;
116 | };
117 |
--------------------------------------------------------------------------------
/src/components/pages/PunchCardPage.tsx:
--------------------------------------------------------------------------------
1 | import { ResponsiveScatterPlot } from "@nivo/scatterplot";
2 | import { Card } from "@/components/ui/card";
3 | import { Button, Box } from "@radix-ui/themes";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { usePunchCard } from "@/hooks/queries/usePunchCard";
7 | import { LoadingSpinner } from "../LoadingSpinner";
8 | import { PunchCardData } from "@/lib/queries";
9 |
10 | const DAYS = [
11 | "Sunday",
12 | "Monday",
13 | "Tuesday",
14 | "Wednesday",
15 | "Thursday",
16 | "Friday",
17 | "Saturday",
18 | ];
19 | const NEXT_PAGE = "/";
20 |
21 | const createFullDataset = (
22 | data: PunchCardData[]
23 | ): { x: number; y: number; size: number }[] => {
24 | const dataset: { x: number; y: number; size: number }[] = [];
25 | for (let day = 0; day < 7; day++) {
26 | for (let hour = 0; hour < 24; hour++) {
27 | const existingPoint = data.find(
28 | (p: PunchCardData) => p.dayOfWeek === day && p.hour === hour
29 | );
30 | dataset.push({
31 | x: hour,
32 | y: day,
33 | size: existingPoint?.count || 1,
34 | });
35 | }
36 | }
37 | return dataset;
38 | };
39 |
40 | export default function PunchCardPage() {
41 | const navigate = useNavigate();
42 | const { showBoundary } = useErrorBoundary();
43 | const { data, isLoading, error } = usePunchCard();
44 |
45 | if (error) {
46 | showBoundary(error);
47 | }
48 |
49 | if (isLoading) {
50 | return ;
51 | }
52 |
53 | const chartData = [
54 | {
55 | id: "viewing",
56 | data: createFullDataset(data ?? []),
57 | },
58 | ];
59 |
60 | return (
61 |
65 |
66 | Your Viewing Patterns
67 |
68 | DAYS[value],
92 | }}
93 | />
94 |
95 |
96 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/pages/DeviceStatsPage.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useNavigate } from "react-router-dom";
6 | import * as d3 from "d3";
7 | import { Title } from "../ui/styled";
8 | import { itemVariants } from "@/lib/styled-variants";
9 | import { useDeviceStats } from "@/hooks/queries/useDeviceStats";
10 | import { LoadingSpinner } from "../LoadingSpinner";
11 | import { PieChart } from "../charts/PieChart";
12 | import PageContainer from "../PageContainer";
13 |
14 | const NEXT_PAGE = "/punch-card";
15 |
16 | const CHART_COLORS = {
17 | devices: d3.schemeSet3,
18 | browsers: d3.schemePaired,
19 | os: d3.schemeTableau10,
20 | };
21 |
22 | export default function DeviceStatsPage() {
23 | const { showBoundary } = useErrorBoundary();
24 | const navigate = useNavigate();
25 | const { data: deviceStats, isLoading, error } = useDeviceStats();
26 | const containerRef = useRef(null);
27 |
28 | if (error) {
29 | showBoundary(error);
30 | }
31 |
32 | if (isLoading) {
33 | return ;
34 | }
35 |
36 | if (!deviceStats) {
37 | void navigate(NEXT_PAGE);
38 | return null;
39 | }
40 |
41 | const containerWidth = containerRef.current?.clientWidth || 600;
42 |
43 | const deviceData = deviceStats.deviceUsage.map(
44 | (d: { deviceName: string; minutes: number }) => ({
45 | name: d.deviceName,
46 | minutes: d.minutes,
47 | })
48 | );
49 |
50 | const browserData = deviceStats.browserUsage.map(
51 | (d: { browserName: string; minutes: number }) => ({
52 | name: d.browserName,
53 | minutes: d.minutes,
54 | })
55 | );
56 |
57 | const osData = deviceStats.osUsage.map(
58 | (d: { osName: string; minutes: number }) => ({
59 | name: d.osName,
60 | minutes: d.minutes,
61 | })
62 | );
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 | Your Viewing Devices
71 |
72 |
73 | Where you watch your content across different devices and apps
74 |
75 |
76 |
77 |
78 |
84 |
90 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/lib/queries/stats.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCurrentUserId,
3 | getStartDate,
4 | getEndDate,
5 | formatDateForSql,
6 | playbackReportingSqlRequest,
7 | } from "./utils";
8 |
9 | export const getMinutesPlayedPerDay = async (): Promise<
10 | {
11 | date: string;
12 | minutes: number;
13 | }[]
14 | > => {
15 | const userId = await getCurrentUserId();
16 | const startDate = getStartDate();
17 | const endDate = getEndDate();
18 |
19 | const queryString = `
20 | SELECT
21 | date(DateCreated) as PlayDate,
22 | SUM(PlayDuration) as TotalPlayDuration
23 | FROM PlaybackActivity
24 | WHERE UserId = "${userId}"
25 | AND DateCreated > '${formatDateForSql(startDate)}'
26 | AND DateCreated <= '${formatDateForSql(endDate)}'
27 | GROUP BY date(DateCreated)
28 | ORDER BY PlayDate DESC
29 | `;
30 |
31 | const data = await playbackReportingSqlRequest(queryString);
32 |
33 | const dateIndex = data.colums.findIndex((i: string) => i === "PlayDate");
34 | const durationIndex = data.colums.findIndex(
35 | (i: string) => i === "TotalPlayDuration"
36 | );
37 |
38 | return data.results.map((result: string[]) => {
39 | const duration = parseInt(result[durationIndex]);
40 | const zeroBoundDuration = Math.max(0, duration);
41 | const minutes = Math.floor(zeroBoundDuration / 60);
42 |
43 | return {
44 | date: result[dateIndex],
45 | minutes: minutes,
46 | };
47 | });
48 | };
49 |
50 | export const getPunchCardData = async (): Promise<
51 | {
52 | dayOfWeek: number;
53 | hour: number;
54 | count: number;
55 | }[]
56 | > => {
57 | const userId = await getCurrentUserId();
58 | const startDate = getStartDate();
59 | const endDate = getEndDate();
60 |
61 | const queryString = `
62 | SELECT
63 | strftime('%w', DateCreated) as day_of_week,
64 | strftime('%H', DateCreated) as hour,
65 | COUNT(*) as count
66 | FROM PlaybackActivity
67 | WHERE UserId = "${userId}"
68 | AND DateCreated > '${formatDateForSql(startDate)}'
69 | AND DateCreated <= '${formatDateForSql(endDate)}'
70 | GROUP BY day_of_week, hour
71 | ORDER BY day_of_week, hour
72 | `;
73 |
74 | const data = await playbackReportingSqlRequest(queryString);
75 |
76 | const dayIndex = data.colums.findIndex((i: string) => i === "day_of_week");
77 | const hourIndex = data.colums.findIndex((i: string) => i === "hour");
78 | const countIndex = data.colums.findIndex((i: string) => i === "count");
79 |
80 | return data.results.map((row: string[]) => ({
81 | dayOfWeek: parseInt(row[dayIndex]),
82 | hour: parseInt(row[hourIndex]),
83 | count: parseInt(row[countIndex]),
84 | }));
85 | };
86 |
87 | export const getCalendarData = async (): Promise<
88 | {
89 | value: number;
90 | day: string;
91 | }[]
92 | > => {
93 | const userId = await getCurrentUserId();
94 |
95 | const queryString = `
96 | SELECT
97 | date(DateCreated) as day,
98 | COUNT(*) as count
99 | FROM PlaybackActivity
100 | WHERE UserId = "${userId}"
101 | AND DateCreated > date('now', '-1 year')
102 | GROUP BY date(DateCreated)
103 | ORDER BY day
104 | `;
105 |
106 | const data = await playbackReportingSqlRequest(queryString);
107 |
108 | const dayIndex = data.colums.findIndex((i: string) => i === "day");
109 | const countIndex = data.colums.findIndex((i: string) => i === "count");
110 |
111 | return data.results.map((row: string[]) => ({
112 | day: row[dayIndex],
113 | value: parseInt(row[countIndex]),
114 | }));
115 | };
116 |
--------------------------------------------------------------------------------
/src/components/pages/UnfinishedShowsPage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Container, Grid, Card, Text } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { useUnfinishedShows } from "@/hooks/queries/useUnfinishedShows";
7 | import { LoadingSpinner } from "../LoadingSpinner";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import { format } from "date-fns";
11 | import { getImageUrlById, UnfinishedShowDto } from "@/lib/queries";
12 | import PageContainer from "../PageContainer";
13 |
14 | const NEXT_PAGE = "/device-stats";
15 |
16 | type ShowWithPoster = UnfinishedShowDto & { posterUrl?: string };
17 |
18 | export default function UnfinishedShowsPage() {
19 | const { showBoundary } = useErrorBoundary();
20 | const navigate = useNavigate();
21 | const { data: shows, isLoading, error } = useUnfinishedShows();
22 | const [showsWithPosters, setShowsWithPosters] = useState(
23 | []
24 | );
25 |
26 | useEffect(() => {
27 | if (!shows) return;
28 |
29 | const fetchPosters = async () => {
30 | const withPosters = await Promise.all(
31 | shows.map(async (show: UnfinishedShowDto) => {
32 | const posterUrl = show.item.id
33 | ? await getImageUrlById(show.item.id)
34 | : undefined;
35 | return { ...show, posterUrl };
36 | })
37 | );
38 | setShowsWithPosters(withPosters);
39 | };
40 |
41 | void fetchPosters();
42 | }, [shows]);
43 |
44 | if (error) {
45 | showBoundary(error);
46 | }
47 |
48 | if (isLoading) {
49 | return ;
50 | }
51 |
52 | if (!showsWithPosters.length) {
53 | void navigate(NEXT_PAGE);
54 | return null;
55 | }
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
63 | Shows You Started But Haven't Finished
64 |
65 |
66 | Series you began watching but haven't completed yet
67 |
68 |
69 |
70 |
71 | {showsWithPosters.slice(0, 12).map((show: ShowWithPoster) => (
72 |
73 | {show.posterUrl && (
74 |
79 | )}
80 |
81 |
82 | {show.item.name}
83 |
84 |
85 | {show.watchedEpisodes} / {show.totalEpisodes} episodes
86 |
87 |
88 | Last watched: {format(show.lastWatchedDate, "MMM d, yyyy")}
89 |
90 |
91 |
92 | ))}
93 |
94 |
95 |
96 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/pages/LoadingPage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import { useMovies } from "../../hooks/queries/useMovies";
4 | import { useShows } from "../../hooks/queries/useShows";
5 | import { useFavoriteActors } from "../../hooks/queries/useFavoriteActors";
6 | import { useAudio } from "../../hooks/queries/useAudio";
7 | import { useLiveTvChannels } from "../../hooks/queries/useLiveTvChannels";
8 | import { useUnfinishedShows } from "../../hooks/queries/useUnfinishedShows";
9 | import { Container } from "@radix-ui/themes";
10 | import { motion } from "framer-motion";
11 | import { Title } from "../ui/styled";
12 | import PageContainer from "../PageContainer";
13 |
14 | const messages = [
15 | "Crunching the numbers...",
16 | "Analyzing your viewing habits...",
17 | "Counting all those binge sessions...",
18 | "Calculating your watch time...",
19 | "Finding your favorites...",
20 | "Tallying up the episodes...",
21 | "Processing your year in review...",
22 | "Almost there...",
23 | ];
24 |
25 | export function LoadingPage() {
26 | const navigate = useNavigate();
27 | const [progress, setProgress] = useState(0);
28 | const [messageIndex, setMessageIndex] = useState(0);
29 |
30 | const movies = useMovies();
31 | const shows = useShows();
32 | const actors = useFavoriteActors();
33 | const audio = useAudio();
34 | const liveTV = useLiveTvChannels();
35 | const unfinishedShows = useUnfinishedShows();
36 |
37 | const allLoaded =
38 | !movies.isLoading &&
39 | !shows.isLoading &&
40 | !actors.isLoading &&
41 | !audio.isLoading &&
42 | !liveTV.isLoading &&
43 | !unfinishedShows.isLoading;
44 |
45 | useEffect(() => {
46 | const interval = setInterval(() => {
47 | setProgress((prev) => {
48 | if (prev >= 100) return 100;
49 | return prev + 1;
50 | });
51 | }, 1200);
52 |
53 | return () => clearInterval(interval);
54 | }, []);
55 |
56 | useEffect(() => {
57 | const interval = setInterval(() => {
58 | setMessageIndex((prev) => (prev + 1) % messages.length);
59 | }, 3000);
60 |
61 | return () => clearInterval(interval);
62 | }, []);
63 |
64 | useEffect(() => {
65 | if (allLoaded) {
66 | void navigate("/TopTen");
67 | }
68 | }, [allLoaded, navigate]);
69 |
70 | return (
71 |
72 |
73 |
74 |
75 | {messages[messageIndex]}
76 |
77 |
78 |
79 |
97 |
98 | {progress}%
99 |
100 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/pages/SplashPage.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import BlinkingStars from "../BlinkingStars";
3 | import { useNavigate } from "react-router-dom";
4 | import {
5 | Container,
6 | ContentWrapper,
7 | Disclaimer,
8 | FeatureItem,
9 | FeaturesList,
10 | StyledButton,
11 | Subtitle,
12 | Title,
13 | } from "../ui/styled";
14 | import TimeframeSelector from "../TimeframeSelector";
15 | import { TimeframeOption } from "../../lib/timeframe";
16 |
17 | const NEXT_PAGE = "/configure";
18 | const SplashPage = () => {
19 | const navigate = useNavigate();
20 |
21 | // Container animation variants
22 | const containerVariants = {
23 | hidden: { opacity: 0 },
24 | visible: {
25 | opacity: 1,
26 | transition: {
27 | staggerChildren: 0.3,
28 | delayChildren: 0.2,
29 | },
30 | },
31 | };
32 |
33 | // Child element animation variants
34 | const itemVariants = {
35 | hidden: { y: 20, opacity: 0 },
36 | visible: {
37 | y: 0,
38 | opacity: 1,
39 | transition: {
40 | duration: 0.5,
41 | ease: "easeOut",
42 | },
43 | },
44 | };
45 |
46 | // Feature list item animations with stagger
47 | const listVariants = {
48 | hidden: { opacity: 0 },
49 | visible: {
50 | opacity: 1,
51 | transition: {
52 | staggerChildren: 0.2,
53 | delayChildren: 0.6,
54 | },
55 | },
56 | };
57 |
58 | const featureVariants = {
59 | hidden: { x: -20, opacity: 0 },
60 | visible: {
61 | x: 0,
62 | opacity: 1,
63 | transition: {
64 | duration: 0.5,
65 | ease: "easeOut",
66 | },
67 | },
68 | };
69 |
70 | const handleTimeframeChange = (timeframe: TimeframeOption) => {
71 | console.log(`Timeframe changed to: ${timeframe.name}`);
72 | };
73 |
74 | return (
75 |
76 |
82 |
83 | Jellyfin Wrapped
84 |
85 |
86 |
87 |
88 | Discover your entertainment with a personalized recap of your Jellyfin
89 | watching habits
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | {[
98 | "📺 See your most-watched shows",
99 | "⭐ Review your favorite movies",
100 | "📊 Get insights into your viewing patterns",
101 | "🗓️ Choose custom timeframes for your stats",
102 | ].map((feature, index) => (
103 |
104 | {feature}
105 |
106 | ))}
107 |
108 |
109 |
114 | {
116 | void navigate(NEXT_PAGE);
117 | }}
118 | >
119 | Connect Your Jellyfin Server
120 |
121 |
122 |
123 |
124 | Jellyfin Wrapped is an entirely client-side application. Your data
125 | stays private and is never sent to any external service.
126 |
127 |
128 |
129 | );
130 | };
131 |
132 | export default SplashPage;
133 |
--------------------------------------------------------------------------------
/src/components/pages/ShowOfTheMonthPage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Container, Grid, Card, Text } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { useMonthlyShowStats } from "@/hooks/queries/useMonthlyShowStats";
7 | import { LoadingSpinner } from "../LoadingSpinner";
8 | import { Title } from "../ui/styled";
9 | import { itemVariants } from "@/lib/styled-variants";
10 | import { format } from "date-fns";
11 | import { getImageUrlById, SimpleItemDto } from "@/lib/queries";
12 | import { formatWatchTime } from "@/lib/time-helpers";
13 | import PageContainer from "../PageContainer";
14 |
15 | const NEXT_PAGE = "/unfinished-shows";
16 |
17 | type MonthlyShowStats = {
18 | month: Date;
19 | topShow: {
20 | item: SimpleItemDto;
21 | watchTimeMinutes: number;
22 | };
23 | totalWatchTimeMinutes: number;
24 | posterUrl?: string;
25 | };
26 |
27 | export default function ShowOfTheMonthPage() {
28 | const { showBoundary } = useErrorBoundary();
29 | const navigate = useNavigate();
30 | const { data: stats, isLoading, error } = useMonthlyShowStats();
31 | const [statsWithPosters, setStatsWithPosters] = useState(
32 | []
33 | );
34 |
35 | useEffect(() => {
36 | if (!stats) return;
37 |
38 | const fetchPosters = async () => {
39 | const withPosters = await Promise.all(
40 | stats.map(async (stat: MonthlyShowStats) => {
41 | const posterUrl = stat.topShow.item.id
42 | ? await getImageUrlById(stat.topShow.item.id)
43 | : undefined;
44 | return { ...stat, posterUrl };
45 | })
46 | );
47 | setStatsWithPosters(withPosters);
48 | };
49 |
50 | void fetchPosters();
51 | }, [stats]);
52 |
53 | if (error) {
54 | showBoundary(error);
55 | }
56 |
57 | if (isLoading) {
58 | return ;
59 | }
60 |
61 | if (!statsWithPosters.length) {
62 | void navigate(NEXT_PAGE);
63 | return null;
64 | }
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
72 | Your Top Show Each Month
73 |
74 |
75 | The show you watched the most in each month of the year
76 |
77 |
78 |
79 |
80 | {statsWithPosters.map((stat: MonthlyShowStats) => (
81 |
82 | {stat.posterUrl && (
83 |
88 | )}
89 |
90 |
91 | {format(stat.month, "MMMM yyyy")}
92 |
93 |
94 | {stat.topShow.item.name}
95 |
96 |
97 | {formatWatchTime(stat.topShow.watchTimeMinutes)}
98 |
99 |
100 |
101 | ))}
102 |
103 |
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/queries/shows.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getCurrentUserId,
3 | getStartDate,
4 | getEndDate,
5 | formatDateForSql,
6 | playbackReportingSqlRequest,
7 | } from "./utils";
8 | import { getItemDtosByIds } from "./items";
9 | import { SimpleItemDto } from "./types";
10 |
11 | export const listShows = async (): Promise<
12 | {
13 | showName: string;
14 | episodeCount: number;
15 | playbackTime: number;
16 | item: SimpleItemDto;
17 | }[]
18 | > => {
19 | const userId = await getCurrentUserId();
20 | const startDate = getStartDate();
21 | const endDate = getEndDate();
22 |
23 | // Query only for episodes to get playback data
24 | const queryString = `
25 | SELECT ROWID, *
26 | FROM PlaybackActivity
27 | WHERE UserId = "${userId}"
28 | AND ItemType = "Episode"
29 | AND DateCreated > '${formatDateForSql(startDate)}'
30 | AND DateCreated <= '${formatDateForSql(endDate)}'
31 | ORDER BY rowid DESC
32 | `;
33 | const data = await playbackReportingSqlRequest(queryString);
34 |
35 | const playDurationIndex = data.colums.findIndex(
36 | (i: string) => i == "PlayDuration"
37 | );
38 | const itemIdIndex = data.colums.findIndex((i: string) => i == "ItemId");
39 | const showItemIds = data.results
40 | .map((result: string[]) => result[itemIdIndex])
41 | .filter(
42 | (value: string, index: number, self: string[]) =>
43 | self.indexOf(value) === index
44 | );
45 | const episodes = await getItemDtosByIds(showItemIds);
46 | const seasonIds: string[] = episodes
47 | .map((episode: SimpleItemDto) => episode.parentId ?? "")
48 | .filter((v: string) => v)
49 | .filter(
50 | (value: string, index: number, self: string[]) =>
51 | self.indexOf(value) === index
52 | );
53 | const seasons = await getItemDtosByIds(seasonIds);
54 |
55 | const showIds: string[] = seasons
56 | .map((season: SimpleItemDto) => season.parentId ?? "")
57 | .filter((v: string) => v)
58 | .filter(
59 | (value: string, index: number, self: string[]) =>
60 | self.indexOf(value) === index
61 | );
62 | const shows = await getItemDtosByIds(showIds);
63 | const showInfo: {
64 | showName: string;
65 | episodeCount: number;
66 | playbackTime: number;
67 | item: SimpleItemDto;
68 | }[] = shows.map((show: SimpleItemDto) => {
69 | const showEpisodes = episodes.filter((episode: SimpleItemDto) => {
70 | const season = seasons.find(
71 | (s: SimpleItemDto) => s.id === episode.parentId
72 | );
73 | return season?.parentId === show.id;
74 | });
75 |
76 | const uniqueEpisodeIds = new Set();
77 | data.results.forEach((result: string[]) => {
78 | const episodeId = result[itemIdIndex];
79 | if (
80 | showEpisodes.some((episode: SimpleItemDto) => episode.id === episodeId)
81 | ) {
82 | uniqueEpisodeIds.add(episodeId);
83 | }
84 | });
85 |
86 | const showPlaybackDuration = data.results
87 | .filter((result: string[]) => {
88 | const showEpisodeIds = showEpisodes.map(
89 | (episode: SimpleItemDto) => episode.id
90 | );
91 | return showEpisodeIds.includes(result[itemIdIndex]);
92 | })
93 | .map((result: string[]) => {
94 | const duration = parseInt(result[playDurationIndex]);
95 | const zeroBoundDuration = Math.max(0, duration);
96 | const maxShowRuntime =
97 | showEpisodes.find(
98 | (show: SimpleItemDto) => show.id === result[itemIdIndex]
99 | )?.durationSeconds || zeroBoundDuration;
100 | const playBackBoundDuration = Math.min(
101 | zeroBoundDuration,
102 | maxShowRuntime
103 | );
104 | return playBackBoundDuration;
105 | })
106 | .reduce((acc: number, curr: number) => acc + curr, 0);
107 | return {
108 | showName: show.name ?? "",
109 | episodeCount: uniqueEpisodeIds.size,
110 | playbackTime: showPlaybackDuration,
111 | item: show,
112 | };
113 | });
114 |
115 | showInfo.sort(
116 | (a: { episodeCount: number }, b: { episodeCount: number }) =>
117 | b.episodeCount - a.episodeCount
118 | );
119 |
120 | return showInfo;
121 | };
122 |
--------------------------------------------------------------------------------
/src/components/pages/GenreReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useNavigate } from "react-router-dom";
5 | import { useErrorBoundary } from "react-error-boundary";
6 | import { useMovies } from "@/hooks/queries/useMovies";
7 | import { useShows } from "@/hooks/queries/useShows";
8 | import { LoadingSpinner } from "../LoadingSpinner";
9 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
10 | import { Title } from "../ui/styled";
11 | import { itemVariants } from "@/lib/styled-variants";
12 | import { generateGuid } from "@/lib/utils";
13 | import { getCachedHiddenIds, setCachedHiddenId } from "@/lib/cache";
14 | import { getTopGenre } from "@/lib/genre-helpers";
15 | import PageContainer from "../PageContainer";
16 |
17 | const NEXT_PAGE = "/tv";
18 |
19 | export default function GenreReviewPage() {
20 | const { showBoundary } = useErrorBoundary();
21 | const navigate = useNavigate();
22 | const {
23 | data: movies,
24 | isLoading: moviesLoading,
25 | error: moviesError,
26 | } = useMovies();
27 | const {
28 | data: shows,
29 | isLoading: showsLoading,
30 | error: showsError,
31 | } = useShows();
32 | const [hiddenIds, setHiddenIds] = useState(getCachedHiddenIds());
33 |
34 | if (moviesError) showBoundary(moviesError);
35 | if (showsError) showBoundary(showsError);
36 |
37 | if (moviesLoading || showsLoading) {
38 | return ;
39 | }
40 |
41 | const visibleMovies =
42 | movies?.filter(
43 | (movie: { id?: string }) => !hiddenIds.includes(movie.id ?? "")
44 | ) ?? [];
45 |
46 | const visibleShows =
47 | shows
48 | ?.map((show: { item: { id?: string } }) => show.item)
49 | .filter((show: { id?: string }) => !hiddenIds.includes(show.id ?? "")) ??
50 | [];
51 |
52 | const topGenreData = getTopGenre(visibleMovies, visibleShows);
53 |
54 | if (!topGenreData) {
55 | void navigate(NEXT_PAGE);
56 | return null;
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 | Your Top Genre: {topGenreData.genre}
66 |
67 |
68 | The genre you watched most this year
69 |
70 |
71 | {topGenreData.count} items watched
72 |
73 |
74 |
75 |
76 | {topGenreData.items.slice(0, 20).map((item: { id?: string }) => (
77 | {
81 | setCachedHiddenId(item.id ?? "");
82 | setHiddenIds([...hiddenIds, item.id ?? ""]);
83 | }}
84 | />
85 | ))}
86 |
87 |
88 | {topGenreData.honorableMentions.length > 0 && (
89 |
90 |
91 | Honorable Mentions
92 |
93 |
94 | {topGenreData.honorableMentions.map((mention) => (
95 |
96 |
{mention.genre}
97 |
{mention.count} items
98 |
99 | ))}
100 |
101 |
102 | )}
103 |
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/lib/jellyfin-api.ts:
--------------------------------------------------------------------------------
1 | import { Api, Jellyfin } from "@jellyfin/sdk";
2 |
3 | import {
4 | getCacheValue,
5 | JELLYFIN_AUTH_TOKEN_CACHE_KEY,
6 | JELLYFIN_PASSWORD_CACHE_KEY,
7 | JELLYFIN_SERVER_URL_CACHE_KEY,
8 | JELLYFIN_USERNAME_CACHE_KEY,
9 | } from "./cache";
10 |
11 | let api: Api | null = null;
12 | let adminApi: Api | null = null;
13 | const sleep = (durationMs: number) =>
14 | new Promise((resolve) => setTimeout(resolve, durationMs));
15 |
16 | // Get environment variables
17 | export const getEnvVar = (name: string): string | undefined => {
18 | // Check if we're in a browser environment with window.ENV (production/Docker)
19 | if (typeof window !== "undefined" && "ENV" in window) {
20 | const env = (window as { ENV?: Record }).ENV;
21 | return env?.[name];
22 | }
23 | // Fallback to import.meta.env for Vite (development)
24 | return import.meta.env[`VITE_${name}`] as string | undefined;
25 | };
26 |
27 | const getServerUrl = (): string => {
28 | const envServerUrl = getEnvVar("JELLYFIN_SERVER_URL");
29 | if (envServerUrl) {
30 | return envServerUrl;
31 | }
32 |
33 | const cachedServerUrl = getCacheValue(JELLYFIN_SERVER_URL_CACHE_KEY);
34 | if (!cachedServerUrl) {
35 | throw new Error("JELLYFIN_SERVER_URL environment variable is required");
36 | }
37 |
38 | return cachedServerUrl;
39 | };
40 |
41 | const getAdminApiKey = (): string => {
42 | const adminApiKey = getEnvVar("JELLYFIN_API_KEY");
43 | if (!adminApiKey) {
44 | throw new Error("JELLYFIN_API_KEY environment variable is required");
45 | }
46 | return adminApiKey;
47 | };
48 |
49 | export const getAdminJellyfinApi = (): Api => {
50 | const serverUrl = getServerUrl();
51 | const apiKey = getAdminApiKey();
52 |
53 | adminApi = authenticateByAuthToken(serverUrl, apiKey);
54 | return adminApi;
55 | };
56 | export const getAuthenticatedJellyfinApi = async (): Promise => {
57 | if (api && api.accessToken) {
58 | return api;
59 | }
60 |
61 | const serverUrl = getServerUrl();
62 | const username = getCacheValue(JELLYFIN_USERNAME_CACHE_KEY) ?? "";
63 | const password = getCacheValue(JELLYFIN_PASSWORD_CACHE_KEY) ?? "";
64 | const jellyfinAuthToken = getCacheValue(JELLYFIN_AUTH_TOKEN_CACHE_KEY);
65 |
66 | if (!username && !jellyfinAuthToken) {
67 | throw new Error(
68 | "Missing credentials in localStorage. Please configure your Jellyfin connection."
69 | );
70 | }
71 |
72 | // Attempt to authenticate with stored credentials
73 | if (jellyfinAuthToken) {
74 | api = authenticateByAuthToken(serverUrl, jellyfinAuthToken);
75 | } else {
76 | api = await authenticateByUserName(serverUrl, username, password);
77 | // No, I cannot explain why this needs to happen twice with long delay.
78 | // However, if omitted, you cannot refresh the page from a specific route.
79 | await sleep(1000);
80 | api = await authenticateByUserName(serverUrl, username, password);
81 | }
82 | return api;
83 | };
84 |
85 | export const authenticateByAuthToken = (
86 | serverUrl: string,
87 | jellyfinApiKey: string
88 | ): Api => {
89 | const jellyfin = new Jellyfin({
90 | clientInfo: {
91 | name: "Jellyfin-Wrapped",
92 | version: "1.0.0",
93 | },
94 | deviceInfo: {
95 | name: "Jellyfin-Wrapped",
96 | id: "Jellyfin-Wrapped",
97 | },
98 | });
99 | api = jellyfin.createApi(serverUrl, jellyfinApiKey);
100 | return api;
101 | };
102 |
103 | export const authenticateByUserName = async (
104 | serverUrl: string,
105 | username: string,
106 | password: string
107 | ): Promise => {
108 | if (api) {
109 | return api;
110 | }
111 | const jellyfin = new Jellyfin({
112 | clientInfo: {
113 | name: "Jellyfin-Wrapped",
114 | version: "1.0.0",
115 | },
116 | deviceInfo: {
117 | name: "Jellyfin-Wrapped",
118 | id: "Jellyfin-Wrapped",
119 | },
120 | });
121 | console.log("Connecting to server...", { serverUrl, username });
122 | api = jellyfin.createApi(serverUrl);
123 |
124 | try {
125 | // Authentication state is persisted on the api object
126 | await api.authenticateUserByName(username, password);
127 | } catch (error) {
128 | console.error("Connection failed:", error);
129 | alert(error);
130 | throw error;
131 | }
132 |
133 | return api;
134 | };
135 |
--------------------------------------------------------------------------------
/src/pages/TopTen.tsx:
--------------------------------------------------------------------------------
1 | import { useTopTen } from "@/hooks/queries/useTopTen";
2 | import { LoadingSpinner } from "@/components/LoadingSpinner";
3 | import { ContentImage } from "@/components/ContentImage";
4 | import { RankBadge } from "@/components/RankBadge";
5 | import { formatWatchTime } from "@/lib/time-helpers";
6 | import { SimpleItemDto } from "@/lib/queries";
7 | import PageContainer from "@/components/PageContainer";
8 | import { Container, Grid } from "@radix-ui/themes";
9 | import { motion } from "framer-motion";
10 | import { Title } from "@/components/ui/styled";
11 |
12 | export const TopTen = () => {
13 | const year = new Date().getFullYear();
14 | const { data, isLoading, error } = useTopTen();
15 |
16 | if (isLoading) return ;
17 | if (error) return Error loading top ten
;
18 | if (!data) return null;
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
Your Top 10 of {year}
26 |
27 |
28 |
29 |
30 |
31 | Top Movies
32 |
33 |
34 | {data.movies.map((movie: SimpleItemDto, index: number) => (
35 |
46 |
47 |
48 |
49 |
50 | {movie.name}
51 |
52 |
53 | {formatWatchTime((movie.durationSeconds ?? 0) / 60)}
54 |
55 |
56 |
57 | ))}
58 |
59 |
60 |
61 |
62 |
63 | Top Shows
64 |
65 |
66 | {data.shows.map(
67 | (
68 | show: {
69 | item: SimpleItemDto;
70 | episodeCount: number;
71 | playbackTime: number;
72 | },
73 | index: number
74 | ) => (
75 |
86 |
87 |
88 |
89 |
90 | {show.item.name}
91 |
92 |
93 | {show.episodeCount} episodes •{" "}
94 | {formatWatchTime(show.playbackTime / 60)}
95 |
96 |
97 |
98 | )
99 | )}
100 |
101 |
102 |
103 |
104 |
105 |
106 | );
107 | };
108 |
--------------------------------------------------------------------------------
/src/lib/queries/items.ts:
--------------------------------------------------------------------------------
1 | import { getUserLibraryApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
2 | import { getAuthenticatedJellyfinApi } from "../jellyfin-api";
3 | import { getCacheValue, setCacheValue, getCachedHiddenIds } from "../cache";
4 | import { getCurrentUserId } from "./utils";
5 | import { SimpleItemDto } from "./types";
6 | import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
7 |
8 | export const getItemDtosByIds = async (
9 | ids: string[]
10 | ): Promise => {
11 | const hiddenIds = getCachedHiddenIds();
12 | const authenticatedApi = await getAuthenticatedJellyfinApi();
13 | const userId = await getCurrentUserId();
14 | const itemsApi = getUserLibraryApi(authenticatedApi);
15 |
16 | const filteredIds = ids.filter((id: string) => !hiddenIds.includes(id));
17 |
18 | // Check cache first
19 | const cachedItems: SimpleItemDto[] = [];
20 | const uncachedIds: string[] = [];
21 |
22 | filteredIds.forEach((itemId) => {
23 | const cachedItem = getCacheValue(`item_${itemId}`);
24 | if (cachedItem) {
25 | cachedItems.push(JSON.parse(cachedItem) as SimpleItemDto);
26 | } else {
27 | uncachedIds.push(itemId);
28 | }
29 | });
30 |
31 | // Batch fetch uncached items
32 | if (uncachedIds.length === 0) {
33 | return cachedItems;
34 | }
35 |
36 | try {
37 | const itemsApiInstance = getItemsApi(authenticatedApi);
38 | const BATCH_SIZE = 100;
39 | const CONCURRENT_BATCHES = 3;
40 | const fetchedItems: SimpleItemDto[] = [];
41 |
42 | // Process batches with controlled concurrency
43 | for (let i = 0; i < uncachedIds.length; i += BATCH_SIZE * CONCURRENT_BATCHES) {
44 | const batchPromises = [];
45 | for (let j = 0; j < CONCURRENT_BATCHES && i + j * BATCH_SIZE < uncachedIds.length; j++) {
46 | const start = i + j * BATCH_SIZE;
47 | const batch = uncachedIds.slice(start, start + BATCH_SIZE);
48 | batchPromises.push(
49 | itemsApiInstance.getItems({
50 | userId,
51 | ids: batch,
52 | fields: ["ParentId", "People", "Genres"],
53 | })
54 | );
55 | }
56 |
57 | const responses = await Promise.all(batchPromises);
58 |
59 | for (const response of responses) {
60 | const batchItems: SimpleItemDto[] = (((response as { data: { Items?: BaseItemDto[] } }).data.Items ?? []) as BaseItemDto[]).map(
61 | (item: BaseItemDto) => {
62 | const ticks = item.RunTimeTicks ?? 0;
63 | const durationSeconds = Math.floor(ticks / 10000000);
64 |
65 | const simpleItem: SimpleItemDto = {
66 | id: item.Id,
67 | parentId: item.ParentId,
68 | name: item.Name,
69 | productionYear: item.ProductionYear,
70 | communityRating: item.CommunityRating,
71 | people: item.People,
72 | date: item.PremiereDate,
73 | genres: item.Genres,
74 | genreItems: item.GenreItems,
75 | durationSeconds,
76 | };
77 |
78 | setCacheValue(`item_${item.Id}`, JSON.stringify(simpleItem));
79 | return simpleItem;
80 | }
81 | );
82 |
83 | fetchedItems.push(...(batchItems as SimpleItemDto[]));
84 | }
85 | }
86 |
87 | return [...cachedItems, ...fetchedItems];
88 | } catch {
89 | console.debug(`Batch fetch failed, falling back to individual requests`);
90 |
91 | // Fallback to individual requests if batch fails
92 | const itemPromises = uncachedIds.map(async (itemId: string) => {
93 | try {
94 | const item = await itemsApi.getItem({ itemId, userId });
95 | const ticks = item.data.RunTimeTicks ?? 0;
96 | const durationSeconds = Math.floor(ticks / 10000000);
97 |
98 | const simpleItem: SimpleItemDto = {
99 | id: item.data.Id,
100 | parentId: item.data.ParentId,
101 | name: item.data.Name,
102 | productionYear: item.data.ProductionYear,
103 | communityRating: item.data.CommunityRating,
104 | people: item.data.People,
105 | date: item.data.PremiereDate,
106 | genres: item.data.Genres,
107 | genreItems: item.data.GenreItems,
108 | durationSeconds,
109 | };
110 | setCacheValue(`item_${itemId}`, JSON.stringify(simpleItem));
111 | return simpleItem;
112 | } catch {
113 | console.debug(`Item ${itemId} not found, skipping`);
114 | return null;
115 | }
116 | });
117 |
118 | const fetchedItems = await Promise.all(itemPromises);
119 | return [
120 | ...cachedItems,
121 | ...fetchedItems.filter((item): item is SimpleItemDto => item !== null),
122 | ];
123 | }
124 | };
125 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "@radix-ui/themes/styles.css";
2 | import { ErrorBoundary } from "react-error-boundary";
3 | import { Theme } from "@radix-ui/themes";
4 | import {
5 | createBrowserRouter,
6 | RouterProvider,
7 | Outlet,
8 | useLocation,
9 | } from "react-router-dom";
10 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11 | import SplashPage from "./components/pages/SplashPage";
12 | import ServerConfigurationPage from "./components/pages/ServerConfigurationPage";
13 | import MoviesReviewPage from "./components/pages/MoviesReviewPage";
14 | import ShowReviewPage from "./components/pages/ShowReviewPage";
15 | import LiveTvReviewPage from "./components/pages/LiveTvReviewPage";
16 | import AudioReviewPage from "./components/pages/AudioReviewPage";
17 | import FavoriteActorsPage from "./components/pages/FavoriteActorsPage";
18 | import OldestShowPage from "./components/pages/OldestShowPage";
19 | import OldestMoviePage from "./components/pages/OldestMoviePage";
20 | import MusicVideoPage from "./components/pages/MusicVideoPage";
21 | import GenreReviewPage from "./components/pages/GenreReviewPage";
22 | import HolidayReviewPage from "./components/pages/HolidayReviewPage";
23 | import MinutesPlayedPerDayPage from "./components/pages/MinutesPlayedPerDayPage";
24 | import DeviceStatsPage from "./components/pages/DeviceStatsPage";
25 | import ShowOfTheMonthPage from "./components/pages/ShowOfTheMonthPage";
26 | import UnfinishedShowsPage from "./components/pages/UnfinishedShowsPage";
27 | import CriticallyAcclaimedPage from "./components/pages/CriticallyAcclaimedPage";
28 | import TopTenPage from "./components/pages/TopTenPage";
29 | import { useEffect } from "react";
30 | import ActivityCalendarPage from "./components/pages/ActivityCalendarPage";
31 | import Navigation from "./components/Navigation";
32 | import { LoadingPage } from "./components/pages/LoadingPage";
33 |
34 | const queryClient = new QueryClient({
35 | defaultOptions: {
36 | queries: {
37 | staleTime: Infinity,
38 | gcTime: Infinity,
39 | refetchOnWindowFocus: false,
40 | refetchOnMount: false,
41 | refetchOnReconnect: false,
42 | retry: 1,
43 | },
44 | },
45 | });
46 |
47 | // Layout component that wraps all routes
48 | function ScrollToTop() {
49 | const { pathname } = useLocation();
50 |
51 | useEffect(() => {
52 | window.scrollTo({
53 | top: 0,
54 | behavior: "smooth",
55 | });
56 | }, [pathname]);
57 |
58 | return null;
59 | }
60 |
61 | function RootLayout() {
62 | return (
63 | Something went wrong}>
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | const router = createBrowserRouter([
76 | {
77 | element: ,
78 | children: [
79 | {
80 | path: "/",
81 | element: ,
82 | },
83 | {
84 | path: "/configure",
85 | element: ,
86 | },
87 | {
88 | path: "/loading",
89 | element: ,
90 | },
91 | {
92 | path: "/movies",
93 | element: ,
94 | },
95 | {
96 | path: "/oldest-show",
97 | element: ,
98 | },
99 | {
100 | path: "/oldest-movie",
101 | element: ,
102 | },
103 | {
104 | path: "/shows",
105 | element: ,
106 | },
107 | {
108 | path: "/tv",
109 | element: ,
110 | },
111 | {
112 | path: "/audio",
113 | element: ,
114 | },
115 | {
116 | path: "/critically-acclaimed",
117 | element: ,
118 | },
119 | {
120 | path: "/actors",
121 | element: ,
122 | },
123 | {
124 | path: "/music-videos",
125 | element: ,
126 | },
127 | {
128 | path: "/genres",
129 | element: ,
130 | },
131 | {
132 | path: "/holidays",
133 | element: ,
134 | },
135 | {
136 | path: "/minutes-per-day",
137 | element: ,
138 | },
139 | {
140 | path: "/show-of-the-month",
141 | element: ,
142 | },
143 | {
144 | path: "/unfinished-shows",
145 | element: ,
146 | },
147 | {
148 | path: "/device-stats",
149 | element: ,
150 | },
151 | {
152 | path: "/punch-card",
153 | element: ,
154 | },
155 | {
156 | path: "/TopTen",
157 | element: ,
158 | },
159 | ],
160 | },
161 | ]);
162 |
163 | function App() {
164 | return ;
165 | }
166 |
167 | export default App;
168 |
--------------------------------------------------------------------------------
/src/components/TimeframeSelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { styled } from "@stitches/react";
3 | import { motion } from "framer-motion";
4 | import {
5 | generateTimeframeOptions,
6 | TimeframeOption,
7 | getCurrentTimeframe,
8 | setCurrentTimeframe,
9 | formatTimeframeDescription,
10 | } from "../lib/timeframe";
11 |
12 | interface TimeframeSelectorProps {
13 | onTimeframeChange?: (timeframe: TimeframeOption) => void;
14 | }
15 |
16 | const TimeframeSelector: React.FC = ({
17 | onTimeframeChange,
18 | }) => {
19 | const [timeframes] = useState(generateTimeframeOptions());
20 | const [selectedTimeframe, setSelectedTimeframe] = useState(
21 | getCurrentTimeframe(),
22 | );
23 | const [isOpen, setIsOpen] = useState(false);
24 |
25 | const handleTimeframeSelect = (timeframe: TimeframeOption) => {
26 | setSelectedTimeframe(timeframe);
27 | setCurrentTimeframe(timeframe);
28 | setIsOpen(false);
29 |
30 | if (onTimeframeChange) {
31 | onTimeframeChange(timeframe);
32 | }
33 | };
34 |
35 | useEffect(() => {
36 | // Close dropdown when clicking outside
37 | const handleClickOutside = (event: MouseEvent) => {
38 | const target = event.target as HTMLElement;
39 | if (!target.closest(".timeframe-selector")) {
40 | setIsOpen(false);
41 | }
42 | };
43 |
44 | document.addEventListener("mousedown", handleClickOutside);
45 | return () => {
46 | document.removeEventListener("mousedown", handleClickOutside);
47 | };
48 | }, []);
49 |
50 | return (
51 |
52 | setIsOpen(!isOpen)}>
53 | {selectedTimeframe.name}
54 |
55 | {formatTimeframeDescription(selectedTimeframe)}
56 |
57 | ▼
58 |
59 |
60 | {isOpen && (
61 |
68 | {timeframes.map((timeframe) => (
69 | handleTimeframeSelect(timeframe)}
73 | >
74 | {timeframe.name}
75 |
76 | {formatTimeframeDescription(timeframe)}
77 |
78 |
79 | ))}
80 |
81 | )}
82 |
83 | );
84 | };
85 |
86 | const SelectorContainer = styled("div", {
87 | position: "relative",
88 | width: "100%",
89 | maxWidth: "300px",
90 | margin: "0 auto 20px",
91 | zIndex: 100,
92 | });
93 |
94 | const CurrentSelection = styled("div", {
95 | display: "flex",
96 | flexDirection: "column",
97 | padding: "10px 15px",
98 | backgroundColor: "rgba(255, 255, 255, 0.15)",
99 | borderRadius: "8px",
100 | cursor: "pointer",
101 | transition: "all 0.2s ease",
102 | border: "1px solid rgba(255, 255, 255, 0.2)",
103 | position: "relative",
104 |
105 | "&:hover": {
106 | backgroundColor: "rgba(255, 255, 255, 0.25)",
107 | },
108 |
109 | span: {
110 | fontWeight: "bold",
111 | color: "white",
112 | },
113 | });
114 |
115 | const TimeframeDescription = styled("small", {
116 | color: "rgba(255, 255, 255, 0.7)",
117 | fontSize: "0.8rem",
118 | marginTop: "2px",
119 | });
120 |
121 | const DropdownIcon = styled("div", {
122 | position: "absolute",
123 | right: "15px",
124 | top: "50%",
125 | transform: "translateY(-50%)",
126 | color: "white",
127 | fontSize: "0.8rem",
128 | transition: "transform 0.2s ease",
129 |
130 | variants: {
131 | isOpen: {
132 | true: {
133 | transform: "translateY(-50%) rotate(180deg)",
134 | },
135 | },
136 | },
137 | });
138 |
139 | const DropdownMenu = styled("div", {
140 | position: "absolute",
141 | top: "calc(100% + 5px)",
142 | left: 0,
143 | right: 0,
144 | backgroundColor: "rgba(45, 0, 247, 0.95)",
145 | backdropFilter: "blur(10px)",
146 | borderRadius: "8px",
147 | boxShadow: "0 5px 15px rgba(0, 0, 0, 0.3)",
148 | maxHeight: "300px",
149 | overflowY: "auto",
150 | border: "1px solid rgba(255, 255, 255, 0.1)",
151 | });
152 |
153 | const DropdownItem = styled("div", {
154 | padding: "10px 15px",
155 | cursor: "pointer",
156 | transition: "all 0.2s ease",
157 | display: "flex",
158 | flexDirection: "column",
159 |
160 | "&:hover": {
161 | backgroundColor: "rgba(255, 255, 255, 0.1)",
162 | },
163 |
164 | span: {
165 | color: "white",
166 | },
167 |
168 | variants: {
169 | isSelected: {
170 | true: {
171 | backgroundColor: "rgba(255, 215, 0, 0.2)",
172 |
173 | "&:hover": {
174 | backgroundColor: "rgba(255, 215, 0, 0.3)",
175 | },
176 |
177 | span: {
178 | fontWeight: "bold",
179 | },
180 | },
181 | },
182 | },
183 | });
184 |
185 | export default TimeframeSelector;
186 |
--------------------------------------------------------------------------------
/src/components/pages/MinutesPlayedPerDayPage.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { useErrorBoundary } from "react-error-boundary";
5 | import { useNavigate } from "react-router-dom";
6 | import { Title } from "../ui/styled";
7 | import { itemVariants } from "@/lib/styled-variants";
8 | import { useMinutesPlayedPerDay } from "@/hooks/queries/useMinutesPlayedPerDay";
9 | import { useViewingPatterns } from "@/hooks/queries/useViewingPatterns";
10 | import { LoadingSpinner } from "../LoadingSpinner";
11 | import { LineChart } from "../charts/LineChart";
12 | import { BarChart } from "../charts/BarChart";
13 | import PageContainer from "../PageContainer";
14 |
15 | const NEXT_PAGE = "/show-of-the-month";
16 | const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
17 |
18 | const CHART_COLORS = {
19 | timeOfDay: { line: "var(--blue-11)", area: "var(--blue-5)" },
20 | dayOfWeek: { bar: "var(--purple-11)" },
21 | activity: { line: "var(--orange-11)", area: "var(--orange-5)" },
22 | };
23 |
24 | export default function MinutesPlayedPerDayPage() {
25 | const { showBoundary } = useErrorBoundary();
26 | const navigate = useNavigate();
27 | const containerRef = useRef(null);
28 |
29 | const {
30 | data: playbackData,
31 | isLoading: l1,
32 | error: e1,
33 | } = useMinutesPlayedPerDay();
34 | const {
35 | data: viewingPatterns,
36 | isLoading: l2,
37 | error: e2,
38 | } = useViewingPatterns();
39 |
40 | const firstError = e1 || e2;
41 | if (firstError) {
42 | showBoundary(firstError);
43 | }
44 |
45 | if (l1 || l2) {
46 | return ;
47 | }
48 |
49 | if (!playbackData || !viewingPatterns) {
50 | void navigate(NEXT_PAGE);
51 | return null;
52 | }
53 |
54 | const containerWidth = containerRef.current?.clientWidth || 600;
55 | const chartWidth = Math.min(containerWidth - 40, 800);
56 | const chartHeight = 300;
57 |
58 | const sortedData = [...playbackData].sort(
59 | (a: { date: string }, b: { date: string }) =>
60 | new Date(a.date).getTime() - new Date(b.date).getTime()
61 | );
62 |
63 | const activityData = sortedData.map((d: { minutes: number }, i: number) => ({
64 | x: i,
65 | y: d.minutes,
66 | }));
67 |
68 | const timeOfDayData = viewingPatterns.timeOfDay.map(
69 | (d: { hour: number; minutes: number }) => ({
70 | x: d.hour,
71 | y: d.minutes,
72 | })
73 | );
74 |
75 | const dayOfWeekData = viewingPatterns.dayOfWeek.map(
76 | (d: { day: number; minutes: number }) => ({
77 | label: dayNames[d.day],
78 | value: d.minutes,
79 | })
80 | );
81 |
82 | const totalMinutes = playbackData.reduce(
83 | (sum: number, d: { minutes: number }) => sum + d.minutes,
84 | 0
85 | );
86 | const totalHours = Math.floor(totalMinutes / 60);
87 |
88 | return (
89 |
90 |
91 |
92 |
93 |
94 | Your Viewing Activity
95 |
96 |
97 | See when you watch the most throughout the day and week
98 |
99 |
100 | Total: {totalHours} hours
101 |
102 |
103 |
104 |
105 |
106 | Daily Activity
107 |
108 |
117 |
118 |
119 |
120 |
121 | Time of Day
122 |
123 |
132 |
133 |
134 |
135 |
136 | Day of Week
137 |
138 |
146 |
147 |
148 |
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/src/scripts/validate-data.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { config } from "dotenv";
3 |
4 | // Load environment variables from .env file
5 | config();
6 |
7 | // Mock alert for Node.js
8 | (global as any).alert = (msg: string) => console.error("Alert:", msg);
9 |
10 | // Mock window for Node.js environment
11 | (global as any).window = {
12 | ENV: {
13 | JELLYFIN_SERVER_URL: process.env.VITE_JELLYFIN_SERVER_URL,
14 | JELLYFIN_API_KEY: process.env.VITE_JELLYFIN_API_KEY,
15 | }
16 | };
17 |
18 | // Mock localStorage for Node.js environment
19 | const localStorageMock = {
20 | store: {} as Record,
21 | getItem(key: string) {
22 | return this.store[key] || null;
23 | },
24 | setItem(key: string, value: string) {
25 | this.store[key] = value;
26 | },
27 | removeItem(key: string) {
28 | delete this.store[key];
29 | },
30 | };
31 |
32 | (global as any).localStorage = localStorageMock;
33 |
34 | // Set authentication credentials
35 | localStorageMock.setItem("jellyfinUsername", "john");
36 | localStorageMock.setItem("jellyfinPassword", "getthejelly");
37 |
38 | import { authenticateByUserName } from "../lib/jellyfin-api.js";
39 | import { listMovies } from "../lib/queries/movies.js";
40 | import { listShows } from "../lib/queries/shows.js";
41 | import { listFavoriteActors } from "../lib/queries/actors.js";
42 | import { listAudio } from "../lib/queries/audio.js";
43 | import { listLiveTvChannels } from "../lib/queries/livetv.js";
44 | import { getTopGenre } from "../lib/genre-helpers.js";
45 | import { getUnfinishedShows } from "../lib/queries/advanced.js";
46 |
47 | async function validateData() {
48 | console.log("🔍 Validating data fetching...\n");
49 | console.log("Server URL:", process.env.VITE_JELLYFIN_SERVER_URL);
50 |
51 | try {
52 | console.log("🔐 Authenticating...");
53 | const authStart = performance.now();
54 | await authenticateByUserName(process.env.VITE_JELLYFIN_SERVER_URL || "", "john", "getthejelly");
55 | const authTime = ((performance.now() - authStart) / 1000).toFixed(2);
56 | console.log(`✅ Authenticated (${authTime}s)\n`);
57 |
58 | console.log("📽️ Fetching movies...");
59 | const moviesStart = performance.now();
60 | const movies = await listMovies();
61 | const moviesTime = ((performance.now() - moviesStart) / 1000).toFixed(2);
62 | console.log(`✅ Movies: ${movies.length} items (${moviesTime}s)\n`);
63 |
64 | console.log("📺 Fetching shows...");
65 | const showsStart = performance.now();
66 | const shows = await listShows();
67 | const showsTime = ((performance.now() - showsStart) / 1000).toFixed(2);
68 | console.log(`✅ Shows: ${shows.length} items (${showsTime}s)\n`);
69 |
70 | console.log("🎭 Fetching favorite actors...");
71 | const actorsStart = performance.now();
72 | const actors = await listFavoriteActors();
73 | const actorsTime = ((performance.now() - actorsStart) / 1000).toFixed(2);
74 | console.log(`✅ Actors: ${actors.length} items (${actorsTime}s)\n`);
75 |
76 | console.log("🎵 Fetching audio...");
77 | const audioStart = performance.now();
78 | const audio = await listAudio();
79 | const audioTime = ((performance.now() - audioStart) / 1000).toFixed(2);
80 | console.log(`✅ Audio: ${audio.length} items (${audioTime}s)\n`);
81 |
82 | console.log("📡 Fetching live TV channels...");
83 | const liveTvStart = performance.now();
84 | const liveTv = await listLiveTvChannels();
85 | const liveTvTime = ((performance.now() - liveTvStart) / 1000).toFixed(2);
86 | console.log(`✅ Live TV: ${liveTv.length} items (${liveTvTime}s)\n`);
87 |
88 | console.log("🎬 Fetching top genre...");
89 | const genreStart = performance.now();
90 | console.log(` Debug: Movies with genres: ${movies.filter(m => m.genres?.length).length}`);
91 | console.log(` Debug: Shows with genres: ${shows.filter(s => s.item?.genres?.length).length}`);
92 | console.log(` Debug: Sample movie genres:`, movies[0]?.genres);
93 | console.log(` Debug: Sample show genres:`, shows[0]?.item?.genres);
94 | const topGenre = getTopGenre(movies, shows.map(s => s.item));
95 | const genreTime = ((performance.now() - genreStart) / 1000).toFixed(2);
96 | console.log(`✅ Top Genre: ${topGenre?.genre || 'None'} with ${topGenre?.items.length || 0} items (${genreTime}s)\n`);
97 |
98 | console.log("📺 Fetching unfinished shows...");
99 | const unfinishedStart = performance.now();
100 | const unfinishedShows = await getUnfinishedShows();
101 | const unfinishedTime = ((performance.now() - unfinishedStart) / 1000).toFixed(2);
102 | console.log(`✅ Unfinished Shows: ${unfinishedShows.length} items (${unfinishedTime}s)\n`);
103 |
104 | console.log("✨ All data validation passed!");
105 | console.log("\n📊 Performance Summary:");
106 | console.log(` Movies: ${moviesTime}s ${parseFloat(moviesTime) > 10 ? '⚠️ SLOW' : '✓'}`);
107 | console.log(` Shows: ${showsTime}s ${parseFloat(showsTime) > 10 ? '⚠️ SLOW' : '✓'}`);
108 | console.log(` Actors: ${actorsTime}s ${parseFloat(actorsTime) > 10 ? '⚠️ SLOW' : '✓'}`);
109 | console.log(` Audio: ${audioTime}s ${parseFloat(audioTime) > 10 ? '⚠️ SLOW' : '✓'}`);
110 | console.log(` Live TV: ${liveTvTime}s ${parseFloat(liveTvTime) > 10 ? '⚠️ SLOW' : '✓'}`);
111 | console.log(` Genre: ${genreTime}s ${parseFloat(genreTime) > 10 ? '⚠️ SLOW' : '✓'}`);
112 | console.log(` Unfinished Shows: ${unfinishedTime}s ${parseFloat(unfinishedTime) > 10 ? '⚠️ SLOW' : '✓'}`);
113 | } catch (error) {
114 | console.error("❌ Validation failed:", error);
115 | throw error;
116 | }
117 | }
118 |
119 | validateData();
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Jellyfin Wrapped
2 |
3 | A web application that generates personalized year-in-review statistics for your Jellyfin media server, inspired by Spotify Wrapped.
4 |
5 | ## ⚠️ Security Warning
6 |
7 | **DO NOT host this application publicly on the internet when using API keys as environment variables.** The API key is embedded in the frontend JavaScript bundle and will be visible to anyone who visits your site. This could expose your Jellyfin server to unauthorized access.
8 |
9 | **Recommended deployment options:**
10 | - Host locally on your home network only
11 | - Use behind a VPN or authentication proxy
12 | - Deploy on a private network/intranet
13 | - Consider implementing server-side API key handling for public deployments
14 |
15 | ## Features
16 |
17 | - 📊 View your most watched movies and TV shows
18 | - 🎵 See your most played music
19 | - ⏱️ Track your total watching/listening time
20 | - 📈 Get insights into your viewing habits
21 | - 🔒 Secure connection to your Jellyfin server
22 | - 📱 Responsive design for mobile and desktop
23 |
24 | ## Getting Started
25 |
26 | ### Prerequisites
27 |
28 | - A Jellyfin server (version 10.8.0 or higher) with Jellyfin's official [Playback Reporting plugin](https://github.com/jellyfin/jellyfin-plugin-playbackreporting) installed
29 | - Admin access to your Jellyfin server to generate an API key
30 | - Your Jellyfin server URL
31 | - A valid Jellyfin username and password for user authentication
32 |
33 | ### Environment Variables
34 |
35 | The application requires the following environment variables to be set:
36 |
37 | **For Development (Vite):**
38 | - `VITE_JELLYFIN_SERVER_URL`: The URL of your Jellyfin server (e.g., `http://192.168.1.100:8096`)
39 | - `VITE_JELLYFIN_API_KEY`: An admin API key for accessing playback reporting data
40 |
41 | **For Production (Docker/Runtime):**
42 | - `JELLYFIN_SERVER_URL`: The URL of your Jellyfin server (e.g., `http://192.168.1.100:8096`)
43 | - `JELLYFIN_API_KEY`: An admin API key for accessing playback reporting data
44 |
45 | You can set these in a `.env` file in the project root for development:
46 |
47 | ```bash
48 | # Copy the example file
49 | cp .env.example .env
50 |
51 | # Edit the .env file with your values
52 | VITE_JELLYFIN_SERVER_URL=http://your-jellyfin-server:8096
53 | VITE_JELLYFIN_API_KEY=your-admin-api-key-here
54 | ```
55 |
56 | #### Getting an Admin API Key
57 |
58 | 1. Log into your Jellyfin server as an administrator
59 | 2. Go to Dashboard → API Keys
60 | 3. Click "+" to create a new API key
61 | 4. Give it a name (e.g., "Jellyfin Wrapped")
62 | 5. Copy the generated API key and use it as `VITE_JELLYFIN_API_KEY` (development) or `JELLYFIN_API_KEY` (production)
63 |
64 | **Note**: The admin API key is required because recent Jellyfin versions restrict the `user_usage_stats/submit_custom_query` endpoint to admin users only.
65 |
66 | ### Usage
67 |
68 | 1. Set up the required environment variables (see Environment Variables section above)
69 | 2. Run the application locally or deploy it to your preferred hosting platform
70 | 3. If server URL is not configured via environment variables, enter your Jellyfin server URL
71 | 4. Log in with your Jellyfin credentials
72 | 5. View your personalized media statistics!
73 |
74 | ## Development
75 |
76 | This project is built with:
77 |
78 | - React 18
79 | - TypeScript
80 | - Vite
81 | - Radix UI
82 | - TailwindCSS
83 | - React Router
84 |
85 | ### Local Development
86 |
87 | ```bash
88 | # Clone the repository
89 | git clone https://github.com/johnpc/jellyfin-wrapped.git
90 |
91 | # Install dependencies
92 | npm install
93 |
94 | # Start development server
95 | npm run dev
96 | ```
97 |
98 | ## Building for Production
99 |
100 | ### Create production build
101 |
102 | ```bash
103 | npm run build
104 | ```
105 |
106 | ### Preview production build
107 |
108 | ```bash
109 | npm run preview
110 | ```
111 |
112 | ## Building with Docker
113 |
114 | To build with docker, run the following commands
115 |
116 | ```bash
117 | npm run build
118 | docker build -t jellyfin-wrapped .
119 | docker run -p 80:80 \
120 | -e JELLYFIN_SERVER_URL=http://your-jellyfin-server:8096 \
121 | -e JELLYFIN_API_KEY=your-admin-api-key \
122 | jellyfin-wrapped
123 | ```
124 |
125 | Or, via docker compose:
126 |
127 | ```bash
128 | # Edit docker-compose.yaml to set your environment variables
129 | # Start the service
130 | docker compose up -d
131 |
132 | # Stop the service
133 | docker compose down
134 |
135 | # Pull latest version
136 | docker compose pull
137 | ```
138 |
139 | Now the Jellyfin Wrapped ui is available at `http://localhost` on port 80.
140 |
141 | ### Updating Published Docker Image
142 |
143 | ```bash
144 | docker build -t jellyfin-wrapped . --no-cache
145 | docker tag jellyfin-wrapped:latest mrorbitman/jellyfin-wrapped:latest
146 | docker push mrorbitman/jellyfin-wrapped:latest
147 | ```
148 |
149 | ## Contributing
150 |
151 | Contributions are welcome! Please feel free to submit a Pull Request.
152 |
153 | ## Privacy
154 |
155 | This application connects directly to your Jellyfin server and does not store any credentials or personal data on external servers. All data is processed locally in your browser.
156 |
157 | ## Demo Screenshots
158 |
159 | 
160 |
161 | 
162 |
163 | 
164 |
165 | 
166 |
167 | 
168 |
--------------------------------------------------------------------------------
/src/components/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useNavigate, useLocation } from "react-router-dom";
3 | import { styled } from "@stitches/react";
4 | import { motion } from "framer-motion";
5 | import TimeframeSelector from "./TimeframeSelector";
6 | import { TimeframeOption } from "../lib/timeframe";
7 |
8 | // Define navigation items with their paths and display names
9 | const navigationItems = [
10 | { path: "/TopTen", name: "Top 10" },
11 | { path: "/movies", name: "Movies" },
12 | { path: "/shows", name: "TV Shows" },
13 | { path: "/audio", name: "Music" },
14 | { path: "/music-videos", name: "Music Videos" },
15 | { path: "/actors", name: "Favorite Actors" },
16 | { path: "/genres", name: "Genres" },
17 | { path: "/tv", name: "Live TV" },
18 | { path: "/critically-acclaimed", name: "Critically Acclaimed" },
19 | { path: "/oldest-movie", name: "Oldest Movie" },
20 | { path: "/oldest-show", name: "Oldest Show" },
21 | { path: "/holidays", name: "Holiday Watching" },
22 | { path: "/minutes-per-day", name: "Minutes Per Day" },
23 | { path: "/show-of-the-month", name: "Show of the Month" },
24 | { path: "/unfinished-shows", name: "Unfinished Shows" },
25 | { path: "/device-stats", name: "Device Stats" },
26 | { path: "/punch-card", name: "Activity Calendar" },
27 | ];
28 |
29 | const Navigation = () => {
30 | const navigate = useNavigate();
31 | const location = useLocation();
32 | const [isOpen, setIsOpen] = useState(false);
33 |
34 | // Don't show navigation on splash or configuration pages
35 | if (location.pathname === "/" || location.pathname === "/configure") {
36 | return null;
37 | }
38 |
39 | const toggleNav = () => {
40 | setIsOpen(!isOpen);
41 | };
42 |
43 | const handleTimeframeChange = (timeframe: TimeframeOption) => {
44 | // Refresh the current page to apply the new timeframe
45 | console.log({timeframe});
46 | void navigate(0);
47 | };
48 |
49 | return (
50 | <>
51 | {isOpen ? "✕" : "☰"}
52 |
53 |
59 |
60 | Jellyfin Wrapped
61 | ✕
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | {navigationItems.map((item) => (
70 | {
74 | void navigate(item.path);
75 | setIsOpen(false);
76 | }}
77 | >
78 | {item.name}
79 |
80 | ))}
81 |
82 |
83 |
84 | {isOpen && }
85 | >
86 | );
87 | };
88 |
89 | const SideNav = styled("nav", {
90 | position: "fixed",
91 | top: 0,
92 | left: 0,
93 | height: "100vh",
94 | width: "280px",
95 | backgroundColor: "rgba(45, 0, 247, 0.95)",
96 | backdropFilter: "blur(10px)",
97 | boxShadow: "2px 0 10px rgba(0, 0, 0, 0.3)",
98 | zIndex: 1000,
99 | overflowY: "auto",
100 | padding: "20px 0",
101 | display: "flex",
102 | flexDirection: "column",
103 | });
104 |
105 | const NavHeader = styled("div", {
106 | display: "flex",
107 | justifyContent: "space-between",
108 | alignItems: "center",
109 | padding: "0 20px 20px",
110 | borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
111 | marginBottom: "20px",
112 | });
113 |
114 | const TimeframeContainer = styled("div", {
115 | padding: "0 20px 20px",
116 | borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
117 | marginBottom: "20px",
118 | });
119 |
120 | const NavTitle = styled("h2", {
121 | color: "#FFD700",
122 | margin: 0,
123 | fontSize: "1.5rem",
124 | fontWeight: "bold",
125 | });
126 |
127 | const CloseButton = styled("button", {
128 | background: "none",
129 | border: "none",
130 | color: "white",
131 | fontSize: "1.5rem",
132 | cursor: "pointer",
133 | padding: "5px",
134 | display: "flex",
135 | alignItems: "center",
136 | justifyContent: "center",
137 | "&:hover": {
138 | color: "#FFD700",
139 | },
140 | });
141 |
142 | const NavList = styled("ul", {
143 | listStyle: "none",
144 | padding: 0,
145 | margin: 0,
146 | flexGrow: 1,
147 | });
148 |
149 | const NavItem = styled("li", {
150 | padding: "12px 20px",
151 | color: "white",
152 | cursor: "pointer",
153 | transition: "all 0.2s ease",
154 | borderLeft: "4px solid transparent",
155 |
156 | "&:hover": {
157 | backgroundColor: "rgba(255, 255, 255, 0.1)",
158 | borderLeftColor: "#FFD700",
159 | },
160 |
161 | variants: {
162 | isActive: {
163 | true: {
164 | backgroundColor: "rgba(255, 255, 255, 0.15)",
165 | borderLeftColor: "#FFD700",
166 | fontWeight: "bold",
167 | },
168 | },
169 | },
170 | });
171 |
172 | const NavToggle = styled("button", {
173 | position: "fixed",
174 | top: "20px",
175 | left: "20px",
176 | zIndex: 1001,
177 | backgroundColor: "rgba(45, 0, 247, 0.8)",
178 | color: "white",
179 | border: "none",
180 | borderRadius: "50%",
181 | width: "40px",
182 | height: "40px",
183 | display: "flex",
184 | alignItems: "center",
185 | justifyContent: "center",
186 | fontSize: "1.5rem",
187 | cursor: "pointer",
188 | boxShadow: "0 2px 10px rgba(0, 0, 0, 0.2)",
189 | transition: "all 0.2s ease",
190 |
191 | "&:hover": {
192 | backgroundColor: "#4D2DFF",
193 | transform: "scale(1.05)",
194 | },
195 | });
196 |
197 | const Overlay = styled("div", {
198 | position: "fixed",
199 | top: 0,
200 | left: 0,
201 | right: 0,
202 | bottom: 0,
203 | backgroundColor: "rgba(0, 0, 0, 0.5)",
204 | zIndex: 999,
205 | });
206 |
207 | export default Navigation;
208 |
--------------------------------------------------------------------------------
/src/components/pages/HolidayReviewPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from "react";
2 | import { Container, Grid } from "@radix-ui/themes";
3 | import { motion } from "framer-motion";
4 | import { Title } from "../ui/styled";
5 | import { itemVariants } from "@/lib/styled-variants";
6 | import { useNavigate } from "react-router-dom";
7 | import { useErrorBoundary } from "react-error-boundary";
8 | import { useWatchedOnDate } from "@/hooks/queries/useWatchedOnDate";
9 | import { LoadingSpinner } from "../LoadingSpinner";
10 | import { MovieCard } from "./MoviesReviewPage/MovieCard";
11 | import { generateGuid } from "@/lib/utils";
12 | import { getCachedHiddenIds, setCachedHiddenId } from "@/lib/cache";
13 | import { getHolidayDates } from "@/lib/holiday-helpers";
14 | import { subDays } from "date-fns";
15 | import PageContainer from "../PageContainer";
16 |
17 | const NEXT_PAGE = "/minutes-per-day";
18 |
19 | export default function HolidayReviewPage() {
20 | const { showBoundary } = useErrorBoundary();
21 | const navigate = useNavigate();
22 | const [hiddenIds, setHiddenIds] = useState(getCachedHiddenIds());
23 |
24 | const dates = useMemo(() => {
25 | const today = new Date();
26 | const holidays = getHolidayDates(today);
27 | return {
28 | christmas: holidays.christmas,
29 | christmasEve: subDays(holidays.christmas, 1),
30 | halloween: holidays.halloween,
31 | valentines: holidays.valentines,
32 | };
33 | }, []);
34 |
35 | const {
36 | data: christmas,
37 | isLoading: l1,
38 | error: e1,
39 | } = useWatchedOnDate(dates.christmas);
40 | const {
41 | data: christmasEve,
42 | isLoading: l2,
43 | error: e2,
44 | } = useWatchedOnDate(dates.christmasEve);
45 | const {
46 | data: halloween,
47 | isLoading: l3,
48 | error: e3,
49 | } = useWatchedOnDate(dates.halloween);
50 | const {
51 | data: valentines,
52 | isLoading: l4,
53 | error: e4,
54 | } = useWatchedOnDate(dates.valentines);
55 |
56 | const firstError = e1 || e2 || e3 || e4;
57 | if (firstError) {
58 | showBoundary(firstError);
59 | }
60 |
61 | if (l1 || l2 || l3 || l4) {
62 | return ;
63 | }
64 |
65 | const filterHidden = (items: { id?: string }[] | undefined) =>
66 | items?.filter(
67 | (item: { id?: string }) => !hiddenIds.includes(item.id ?? "")
68 | ) ?? [];
69 |
70 | const christmasItems = filterHidden(christmas);
71 | const christmasEveItems = filterHidden(christmasEve);
72 | const halloweenItems = filterHidden(halloween);
73 | const valentinesItems = filterHidden(valentines);
74 |
75 | if (
76 | !christmasItems.length &&
77 | !christmasEveItems.length &&
78 | !halloweenItems.length &&
79 | !valentinesItems.length
80 | ) {
81 | void navigate(NEXT_PAGE);
82 | return null;
83 | }
84 |
85 | return (
86 |
87 |
88 |
89 |
90 |
91 | What You Watched on Holidays
92 |
93 |
94 | Your viewing activity during special occasions
95 |
96 |
97 |
98 | {christmasItems.length > 0 && (
99 | <>
100 |
101 | Christmas
102 |
103 |
107 | {christmasItems.map((item: { id?: string }) => (
108 | {
112 | setCachedHiddenId(item.id ?? "");
113 | setHiddenIds([...hiddenIds, item.id ?? ""]);
114 | }}
115 | />
116 | ))}
117 |
118 | >
119 | )}
120 |
121 | {christmasEveItems.length > 0 && (
122 | <>
123 |
124 | Christmas Eve
125 |
126 |
130 | {christmasEveItems.map((item: { id?: string }) => (
131 | {
135 | setCachedHiddenId(item.id ?? "");
136 | setHiddenIds([...hiddenIds, item.id ?? ""]);
137 | }}
138 | />
139 | ))}
140 |
141 | >
142 | )}
143 |
144 | {halloweenItems.length > 0 && (
145 | <>
146 |
147 | Halloween
148 |
149 |
153 | {halloweenItems.map((item: { id?: string }) => (
154 | {
158 | setCachedHiddenId(item.id ?? "");
159 | setHiddenIds([...hiddenIds, item.id ?? ""]);
160 | }}
161 | />
162 | ))}
163 |
164 | >
165 | )}
166 |
167 | {valentinesItems.length > 0 && (
168 | <>
169 |
170 | Valentine's Day
171 |
172 |
176 | {valentinesItems.map((item: { id?: string }) => (
177 | {
181 | setCachedHiddenId(item.id ?? "");
182 | setHiddenIds([...hiddenIds, item.id ?? ""]);
183 | }}
184 | />
185 | ))}
186 |
187 | >
188 | )}
189 |
190 |
191 |
192 | );
193 | }
194 |
--------------------------------------------------------------------------------