From f98750d0b3e96b234d6f57b82bf7aa8729a091f2 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Tue, 22 Apr 2025 18:30:46 +0200 Subject: [PATCH] feat(media-server): add option to only show playing sessions (#2899) --- .../api/src/router/widgets/media-server.ts | 15 ++++++--- .../src/jobs/integrations/media-server.ts | 2 +- .../integrations/src/emby/emby-integration.ts | 5 +-- .../src/interfaces/media-server/session.ts | 4 +++ .../src/jellyfin/jellyfin-integration.ts | 5 +-- .../integrations/src/plex/plex-integration.ts | 4 +-- packages/old-import/src/widgets/options.ts | 4 ++- packages/request-handler/src/media-server.ts | 8 +++-- packages/translation/src/lang/en.json | 7 +++- .../widgets/src/media-server/component.tsx | 33 ++++++++++++------- packages/widgets/src/media-server/index.ts | 5 ++- 11 files changed, 63 insertions(+), 29 deletions(-) diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 8d3fe4537..50d52e594 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -1,4 +1,5 @@ import { observable } from "@trpc/server/observable"; +import { z } from "zod"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; @@ -14,10 +15,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => export const mediaServerRouter = createTRPCRouter({ getCurrentStreams: publicProcedure .unstable_concat(createMediaServerIntegrationMiddleware("query")) - .query(async ({ ctx }) => { + .input(z.object({ showOnlyPlaying: z.boolean() })) + .query(async ({ ctx, input }) => { return await Promise.all( ctx.integrations.map(async (integration) => { - const innerHandler = mediaServerRequestHandler.handler(integration, {}); + const innerHandler = mediaServerRequestHandler.handler(integration, { + showOnlyPlaying: input.showOnlyPlaying, + }); const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, @@ -29,11 +33,14 @@ export const mediaServerRouter = createTRPCRouter({ }), subscribeToCurrentStreams: publicProcedure .unstable_concat(createMediaServerIntegrationMiddleware("query")) - .subscription(({ ctx }) => { + .input(z.object({ showOnlyPlaying: z.boolean() })) + .subscription(({ ctx, input }) => { return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integration of ctx.integrations) { - const innerHandler = mediaServerRequestHandler.handler(integration, {}); + const innerHandler = mediaServerRequestHandler.handler(integration, { + showOnlyPlaying: input.showOnlyPlaying, + }); const unsubscribe = innerHandler.subscribe((sessions) => { emit.next({ diff --git a/packages/cron-jobs/src/jobs/integrations/media-server.ts b/packages/cron-jobs/src/jobs/integrations/media-server.ts index 3ba0324cb..099ea53ae 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-server.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-server.ts @@ -8,7 +8,7 @@ export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).with createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, { widgetKinds: ["mediaServer"], getInput: { - mediaServer: () => ({}), + mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }), }, }), ); diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index 911901243..7c0db54e5 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { Integration } from "../base/integration"; -import type { StreamSession } from "../interfaces/media-server/session"; +import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import { convertJellyfinType } from "../jellyfin/jellyfin-integration"; const sessionSchema = z.object({ @@ -47,7 +47,7 @@ export class EmbyIntegration extends Integration { }); } - public async getCurrentSessionsAsync(): Promise { + public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise { const apiKey = super.getSecretValue("apiKey"); const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), { headers: { @@ -69,6 +69,7 @@ export class EmbyIntegration extends Integration { return result.data .filter((sessionInfo) => sessionInfo.UserId !== undefined) .filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId) + .filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined) .map((sessionInfo): StreamSession => { let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; diff --git a/packages/integrations/src/interfaces/media-server/session.ts b/packages/integrations/src/interfaces/media-server/session.ts index ef58c77a6..74bdcdbca 100644 --- a/packages/integrations/src/interfaces/media-server/session.ts +++ b/packages/integrations/src/interfaces/media-server/session.ts @@ -15,3 +15,7 @@ export interface StreamSession { episodeCount?: number | null; } | null; } + +export interface CurrentSessionsInput { + showOnlyPlaying: boolean; +} diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 29a90c549..95962bdcf 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -6,7 +6,7 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api"; import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server"; import { Integration } from "../base/integration"; -import type { StreamSession } from "../interfaces/media-server/session"; +import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; export class JellyfinIntegration extends Integration { private readonly jellyfin: Jellyfin = new Jellyfin({ @@ -26,7 +26,7 @@ export class JellyfinIntegration extends Integration { await systemApi.getPingSystem(); } - public async getCurrentSessionsAsync(): Promise { + public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise { const api = await this.getApiAsync(); const sessionApi = getSessionApi(api); const sessions = await sessionApi.getSessions(); @@ -38,6 +38,7 @@ export class JellyfinIntegration extends Integration { return sessions.data .filter((sessionInfo) => sessionInfo.UserId !== undefined) .filter((sessionInfo) => sessionInfo.DeviceId !== "homarr") + .filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined) .map((sessionInfo): StreamSession => { let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 52d5ce189..edca5779e 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -5,11 +5,11 @@ import { logger } from "@homarr/log"; import { Integration } from "../base/integration"; import { IntegrationTestConnectionError } from "../base/test-connection-error"; -import type { StreamSession } from "../interfaces/media-server/session"; +import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import type { PlexResponse } from "./interface"; export class PlexIntegration extends Integration { - public async getCurrentSessionsAsync(): Promise { + public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise { const token = super.getSecretValue("apiKey"); const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), { diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index f35306ad5..d8c9678af 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -133,7 +133,9 @@ const optionMapping: OptionMapping = { automationId: (oldOptions) => oldOptions.automationId, displayName: (oldOptions) => oldOptions.displayName, }, - mediaServer: {}, + mediaServer: { + showOnlyPlaying: () => undefined, + }, indexerManager: { openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, }, diff --git a/packages/request-handler/src/media-server.ts b/packages/request-handler/src/media-server.ts index 2fadad23f..b13b90eb0 100644 --- a/packages/request-handler/src/media-server.ts +++ b/packages/request-handler/src/media-server.ts @@ -9,11 +9,13 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration- export const mediaServerRequestHandler = createCachedIntegrationRequestHandler< StreamSession[], IntegrationKindByCategory<"mediaService">, - Record + { + showOnlyPlaying: boolean; + } >({ - async requestAsync(integration, _input) { + async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getCurrentSessionsAsync(); + return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying }); }, cacheDuration: dayjs.duration(5, "seconds"), queryKey: "mediaServerSessions", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 122b818ae..64cbc463f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1766,7 +1766,12 @@ "mediaServer": { "name": "Current media server streams", "description": "Show the current streams on your media servers", - "option": {}, + "option": { + "showOnlyPlaying": { + "label": "Show only currently playing", + "description": "Disabling this will not work for plex" + } + }, "items": { "currentlyPlaying": "Currently playing", "user": "User", diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 146f7915f..51c3aeea3 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -17,10 +17,15 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; -export default function MediaServerWidget({ integrationIds, isEditMode }: WidgetComponentProps<"mediaServer">) { +export default function MediaServerWidget({ + options, + integrationIds, + isEditMode, +}: WidgetComponentProps<"mediaServer">) { const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery( { integrationIds, + showOnlyPlaying: options.showOnlyPlaying, }, { refetchOnMount: false, @@ -80,21 +85,25 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription( { integrationIds, + showOnlyPlaying: options.showOnlyPlaying, }, { enabled: !isEditMode, onData(data) { - utils.widget.mediaServer.getCurrentStreams.setData({ integrationIds }, (previousData) => { - return previousData?.map((pair) => { - if (pair.integrationId === data.integrationId) { - return { - ...pair, - sessions: data.data, - }; - } - return pair; - }); - }); + utils.widget.mediaServer.getCurrentStreams.setData( + { integrationIds, showOnlyPlaying: options.showOnlyPlaying }, + (previousData) => { + return previousData?.map((pair) => { + if (pair.integrationId === data.integrationId) { + return { + ...pair, + sessions: data.data, + }; + } + return pair; + }); + }, + ); }, }, ); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index c7f4aeea5..bdd6cc91c 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -1,11 +1,14 @@ import { IconVideo } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { icon: IconVideo, createOptions() { - return {}; + return optionsBuilder.from((factory) => ({ + showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), + })); }, supportedIntegrations: ["jellyfin", "plex", "emby"], }).withDynamicImport(() => import("./component"));