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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
37
packages/integrations/src/plex/interface.ts
Normal file
37
packages/integrations/src/plex/interface.ts
Normal file
@@ -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;
|
||||
}
|
||||
103
packages/integrations/src/plex/plex-integration.ts
Normal file
103
packages/integrations/src/plex/plex-integration.ts
Normal file
@@ -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<StreamSession[]> {
|
||||
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<PlexResponse>(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<void> {
|
||||
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<PlexResponse>(result);
|
||||
return;
|
||||
} catch {
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static parseXml<T>(xml: string): Promise<T> {
|
||||
return parseStringPromise(xml) as Promise<T>;
|
||||
}
|
||||
|
||||
static getCurrentlyPlayingType(type: string): NonNullable<StreamSession["currentlyPlaying"]>["type"] {
|
||||
switch (type) {
|
||||
case "movie":
|
||||
return "movie";
|
||||
case "episode":
|
||||
return "video";
|
||||
case "track":
|
||||
return "audio";
|
||||
default:
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user