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/log": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"eslint": "^9.13.0",
|
"eslint": "^9.13.0",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
|
|||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
|
import { PlexIntegration } from "../plex/plex-integration";
|
||||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export const integrationCreators = {
|
|||||||
adGuardHome: AdGuardHomeIntegration,
|
adGuardHome: AdGuardHomeIntegration,
|
||||||
homeAssistant: HomeAssistantIntegration,
|
homeAssistant: HomeAssistantIntegration,
|
||||||
jellyfin: JellyfinIntegration,
|
jellyfin: JellyfinIntegration,
|
||||||
|
plex: PlexIntegration,
|
||||||
sonarr: SonarrIntegration,
|
sonarr: SonarrIntegration,
|
||||||
radarr: RadarrIntegration,
|
radarr: RadarrIntegration,
|
||||||
sabNzbd: SabnzbdIntegration,
|
sabNzbd: SabnzbdIntegration,
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
// General integrations
|
// General integrations
|
||||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
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 { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
|
||||||
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
||||||
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
||||||
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
export { PlexIntegration } from "./plex/plex-integration";
|
||||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-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
|
// 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 type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
|
||||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
export type { MediaRequestList, MediaRequestStats } 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 { 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
|
// Schemas
|
||||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
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", {
|
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
|
||||||
icon: IconVideo,
|
icon: IconVideo,
|
||||||
options: {},
|
options: {},
|
||||||
supportedIntegrations: ["jellyfin"],
|
supportedIntegrations: ["jellyfin", "plex"],
|
||||||
}).withDynamicImport(() => import("./component"));
|
}).withDynamicImport(() => import("./component"));
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -1004,6 +1004,9 @@ importers:
|
|||||||
'@jellyfin/sdk':
|
'@jellyfin/sdk':
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0(axios@1.7.7)
|
version: 0.10.0(axios@1.7.7)
|
||||||
|
xml2js:
|
||||||
|
specifier: ^0.6.2
|
||||||
|
version: 0.6.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -1014,6 +1017,9 @@ importers:
|
|||||||
'@homarr/tsconfig':
|
'@homarr/tsconfig':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
|
'@types/xml2js':
|
||||||
|
specifier: ^0.4.14
|
||||||
|
version: 0.4.14
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.13.0
|
specifier: ^9.13.0
|
||||||
version: 9.13.0
|
version: 9.13.0
|
||||||
@@ -3676,6 +3682,9 @@ packages:
|
|||||||
'@types/ws@8.5.12':
|
'@types/ws@8.5.12':
|
||||||
resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.11.0':
|
||||||
resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==}
|
resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -6959,6 +6968,9 @@ packages:
|
|||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
sax@1.4.1:
|
||||||
|
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -8007,9 +8019,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
xml2js@0.6.2:
|
||||||
|
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
xml@1.0.1:
|
xml@1.0.1:
|
||||||
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
|
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:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
@@ -10040,6 +10060,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.16.15
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.11.1
|
'@eslint-community/regexpp': 4.11.1
|
||||||
@@ -13753,6 +13777,8 @@ snapshots:
|
|||||||
immutable: 4.3.7
|
immutable: 4.3.7
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
sax@1.4.1: {}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
@@ -14939,8 +14965,15 @@ snapshots:
|
|||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
|
xml2js@0.6.2:
|
||||||
|
dependencies:
|
||||||
|
sax: 1.4.1
|
||||||
|
xmlbuilder: 11.0.1
|
||||||
|
|
||||||
xml@1.0.1: {}
|
xml@1.0.1: {}
|
||||||
|
|
||||||
|
xmlbuilder@11.0.1: {}
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.0.0: {}
|
xmlhttprequest-ssl@2.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user