diff --git a/src/server/api/root.ts b/src/server/api/root.ts index f76df3cbe..e3731c3aa 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -11,6 +11,7 @@ import { mediaRequestsRouter } from './routers/media-request'; import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; import { usenetRouter } from './routers/usenet/route'; +import { calendarRouter } from './routers/calendar'; /** * This is the primary router for your server. @@ -30,6 +31,7 @@ export const rootRouter = createTRPCRouter({ mediaServer: mediaServerRouter, overseerr: overseerrRouter, usenet: usenetRouter, + calendar: calendarRouter, }); // export type definition of API diff --git a/src/server/api/routers/calendar.ts b/src/server/api/routers/calendar.ts new file mode 100644 index 000000000..42761c073 --- /dev/null +++ b/src/server/api/routers/calendar.ts @@ -0,0 +1,95 @@ +import axios from 'axios'; +import Consola from 'consola'; +import { z } from 'zod'; +import { getConfig } from '~/tools/config/getConfig'; +import { AppIntegrationType } from '~/types/app'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const calendarRouter = createTRPCRouter({ + medias: publicProcedure + .input( + z.object({ + configName: z.string(), + month: z.number().min(1).max(12), + year: z.number().min(1900).max(2300), + options: z.object({ + useSonarrv4: z.boolean().optional().default(false), + }), + }) + ) + .query(async ({ input }) => { + const { configName, month, year, options } = input; + const config = getConfig(configName); + + const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [ + 'sonarr', + 'radarr', + 'readarr', + 'lidarr', + ]; + const mediaApps = config.apps.filter( + (app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type) + ); + + const integrationTypeEndpointMap = new Map([ + ['sonarr', input.options.useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'], + ['radarr', '/api/v3/calendar'], + ['lidarr', '/api/v1/calendar'], + ['readarr', '/api/v1/calendar'], + ]); + + const promises = mediaApps.map(async (app) => { + const integration = app.integration!; + const endpoint = integrationTypeEndpointMap.get(integration.type); + if (!endpoint) { + return { + type: integration.type, + items: [], + success: false, + }; + } + + // Get the origin URL + let { href: origin } = new URL(app.url); + if (origin.endsWith('/')) { + origin = origin.slice(0, -1); + } + + const start = new Date(year, month - 1, 1); // First day of month + const end = new Date(year, month, 0); // Last day of month + + const apiKey = integration.properties.find((x) => x.field === 'apiKey')?.value; + if (!apiKey) return { type: integration.type, items: [], success: false }; + return axios + .get( + `${origin}${endpoint}?apiKey=${apiKey}&end=${end.toISOString()}&start=${start.toISOString()}&includeSeries=true&includeEpisodeFile=true&includeEpisodeImages=true` + ) + .then((x) => ({ type: integration.type, items: x.data as any[], success: true })) + .catch((err) => { + Consola.error( + `failed to process request to app '${integration.type}' (${app.id}): ${err}` + ); + return { + type: integration.type, + items: [], + success: false, + }; + }); + }); + + const medias = await Promise.all(promises); + + const countFailed = medias.filter((x) => !x.success).length; + if (countFailed > 0) { + Consola.warn(`A total of ${countFailed} apps for the calendar widget failed`); + } + + return { + tvShows: medias.filter((m) => m.type === 'sonarr').flatMap((m) => m.items), + movies: medias.filter((m) => m.type === 'radarr').flatMap((m) => m.items), + books: medias.filter((m) => m.type === 'readarr').flatMap((m) => m.items), + musics: medias.filter((m) => m.type === 'lidarr').flatMap((m) => m.items), + totalCount: medias.reduce((p, c) => p + c.items.length, 0), + }; + }), +}); diff --git a/src/widgets/calendar/CalendarTile.tsx b/src/widgets/calendar/CalendarTile.tsx index c776e19d9..de320e212 100644 --- a/src/widgets/calendar/CalendarTile.tsx +++ b/src/widgets/calendar/CalendarTile.tsx @@ -1,16 +1,16 @@ import { useMantineTheme } from '@mantine/core'; import { Calendar } from '@mantine/dates'; import { IconCalendarTime } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; import { i18n } from 'next-i18next'; import { useState } from 'react'; +import { api } from '~/utils/api'; +import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore'; import { useConfigContext } from '../../config/provider'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; import { CalendarDay } from './CalendarDay'; import { getBgColorByDateAndTheme } from './bg-calculator'; import { MediasType } from './type'; -import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore'; const definition = defineWidget({ id: 'calendar', @@ -55,22 +55,18 @@ function CalendarTile({ widget }: CalendarTileProps) { const [month, setMonth] = useState(new Date()); const isEditMode = useEditModeStore((x) => x.enabled); - const { data: medias } = useQuery({ - queryKey: [ - 'calendar/medias', - { month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 }, - ], - staleTime: 1000 * 60 * 60 * 5, - enabled: isEditMode === false, - queryFn: async () => - (await ( - await fetch( - `/api/modules/calendar?year=${month.getFullYear()}&month=${ - month.getMonth() + 1 - }&configName=${configName}&widgetId=${widget.id}` - ) - ).json()) as MediasType, - }); + const { data: medias } = api.calendar.medias.useQuery( + { + configName: configName!, + month: month.getMonth() + 1, + year: month.getFullYear(), + options: { useSonarrv4: widget.properties.useSonarrv4 }, + }, + { + staleTime: 1000 * 60 * 60 * 5, + enabled: isEditMode === false, + } + ); return (