feat(media-server): add option to only show playing sessions (#2899)

This commit is contained in:
Meier Lukas
2025-04-22 18:30:46 +02:00
committed by GitHub
parent c51424717d
commit f98750d0b3
11 changed files with 63 additions and 29 deletions

View File

@@ -1,4 +1,5 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations"; import type { StreamSession } from "@homarr/integrations";
@@ -14,10 +15,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
export const mediaServerRouter = createTRPCRouter({ export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure getCurrentStreams: publicProcedure
.unstable_concat(createMediaServerIntegrationMiddleware("query")) .unstable_concat(createMediaServerIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { 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 }); const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
@@ -29,11 +33,14 @@ export const mediaServerRouter = createTRPCRouter({
}), }),
subscribeToCurrentStreams: publicProcedure subscribeToCurrentStreams: publicProcedure
.unstable_concat(createMediaServerIntegrationMiddleware("query")) .unstable_concat(createMediaServerIntegrationMiddleware("query"))
.subscription(({ ctx }) => { .input(z.object({ showOnlyPlaying: z.boolean() }))
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const innerHandler = mediaServerRequestHandler.handler(integration, {}); const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
const unsubscribe = innerHandler.subscribe((sessions) => { const unsubscribe = innerHandler.subscribe((sessions) => {
emit.next({ emit.next({

View File

@@ -8,7 +8,7 @@ export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).with
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, { createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
widgetKinds: ["mediaServer"], widgetKinds: ["mediaServer"],
getInput: { getInput: {
mediaServer: () => ({}), mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }),
}, },
}), }),
); );

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration"; 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"; import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
const sessionSchema = z.object({ const sessionSchema = z.object({
@@ -47,7 +47,7 @@ export class EmbyIntegration extends Integration {
}); });
} }
public async getCurrentSessionsAsync(): Promise<StreamSession[]> { public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
const apiKey = super.getSecretValue("apiKey"); const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), { const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), {
headers: { headers: {
@@ -69,6 +69,7 @@ export class EmbyIntegration extends Integration {
return result.data return result.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined) .filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId) .filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId)
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
.map((sessionInfo): StreamSession => { .map((sessionInfo): StreamSession => {
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;

View File

@@ -15,3 +15,7 @@ export interface StreamSession {
episodeCount?: number | null; episodeCount?: number | null;
} | null; } | null;
} }
export interface CurrentSessionsInput {
showOnlyPlaying: boolean;
}

View File

@@ -6,7 +6,7 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server"; import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration"; 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 { export class JellyfinIntegration extends Integration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -26,7 +26,7 @@ export class JellyfinIntegration extends Integration {
await systemApi.getPingSystem(); await systemApi.getPingSystem();
} }
public async getCurrentSessionsAsync(): Promise<StreamSession[]> { public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
const api = await this.getApiAsync(); const api = await this.getApiAsync();
const sessionApi = getSessionApi(api); const sessionApi = getSessionApi(api);
const sessions = await sessionApi.getSessions(); const sessions = await sessionApi.getSessions();
@@ -38,6 +38,7 @@ export class JellyfinIntegration extends Integration {
return sessions.data return sessions.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined) .filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr") .filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
.map((sessionInfo): StreamSession => { .map((sessionInfo): StreamSession => {
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;

View File

@@ -5,11 +5,11 @@ import { logger } from "@homarr/log";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error"; 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"; import type { PlexResponse } from "./interface";
export class PlexIntegration extends Integration { export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise<StreamSession[]> { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); const token = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), { const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), {

View File

@@ -133,7 +133,9 @@ const optionMapping: OptionMapping = {
automationId: (oldOptions) => oldOptions.automationId, automationId: (oldOptions) => oldOptions.automationId,
displayName: (oldOptions) => oldOptions.displayName, displayName: (oldOptions) => oldOptions.displayName,
}, },
mediaServer: {}, mediaServer: {
showOnlyPlaying: () => undefined,
},
indexerManager: { indexerManager: {
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
}, },

View File

@@ -9,11 +9,13 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const mediaServerRequestHandler = createCachedIntegrationRequestHandler< export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
StreamSession[], StreamSession[],
IntegrationKindByCategory<"mediaService">, IntegrationKindByCategory<"mediaService">,
Record<string, never> {
showOnlyPlaying: boolean;
}
>({ >({
async requestAsync(integration, _input) { async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getCurrentSessionsAsync(); return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
}, },
cacheDuration: dayjs.duration(5, "seconds"), cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "mediaServerSessions", queryKey: "mediaServerSessions",

View File

@@ -1766,7 +1766,12 @@
"mediaServer": { "mediaServer": {
"name": "Current media server streams", "name": "Current media server streams",
"description": "Show the current streams on your media servers", "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": { "items": {
"currentlyPlaying": "Currently playing", "currentlyPlaying": "Currently playing",
"user": "User", "user": "User",

View File

@@ -17,10 +17,15 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition"; 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( const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
{ {
integrationIds, integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
}, },
{ {
refetchOnMount: false, refetchOnMount: false,
@@ -80,21 +85,25 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription( clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription(
{ {
integrationIds, integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
}, },
{ {
enabled: !isEditMode, enabled: !isEditMode,
onData(data) { onData(data) {
utils.widget.mediaServer.getCurrentStreams.setData({ integrationIds }, (previousData) => { utils.widget.mediaServer.getCurrentStreams.setData(
return previousData?.map((pair) => { { integrationIds, showOnlyPlaying: options.showOnlyPlaying },
if (pair.integrationId === data.integrationId) { (previousData) => {
return { return previousData?.map((pair) => {
...pair, if (pair.integrationId === data.integrationId) {
sessions: data.data, return {
}; ...pair,
} sessions: data.data,
return pair; };
}); }
}); return pair;
});
},
);
}, },
}, },
); );

View File

@@ -1,11 +1,14 @@
import { IconVideo } from "@tabler/icons-react"; import { IconVideo } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
createOptions() { createOptions() {
return {}; return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
}));
}, },
supportedIntegrations: ["jellyfin", "plex", "emby"], supportedIntegrations: ["jellyfin", "plex", "emby"],
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));