feat(media-server): add stats for nerds (#4170)

This commit is contained in:
Meier Lukas
2025-10-02 19:54:53 +02:00
committed by GitHub
parent dcb845b609
commit 6e93c3b608
7 changed files with 133 additions and 5 deletions

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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,
},
},
};
}

View File

@@ -28,6 +28,7 @@ export class MediaServerMockService implements IMediaServerIntegration {
episodeName: null,
albumName: null,
episodeCount: null,
metadata: null,
}
: null,
};

View File

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

View File

@@ -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": {

View File

@@ -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 (
<Stack align="center">
<Flex direction="column" gap="xs" align="center">
@@ -255,6 +262,32 @@ const itemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
/>
<NormalizedLine itemKey={t("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</Text>} />
{metadata ? (
<Stack w="100%" gap={0}>
<Divider label={t("metadata.title")} labelPosition="center" mt="lg" mb="sm" />
<Group align="flex-start">
{objectEntries(metadata).map(([key, value], index) => (
<Fragment key={key}>
{index !== 0 && <Divider key={index} orientation="vertical" />}
<Stack gap={4}>
<Text fw="bold">{t(`metadata.${key}.title`)}</Text>
{Object.entries(value)
.filter(([_, value]) => Boolean(value))
.map(([innerKey, value]) => (
<Group justify="space-between" w="100%" key={innerKey} wrap="nowrap">
<Text>{t(`metadata.${key}.${innerKey}` as never)}</Text>
<Text>{value}</Text>
</Group>
))}
</Stack>
</Fragment>
))}
</Group>
</Stack>
) : null}
</Stack>
);
}).withOptions({
@@ -280,3 +313,23 @@ const mediaTypeIconMap = {
video: IconVideo,
audio: IconHeadphones,
} satisfies Record<Exclude<StreamSession["currentlyPlaying"], null>["type"], TablerIcon>;
const constructMetadata = (metadata: Exclude<Exclude<StreamSession["currentlyPlaying"], null>["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(),
},
});