From cf9b0581a486b3d6b1207e8a612c9fe4665c6d49 Mon Sep 17 00:00:00 2001 From: Yossi Hillali Date: Wed, 23 Oct 2024 17:18:55 +0300 Subject: [PATCH] feat: plex integration (#1342) * feat: plex integration * feat: plex integration * fix: DeepSource error * fix: lint error * fix: pnpm-lock * fix: lint error * fix: errors * fix: pnpm-lock * fix: reviewed changes * fix: reviewed changes * fix: reviewed changes * fix: pnpm-lock --- packages/integrations/package.json | 4 +- packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 21 ++-- packages/integrations/src/plex/interface.ts | 37 +++++++ .../integrations/src/plex/plex-integration.ts | 103 ++++++++++++++++++ packages/widgets/src/media-server/index.ts | 2 +- pnpm-lock.yaml | 33 ++++++ 7 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 packages/integrations/src/plex/interface.ts create mode 100644 packages/integrations/src/plex/plex-integration.ts diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 82a59fc3a..a3d944fa8 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -33,12 +33,14 @@ "@homarr/log": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@jellyfin/sdk": "^0.10.0" + "@jellyfin/sdk": "^0.10.0", + "xml2js": "^0.6.2" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", + "@types/xml2js": "^0.4.14", "eslint": "^9.13.0", "typescript": "^5.6.3" } diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index beaf6d1a3..5894aea9e 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -17,6 +17,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration" import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; +import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -51,6 +52,7 @@ export const integrationCreators = { adGuardHome: AdGuardHomeIntegration, homeAssistant: HomeAssistantIntegration, jellyfin: JellyfinIntegration, + plex: PlexIntegration, sonarr: SonarrIntegration, radarr: RadarrIntegration, sabNzbd: SabnzbdIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 0c650a0e3..8a0556c6b 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,30 +1,31 @@ // General integrations export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; +export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; +export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; +export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; +export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; +export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; -export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration"; +export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration"; export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; +export { PlexIntegration } from "./plex/plex-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; -export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; -export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; -export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; -export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; -export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; // Types +export type { IntegrationInput } from "./base/integration"; +export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; +export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; +export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request"; export type { StreamSession } from "./interfaces/media-server/session"; -export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; -export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; -export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; -export type { IntegrationInput } from "./base/integration"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; diff --git a/packages/integrations/src/plex/interface.ts b/packages/integrations/src/plex/interface.ts new file mode 100644 index 000000000..ca7ce3e67 --- /dev/null +++ b/packages/integrations/src/plex/interface.ts @@ -0,0 +1,37 @@ +interface MediaContainer { + Video?: Session[]; + Track?: Session[]; +} + +interface Session { + User?: { + $: { + id: string; + title: string; + thumb?: string; + }; + }[]; + Player?: { + $: { + product: string; + title: string; + }; + }[]; + Session?: { + $: { + id: string; + }; + }[]; + $: { + grandparentTitle?: string; + parentTitle?: string; + title?: string; + index?: number; + type: string; + live?: string; + }; +} + +export interface PlexResponse { + MediaContainer: MediaContainer; +} diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts new file mode 100644 index 000000000..cdbad1d41 --- /dev/null +++ b/packages/integrations/src/plex/plex-integration.ts @@ -0,0 +1,103 @@ +import { parseStringPromise } from "xml2js"; + +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 { PlexResponse } from "./interface"; + +export class PlexIntegration extends Integration { + public async getCurrentSessionsAsync(): Promise { + const token = super.getSecretValue("apiKey"); + + const response = await fetch(`${this.integration.url}/status/sessions`, { + headers: { + "X-Plex-Token": token, + }, + }); + const body = await response.text(); + // convert xml response to objects, as there is no JSON api + const data = await PlexIntegration.parseXml(body); + const mediaContainer = data.MediaContainer; + const mediaElements = [mediaContainer.Video ?? [], mediaContainer.Track ?? []].flat(); + + // no sessions are open or available + if (mediaElements.length === 0) { + logger.info("No active video sessions found in MediaContainer"); + return []; + } + + const medias = mediaElements + .map((mediaElement): StreamSession | undefined => { + const userElement = mediaElement.User ? mediaElement.User[0] : undefined; + const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined; + const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined; + + if (!playerElement) { + return undefined; + } + + return { + sessionId: sessionElement?.$.id ?? "unknown", + sessionName: `${playerElement.$.product} (${playerElement.$.title})`, + user: { + userId: userElement?.$.id ?? "Anonymous", + username: userElement?.$.title ?? "Anonymous", + profilePictureUrl: userElement?.$.thumb ?? null, + }, + currentlyPlaying: { + type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type), + name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown", + seasonName: mediaElement.$.parentTitle, + episodeName: mediaElement.$.title ?? null, + albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, + episodeCount: mediaElement.$.index ?? null, + }, + }; + }) + .filter((session): session is StreamSession => session !== undefined); + + return medias; + } + + public async testConnectionAsync(): Promise { + const token = super.getSecretValue("apiKey"); + + await super.handleTestConnectionResponseAsync({ + queryFunctionAsync: async () => { + return await fetch(this.integration.url, { + headers: { + "X-Plex-Token": token, + }, + }); + }, + handleResponseAsync: async (response) => { + try { + const result = await response.text(); + await PlexIntegration.parseXml(result); + return; + } catch { + throw new IntegrationTestConnectionError("invalidCredentials"); + } + }, + }); + } + + static parseXml(xml: string): Promise { + return parseStringPromise(xml) as Promise; + } + + static getCurrentlyPlayingType(type: string): NonNullable["type"] { + switch (type) { + case "movie": + return "movie"; + case "episode": + return "video"; + case "track": + return "audio"; + default: + return "video"; + } + } +} diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index e69919927..f5cd3ad57 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -5,5 +5,5 @@ import { createWidgetDefinition } from "../definition"; export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { icon: IconVideo, options: {}, - supportedIntegrations: ["jellyfin"], + supportedIntegrations: ["jellyfin", "plex"], }).withDynamicImport(() => import("./component")); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7ec62f5..63afed62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1004,6 +1004,9 @@ importers: '@jellyfin/sdk': specifier: ^0.10.0 version: 0.10.0(axios@1.7.7) + xml2js: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1014,6 +1017,9 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 eslint: specifier: ^9.13.0 version: 9.13.0 @@ -3676,6 +3682,9 @@ packages: '@types/ws@8.5.12': resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@typescript-eslint/eslint-plugin@8.11.0': resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6959,6 +6968,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -8007,9 +8019,17 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -10040,6 +10060,10 @@ snapshots: dependencies: '@types/node': 20.16.15 + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.16.15 + '@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0)(typescript@5.6.3))(eslint@9.13.0)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.11.1 @@ -13753,6 +13777,8 @@ snapshots: immutable: 4.3.7 source-map-js: 1.2.1 + sax@1.4.1: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -14939,8 +14965,15 @@ snapshots: xml-name-validator@5.0.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} xmlhttprequest-ssl@2.0.0: {}