diff --git a/README.md b/README.md index f85e1164..8aff3198 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Present [Plex](https://plex.tv) user statistics and habits in a beautiful and or - 📆 Rewind - allows your Plex users view their statistics and habits for a given year. - 👀 Dashboard - provides an easily glanceable overview of activity on your server for all your libraries. - ✨ Beautiful animations with [Framer Motion](https://www.framer.com/motion). -- 🔗 Integrates with [Overseerr](https://overseerr.dev) & [Tautulli](https://tautulli.com). +- 📊 Fuelled by data from [Tautulli](https://tautulli.com) - the backbone responsible for the heavy lifting regarding stats. +- 🔗 Integrates with [Overseerr](https://overseerr.dev) - show request breakdowns and totals. - 🔐 Log in with Plex - uses [NextAuth.js](https://next-auth.js.org) to enable secure login and session management with your Plex account. - 🚀 PWA support - installable on mobile devices and desktops thanks to [Serwist](https://github.com/serwist/serwist). - 🐳 Easy deployment - run the application in a containerized environment with [Docker](https://www.docker.com). @@ -53,6 +54,12 @@ services: > _NOTE: If you run into authentication issues, try setting `NEXTAUTH_URL` and `NEXT_PUBLIC_SITE_URL` to your external Docker IP, instead of localhost. For example `http://192.168.1.1:8383`._ +### Unraid + +Plex Rewind is available in the Community Apps store for Unraid. Search for "Plex Rewind" and install it from grtgbln's repository. + +As noted in the installation instructions, you will need to download a copy of "settings.json" into the associated settings path **before** running the application. To download the file, you can open a terminal, enter the directory and run `curl -o settings.json https://raw.githubusercontent.com/RaunoT/plex-rewind/main/config/settings.example.json`. + ## Updating To update, run `docker compose pull` and then `docker compose up -d`. diff --git a/config/settings.example.json b/config/settings.example.json new file mode 100644 index 00000000..66230a7c --- /dev/null +++ b/config/settings.example.json @@ -0,0 +1,28 @@ +{ + "connection": { + "tautulliUrl": "", + "tautulliApiKey": "", + "overseerrUrl": "", + "overseerrApiKey": "", + "tmdbApiKey": "" + }, + "features": { + "isRewindActive": true, + "isDashboardActive": true, + "isUsersPageActive": true, + "activeLibraries": [], + "activeDashboardItemStatistics": [ + "year", + "rating", + "duration", + "plays", + "users", + "requests" + ], + "activeDashboardTotalStatistics": ["size", "duration", "count", "requests"], + "dashboardDefaultPeriod": "custom", + "dashboardCustomPeriod": "30", + "googleAnalyticsId": "" + }, + "test": false +} diff --git a/next.config.mjs b/next.config.mjs index 5361a0ca..6938b964 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,13 +14,17 @@ const nextConfig = { protocol: 'https', hostname: 'plex.tv', }, + { + protocol: 'https', + hostname: 'image.tmdb.org', + }, ], }, - logging: { - fetches: { - fullUrl: true, - }, - }, + // logging: { + // fetches: { + // fullUrl: true, + // }, + // }, async headers() { return [ { diff --git a/src/actions/update-feature-settings.ts b/src/actions/update-feature-settings.ts index 94ad13c4..c0b20ea5 100644 --- a/src/actions/update-feature-settings.ts +++ b/src/actions/update-feature-settings.ts @@ -1,6 +1,10 @@ 'use server' -import { SettingsFormInitialState } from '@/types' +import { + DashboardItemStatistics, + DashboardTotalStatistics, + SettingsFormInitialState, +} from '@/types' import { SETTINGS_PATH } from '@/utils/constants' import getSettings from '@/utils/getSettings' import { promises as fs } from 'fs' @@ -12,8 +16,10 @@ const schema = z.object({ isDashboardActive: z.boolean(), isUsersPageActive: z.boolean(), activeLibraries: z.array(z.string()), - activeDashboardStatistics: z.array(z.string()), - dashboardDefaultPeriod: z.string().refine( + activeDashboardItemStatistics: z.array(z.string()), + activeDashboardTotalStatistics: z.array(z.string()), + dashboardDefaultPeriod: z.string(), + dashboardCustomPeriod: z.string().refine( (value) => { const number = parseFloat(value) @@ -35,10 +41,14 @@ export async function saveFeaturesSettings( isDashboardActive: formData.get('isDashboardActive') === 'on', isUsersPageActive: formData.get('isUsersPageActive') === 'on', activeLibraries: formData.getAll('activeLibraries') as string[], - activeDashboardStatistics: formData.getAll( - 'activeDashboardStatistics', - ) as string[], + activeDashboardItemStatistics: formData.getAll( + 'activeDashboardItemStatistics', + ) as DashboardItemStatistics, + activeDashboardTotalStatistics: formData.getAll( + 'activeDashboardTotalStatistics', + ) as DashboardTotalStatistics, dashboardDefaultPeriod: formData.get('dashboardDefaultPeriod') as string, + dashboardCustomPeriod: formData.get('dashboardCustomPeriod') as string, googleAnalyticsId: formData.get('googleAnalyticsId') as string, } diff --git a/src/app/_components/Home.tsx b/src/app/_components/Home.tsx index 310b01c7..15df5fed 100644 --- a/src/app/_components/Home.tsx +++ b/src/app/_components/Home.tsx @@ -106,7 +106,7 @@ export default function Home({ settings }: Props) { return (
{session?.user?.image && ( -
+
{`${session?.user?.name} )} -
-

- Plex logo - rewind -

-
+

+ Plex logo + rewind +

{!isLoggedIn && ( diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts new file mode 100644 index 00000000..40d0bf89 --- /dev/null +++ b/src/app/api/image/route.ts @@ -0,0 +1,28 @@ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const url = searchParams.get('url') + + if (!url) { + return new Response('URL parameter is missing', { status: 400 }) + } + + const res = await fetch(url) + + if (!res.ok) { + return new Response('Failed to fetch the image', { + status: res.status, + }) + } + + const headers = new Headers(res.headers) + const body = await res.arrayBuffer() + + return new Response(body, { + status: res.status, + headers: headers, + }) + } catch (error) { + return new Response('An error occurred', { status: 500 }) + } +} diff --git a/src/app/dashboard/[slug]/page.tsx b/src/app/dashboard/[slug]/page.tsx index 399fd68d..d0494191 100644 --- a/src/app/dashboard/[slug]/page.tsx +++ b/src/app/dashboard/[slug]/page.tsx @@ -42,10 +42,17 @@ async function DashboardContent({ params, searchParams }: Props) { const period = getPeriod(searchParams, settings) const [items, totalDuration, totalSize, serverId] = await Promise.all([ getItems(library, period.daysAgo), - getTotalDuration(library, period.string), - getTotalSize(library), + getTotalDuration(library, period.string, settings), + getTotalSize(library, settings), getServerId(), ]) + const isCountActive = + settings.features.activeDashboardTotalStatistics.includes('count') + const countValue = + library.section_type === 'movie' + ? Number(library.count) + : Number(library.child_count) + const count = isCountActive ? countValue.toLocaleString('en-US') : undefined return ( ) diff --git a/src/app/dashboard/_components/Dashboard.tsx b/src/app/dashboard/_components/Dashboard.tsx index 8ebd3685..77957189 100644 --- a/src/app/dashboard/_components/Dashboard.tsx +++ b/src/app/dashboard/_components/Dashboard.tsx @@ -14,9 +14,8 @@ type Props = { title: string items?: TautulliItemRow[] totalDuration?: string - totalSize?: string | null - totalRequests?: number - type?: string + totalSize?: string | number + type: 'movie' | 'show' | 'artist' | 'users' serverId?: string count?: string settings: Settings @@ -27,8 +26,7 @@ export default function Dashboard({ items, totalDuration, totalSize, - totalRequests, - type = '', + type, serverId = '', count, settings, @@ -42,8 +40,9 @@ export default function Dashboard({
    {totalSize && (
  • - + {type === 'users' ? : } {totalSize} + {type === 'users' && ' users'}
  • )} {count && ( @@ -58,7 +57,7 @@ export default function Dashboard({ ? 'episodes' : type === 'artist' ? 'tracks' - : 'users'} + : 'requests'} @@ -69,12 +68,6 @@ export default function Dashboard({ {totalDuration} )} - {!!totalRequests && ( -
  • - - {totalRequests} requests -
  • - )}
{items?.length ? ( @@ -105,7 +98,7 @@ function getTitleIcon(type: string) { return case 'artist': return - default: + case 'users': return } } @@ -118,7 +111,7 @@ function getCountIcon(type: string) { return case 'artist': return - default: - return + case 'users': + return } } diff --git a/src/app/dashboard/_components/PeriodSelectContent.tsx b/src/app/dashboard/_components/PeriodSelectContent.tsx index 7b94dfd3..b5bf8b77 100644 --- a/src/app/dashboard/_components/PeriodSelectContent.tsx +++ b/src/app/dashboard/_components/PeriodSelectContent.tsx @@ -10,11 +10,56 @@ type Props = { settings: Settings } +const DEFAULT_PERIOD_OPTIONS = [ + { label: '7 days', value: '7days' }, + { label: '30 days', value: '30days' }, + { label: 'Past year', value: 'pastYear' }, + { label: 'All time', value: 'allTime' }, +] + +function getPeriodValue(period: string, customPeriod: number): number { + switch (period) { + case '7days': + return 7 + case '30days': + return 30 + case 'pastYear': + return 365 + case 'allTime': + return Infinity + case 'custom': + return customPeriod + default: + return 0 + } +} + export default function PeriodSelectContent({ settings }: Props) { const pathname = usePathname() const searchParams = useSearchParams() const period = searchParams.get('period') - const customPeriod = parseInt(settings.features.dashboardDefaultPeriod) + const customPeriod = parseInt(settings.features.dashboardCustomPeriod) + const defaultPeriod = settings.features.dashboardDefaultPeriod + // Replace '30 days' with custom period if it exists + const periodOptions = customPeriod + ? [ + { label: '7 days', value: '7days' }, + { + label: `${pluralize(customPeriod, 'day')}`, + value: 'custom', + }, + { label: 'Past year', value: 'pastYear' }, + { label: 'All time', value: 'allTime' }, + ] + : DEFAULT_PERIOD_OPTIONS + + // Sort period options + periodOptions.sort((a, b) => { + return ( + getPeriodValue(a.value, customPeriod) - + getPeriodValue(b.value, customPeriod) + ) + }) useEffect(() => { window.scrollTo(0, 0) @@ -22,38 +67,21 @@ export default function PeriodSelectContent({ settings }: Props) { return (
    -
  • - - 7 days - -
  • -
  • - - {customPeriod ? `${pluralize(customPeriod, 'day')}` : '30 days'} - -
  • -
  • - - Past year - -
  • -
  • - - All time - -
  • + {periodOptions.map(({ label, value }) => { + const isDefault = value === defaultPeriod + + return ( +
  • + + {label} + +
  • + ) + })}
) } diff --git a/src/app/dashboard/users/page.tsx b/src/app/dashboard/users/page.tsx index 85807a86..afa6a564 100644 --- a/src/app/dashboard/users/page.tsx +++ b/src/app/dashboard/users/page.tsx @@ -1,4 +1,4 @@ -import { SearchParams, TautulliItem } from '@/types' +import { SearchParams, Settings, TautulliItem } from '@/types' import { fetchOverseerrUserId, fetchPaginatedOverseerrStats, @@ -47,8 +47,10 @@ async function getUsers( getLibrariesByType('artist'), ]) let usersRequestsCounts: UserRequestCounts[] = [] + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey - if (settings.connection.overseerrUrl) { + if (isOverseerrActive) { const overseerrUserIds = await Promise.all( users.map(async (user) => { const overseerrId = await fetchOverseerrUserId(String(user.user_id)) @@ -133,30 +135,48 @@ async function getUsers( return users } -async function getTotalDuration(period: string) { - const totalDuration = await fetchTautulli<{ total_duration: string }>( - 'get_history', - { - after: period, - length: 0, - }, - ) +async function getTotalDuration(period: string, settings: Settings) { + if (settings.features.activeDashboardTotalStatistics.includes('duration')) { + const totalDuration = await fetchTautulli<{ total_duration: string }>( + 'get_history', + { + after: period, + length: 0, + }, + ) - return secondsToTime( - timeToSeconds(totalDuration?.response?.data?.total_duration || '0'), - ) + return secondsToTime( + timeToSeconds(totalDuration?.response?.data?.total_duration || '0'), + ) + } + + return undefined } -async function getUsersCount() { - const usersCount = await fetchTautulli<[]>('get_users') +async function getUsersCount(settings: Settings) { + if (settings.features.activeDashboardTotalStatistics.includes('count')) { + const usersCount = await fetchTautulli<[]>('get_users') - return usersCount?.response?.data.slice(1).length || 0 + return usersCount?.response?.data.slice(1).length + } + + return undefined } -async function getTotalRequests(period: string) { - const requests = await fetchPaginatedOverseerrStats('request', period) +async function getTotalRequests(period: string, settings: Settings) { + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey + + if ( + settings.features.activeDashboardTotalStatistics.includes('requests') && + isOverseerrActive + ) { + const requests = await fetchPaginatedOverseerrStats('request', period) + + return requests.length.toString() + } - return requests.length + return undefined } type Props = { @@ -174,9 +194,9 @@ async function DashboardUsersContent({ searchParams }: Props) { const [usersData, totalDuration, usersCount, totalRequests] = await Promise.all([ getUsers(period.daysAgo, period.date, period.string), - getTotalDuration(period.string), - getUsersCount(), - getTotalRequests(period.date), + getTotalDuration(period.string, settings), + getUsersCount(settings), + getTotalRequests(period.date, settings), ]) return ( @@ -184,10 +204,10 @@ async function DashboardUsersContent({ searchParams }: Props) { title='Users' items={usersData} totalDuration={totalDuration} - count={String(usersCount)} + totalSize={usersCount} type='users' settings={settings} - totalRequests={totalRequests} + count={totalRequests} /> ) } diff --git a/src/app/rewind/_components/RewindStories.tsx b/src/app/rewind/_components/RewindStories.tsx index 12662eef..7023a37d 100644 --- a/src/app/rewind/_components/RewindStories.tsx +++ b/src/app/rewind/_components/RewindStories.tsx @@ -48,13 +48,15 @@ export default function RewindStories({ userRewind, settings }: Props) { } } + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey const stories = [ createStory(StoryWelcome, 5000), createStory(StoryTotal, 8000), ...(userRewind.libraries_total_size ? [createStory(StoryLibraries, 9000)] : []), - ...(settings.connection.overseerrUrl + ...(isOverseerrActive ? [createStory(StoryRequests, userRewind.requests?.total ? 9000 : 4000)] : []), ...(userRewind.duration.user diff --git a/src/app/rewind/page.tsx b/src/app/rewind/page.tsx index eb7ed23a..3c4a174b 100644 --- a/src/app/rewind/page.tsx +++ b/src/app/rewind/page.tsx @@ -95,8 +95,10 @@ export default async function RewindPage({ searchParams }: Props) { server_id: serverId, user: user, } + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey - if (settings.connection.overseerrUrl) { + if (isOverseerrActive) { const requestTotals = await getRequestsTotals(user.id) userRewind.requests = requestTotals diff --git a/src/app/settings/connection/_components/ConnectionSettingsForm.tsx b/src/app/settings/connection/_components/ConnectionSettingsForm.tsx index 53a83a6b..727cea43 100644 --- a/src/app/settings/connection/_components/ConnectionSettingsForm.tsx +++ b/src/app/settings/connection/_components/ConnectionSettingsForm.tsx @@ -24,7 +24,7 @@ export default function ConnectionSettingsForm({ settings }: Props) { required defaultValue={connectionSettings.tautulliUrl} /> - Tautulli URL + URL
@@ -47,7 +47,7 @@ export default function ConnectionSettingsForm({ settings }: Props) { defaultValue={connectionSettings.tmdbApiKey} required /> - TMDB API key + API key
@@ -60,7 +60,7 @@ export default function ConnectionSettingsForm({ settings }: Props) { name='overseerrUrl' defaultValue={connectionSettings.overseerrUrl} /> - Overseerr URL + URL
diff --git a/src/app/settings/features/_components/FeaturesSettingsForm.tsx b/src/app/settings/features/_components/FeaturesSettingsForm.tsx index d927d343..64197409 100644 --- a/src/app/settings/features/_components/FeaturesSettingsForm.tsx +++ b/src/app/settings/features/_components/FeaturesSettingsForm.tsx @@ -13,6 +13,8 @@ type Props = { export default function FeaturesSettingsForm({ settings, libraries }: Props) { const featuresSettings = settings.features + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey return ( @@ -69,9 +71,9 @@ export default function FeaturesSettingsForm({ settings, libraries }: Props) {
Users - {settings.connection.overseerrUrl && - settings.connection.overseerrApiKey && ( - - - Requests - - )} + {isOverseerrActive && ( + + + Requests + + )} +
+ + + +
+ + + Size + + + + Duration + + + + Count + + {isOverseerrActive && ( + + + Requests + + )}
- +
+
+
+ +
+ Default period +
diff --git a/src/components/MediaItem/MediaItem.tsx b/src/components/MediaItem/MediaItem.tsx index 4c01861f..798cd5e4 100644 --- a/src/components/MediaItem/MediaItem.tsx +++ b/src/components/MediaItem/MediaItem.tsx @@ -15,9 +15,11 @@ import { } from '@heroicons/react/24/outline' import clsx from 'clsx' import { motion } from 'framer-motion' +import Image from 'next/image' import { useEffect, useRef, useState } from 'react' import MediaItemTitle from './MediaItemTitle' import PlexDeeplink from './PlexDeeplink' +import placeholderSvg from './placeholder.svg' type Props = { data: TautulliItemRow @@ -38,11 +40,19 @@ export default function MediaItem({ settings, }: Props) { const tautulliUrl = settings.connection.tautulliUrl - const posterSrc = `${tautulliUrl}/pms_image_proxy?img=${ - type === 'users' ? data.user_thumb : data.thumb - }&width=300` + const isTmdbPoster = data.thumb?.startsWith('https://image.tmdb.org') + const posterSrc = isTmdbPoster + ? data.thumb + : `/api/image?url=${encodeURIComponent( + `${tautulliUrl}/pms_image_proxy?img=${ + type === 'users' ? data.user_thumb : data.thumb + }&width=300`, + )}` const [dataKey, setDataKey] = useState(0) const titleContainerRef = useRef(null) + const isOverseerrActive = + settings.connection.overseerrUrl && settings.connection.overseerrApiKey + const [imageSrc, setImageSrc] = useState(posterSrc) useEffect(() => { setDataKey((prevDataKey) => prevDataKey + 1) @@ -57,20 +67,19 @@ export default function MediaItem({ animate='show' transition={{ delay: i * 0.075 }} > -
- {/* eslint-disable-next-line @next/next/no-img-element */} - { - e.currentTarget.src = '/placeholder.svg' - }} +
+ { setImageSrc(placeholderSvg)} + priority />
-
{data.is_deleted ? ( - settings.connection.overseerrUrl ? ( + isOverseerrActive ? ( ))} diff --git a/public/placeholder.svg b/src/components/MediaItem/placeholder.svg similarity index 100% rename from public/placeholder.svg rename to src/components/MediaItem/placeholder.svg diff --git a/src/styles/globals.css b/src/styles/globals.css index 5956a0af..4ceac104 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -107,6 +107,10 @@ @apply ring-1 ring-neutral-300; } + .required::after { + @apply ml-1 align-text-bottom text-red-500 content-['*']; + } + .input { @apply block w-full rounded-xl border-none bg-neutral-500 px-3 py-2 placeholder:text-neutral-400 focus-within:bg-neutral-400; @@ -118,13 +122,23 @@ } } - &[required] + .label::after { - @apply ml-1 align-text-bottom text-red-500 content-['*']; + &[required] + .label { + @apply required; } } .input-wrapper { - @apply flex flex-col-reverse gap-2 sm:flex-row-reverse sm:items-center; + @apply relative flex flex-col-reverse gap-2 sm:flex-row-reverse sm:items-center; + } + + .select-wrapper { + @apply relative w-full; + + &::after { + @apply absolute inset-y-2 right-12 -mr-px h-auto w-px bg-neutral-300; + + content: ''; + } } .label { @@ -292,3 +306,20 @@ animation-delay: 2s; } } + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + @apply appearance-none; +} + +input[type='number'] { + appearance: textfield; +} + +select.input { + @apply relative cursor-pointer appearance-none bg-no-repeat pr-12; + + background-image: url('data:image/svg+xml;charset=US-ASCII,'); + background-position: right 12px center; + background-size: 24px; +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 64a35d10..3b238754 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -41,7 +41,13 @@ export type RewindStory = { } export type TmdbItem = { - results: [{ id: number; vote_average: number; first_air_date: string }] + results: [ + { + id: number + vote_average: number + first_air_date: number + }, + ] } export type TmdbExternalId = { imdb_id: string } @@ -53,16 +59,16 @@ export type TautulliItem = { export type TautulliItemRow = { title: string - year: number + year: number | null total_plays: number total_duration: number users_watched: number | undefined rating_key: number thumb: string is_deleted: boolean - rating: string - tmdb_id: number - imdb_id: string + rating: string | null + tmdb_id: number | null + imdb_id: string | null user_thumb: string user: string requests: number @@ -109,13 +115,31 @@ export type ConnectionSettings = { tmdbApiKey: string } +// Define the allowed strings for each type +export type DashboardItemStatistics = ( + | 'year' + | 'rating' + | 'duration' + | 'plays' + | 'users' + | 'requests' +)[] +export type DashboardTotalStatistics = ( + | 'size' + | 'duration' + | 'count' + | 'requests' +)[] + export type FeaturesSettings = { isRewindActive: boolean isDashboardActive: boolean isUsersPageActive: boolean activeLibraries: string[] - activeDashboardStatistics: string[] + activeDashboardItemStatistics: DashboardItemStatistics + activeDashboardTotalStatistics: DashboardTotalStatistics dashboardDefaultPeriod: string + dashboardCustomPeriod: string googleAnalyticsId: string } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f6d9bc2f..6dd816e3 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,4 @@ +import { Settings } from '@/types' import { env } from 'next-runtime-env' import path from 'path' @@ -64,7 +65,7 @@ export const PLEX_PRODUCT_NAME = 'Plex Rewind' export const APP_URL = env('NEXT_PUBLIC_SITE_URL') || 'http://localhost:8383' export const SETTINGS_PATH = path.join(process.cwd(), 'config/settings.json') -export const DEFAULT_SETTINGS = { +export const DEFAULT_SETTINGS: Settings = { connection: { tautulliUrl: '', tautulliApiKey: '', @@ -77,7 +78,7 @@ export const DEFAULT_SETTINGS = { isDashboardActive: true, isUsersPageActive: true, activeLibraries: [], - activeDashboardStatistics: [ + activeDashboardItemStatistics: [ 'year', 'rating', 'duration', @@ -85,7 +86,9 @@ export const DEFAULT_SETTINGS = { 'users', 'requests', ], - dashboardDefaultPeriod: '30', + activeDashboardTotalStatistics: ['size', 'duration', 'count', 'requests'], + dashboardDefaultPeriod: 'custom', + dashboardCustomPeriod: '30', googleAnalyticsId: '', }, test: false, diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index e2f9fc2e..49699ad1 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -42,8 +42,8 @@ export function removeAfterMinutes(timeString: string): string { return timeString.replace(/mins.*/, 'mins') } -export function bytesToSize(bytes: number, decimals = 2): string | null { - if (!+bytes) return null +export function bytesToSize(bytes: number, decimals = 2): string | undefined { + if (!+bytes) return const k = 1024 const dm = decimals < 0 ? 0 : decimals diff --git a/src/utils/getDashboard.ts b/src/utils/getDashboard.ts index 5311b9a8..80386e53 100644 --- a/src/utils/getDashboard.ts +++ b/src/utils/getDashboard.ts @@ -1,4 +1,4 @@ -import { Library, TautulliItem } from '@/types' +import { Library, Settings, TautulliItem } from '@/types' import fetchTautulli from './fetchTautulli' import { bytesToSize, secondsToTime, timeToSeconds } from './formatting' import getMediaAdditionalData from './getMediaAdditionalData' @@ -71,29 +71,41 @@ export async function getItems(library: Library, period: number) { return items } -export async function getTotalDuration(library: Library, period: string) { - const totalDuration = await fetchTautulli<{ total_duration: string }>( - 'get_history', - { - section_id: library.section_id, - after: period, - length: 0, - }, - ) - - return secondsToTime( - timeToSeconds(totalDuration?.response?.data?.total_duration || '0'), - ) +export async function getTotalDuration( + library: Library, + period: string, + settings: Settings, +) { + if (settings.features.activeDashboardTotalStatistics.includes('duration')) { + const totalDuration = await fetchTautulli<{ total_duration: string }>( + 'get_history', + { + section_id: library.section_id, + after: period, + length: 0, + }, + ) + + return secondsToTime( + timeToSeconds(totalDuration?.response?.data?.total_duration || '0'), + ) + } + + return undefined } -export async function getTotalSize(library: Library) { - const totalSize = await fetchTautulli<{ total_file_size: number }>( - 'get_library_media_info', - { - section_id: library.section_id, - length: 0, - }, - ) +export async function getTotalSize(library: Library, settings: Settings) { + if (settings.features.activeDashboardTotalStatistics.includes('size')) { + const totalSize = await fetchTautulli<{ total_file_size: number }>( + 'get_library_media_info', + { + section_id: library.section_id, + length: 0, + }, + ) + + return bytesToSize(totalSize?.response?.data.total_file_size || 0) + } - return bytesToSize(totalSize?.response?.data.total_file_size || 0) + return undefined } diff --git a/src/utils/getMediaAdditionalData.ts b/src/utils/getMediaAdditionalData.ts index e827d2b0..6a6164d0 100644 --- a/src/utils/getMediaAdditionalData.ts +++ b/src/utils/getMediaAdditionalData.ts @@ -1,11 +1,7 @@ -import { - TautulliItem, - TautulliItemRow, - TmdbExternalId, - TmdbItem, -} from '@/types' +import { TautulliItemRow, TmdbExternalId, TmdbItem } from '@/types' import fetchTautulli from './fetchTautulli' import fetchTmdb from './fetchTmdb' +import getSettings from './getSettings' export default async function getMediaAdditionalData( media: TautulliItemRow[], @@ -20,7 +16,9 @@ export default async function getMediaAdditionalData( const additionalData = await Promise.all( ratingKeys.map(async (key, i) => { - const mediaTautulli = await fetchTautulli( + // TODO: We're basically only doing this fetch to determine if the item was deleted + // Maybe there's a better way to do this? + const mediaTautulli = await fetchTautulli( 'get_metadata', { rating_key: key, @@ -31,10 +29,11 @@ export default async function getMediaAdditionalData( // Tautulli doesn't return rating for removed items, so we're using TMDB const mediaTmdb = await fetchTmdb(`search/${type}`, { query: media[i].title, - first_air_date_year: type === 'tv' ? '' : media[i].year, + first_air_date_year: type === 'tv' ? '' : media[i].year || '', }) const tmdbResult = mediaTmdb?.results?.[0] const tmdbId = tmdbResult?.id + let poster = mediaTautulliData?.thumb || '' let imdbId = null if (tmdbId) { @@ -43,6 +42,20 @@ export default async function getMediaAdditionalData( ) } + const settings = await getSettings() + const tautulliUrl = settings.connection.tautulliUrl + + // Test if thumb exists, if not, fetch from TMDB + if (!poster && tautulliUrl) { + const tmdbImage = await fetchTmdb<{ poster_path: string }>( + `${type}/${tmdbId}`, + ) + + if (tmdbImage?.poster_path) { + poster = `https://image.tmdb.org/t/p/w300/${tmdbImage.poster_path}` + } + } + return { year: tmdbResult?.first_air_date ? new Date(tmdbResult.first_air_date).getFullYear() @@ -55,18 +68,20 @@ export default async function getMediaAdditionalData( : null, tmdb_id: tmdbId || null, imdb_id: imdbId?.imdb_id || null, + thumb: poster, } }), ) media.map((mediaItem, i) => { mediaItem.is_deleted = additionalData[i].is_deleted - mediaItem.rating = additionalData[i].rating || '0' - mediaItem.tmdb_id = additionalData[i].tmdb_id || 0 - mediaItem.imdb_id = additionalData[i].imdb_id || '0' + mediaItem.rating = additionalData[i].rating + mediaItem.tmdb_id = additionalData[i].tmdb_id + mediaItem.imdb_id = additionalData[i].imdb_id + mediaItem.thumb = additionalData[i].thumb if (type === 'tv') { - mediaItem.year = additionalData[i].year || 0 + mediaItem.year = additionalData[i].year if (usersWatchedData) { const watchedData = usersWatchedData.find( diff --git a/src/utils/getPeriod.ts b/src/utils/getPeriod.ts index d7959edb..e6499712 100644 --- a/src/utils/getPeriod.ts +++ b/src/utils/getPeriod.ts @@ -6,12 +6,13 @@ export default function getPeriod( settings: Settings, ) { const periodSearchParams = searchParams?.period - const customPeriod = parseInt(settings.features.dashboardDefaultPeriod) - let period = PERIODS['30days'] + const defaultPeriod = settings.features.dashboardDefaultPeriod + const customPeriod = parseInt(settings.features.dashboardCustomPeriod) + let period = PERIODS[defaultPeriod] || PERIODS['30days'] if (periodSearchParams && PERIODS[periodSearchParams]) { period = PERIODS[periodSearchParams] - } else if (customPeriod) { + } else if (defaultPeriod === 'custom' || periodSearchParams === 'custom') { const DAYS_AGO_CUSTOM: Date = new Date( new Date().setDate(new Date().getDate() - customPeriod), ) diff --git a/src/utils/getSettings.ts b/src/utils/getSettings.ts index 125eae19..4c8484fa 100644 --- a/src/utils/getSettings.ts +++ b/src/utils/getSettings.ts @@ -1,34 +1,73 @@ -'use server' - import { Settings } from '@/types' import { promises as fs } from 'fs' import { DEFAULT_SETTINGS, SETTINGS_PATH } from './constants' export default async function getSettings(): Promise { try { - try { - // Attempt to read the file - const file = await fs.readFile(SETTINGS_PATH, 'utf8') + // Attempt to read the file + const file = await fs.readFile(SETTINGS_PATH, 'utf8') + const settings: Partial = JSON.parse(file) + let updated = false - return JSON.parse(file) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - // If reading fails because the file does not exist, create the file with default settings - if (error.code === 'ENOENT') { - console.warn('Settings file not found. Creating a new one.') + // Ensure connection settings + const connectionSettings = { + ...DEFAULT_SETTINGS.connection, + ...settings.connection, + } + if ( + JSON.stringify(settings.connection) !== JSON.stringify(connectionSettings) + ) { + settings.connection = connectionSettings + updated = true + } + // Ensure features settings + const featuresSettings = { + ...DEFAULT_SETTINGS.features, + ...settings.features, + } + if ( + JSON.stringify(settings.features) !== JSON.stringify(featuresSettings) + ) { + settings.features = featuresSettings + updated = true + } + + // Ensure test setting + if (settings.test === undefined) { + settings.test = DEFAULT_SETTINGS.test + updated = true + } + + if (updated) { + try { await fs.writeFile( SETTINGS_PATH, - JSON.stringify(DEFAULT_SETTINGS, null, 2), + JSON.stringify(settings, null, 2), 'utf8', ) - - return DEFAULT_SETTINGS - } else { - throw new Error('Could not read settings file') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + throw new Error('Unable to write updated settings to the file!') } } - } catch (error) { - throw new Error('Unexpected error handling settings file') + + return settings as Settings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + // If reading fails because the file does not exist, create the file with default settings + if (error.code === 'ENOENT') { + console.warn('Settings file not found. Creating a new one.') + + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(DEFAULT_SETTINGS, null, 2), + 'utf8', + ) + + return DEFAULT_SETTINGS + } else { + throw new Error('Unable to read the settings file!') + } } }