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(