feat(media-server): add stats for nerds (#4170)
This commit is contained in:
@@ -117,6 +117,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||||
|
metadata: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ export interface StreamSession {
|
|||||||
episodeName?: string | null;
|
episodeName?: string | null;
|
||||||
albumName?: string | null;
|
albumName?: string | null;
|
||||||
episodeCount?: number | 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;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class MediaServerMockService implements IMediaServerIntegration {
|
|||||||
episodeName: null,
|
episodeName: null,
|
||||||
albumName: null,
|
albumName: null,
|
||||||
episodeCount: null,
|
episodeCount: null,
|
||||||
|
metadata: null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
episodeName: mediaElement.$.title ?? null,
|
episodeName: mediaElement.$.title ?? null,
|
||||||
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
|
||||||
episodeCount: mediaElement.$.index ?? null,
|
episodeCount: mediaElement.$.index ?? null,
|
||||||
|
metadata: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1980,7 +1980,25 @@
|
|||||||
"currentlyPlaying": "Currently playing",
|
"currentlyPlaying": "Currently playing",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"name": "Name",
|
"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": {
|
"downloads": {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { Fragment, useMemo } from "react";
|
||||||
import { Avatar, Flex, Group, Stack, Text, Title } from "@mantine/core";
|
import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
|
import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
import { getIconUrl, integrationDefs } from "@homarr/definitions";
|
import { getIconUrl, integrationDefs } from "@homarr/definitions";
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
import type { StreamSession } from "@homarr/integrations";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
@@ -123,7 +124,7 @@ export default function MediaServerWidget({
|
|||||||
[currentStreams],
|
[currentStreams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { openModal } = useModalAction(itemInfoModal);
|
const { openModal } = useModalAction(ItemInfoModal);
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
columns,
|
columns,
|
||||||
data: flatSessions,
|
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 t = useScopedI18n("widget.mediaServer.items");
|
||||||
const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null;
|
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 (
|
return (
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Flex direction="column" gap="xs" 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("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
|
||||||
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
@@ -280,3 +313,23 @@ const mediaTypeIconMap = {
|
|||||||
video: IconVideo,
|
video: IconVideo,
|
||||||
audio: IconHeadphones,
|
audio: IconHeadphones,
|
||||||
} satisfies Record<Exclude<StreamSession["currentlyPlaying"], null>["type"], TablerIcon>;
|
} 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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user