From df300919fcee454cf164487ae23add38e4d2466e Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:31:27 +1000 Subject: [PATCH 01/14] Gated season/episode fields on episode type - was showing S/E labels on movies and music too, using the episode number as season --- packages/integrations/src/emby/emby-integration.ts | 8 +++++--- .../integrations/src/jellyfin/jellyfin-integration.ts | 7 ++++--- packages/integrations/src/plex/plex-integration.ts | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index 8fec4f4b1..d2410a02c 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -112,13 +112,15 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; if (sessionInfo.NowPlayingItem) { + const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode; + currentlyPlaying = { type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", - seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null, - episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, - episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null, + seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null, + episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null, + episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, playback: { diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 7450e6226..8b2fa1b08 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -59,14 +59,15 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte const bitrateBps = sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null; const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; + const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode; currentlyPlaying = { type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", - seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null, - episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, - episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null, + seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null, + episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null, + episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null, albumName: sessionInfo.NowPlayingItem.Album ?? "", episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, playback: { diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index ec46a466c..01f971abf 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type), name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown", seasonName: mediaElement.$.parentTitle, - seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex), - episodeName: mediaElement.$.title ?? null, - episodeNumber: parseOptionalNumber(mediaElement.$.index), + seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null, + episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null, + episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, episodeCount: parseOptionalNumber(mediaElement.$.index), playback: { From 33991f4e22baf51e4f10ba2b718c8fdebab4ed2f Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:31:33 +1000 Subject: [PATCH 02/14] Added flexShrink to the session avatar so it stops getting squished in a tight row --- packages/widgets/src/media-server/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 238ced5fc..0e05b6394 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -315,7 +315,7 @@ export default function MediaServerWidget({ {columnVisibility.user && ( - + {session.user?.username ?? t("items.unknownUser")} From d9c35744f6c67c1fe423a2b317d18af1903fbdb0 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:31:58 +1000 Subject: [PATCH 03/14] Only show a server's badge in the footer when it actually has an active session - selecting both Plex and Jellyfin always showed both badges even if only one was streaming. Also gave the footer badges a pill style and added a separator between the stream count and bitrate --- .../widgets/src/media-server/component.tsx | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 0e05b6394..86c1e8a0e 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -195,12 +195,14 @@ export default function MediaServerWidget({ current?.column === column ? { column, descending: !current.descending } : { column, descending: false }, ); - const uniqueIntegrations = currentStreams.map((stream) => ({ - integrationId: stream.integrationId, - integrationKind: stream.integrationKind, - integrationIcon: getIconUrl(stream.integrationKind), - integrationName: stream.integrationName, - })); + const uniqueIntegrations = currentStreams + .filter((stream) => stream.sessions.length > 0) + .map((stream) => ({ + integrationId: stream.integrationId, + integrationKind: stream.integrationKind, + integrationIcon: getIconUrl(stream.integrationKind), + integrationName: stream.integrationName, + })); const playingCount = flatSessions.filter( (session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused", @@ -386,25 +388,42 @@ export default function MediaServerWidget({ borderTop: "1px solid var(--border-color)", }} > - + - + {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { count: flatSessions.length, })} {totalBitrateLabel && (isAdvanced || width >= 300) && ( - - {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} - + <> + + • + + + {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} + + )} - + {uniqueIntegrations.map((integration) => ( - - + + {(isAdvanced || width >= 480) && ( - + {integration.integrationName} )} From 3769d88ebb01b9440e126f9734fc97349222dd52 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:32:15 +1000 Subject: [PATCH 04/14] Dimmed the column header labels and added a bottom border under the sticky header, so it reads as its own band instead of the same weight as the row data --- packages/widgets/src/media-server/component.module.css | 1 + packages/widgets/src/media-server/component.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/widgets/src/media-server/component.module.css b/packages/widgets/src/media-server/component.module.css index b5ca6ce77..ec08b16c0 100644 --- a/packages/widgets/src/media-server/component.module.css +++ b/packages/widgets/src/media-server/component.module.css @@ -31,6 +31,7 @@ .tableViewport :global(th) { background-color: var(--background-color); + border-bottom: 1px solid var(--border-color); } .sortButton { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 86c1e8a0e..71c19c0bc 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -131,13 +131,17 @@ function StreamTableHeader({ {sortable ? ( onSort(column)}> - + {label} - + ) : ( - + {label} )} From 96676182f4d598e0e22d814c725536b9ba290434 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:32:21 +1000 Subject: [PATCH 05/14] Gave the status column a fixed 140px width - it had no explicit width so it shared an equal, unconstrained share of the row with the other columns despite only holding a small badge --- packages/widgets/src/media-server/component.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 71c19c0bc..a91035c7e 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -117,18 +117,20 @@ function StreamTableHeader({ sortable, sort, onSort, + width, }: { column: SortColumn; label: string; sortable: boolean; sort: SortState; onSort: (column: SortColumn) => void; + width?: number; }) { const active = sort?.column === column; const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; return ( - + {sortable ? ( onSort(column)}> @@ -285,6 +287,7 @@ export default function MediaServerWidget({ sortable={isAdvanced} sort={sort} onSort={toggleSort} + width={140} /> )} From c7563f2c394d0041904dfa2791f034008ce012af Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:32:28 +1000 Subject: [PATCH 06/14] Fixed Jellyfin bitrate showing null on direct play - the sessions endpoint only reliably fills in MediaSources bitrate while transcoding, so sum the individual media streams' own bitrates as a fallback --- .../integrations/src/jellyfin/jellyfin-integration.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 8b2fa1b08..7b75e938b 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -56,8 +56,17 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte if (sessionInfo.NowPlayingItem) { const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); + // Jellyfin 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 = - sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null; + sessionInfo.TranscodingInfo?.Bitrate ?? + sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? + (mediaStreamsBitrateBps || null); const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode; From 6b10ac2ab525af4aa4f555df2c86e1ef603573e9 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:32:34 +1000 Subject: [PATCH 07/14] Gave User a fixed 200px width so Currently playing (the busiest column) gets the largest share of the row instead of splitting it evenly with User --- packages/widgets/src/media-server/component.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index a91035c7e..73b229c31 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -271,6 +271,7 @@ export default function MediaServerWidget({ sortable={isAdvanced} sort={sort} onSort={toggleSort} + width={200} /> )} Date: Wed, 19 Aug 2026 13:32:40 +1000 Subject: [PATCH 08/14] Added LAN/WAN detection to Jellyfin, matching what Plex already reports - Jellyfin's sessions endpoint only gives a client IP (RemoteEndPoint), so classify it against the standard private IP ranges ourselves --- .../src/jellyfin/jellyfin-integration.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 7b75e938b..d97dcc127 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -1,3 +1,4 @@ +import { BlockList, isIP } from "node:net"; import { Jellyfin } from "@jellyfin/sdk"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; @@ -21,6 +22,34 @@ function ticksToMs(ticks: number | null | undefined): number | null { return ticks ? Math.round(ticks / 10_000) : null; } +// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the +// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves. +const privateAddresses = new BlockList(); +privateAddresses.addSubnet("10.0.0.0", 8, "ipv4"); +privateAddresses.addSubnet("172.16.0.0", 12, "ipv4"); +privateAddresses.addSubnet("192.168.0.0", 16, "ipv4"); +privateAddresses.addSubnet("127.0.0.0", 8, "ipv4"); +privateAddresses.addSubnet("fc00::", 7, "ipv6"); +privateAddresses.addAddress("::1", "ipv6"); + +function extractHost(remoteEndPoint: string): string { + if (remoteEndPoint.startsWith("[")) { + const end = remoteEndPoint.indexOf("]"); + return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end); + } + // A bare IPv6 address (no brackets, no port) has more than one colon. + const isIPv4WithPort = remoteEndPoint.split(":").length === 2; + return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint; +} + +function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null { + if (!remoteEndPoint) return null; + const host = extractHost(remoteEndPoint); + const family = isIP(host); + if (!family) return null; + return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan"; +} + @HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { private readonly jellyfin: Jellyfin = new Jellyfin({ @@ -84,7 +113,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte positionMs, durationMs, }, - location: null, + location: parseLocation(sessionInfo.RemoteEndPoint), metadata: { video: { resolution: From c6f77cec2eced739c1e8ad0786af21b60204ac22 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:33:08 +1000 Subject: [PATCH 09/14] Added resolution to the status column, restructured into two lines: status + LAN/WAN on top, resolution + bitrate underneath --- .../widgets/src/media-server/component.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 73b229c31..d76c83d58 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -288,7 +288,7 @@ export default function MediaServerWidget({ sortable={isAdvanced} sort={sort} onSort={toggleSort} - width={140} + width={170} /> )} @@ -300,6 +300,8 @@ export default function MediaServerWidget({ const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); const location = showLocation ? currentlyPlaying?.location : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; + const resolution = currentlyPlaying?.metadata?.video.resolution; + const resolutionLabel = resolution ? `${resolution.width}x${resolution.height}` : null; const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); return ( @@ -350,24 +352,30 @@ export default function MediaServerWidget({ {currentlyPlaying && ( - - {t(`items.${status}` as never)} - - {(location ?? bitrateLabel) && ( - + + + {t(`items.${status}` as never)} + + {location && ( - {location && - (location === "lan" ? ( - - ) : ( - - ))} - {location && ( - - {location} - + {location === "lan" ? ( + + ) : ( + )} + + {location} + + )} + + {(resolutionLabel ?? bitrateLabel) && ( + + {resolutionLabel && ( + + {resolutionLabel} + + )} {bitrateLabel && ( {bitrateLabel} From 6cac34e96885d19a95d3e82dbe52c477b6cf7ecb Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:33:14 +1000 Subject: [PATCH 10/14] Changed resolution to show the actual streaming resolution (transcode target) instead of the source file's, formatted as e.g. 1080p instead of 1920x1080 --- packages/widgets/src/media-server/component.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index d76c83d58..6a9553a85 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -300,8 +300,12 @@ export default function MediaServerWidget({ const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); const location = showLocation ? currentlyPlaying?.location : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; - const resolution = currentlyPlaying?.metadata?.video.resolution; - const resolutionLabel = resolution ? `${resolution.width}x${resolution.height}` : null; + // The transcoding resolution reflects what's actually being streamed - fall back to the + // source's own resolution when it isn't set (direct play, or a transcode that only + // touches audio/container and leaves the video resolution untouched). + const resolution = + currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution; + const resolutionLabel = resolution ? `${resolution.height}p` : null; const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); return ( From 4b20d6ecd0d4ff4a0e65eb731f3014395633605d Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:33:21 +1000 Subject: [PATCH 11/14] Widened the status column from 140 to 190px - the Np resolution format is shorter so line 2 had room, but Transcoding video/audio badges are noticeably longer than Direct play and needed the space on line 1 --- packages/widgets/src/media-server/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 6a9553a85..4118ff7ef 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -288,7 +288,7 @@ export default function MediaServerWidget({ sortable={isAdvanced} sort={sort} onSort={toggleSort} - width={170} + width={190} /> )} From 90fa9e8741f89f6c73701b760886f9825e410489 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 13:52:14 +1000 Subject: [PATCH 12/14] Added tests for the LAN/WAN parsing and fixed IPv4-mapped IPv6 addresses being misclassified as WAN - a dual-stack server can report a LAN client as ::ffff:192.168.x.x, which the private-range check was missing since it only matched literal IPv6 ranges --- .../src/jellyfin/jellyfin-integration.spec.ts | 58 +++++++++++++++++++ .../src/jellyfin/jellyfin-integration.ts | 14 +++-- 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/src/jellyfin/jellyfin-integration.spec.ts diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts b/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts new file mode 100644 index 000000000..46e21c7ed --- /dev/null +++ b/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; + +import { extractHost, parseLocation } from "./jellyfin-integration"; + +describe("extractHost", () => { + test("strips the port from an IPv4 endpoint", () => { + expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100"); + }); + + test("returns an IPv4 address unchanged when there's no port", () => { + expect(extractHost("8.8.8.8")).toBe("8.8.8.8"); + }); + + test("strips brackets and the port from a bracketed IPv6 endpoint", () => { + expect(extractHost("[::1]:8096")).toBe("::1"); + }); + + test("returns a bare IPv6 address unchanged when there's no port", () => { + expect(extractHost("::1")).toBe("::1"); + }); +}); + +describe("parseLocation", () => { + test("returns null when there's no endpoint to classify", () => { + expect(parseLocation(null)).toBeNull(); + expect(parseLocation(undefined)).toBeNull(); + expect(parseLocation("")).toBeNull(); + }); + + test("returns null for a value that isn't an IP", () => { + expect(parseLocation("not-an-ip:1234")).toBeNull(); + }); + + test("classifies private IPv4 ranges as lan", () => { + expect(parseLocation("192.168.1.100:54321")).toBe("lan"); + expect(parseLocation("10.0.0.5:1234")).toBe("lan"); + expect(parseLocation("172.16.5.5:1234")).toBe("lan"); + expect(parseLocation("127.0.0.1:8096")).toBe("lan"); + }); + + test("classifies a public IPv4 address as wan", () => { + expect(parseLocation("8.8.8.8:1234")).toBe("wan"); + }); + + test("classifies IPv6 loopback and unique-local addresses as lan", () => { + expect(parseLocation("[::1]:8096")).toBe("lan"); + expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan"); + }); + + test("classifies a public IPv6 address as wan", () => { + expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan"); + }); + + test("unwraps an IPv4-mapped IPv6 address before classifying it", () => { + expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan"); + expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan"); + }); +}); diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index d97dcc127..e5cc6511e 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -32,7 +32,7 @@ privateAddresses.addSubnet("127.0.0.0", 8, "ipv4"); privateAddresses.addSubnet("fc00::", 7, "ipv6"); privateAddresses.addAddress("::1", "ipv6"); -function extractHost(remoteEndPoint: string): string { +export function extractHost(remoteEndPoint: string): string { if (remoteEndPoint.startsWith("[")) { const end = remoteEndPoint.indexOf("]"); return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end); @@ -42,10 +42,16 @@ function extractHost(remoteEndPoint: string): string { return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint; } -function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null { +export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null { if (!remoteEndPoint) return null; - const host = extractHost(remoteEndPoint); - const family = isIP(host); + let host = extractHost(remoteEndPoint); + let family = isIP(host); + // A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address + // (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply. + if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) { + host = host.slice(7); + family = 4; + } if (!family) return null; return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan"; } From a4bbed0cb87d12d249add18f5e6689b800408419 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 14:54:48 +1000 Subject: [PATCH 13/14] Fixed resolution showing the raw cropped height (e.g. 804p) instead of the nominal class Plex/Jellyfin display (1080p) - letterboxed/cinemascope sources are shorter than their resolution tier, so classify off the width-derived 16:9 height instead --- .../src/media-server/component.spec.tsx | 28 ++++++++++++++++++- .../widgets/src/media-server/component.tsx | 13 ++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index 0f23b232c..8e501ee6c 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { StreamSession } from "@homarr/integrations"; -import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component"; +import { + getMediaServerColumnVisibility, + getResolutionLabel, + getSeasonEpisodeParams, + SessionDetailsPopover, +} from "./component"; vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key, @@ -124,3 +129,24 @@ describe("getSeasonEpisodeParams", () => { expect(getSeasonEpisodeParams(null, undefined)).toBeNull(); }); }); + +describe("getResolutionLabel", () => { + test("labels a standard 16:9 resolution by its exact height", () => { + expect(getResolutionLabel(1920, 1080)).toBe("1080p"); + expect(getResolutionLabel(1280, 720)).toBe("720p"); + }); + + test("classifies a cinemascope source by its width, not its cropped height", () => { + // A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source. + expect(getResolutionLabel(1920, 804)).toBe("1080p"); + expect(getResolutionLabel(1280, 640)).toBe("720p"); + }); + + test("rounds a near-standard resolution up to its nearest tier", () => { + expect(getResolutionLabel(1912, 796)).toBe("1080p"); + }); + + test("falls back to the raw height when it doesn't clear any known tier", () => { + expect(getResolutionLabel(100, 100)).toBe("100p"); + }); +}); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 4118ff7ef..b73822ec9 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -77,6 +77,17 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null { return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; } +const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const; + +export function getResolutionLabel(width: number, height: number): string { + // Letterboxed/cinemascope sources are cropped shorter than their nominal resolution class + // (a 1920x804 encode is still "1080p"), so classify off the width-derived 16:9 height instead + // of the raw (possibly cropped) height - matches how Plex/Jellyfin label these themselves. + const effectiveHeight = Math.max(height, Math.round((width * 9) / 16)); + const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveHeight >= candidate * 0.9); + return `${tier ?? effectiveHeight}p`; +} + export function getSeasonEpisodeParams( seasonNumber: number | null | undefined, episodeNumber: number | null | undefined, @@ -305,7 +316,7 @@ export default function MediaServerWidget({ // touches audio/container and leaves the video resolution untouched). const resolution = currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution; - const resolutionLabel = resolution ? `${resolution.height}p` : null; + const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null; const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); return ( From 76ba13fbad16fb828f0f19a66fbb57fd7bac5a01 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Wed, 19 Aug 2026 16:45:09 +1000 Subject: [PATCH 14/14] Address Codex review feedback on PR #6627 - Classify IPv4/IPv6 link-local addresses (169.254.0.0/16, fe80::/10) as LAN in Jellyfin location parsing - they can't traverse a router, so a false WAN reading was possible for common autoconfig/link-local sessions. - Fix portrait video resolution mislabeling - a 1080x1920 clip was classified off its long-axis height (1920) and showed as 1440p. Classify by the short axis instead so orientation doesn't flip the result. - Raise the compact-mode status column breakpoint from 420 to 540px - at 420 the fixed-width user (200px) and status (190px) columns left almost no room for the currentlyPlaying column, the widget's primary content. - Document the new resolution indicator in the media-server widget docs. --- apps/docs/docs/widgets/media-server/index.mdx | 2 +- .../src/jellyfin/jellyfin-integration.spec.ts | 7 +++++++ .../src/jellyfin/jellyfin-integration.ts | 2 ++ .../src/media-server/component.spec.tsx | 9 ++++++++- .../widgets/src/media-server/component.tsx | 20 ++++++++++++------- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index 0c4dc18d0..a2ab91c9c 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex"; The media server widget shows active sessions across your media servers. -It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. +It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable. diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts b/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts index 46e21c7ed..07bbd2a9d 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.spec.ts @@ -55,4 +55,11 @@ describe("parseLocation", () => { expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan"); expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan"); }); + + test("classifies link-local addresses as lan", () => { + // Link-local traffic can't traverse a router, so it's always LAN even though + // it isn't a private range in the traditional (RFC 1918 / ULA) sense. + expect(parseLocation("169.254.1.5:8096")).toBe("lan"); + expect(parseLocation("[fe80::1]:8096")).toBe("lan"); + }); }); diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index e5cc6511e..7e3f882f3 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -29,7 +29,9 @@ privateAddresses.addSubnet("10.0.0.0", 8, "ipv4"); privateAddresses.addSubnet("172.16.0.0", 12, "ipv4"); privateAddresses.addSubnet("192.168.0.0", 16, "ipv4"); privateAddresses.addSubnet("127.0.0.0", 8, "ipv4"); +privateAddresses.addSubnet("169.254.0.0", 16, "ipv4"); privateAddresses.addSubnet("fc00::", 7, "ipv6"); +privateAddresses.addSubnet("fe80::", 10, "ipv6"); privateAddresses.addAddress("::1", "ipv6"); export function extractHost(remoteEndPoint: string): string { diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index 8e501ee6c..997635f2e 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -109,7 +109,8 @@ describe("getMediaServerColumnVisibility", () => { test("keeps a stable responsive column model from the zero-sized first render", () => { expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); - expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true }); + expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false }); + expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true }); }); }); @@ -149,4 +150,10 @@ describe("getResolutionLabel", () => { test("falls back to the raw height when it doesn't clear any known tier", () => { expect(getResolutionLabel(100, 100)).toBe("100p"); }); + + test("classifies a portrait video by its width, not its long-axis height", () => { + // A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one. + expect(getResolutionLabel(1080, 1920)).toBe("1080p"); + expect(getResolutionLabel(720, 1280)).toBe("720p"); + }); }); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index b73822ec9..1c09fd637 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -51,7 +51,9 @@ type SortState = { column: SortColumn; descending: boolean } | null; export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ user: isAdvanced || width >= 300, - status: isAdvanced || width >= 420, + // The user (200px) and status (190px) columns together need a floor here, otherwise the + // currentlyPlaying column - the primary content - gets squeezed to almost nothing. + status: isAdvanced || width >= 540, }); function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { @@ -80,12 +82,16 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null { const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const; export function getResolutionLabel(width: number, height: number): string { - // Letterboxed/cinemascope sources are cropped shorter than their nominal resolution class - // (a 1920x804 encode is still "1080p"), so classify off the width-derived 16:9 height instead - // of the raw (possibly cropped) height - matches how Plex/Jellyfin label these themselves. - const effectiveHeight = Math.max(height, Math.round((width * 9) / 16)); - const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveHeight >= candidate * 0.9); - return `${tier ?? effectiveHeight}p`; + // Classify off the short axis (height for landscape, width for portrait) so orientation + // doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped + // shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive + // a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves. + const isPortrait = height > width; + const shortAxis = isPortrait ? width : height; + const longAxis = isPortrait ? height : width; + const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16)); + const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9); + return `${tier ?? effectiveShortAxis}p`; } export function getSeasonEpisodeParams(