diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 9ed2af4ac..e4bda472a 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -85,6 +85,12 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", category: ["mediaService"], }, + emby: { + name: "Emby", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", + category: ["mediaService"], + }, plex: { name: "Plex", secretKinds: [["apiKey"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index ecf184ba8..af2a8d2bd 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -10,6 +10,7 @@ import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration" import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; +import { EmbyIntegration } from "../emby/emby-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; @@ -74,4 +75,5 @@ export const integrationCreators = { dashDot: DashDotIntegration, tdarr: TdarrIntegration, proxmox: ProxmoxIntegration, + emby: EmbyIntegration, } satisfies Record Integration>; diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts new file mode 100644 index 000000000..911901243 --- /dev/null +++ b/packages/integrations/src/emby/emby-integration.ts @@ -0,0 +1,98 @@ +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; +import { z } from "zod"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; + +import { Integration } from "../base/integration"; +import type { StreamSession } from "../interfaces/media-server/session"; +import { convertJellyfinType } from "../jellyfin/jellyfin-integration"; + +const sessionSchema = z.object({ + NowPlayingItem: z + .object({ + Type: z.nativeEnum(BaseItemKind).optional(), + SeriesName: z.string().nullish(), + Name: z.string().nullish(), + SeasonName: z.string().nullish(), + EpisodeTitle: z.string().nullish(), + Album: z.string().nullish(), + EpisodeCount: z.number().nullish(), + }) + .optional(), + Id: z.string(), + Client: z.string().nullish(), + DeviceId: z.string().nullish(), + DeviceName: z.string().nullish(), + UserId: z.string().optional(), + UserName: z.string().nullish(), +}); + +export class EmbyIntegration extends Integration { + private static readonly apiKeyHeader = "X-Emby-Token"; + private static readonly deviceId = "homarr-emby-integration"; + private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`; + + public async testConnectionAsync(): Promise { + const apiKey = super.getSecretValue("apiKey"); + + await super.handleTestConnectionResponseAsync({ + queryFunctionAsync: async () => { + return await fetchWithTrustedCertificatesAsync(super.url("/emby/System/Ping"), { + headers: { + [EmbyIntegration.apiKeyHeader]: apiKey, + Authorization: EmbyIntegration.authorizationHeaderValue, + }, + }); + }, + }); + } + + public async getCurrentSessionsAsync(): Promise { + const apiKey = super.getSecretValue("apiKey"); + const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), { + headers: { + [EmbyIntegration.apiKeyHeader]: apiKey, + Authorization: EmbyIntegration.authorizationHeaderValue, + }, + }); + + if (!response.ok) { + throw new Error(`Emby server ${this.integration.id} returned a non successful status code: ${response.status}`); + } + + const result = z.array(sessionSchema).safeParse(await response.json()); + + if (!result.success) { + throw new Error(`Emby server ${this.integration.id} returned an unexpected response: ${result.error.message}`); + } + + return result.data + .filter((sessionInfo) => sessionInfo.UserId !== undefined) + .filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId) + .map((sessionInfo): StreamSession => { + let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; + + if (sessionInfo.NowPlayingItem) { + currentlyPlaying = { + type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), + name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", + seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", + episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, + albumName: sessionInfo.NowPlayingItem.Album ?? "", + episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + }; + } + + return { + sessionId: `${sessionInfo.Id}`, + sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`, + user: { + profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(), + userId: sessionInfo.UserId ?? "", + username: sessionInfo.UserName ?? "", + }, + currentlyPlaying, + }; + }); + } +} diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 2e40391cb..29a90c549 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -1,4 +1,5 @@ import { Jellyfin } from "@jellyfin/sdk"; +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api"; @@ -34,31 +35,34 @@ export class JellyfinIntegration extends Integration { throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`); } - return sessions.data.map((sessionInfo): StreamSession => { - let nowPlaying: StreamSession["currentlyPlaying"] | null = null; + return sessions.data + .filter((sessionInfo) => sessionInfo.UserId !== undefined) + .filter((sessionInfo) => sessionInfo.DeviceId !== "homarr") + .map((sessionInfo): StreamSession => { + let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; - if (sessionInfo.NowPlayingItem) { - nowPlaying = { - type: "tv", - name: sessionInfo.NowPlayingItem.Name ?? "", - seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", - episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, - albumName: sessionInfo.NowPlayingItem.Album ?? "", - episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + if (sessionInfo.NowPlayingItem) { + currentlyPlaying = { + type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), + name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", + seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", + episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, + albumName: sessionInfo.NowPlayingItem.Album ?? "", + episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, + }; + } + + return { + sessionId: `${sessionInfo.Id}`, + sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`, + user: { + profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(), + userId: sessionInfo.UserId ?? "", + username: sessionInfo.UserName ?? "", + }, + currentlyPlaying, }; - } - - return { - sessionId: `${sessionInfo.Id}`, - sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`, - user: { - profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(), - userId: sessionInfo.UserId ?? "", - username: sessionInfo.UserName ?? "", - }, - currentlyPlaying: nowPlaying, - }; - }); + }); } /** @@ -81,3 +85,24 @@ export class JellyfinIntegration extends Integration { return apiClient; } } + +export const convertJellyfinType = ( + kind: BaseItemKind | undefined, +): Exclude["type"] => { + switch (kind) { + case BaseItemKind.Audio: + case BaseItemKind.MusicVideo: + return "audio"; + case BaseItemKind.Episode: + case BaseItemKind.Video: + return "video"; + case BaseItemKind.Movie: + return "movie"; + case BaseItemKind.TvChannel: + case BaseItemKind.TvProgram: + case BaseItemKind.LiveTvChannel: + case BaseItemKind.LiveTvProgram: + default: + return "tv"; + } +}; diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 5e9fe1f3a..c7f4aeea5 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -7,5 +7,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ createOptions() { return {}; }, - supportedIntegrations: ["jellyfin", "plex"], + supportedIntegrations: ["jellyfin", "plex", "emby"], }).withDynamicImport(() => import("./component"));