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 5390a2d5d..c9a287155 100644 --- a/packages/integrations/src/interfaces/media-server/media-server-types.ts +++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts @@ -41,7 +41,11 @@ export interface StreamSession { audioCodec: string | null; videoCodec: string | null; }; + isVideoDirect: boolean; + isAudioDirect: boolean; + containerChanged: boolean; }; + bitrateKbps: number | null; } | null; } | null; } diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 4c3370020..a7be2d409 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -56,6 +56,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte if (sessionInfo.NowPlayingItem) { const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); + const bitrateBps = + sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null; + const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; currentlyPlaying = { type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), @@ -98,7 +101,11 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null, }, container: sessionInfo.TranscodingInfo?.Container ?? null, + isVideoDirect: sessionInfo.TranscodingInfo?.IsVideoDirect ?? true, + isAudioDirect: sessionInfo.TranscodingInfo?.IsAudioDirect ?? true, + containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream", }, + bitrateKbps, }, }; } diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 9f8439828..6b772a589 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -43,6 +43,12 @@ function parseResolution( return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null; } +function isStreamDirect(decision: string | undefined): boolean { + // Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed + // without re-encoding) and an absent decision both count as direct. + return decision !== "transcode"; +} + export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise { const token = super.getSecretValue("apiKey"); @@ -87,6 +93,16 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat const videoStream = streams.find((stream) => stream.$.streamType === "1"); const audioStream = streams.find((stream) => stream.$.streamType === "2"); + const isVideoDirect = isStreamDirect(transcodeElement?.$.videoDecision); + const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision); + const containerChanged = Boolean( + transcodeElement?.$.container && + mediaInfoElement?.$.container && + transcodeElement.$.container !== mediaInfoElement.$.container, + ); + const bitrateKbps = + parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate); + return { sessionId: sessionElement?.$.id ?? "unknown", sessionName: `${playerElement.$.product} (${playerElement.$.title})`, @@ -124,7 +140,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat audioCodec: transcodeElement?.$.audioCodec ?? null, videoCodec: transcodeElement?.$.videoCodec ?? null, }, + isVideoDirect, + isAudioDirect, + containerChanged, }, + bitrateKbps, }, }, }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 3a1253be5..0c7b91dd9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2736,8 +2736,20 @@ "showOnlyPlaying": { "label": "Show only currently playing", "description": "Disabling this will not work for plex" + }, + "showBitrate": { + "label": "Show bitrate", + "description": "Show the current playback bitrate next to the location" + }, + "showLocation": { + "label": "Show location", + "description": "Show whether the stream is on your LAN or WAN" } }, + "footer": { + "streams": "{count, plural, one {# active stream} other {# active streams}}", + "totalBitrate": "Total: {bitrate}" + }, "items": { "currentlyPlaying": "Currently playing", "user": "User", @@ -2745,6 +2757,9 @@ "id": "Id", "status": "Status", "directPlay": "Direct play", + "directStream": "Direct stream", + "transcodeVideo": "Transcoding video", + "transcodeAudio": "Transcoding audio", "transcoding": "Transcoding", "paused": "Paused", "remaining": "{minutes} min left", diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 439857a8f..2b127a234 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -26,6 +26,35 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; +type TranscodingDecision = NonNullable< + NonNullable["metadata"] +>["transcoding"]; + +type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding"; + +function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { + if (!transcoding) return "directPlay"; + const { isVideoDirect, isAudioDirect, containerChanged } = transcoding; + + if (isVideoDirect && isAudioDirect) return containerChanged ? "directStream" : "directPlay"; + if (!isVideoDirect && isAudioDirect) return "transcodeVideo"; + if (isVideoDirect && !isAudioDirect) return "transcodeAudio"; + return "transcoding"; +} + +const playbackStatusColorMap = { + directPlay: "green", + directStream: "teal", + transcodeVideo: "orange", + transcodeAudio: "yellow", + transcoding: "red", +} satisfies Record; + +function formatBitrate(bitrateKbps: number | null | undefined): string | null { + if (!bitrateKbps || bitrateKbps <= 0) return null; + return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; +} + export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) { const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, @@ -122,23 +151,30 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom const currentlyPlaying = row.original.currentlyPlaying; if (!currentlyPlaying) return null; - const isTranscoding = Boolean( - currentlyPlaying.metadata?.transcoding.target.videoCodec ?? - currentlyPlaying.metadata?.transcoding.target.audioCodec ?? - currentlyPlaying.metadata?.transcoding.container, - ); + const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding); + const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps); return ( - - {isTranscoding ? t("items.transcoding") : t("items.directPlay")} + + {t(`items.${status}` as never)} - {currentlyPlaying.location && ( - - {currentlyPlaying.location === "lan" ? : } - - {currentlyPlaying.location} - + {(currentlyPlaying.location ?? bitrateLabel) && ( + + + {currentlyPlaying.location && + (currentlyPlaying.location === "lan" ? : )} + {currentlyPlaying.location && ( + + {currentlyPlaying.location} + + )} + + {bitrateLabel && ( + + {bitrateLabel} + + )} )} @@ -238,6 +274,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom }; }); + const totalBitrateKbps = flatSessions.reduce( + (sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0), + 0, + ); + const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null; + return ( @@ -246,19 +288,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom h={30} px="xs" pr="md" - justify="flex-end" + justify="space-between" style={{ borderTop: "1px solid var(--border-color)", }} > - {uniqueIntegrations.map((integration) => ( - - - - {integration.integrationName} + + + + {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { + count: flatSessions.length, + })} + + {totalBitrateLabel && ( + + {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} - - ))} + )} + + + {uniqueIntegrations.map((integration) => ( + + + + {integration.integrationName} + + + ))} + ); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 54f00476c..ef746e921 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -11,6 +11,8 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ createOptions() { return optionsBuilder.from((factory) => ({ showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), + showBitrate: factory.switch({ defaultValue: true, withDescription: true }), + showLocation: factory.switch({ defaultValue: true, withDescription: true }), })); }, supportedIntegrations: getIntegrationKindsByCategory("mediaService"),