From 6e93c3b60813671f027062d55b2f16d2c8445475 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 2 Oct 2025 19:54:53 +0200 Subject: [PATCH] feat(media-server): add stats for nerds (#4170) --- .../integrations/src/emby/emby-integration.ts | 1 + .../media-server/media-server-types.ts | 24 ++++++++ .../src/jellyfin/jellyfin-integration.ts | 30 +++++++++ .../src/mock/data/media-server.ts | 1 + .../integrations/src/plex/plex-integration.ts | 1 + packages/translation/src/lang/en.json | 20 +++++- .../widgets/src/media-server/component.tsx | 61 +++++++++++++++++-- 7 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index dff55513e..f103ed93b 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -117,6 +117,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + metadata: null, }; } diff --git a/packages/integrations/src/interfaces/media-server/media-server-types.ts b/packages/integrations/src/interfaces/media-server/media-server-types.ts index 74bdcdbca..cdfc43261 100644 --- a/packages/integrations/src/interfaces/media-server/media-server-types.ts +++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts @@ -13,6 +13,30 @@ export interface StreamSession { episodeName?: string | null; albumName?: string | null; episodeCount?: number | null; + metadata: { + video: { + resolution: { + width: number; + height: number; + } | null; + frameRate: number | null; + }; + audio: { + channelCount: number | null; + codec: string | null; + }; + transcoding: { + container: string | null; + resolution: { + width: number; + height: number; + } | null; + target: { + audioCodec: string | null; + videoCodec: string | null; + }; + }; + } | null; } | null; } diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 0a31af85b..79c4c79ce 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -57,6 +57,36 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + metadata: { + video: { + resolution: + sessionInfo.NowPlayingItem.Width && sessionInfo.NowPlayingItem.Height + ? { + width: sessionInfo.NowPlayingItem.Width, + height: sessionInfo.NowPlayingItem.Height, + } + : null, + frameRate: sessionInfo.TranscodingInfo?.Framerate ?? null, + }, + audio: { + channelCount: sessionInfo.TranscodingInfo?.AudioChannels ?? null, + codec: sessionInfo.TranscodingInfo?.AudioCodec ?? null, + }, + transcoding: { + resolution: + sessionInfo.TranscodingInfo?.Width && sessionInfo.TranscodingInfo.Height + ? { + width: sessionInfo.TranscodingInfo.Width, + height: sessionInfo.TranscodingInfo.Height, + } + : null, + target: { + audioCodec: sessionInfo.TranscodingInfo?.AudioCodec ?? null, + videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null, + }, + container: sessionInfo.TranscodingInfo?.Container ?? null, + }, + }, }; } diff --git a/packages/integrations/src/mock/data/media-server.ts b/packages/integrations/src/mock/data/media-server.ts index 6a3211b63..511d47f59 100644 --- a/packages/integrations/src/mock/data/media-server.ts +++ b/packages/integrations/src/mock/data/media-server.ts @@ -28,6 +28,7 @@ export class MediaServerMockService implements IMediaServerIntegration { episodeName: null, albumName: null, episodeCount: null, + metadata: null, } : null, }; diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 30e289ee8..3bb6a404f 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -61,6 +61,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat episodeName: mediaElement.$.title ?? null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, episodeCount: mediaElement.$.index ?? null, + metadata: null, }, }; }) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 043cf8af3..a7706c3e9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1980,7 +1980,25 @@ "currentlyPlaying": "Currently playing", "user": "User", "name": "Name", - "id": "Id" + "id": "Id", + "metadata": { + "title": "Stats for nerds", + "video": { + "title": "Video", + "resolution": "Resolution" + }, + "audio": { + "title": "Audio", + "channelCount": "Audio channels", + "codec": "Audio codec" + }, + "transcoding": { + "title": "Transcoding", + "container": "Container", + "resolution": "Resolution", + "target": "Target codec" + } + } } }, "downloads": { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 51c3aeea3..9b540339e 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -1,13 +1,14 @@ "use client"; import type { ReactNode } from "react"; -import { useMemo } from "react"; -import { Avatar, Flex, Group, Stack, Text, Title } from "@mantine/core"; +import { Fragment, useMemo } from "react"; +import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core"; import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; +import { objectEntries } from "@homarr/common"; import { getIconUrl, integrationDefs } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { createModal, useModalAction } from "@homarr/modals"; @@ -123,7 +124,7 @@ export default function MediaServerWidget({ [currentStreams], ); - const { openModal } = useModalAction(itemInfoModal); + const { openModal } = useModalAction(ItemInfoModal); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, @@ -219,10 +220,16 @@ export default function MediaServerWidget({ ); } -const itemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { +const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { const t = useScopedI18n("widget.mediaServer.items"); const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null; + const metadata = useMemo(() => { + return innerProps.item.currentlyPlaying?.metadata + ? constructMetadata(innerProps.item.currentlyPlaying.metadata) + : null; + }, [innerProps.item.currentlyPlaying?.metadata]); + return ( @@ -255,6 +262,32 @@ const itemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { /> {innerProps.item.sessionName}} /> {innerProps.item.sessionId}} /> + + {metadata ? ( + + + + + {objectEntries(metadata).map(([key, value], index) => ( + + {index !== 0 && } + + {t(`metadata.${key}.title`)} + + {Object.entries(value) + .filter(([_, value]) => Boolean(value)) + .map(([innerKey, value]) => ( + + {t(`metadata.${key}.${innerKey}` as never)} + {value} + + ))} + + + ))} + + + ) : null} ); }).withOptions({ @@ -280,3 +313,23 @@ const mediaTypeIconMap = { video: IconVideo, audio: IconHeadphones, } satisfies Record["type"], TablerIcon>; + +const constructMetadata = (metadata: Exclude["metadata"], null>) => ({ + video: { + resolution: metadata.video.resolution + ? `${metadata.video.resolution.width}x${metadata.video.resolution.height}` + : null, + frameRate: metadata.video.frameRate, + }, + audio: { + channelCount: metadata.audio.channelCount, + codec: metadata.audio.codec, + }, + transcoding: { + container: metadata.transcoding.container, + resolution: metadata.transcoding.resolution + ? `${metadata.transcoding.resolution.width}x${metadata.transcoding.resolution.height}` + : null, + target: `${metadata.transcoding.target.videoCodec} ${metadata.transcoding.target.audioCodec}`.trim(), + }, +});