From b8bc304c5c8ec21e012a6473967554a22c9f0f0b Mon Sep 17 00:00:00 2001 From: Bergasha Date: Mon, 24 Aug 2026 11:16:20 +1000 Subject: [PATCH] fix(mediaServer): use proportional column widths, populate Emby playback/transcoding metadata The User and Status columns used hardcoded pixel widths with table-layout: fixed, so they stayed narrow regardless of how wide the widget actually rendered - text truncated even with plenty of unused space in the "currently playing" column. Switch both to percentages so they scale with the widget. Emby's integration also hardcoded playback/metadata to null for every session, so the widget's existing progress bar, resolution, and bitrate display never had anything to show. Port the same TranscodingInfo/ MediaStreams parsing Jellyfin already does (same underlying /Sessions API shape) so Emby sessions get real position/duration, resolution, bitrate, and transcode-status data. Also fix ticksToMs treating a position of exactly 0 as missing, and share the one implementation between the two integrations instead of duplicating it. --- .../integrations/src/emby/emby-integration.ts | 87 +++++++++++++++++-- .../src/jellyfin/jellyfin-integration.ts | 4 +- .../widgets/src/media-server/component.tsx | 8 +- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index d2410a02c..f9ccde099 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -10,9 +10,22 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration"; import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types"; -import { convertJellyfinType } from "../jellyfin/jellyfin-integration"; +import { convertJellyfinType, parseLocation, ticksToMs } from "../jellyfin/jellyfin-integration"; import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types"; +const transcodingInfoSchema = z.object({ + Bitrate: z.number().nullish(), + Framerate: z.number().nullish(), + AudioChannels: z.number().nullish(), + AudioCodec: z.string().nullish(), + VideoCodec: z.string().nullish(), + Container: z.string().nullish(), + IsVideoDirect: z.boolean().nullish(), + IsAudioDirect: z.boolean().nullish(), + Width: z.number().nullish(), + Height: z.number().nullish(), +}); + const sessionSchema = z.object({ NowPlayingItem: z .object({ @@ -25,6 +38,11 @@ const sessionSchema = z.object({ IndexNumber: z.number().nullish(), Album: z.string().nullish(), EpisodeCount: z.number().nullish(), + RunTimeTicks: z.number().nullish(), + Width: z.number().nullish(), + Height: z.number().nullish(), + MediaStreams: z.array(z.object({ BitRate: z.number().nullish() })).nullish(), + MediaSources: z.array(z.object({ Bitrate: z.number().nullish() })).nullish(), }) .optional(), Id: z.string(), @@ -33,6 +51,15 @@ const sessionSchema = z.object({ DeviceName: z.string().nullish(), UserId: z.string().optional(), UserName: z.string().nullish(), + RemoteEndPoint: z.string().nullish(), + PlayState: z + .object({ + IsPaused: z.boolean().nullish(), + PositionTicks: z.number().nullish(), + PlayMethod: z.string().nullish(), + }) + .nullish(), + TranscodingInfo: transcodingInfoSchema.nullish(), }); const itemSchema = z.object({ @@ -113,6 +140,21 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat if (sessionInfo.NowPlayingItem) { const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode; + const transcodingInfo = sessionInfo.TranscodingInfo; + const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); + const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); + // Emby only reliably fills in MediaSources[].Bitrate for a transcoding session - + // /Sessions has no `fields` selector to force it, so fall back to summing the individual + // media streams' own bitrates (populated from the source file's metadata) for direct play. + const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce( + (sum, stream) => sum + (stream.BitRate ?? 0), + 0, + ); + const bitrateBps = + transcodingInfo?.Bitrate ?? + sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? + (mediaStreamsBitrateBps || null); + const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; currentlyPlaying = { type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), @@ -124,12 +166,45 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, playback: { - state: null, - positionMs: null, - durationMs: null, + state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing", + positionMs, + durationMs, + }, + location: parseLocation(sessionInfo.RemoteEndPoint), + metadata: { + video: { + resolution: + sessionInfo.NowPlayingItem.Width && sessionInfo.NowPlayingItem.Height + ? { + width: sessionInfo.NowPlayingItem.Width, + height: sessionInfo.NowPlayingItem.Height, + } + : null, + frameRate: transcodingInfo?.Framerate ?? null, + }, + audio: { + channelCount: transcodingInfo?.AudioChannels ?? null, + codec: transcodingInfo?.AudioCodec ?? null, + }, + transcoding: { + resolution: + transcodingInfo?.Width && transcodingInfo.Height + ? { + width: transcodingInfo.Width, + height: transcodingInfo.Height, + } + : null, + target: { + audioCodec: transcodingInfo?.AudioCodec ?? null, + videoCodec: transcodingInfo?.VideoCodec ?? null, + }, + container: transcodingInfo?.Container ?? null, + isVideoDirect: transcodingInfo?.IsVideoDirect ?? true, + isAudioDirect: transcodingInfo?.IsAudioDirect ?? true, + containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream", + }, + bitrateKbps, }, - location: null, - metadata: null, }; } diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 7e3f882f3..8167ee9be 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -18,8 +18,8 @@ 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; +export function ticksToMs(ticks: number | null | undefined): number | null { + return ticks == null ? null : Math.round(ticks / 10_000); } // Unlike Plex, Jellyfin doesn't report a session's network location directly - only the diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 2900d1043..f4b67a648 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -50,7 +50,7 @@ type SortState = { column: SortColumn; descending: boolean } | null; export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ user: isAdvanced || width >= 300, - // The user (200px) and status (190px) columns together need a floor here, otherwise the + // The user (26%) and status (22%) columns together need a floor here, otherwise the // currentlyPlaying column - the primary content - gets squeezed to almost nothing. status: isAdvanced || width >= 540, }); @@ -140,7 +140,7 @@ function StreamTableHeader({ sortable: boolean; sort: SortState; onSort: (column: SortColumn) => void; - width?: number; + width?: number | string; }) { const active = sort?.column === column; const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; @@ -285,7 +285,7 @@ export default function MediaServerWidget({ sortable={isAdvanced} sort={sort} onSort={toggleSort} - width={200} + width="26%" /> )} )}