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(),
+ },
+});