├── .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 |