diff --git a/src/hooks/widgets/media-servers/useGetMediaServers.tsx b/src/hooks/widgets/media-servers/useGetMediaServers.tsx index 7b3d10164..7a0b86029 100644 --- a/src/hooks/widgets/media-servers/useGetMediaServers.tsx +++ b/src/hooks/widgets/media-servers/useGetMediaServers.tsx @@ -1,17 +1,20 @@ -import { useQuery } from '@tanstack/react-query'; -import { MediaServersResponseType } from '../../../types/api/media-server/response'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; interface GetMediaServersParams { enabled: boolean; } -export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => - useQuery({ - queryKey: ['media-servers'], - queryFn: async (): Promise => { - const response = await fetch('/api/modules/media-server'); - return response.json(); +export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => { + const { name: configName } = useConfigContext(); + + return api.mediaServer.all.useQuery( + { + configName: configName!, }, - enabled, - refetchInterval: 10 * 1000, - }); + { + enabled, + refetchInterval: 10 * 1000, + } + ); +}; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index ad550bbc8..9d94f4de4 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -8,6 +8,7 @@ import { dashDotRouter } from './routers/dash-dot'; import { dnsHoleRouter } from './routers/dns-hole'; import { downloadRouter } from './routers/download'; import { mediaRequestsRouter } from './routers/media-request'; +import { mediaServerRouter } from './routers/media-server'; /** * This is the primary router for your server. @@ -24,6 +25,7 @@ export const rootRouter = createTRPCRouter({ dnsHole: dnsHoleRouter, download: downloadRouter, mediaRequest: mediaRequestsRouter, + mediaServer: mediaServerRouter, }); // export type definition of API diff --git a/src/server/api/routers/media-server.ts b/src/server/api/routers/media-server.ts new file mode 100644 index 000000000..f645fe62b --- /dev/null +++ b/src/server/api/routers/media-server.ts @@ -0,0 +1,222 @@ +import { Jellyfin } from '@jellyfin/sdk'; +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; +import Consola from 'consola'; +import { z } from 'zod'; +import { getConfig } from '~/tools/config/getConfig'; +import { PlexClient } from '~/tools/server/sdk/plex/plexClient'; +import { GenericMediaServer } from '~/types/api/media-server/media-server'; +import { MediaServersResponseType } from '~/types/api/media-server/response'; +import { GenericCurrentlyPlaying, GenericSessionInfo } from '~/types/api/media-server/session-info'; +import { ConfigAppType } from '~/types/app'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +const jellyfin = new Jellyfin({ + clientInfo: { + name: 'Homarr', + version: '0.0.1', + }, + deviceInfo: { + name: 'Homarr Jellyfin Widget', + id: 'homarr-jellyfin-widget', + }, +}); + +export const mediaServerRouter = createTRPCRouter({ + all: publicProcedure + .input( + z.object({ + configName: z.string(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const apps = config.apps.filter((app) => + ['jellyfin', 'plex'].includes(app.integration?.type ?? '') + ); + + const servers = await Promise.all( + apps.map(async (app): Promise => { + try { + return await handleServer(app); + } catch (error) { + Consola.error( + `failed to communicate with media server '${app.name}' (${app.id}): ${error}` + ); + return { + serverAddress: app.url, + sessions: [], + success: false, + version: undefined, + type: undefined, + appId: app.id, + }; + } + }) + ); + + return { + servers: servers.filter( + (server): server is Exclude => server !== undefined + ), + } satisfies MediaServersResponseType; + }), +}); + +const handleServer = async (app: ConfigAppType): Promise => { + switch (app.integration?.type) { + case 'jellyfin': { + const username = app.integration.properties.find((x) => x.field === 'username'); + + if (!username || !username.value) { + return { + appId: app.id, + serverAddress: app.url, + sessions: [], + type: 'jellyfin', + version: undefined, + success: false, + }; + } + + const password = app.integration.properties.find((x) => x.field === 'password'); + + if (!password || !password.value) { + return { + appId: app.id, + serverAddress: app.url, + sessions: [], + type: 'jellyfin', + version: undefined, + success: false, + }; + } + + const api = jellyfin.createApi(app.url); + const infoApi = await getSystemApi(api).getPublicSystemInfo(); + await api.authenticateUserByName(username.value, password.value); + const sessionApi = await getSessionApi(api); + const sessions = await sessionApi.getSessions(); + return { + type: 'jellyfin', + appId: app.id, + serverAddress: app.url, + version: infoApi.data.Version ?? undefined, + sessions: sessions.data.map( + (session): GenericSessionInfo => ({ + id: session.Id ?? '?', + username: session.UserName ?? undefined, + sessionName: `${session.Client} (${session.DeviceName})`, + supportsMediaControl: session.SupportsMediaControl ?? false, + currentlyPlaying: session.NowPlayingItem + ? { + name: `${session.NowPlayingItem.SeriesName ?? session.NowPlayingItem.Name}`, + seasonName: session.NowPlayingItem.SeasonName as string, + episodeName: session.NowPlayingItem.Name as string, + albumName: session.NowPlayingItem.Album as string, + episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined, + metadata: { + video: + session.NowPlayingItem && + session.NowPlayingItem.Width && + session.NowPlayingItem.Height + ? { + videoCodec: undefined, + width: session.NowPlayingItem.Width ?? undefined, + height: session.NowPlayingItem.Height ?? undefined, + bitrate: undefined, + videoFrameRate: session.TranscodingInfo?.Framerate + ? String(session.TranscodingInfo?.Framerate) + : undefined, + } + : undefined, + audio: session.TranscodingInfo + ? { + audioChannels: session.TranscodingInfo.AudioChannels ?? undefined, + audioCodec: session.TranscodingInfo.AudioCodec ?? undefined, + } + : undefined, + transcoding: session.TranscodingInfo + ? { + audioChannels: session.TranscodingInfo.AudioChannels ?? -1, + audioCodec: session.TranscodingInfo.AudioCodec ?? undefined, + container: session.TranscodingInfo.Container ?? undefined, + width: session.TranscodingInfo.Width ?? undefined, + height: session.TranscodingInfo.Height ?? undefined, + videoCodec: session.TranscodingInfo?.VideoCodec ?? undefined, + audioDecision: undefined, + context: undefined, + duration: undefined, + error: undefined, + sourceAudioCodec: undefined, + sourceVideoCodec: undefined, + timeStamp: undefined, + transcodeHwRequested: undefined, + videoDecision: undefined, + } + : undefined, + }, + type: convertJellyfinType(session.NowPlayingItem.Type), + } + : undefined, + userProfilePicture: undefined, + }) + ), + success: true, + }; + } + case 'plex': { + const apiKey = app.integration.properties.find((x) => x.field === 'apiKey'); + + if (!apiKey || !apiKey.value) { + return { + serverAddress: app.url, + sessions: [], + type: 'plex', + appId: app.id, + version: undefined, + success: false, + }; + } + + const plexClient = new PlexClient(app.url, apiKey.value); + const sessions = await plexClient.getSessions(); + return { + serverAddress: app.url, + sessions, + type: 'plex', + version: undefined, + appId: app.id, + success: true, + }; + } + default: { + Consola.warn( + `media-server api entered a fallback case. This should normally not happen and must be reported. Cause: '${app.name}' (${app.id})` + ); + return undefined; + } + } +}; + +const convertJellyfinType = (kind: BaseItemKind | undefined): GenericCurrentlyPlaying['type'] => { + switch (kind) { + case BaseItemKind.Audio: + case BaseItemKind.MusicVideo: + return 'audio'; + case BaseItemKind.Episode: + case BaseItemKind.Video: + return 'video'; + case BaseItemKind.Movie: + return 'movie'; + case BaseItemKind.TvChannel: + case BaseItemKind.TvProgram: + case BaseItemKind.LiveTvChannel: + case BaseItemKind.LiveTvProgram: + return 'tv'; + default: + return undefined; + } +};