Skip to content

Commit

Permalink
feat: plex integration (#1342)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hillaliy authored Oct 23, 2024
1 parent d4919dc commit cf9b058
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 12 deletions.
4 changes: 3 additions & 1 deletion packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -51,6 +52,7 @@ export const integrationCreators = {
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
Expand Down
21 changes: 11 additions & 10 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
37 changes: 37 additions & 0 deletions packages/integrations/src/plex/interface.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 103 additions & 0 deletions packages/integrations/src/plex/plex-integration.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
}
2 changes: 1 addition & 1 deletion packages/widgets/src/media-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 33 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit cf9b058

Please sign in to comment.