From 5144e14da4e18b192756d59075345931953cbf39 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Thu, 16 Jul 2026 00:27:58 +1000 Subject: [PATCH] fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) --- .../integrations/src/emby/emby-integration.ts | 6 + .../media-server/media-server-types.ts | 6 + .../src/jellyfin/jellyfin-integration.ts | 13 ++ .../src/mock/data/media-server.ts | 12 ++ packages/integrations/src/plex/interface.ts | 57 ++++++++ .../integrations/src/plex/plex-integration.ts | 64 ++++++++- packages/translation/src/lang/en.json | 5 + .../widgets/src/media-server/component.tsx | 128 ++++++++++++++---- 8 files changed, 266 insertions(+), 25 deletions(-) diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index def4bdd82..23e5bc2a9 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -117,6 +117,12 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + playback: { + state: null, + positionMs: null, + durationMs: null, + }, + location: null, 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 cdfc43261..5390a2d5d 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,12 @@ export interface StreamSession { episodeName?: string | null; albumName?: string | null; episodeCount?: number | null; + playback: { + state: "playing" | "paused" | "buffering" | null; + positionMs: number | null; + durationMs: number | null; + }; + location: "lan" | "wan" | null; metadata: { video: { resolution: { diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index b2179987c..4c3370020 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -17,6 +17,10 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types"; import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types"; +function ticksToMs(ticks: number | null | undefined): number | null { + return ticks ? Math.round(ticks / 10_000) : null; +} + @HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { private readonly jellyfin: Jellyfin = new Jellyfin({ @@ -50,6 +54,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; if (sessionInfo.NowPlayingItem) { + const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); + const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); + currentlyPlaying = { type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", @@ -57,6 +64,12 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + playback: { + state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing", + positionMs, + durationMs, + }, + location: null, metadata: { video: { resolution: diff --git a/packages/integrations/src/mock/data/media-server.ts b/packages/integrations/src/mock/data/media-server.ts index 96640d1ae..3acae2016 100644 --- a/packages/integrations/src/mock/data/media-server.ts +++ b/packages/integrations/src/mock/data/media-server.ts @@ -13,6 +13,8 @@ const sessions: StreamSession[] = [ episodeName: null, albumName: null, episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, @@ -27,6 +29,8 @@ const sessions: StreamSession[] = [ episodeName: "Ozymandias", albumName: null, episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, @@ -41,6 +45,8 @@ const sessions: StreamSession[] = [ episodeName: null, albumName: null, episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, @@ -55,6 +61,8 @@ const sessions: StreamSession[] = [ episodeName: "The We We Are", albumName: null, episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, @@ -69,6 +77,8 @@ const sessions: StreamSession[] = [ episodeName: null, albumName: "Random Access Memories", episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, @@ -83,6 +93,8 @@ const sessions: StreamSession[] = [ episodeName: null, albumName: null, episodeCount: null, + playback: { state: null, positionMs: null, durationMs: null }, + location: null, metadata: null, }, }, diff --git a/packages/integrations/src/plex/interface.ts b/packages/integrations/src/plex/interface.ts index ca7ce3e67..5c7c01e6e 100644 --- a/packages/integrations/src/plex/interface.ts +++ b/packages/integrations/src/plex/interface.ts @@ -3,6 +3,54 @@ interface MediaContainer { Track?: Session[]; } +interface Stream { + $: { + streamType: string; + codec?: string; + language?: string; + selected?: string; + width?: string; + height?: string; + frameRate?: string; + channels?: string; + }; +} + +interface Part { + Stream?: Stream[]; + $: { + container?: string; + }; +} + +interface Media { + Part?: Part[]; + $: { + videoResolution?: string; + videoCodec?: string; + audioCodec?: string; + bitrate?: string; + container?: string; + }; +} + +interface TranscodeSession { + $: { + throttled?: string; + progress?: string; + speed?: string; + videoDecision?: string; + audioDecision?: string; + sourceVideoCodec?: string; + sourceAudioCodec?: string; + videoCodec?: string; + audioCodec?: string; + container?: string; + width?: string; + height?: string; + }; +} + interface Session { User?: { $: { @@ -15,13 +63,20 @@ interface Session { $: { product: string; title: string; + address?: string; + state?: string; + local?: string; }; }[]; Session?: { $: { id: string; + bandwidth?: string; + location?: string; }; }[]; + TranscodeSession?: TranscodeSession[]; + Media?: Media[]; $: { grandparentTitle?: string; parentTitle?: string; @@ -29,6 +84,8 @@ interface Session { index?: number; type: string; live?: string; + viewOffset?: string; + duration?: string; }; } diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index a031343c0..9f8439828 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -17,6 +17,32 @@ import type { PlexResponse } from "./interface"; const logger = createLogger({ module: "plexIntegration" }); +function parseOptionalNumber(value: string | undefined): number | null { + if (!value) { + return null; + } + + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function parseLocation(value: string | undefined): "lan" | "wan" | null { + return value === "lan" || value === "wan" ? value : null; +} + +function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null { + return value === "playing" || value === "paused" || value === "buffering" ? value : null; +} + +function parseResolution( + width: string | undefined, + height: string | undefined, +): { width: number; height: number } | null { + const parsedWidth = parseOptionalNumber(width); + const parsedHeight = parseOptionalNumber(height); + return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null; +} + export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise { const token = super.getSecretValue("apiKey"); @@ -43,11 +69,24 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat const userElement = mediaElement.User ? mediaElement.User[0] : undefined; const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined; const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined; + const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined; + const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined; if (!playerElement) { return undefined; } + const positionMs = parseOptionalNumber(mediaElement.$.viewOffset); + const durationMs = parseOptionalNumber(mediaElement.$.duration); + + const playbackState = parsePlaybackState(playerElement.$.state); + + const location = parseLocation(sessionElement?.$.location); + + const streams = mediaInfoElement?.Part?.[0]?.Stream ?? []; + const videoStream = streams.find((stream) => stream.$.streamType === "1"); + const audioStream = streams.find((stream) => stream.$.streamType === "2"); + return { sessionId: sessionElement?.$.id ?? "unknown", sessionName: `${playerElement.$.product} (${playerElement.$.title})`, @@ -63,7 +102,30 @@ 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, + playback: { + state: playbackState, + positionMs, + durationMs, + }, + location, + metadata: { + video: { + resolution: parseResolution(videoStream?.$.width, videoStream?.$.height), + frameRate: parseOptionalNumber(videoStream?.$.frameRate), + }, + audio: { + channelCount: parseOptionalNumber(audioStream?.$.channels), + codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null, + }, + transcoding: { + container: transcodeElement?.$.container ?? null, + resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height), + target: { + audioCodec: transcodeElement?.$.audioCodec ?? null, + videoCodec: transcodeElement?.$.videoCodec ?? null, + }, + }, + }, }, }; }) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ba5540adb..594ab29a8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2717,6 +2717,11 @@ "user": "User", "name": "Name", "id": "Id", + "status": "Status", + "directPlay": "Direct play", + "transcoding": "Transcoding", + "paused": "Paused", + "remaining": "{minutes} min left", "metadata": { "title": "Stats for nerds", "video": { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index e63c12a90..ceeb21bd9 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -2,8 +2,16 @@ import type { ReactNode } from "react"; 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 { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; +import { + IconDeviceTv, + IconHeadphones, + IconMovie, + IconPlayerPause, + IconVideo, + IconWifi, + IconWorld, +} from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; @@ -27,24 +35,26 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( () => [ - { - accessorKey: "sessionName", - header: t("items.name"), - - Cell: ({ row }) => ( - - {row.original.sessionName} - - ), - }, { accessorKey: "user.username", header: t("items.user"), + size: 160, Cell: ({ row }) => ( - - - {row.original.user.username} + + + + + {row.original.user.username} + + + {row.original.sessionName} + + ), }, @@ -53,17 +63,83 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom header: t("items.currentlyPlaying"), Cell: ({ row }) => { - if (!row.original.currentlyPlaying) return null; + const currentlyPlaying = row.original.currentlyPlaying; + if (!currentlyPlaying) return null; - const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type]; + const isPaused = currentlyPlaying.playback.state === "paused"; + const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type]; + + const { positionMs, durationMs } = currentlyPlaying.playback; + const progressPercent = + positionMs !== null && durationMs !== null && durationMs > 0 + ? Math.min(100, Math.round((positionMs / durationMs) * 100)) + : null; + const remainingMinutes = + positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; return ( - - - - {row.original.currentlyPlaying.name} - - + + + + + {currentlyPlaying.name} + + {isPaused && ( + + {t("items.paused")} + + )} + {!isPaused && remainingMinutes !== null && ( + + {t("items.remaining", { minutes: remainingMinutes.toString() })} + + )} + + {progressPercent !== null && ( + + )} + + ); + }, + }, + { + id: "status", + header: t("items.status"), + size: 110, + + Cell: ({ row }) => { + 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, + ); + + return ( + + + {isTranscoding ? t("items.transcoding") : t("items.directPlay")} + + {currentlyPlaying.location && ( + + {currentlyPlaying.location === "lan" ? : } + + {currentlyPlaying.location} + + + )} + ); }, }, @@ -107,7 +183,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom initialState: { density: "xs", columnPinning: { - right: ["currentlyPlaying"], + right: ["currentlyPlaying", "status"], }, }, mantineTableHeadProps: { @@ -144,6 +220,10 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom ); }, py: 4, + style: { + overflowX: "hidden", + overflowY: "visible", + }, }), });