From c6c4428f7d4abc64626e65c96a3899b69b7498aa Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 15 Jul 2026 14:55:18 +0200 Subject: [PATCH 1/4] Revert "fix(docker): report current CPU usage instead of the lifetime average" (#6357) --- packages/request-handler/src/docker.ts | 21 ++++--------------- .../request-handler/src/test/docker.spec.ts | 20 ------------------ 2 files changed, 4 insertions(+), 37 deletions(-) diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index a238b4a3c..12a78c05a 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -300,10 +300,7 @@ async function getContainersWithStatsAsync() { const stats = await instance .getContainer(container.Id) - // Not in one-shot mode: the daemon then includes precpu_stats (the - // previous reading), which is required to compute the current CPU usage - // as a delta. One-shot omits it and would only expose lifetime totals. - .stats({ stream: false }) + .stats({ stream: false, "one-shot": true }) .catch( () => ({ @@ -339,22 +336,12 @@ export function calculateCpuUsage(stats: ContainerStats): number { } const numberOfCpus = stats.cpu_stats.online_cpus; - - // Docker's own formula for the current (instantaneous) CPU usage: it compares - // the latest reading against the previous one (precpu_stats). Dividing the - // cumulative total_usage by the cumulative system_cpu_usage instead would - // report the container's lifetime average, which stays high long after a load - // spike has ended. precpu_stats is only present when the stats are NOT fetched - // in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the - // cumulative totals, matching the previous behaviour. - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0); - const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0); - - if (systemDelta <= 0 || cpuDelta < 0) { + const usage = stats.cpu_stats.system_cpu_usage; + if (!usage || usage === 0) { return 0; } - return (cpuDelta / systemDelta) * numberOfCpus * 100; + return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100; } export function calculateMemoryUsage(stats: ContainerStats): number { diff --git a/packages/request-handler/src/test/docker.spec.ts b/packages/request-handler/src/test/docker.spec.ts index 12dbb347b..b4e34f816 100644 --- a/packages/request-handler/src/test/docker.spec.ts +++ b/packages/request-handler/src/test/docker.spec.ts @@ -75,26 +75,6 @@ describe("calculateCpuUsage", () => { // (500 / 100000) * 2 * 100 = 1 expect(calculateCpuUsage(stats)).toBe(1); }); - - test("should use the delta against precpu_stats for the current usage", () => { - const stats = createStats({ - cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 }, - precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 }, - }); - // Despite a large lifetime total_usage, only the recent delta counts: - // (2000 / 10000) * 4 * 100 = 80 - expect(calculateCpuUsage(stats)).toBe(80); - }); - - test("should report 0 for a container that was busy over its lifetime but is idle now", () => { - const stats = createStats({ - cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 }, - precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 }, - }); - // No CPU delta since the last reading -> current usage is 0, even though the - // lifetime average would be high. This is the regression the fix addresses. - expect(calculateCpuUsage(stats)).toBe(0); - }); }); describe("calculateMemoryUsage", () => { From 8528d5068b4791303de3aeb91d65d804e910d6bf Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 15 Jul 2026 16:27:24 +0200 Subject: [PATCH 2/4] feat(media-requests): filter requests list by status and time window (#6320) --- .../docs/widgets/media-request-list/index.mdx | 48 ++++++++++--------- .../docs/widgets/media-request-list/index.ts | 12 +++++ .../api/src/router/widgets/media-requests.ts | 12 +++-- packages/old-import/src/widgets/options.ts | 2 + .../request-handler/src/media-request-list.ts | 34 +++++++++++-- packages/translation/src/lang/en.json | 6 +++ .../src/media-requests/list/component.tsx | 5 ++ .../widgets/src/media-requests/list/index.ts | 14 ++++++ 8 files changed, 103 insertions(+), 30 deletions(-) diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 2f52a8e1f..1c491a051 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -1,41 +1,44 @@ --- -title: 'Media Request List' -description: 'See a list of all media requests from your integration' +title: "Media Request List" +description: "See a list of all media requests from your integration" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaRequestListWidget } from '.'; -import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; -import { overseerrIntegration } from '@site/docs/integrations/overseerr'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaRequestListWidget } from "."; +import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; +import { overseerrIntegration } from "@site/docs/integrations/overseerr"; - - + Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. +You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days. + ### Screenshots -import mediaRequestList from './img/list.png'; +import mediaRequestList from "./img/list.png"; media-request-list ### Supported Integrations - + ### Interactions @@ -47,4 +50,5 @@ import mediaRequestList from './img/list.png'; ### Configuration + diff --git a/apps/docs/docs/widgets/media-request-list/index.ts b/apps/docs/docs/widgets/media-request-list/index.ts index 8eb9d8bfb..1732d6fd7 100644 --- a/apps/docs/docs/widgets/media-request-list/index.ts +++ b/apps/docs/docs/widgets/media-request-list/index.ts @@ -14,6 +14,18 @@ export const mediaRequestListWidget: WidgetDefinition = { values: { type: "boolean" }, defaultValue: "yes", }, + { + name: "Statuses to show", + description: "Only requests with these statuses will be shown. Defaults to all statuses.", + values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'", + defaultValue: "All statuses", + }, + { + name: "Show only recent requests", + description: "Limit to requests created within this many days. Set to 0 to disable the time filter.", + values: "0-365", + defaultValue: "0", + }, ], }, }; diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 99c4906e3..fbd192499 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -3,7 +3,10 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; -import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; +import { + mediaRequestListInputSchema, + mediaRequestListRequestHandler, +} from "@homarr/request-handler/media-request-list"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; @@ -16,13 +19,14 @@ export const mediaRequestsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", + "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", }, }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) - .query(async ({ ctx }) => { + .input(mediaRequestListInputSchema) + .query(async ({ ctx, input }) => { const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync(); + const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; }); return results diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index a4ae66bb0..a52de2b04 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -23,6 +23,8 @@ type OptionMapping = { const optionMapping: OptionMapping = { "mediaRequests-requestList": { linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, + statusFilter: () => undefined, + recentDays: () => undefined, }, "mediaRequests-requestStats": {}, bookmarks: { diff --git a/packages/request-handler/src/media-request-list.ts b/packages/request-handler/src/media-request-list.ts index 7a99dae76..d8a7c6336 100644 --- a/packages/request-handler/src/media-request-list.ts +++ b/packages/request-handler/src/media-request-list.ts @@ -1,17 +1,43 @@ +import { z } from "zod/v4"; + import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import type { MediaRequest } from "@homarr/integrations/types"; +import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; +const mediaRequestStatusValues = [ + "pending", + "approved", + "declined", + "failed", + "completed", +] as const satisfies readonly MediaRequestStatus[]; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const mediaRequestListInputSchema = z.object({ + statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(), + recentDays: z.number().min(0).max(365), +}); + +export type MediaRequestListInput = z.infer; + export const mediaRequestListRequestHandler = createIntegrationRequestHandler< MediaRequest[], IntegrationKindByCategory<"mediaRequest">, - Record + MediaRequestListInput >({ - async requestAsync(integration, _input) { + async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getRequestsAsync(); + const requests = await integrationInstance.getRequestsAsync(); + const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null; + const statusSet = new Set(input.statuses); + return requests.filter((request) => { + if (!statusSet.has(request.status)) return false; + if (cutoff !== null && request.createdAt.getTime() < cutoff) return false; + return true; + }); }, cacheTtlMs: 60_000, fallbackToStaleOnError: true, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 86ecfdee2..ba5540adb 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3201,6 +3201,12 @@ "option": { "linksTargetNewTab": { "label": "Open links in new tab" + }, + "statusFilter": { + "label": "Statuses to show" + }, + "recentDays": { + "label": "Show only recent requests" } }, "pending": { diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index bbbd0047e..c5dc5bf06 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -38,6 +38,11 @@ export default function MediaServerWidget({ }: WidgetComponentProps<"mediaRequests-requestList">) { const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, + statuses: + options.statusFilter.length > 0 + ? options.statusFilter + : ["pending", "approved", "declined", "failed", "completed"], + recentDays: options.recentDays, }); if (!mediaRequests) return ; diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index d4e0fcd54..1f65cd0c3 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,4 +1,5 @@ import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; +import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; @@ -6,6 +7,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; +const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const; + export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { icon: IconZoomQuestion, createOptions() { @@ -13,6 +16,17 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ linksTargetNewTab: factory.switch({ defaultValue: true, }), + statusFilter: factory.multiSelect({ + defaultValue: [...mediaRequestStatusValues], + options: mediaRequestStatusValues.map((value) => ({ + value, + label: (t) => t(`widget.mediaRequests-requestList.status.${value}`), + })), + }), + recentDays: factory.number({ + validate: z.number().min(0).max(365), + defaultValue: 0, + }), })); }, contextActions: ({ integrationIds }) => [ From 5144e14da4e18b192756d59075345931953cbf39 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Thu, 16 Jul 2026 00:27:58 +1000 Subject: [PATCH 3/4] 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", + }, }), }); From 06719cdef0706179518f606ea58a3aedf9dd1abd Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 16 Jul 2026 10:21:07 +0200 Subject: [PATCH 4/4] fix(ci): remove coverage report, scope format to changed files, fix oxlint config (#6359) --- .github/workflows/ci.yml | 7 ------- apps/docs/.oxlintrc.json | 3 --- pnpm-workspace.yaml | 2 +- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f62afeb28..cf509dbba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,9 +81,6 @@ jobs: - name: Copy environment defaults run: cp .env.example .env - - name: Check formatting in affected workspaces - run: pnpm turbo run format --affected --continue -- --check - - name: Lint, typecheck, and build affected workspaces run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful @@ -93,10 +90,6 @@ jobs: - name: Run unit tests run: pnpm test - - name: Report coverage - if: always() && !env.ACT - uses: davelosert/vitest-coverage-report-action@v2 - container: name: Container, E2E, and memory needs: fast-gate diff --git a/apps/docs/.oxlintrc.json b/apps/docs/.oxlintrc.json index 58480a9ab..3eba05d31 100644 --- a/apps/docs/.oxlintrc.json +++ b/apps/docs/.oxlintrc.json @@ -23,9 +23,6 @@ "react-hooks/exhaustive-deps": "warn", "eslint/no-unused-expressions": "warn" }, - "options": { - "reportUnusedDisableDirectives": "off" - }, "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"], "settings": { "react": { "version": "19" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d56ea7922..863ecdcf6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -239,7 +239,7 @@ allowBuilds: "@tree-sitter-grammars/tree-sitter-yaml": true bcrypt: true better-sqlite3: true - cpu-features: true + cpu-features: false esbuild: true sharp: true ssh2: true