diff --git a/server/src/modules/api/external-api/external-api.service.ts b/server/src/modules/api/external-api/external-api.service.ts index 8853e353..c7c487ab 100644 --- a/server/src/modules/api/external-api/external-api.service.ts +++ b/server/src/modules/api/external-api/external-api.service.ts @@ -25,7 +25,7 @@ export class ExternalApiService { this.axios = axios.create({ baseURL: baseUrl, params, - timeout: 5000, // timeout after 5s + timeout: 10000, // timeout after 10s headers: { 'Content-Type': 'application/json', Accept: 'application/json', diff --git a/server/src/modules/api/lib/plexApi.ts b/server/src/modules/api/lib/plexApi.ts index dc07d5ea..533656d8 100644 --- a/server/src/modules/api/lib/plexApi.ts +++ b/server/src/modules/api/lib/plexApi.ts @@ -155,6 +155,18 @@ class PlexApi extends NodePlexAPI { } return obj; } + + public async getStatus(): Promise { + try { + const status: { MediaContainer: any } = await this.query( + { uri: `/` }, + false, + ); + return status?.MediaContainer ? true : false; + } catch (err) { + return false; + } + } } export default PlexApi; diff --git a/server/src/modules/api/lib/plextvApi.ts b/server/src/modules/api/lib/plextvApi.ts index 8be2bf23..1f122ecc 100644 --- a/server/src/modules/api/lib/plextvApi.ts +++ b/server/src/modules/api/lib/plextvApi.ts @@ -2,6 +2,8 @@ import { Logger } from '@nestjs/common'; import { ExternalApiService } from '../external-api/external-api.service'; import cacheManager from './cache'; import { parseStringPromise } from 'xml2js'; +import { PlexDevice } from '../../api/plex-api/interfaces/server.interface'; +import xml2js from 'xml2js'; interface PlexAccountResponse { user: PlexUser; @@ -245,6 +247,59 @@ export class PlexTvApi extends ExternalApiService { }; } } + + public async getDevices(): Promise { + try { + const devicesResp = await this.get('/api/resources?includeHttps=1', { + transformResponse: [], + responseType: 'text', + }); + const parsedXml = await xml2js.parseStringPromise( + devicesResp as DeviceResponse, + ); + return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ + name: pxml.$.name, + product: pxml.$.product, + productVersion: pxml.$.productVersion, + platform: pxml.$?.platform, + platformVersion: pxml.$?.platformVersion, + device: pxml.$?.device, + clientIdentifier: pxml.$.clientIdentifier, + createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000), + lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000), + provides: pxml.$.provides.split(','), + owned: pxml.$.owned == '1' ? true : false, + accessToken: pxml.$?.accessToken, + publicAddress: pxml.$?.publicAddress, + publicAddressMatches: + pxml.$?.publicAddressMatches == '1' ? true : false, + httpsRequired: pxml.$?.httpsRequired == '1' ? true : false, + synced: pxml.$?.synced == '1' ? true : false, + relay: pxml.$?.relay == '1' ? true : false, + dnsRebindingProtection: + pxml.$?.dnsRebindingProtection == '1' ? true : false, + natLoopbackSupported: + pxml.$?.natLoopbackSupported == '1' ? true : false, + presence: pxml.$?.presence == '1' ? true : false, + ownerID: pxml.$?.ownerID, + home: pxml.$?.home == '1' ? true : false, + sourceTitle: pxml.$?.sourceTitle, + connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({ + protocol: conn.$.protocol, + address: conn.$.address, + port: parseInt(conn.$.port, 10), + uri: conn.$.uri, + local: conn.$.local == '1' ? true : false, + })), + })); + } catch (e) { + this.logger.error( + 'Something went wrong getting the devices from plex.tv', + ); + this.logger.debug(e); + return []; + } + } } export default PlexTvApi; diff --git a/server/src/modules/api/plex-api/interfaces/server.interface.ts b/server/src/modules/api/plex-api/interfaces/server.interface.ts index 5ef82ff6..48b59303 100644 --- a/server/src/modules/api/plex-api/interfaces/server.interface.ts +++ b/server/src/modules/api/plex-api/interfaces/server.interface.ts @@ -10,3 +10,40 @@ export interface PlexStatusResponse { export interface PlexAccountsResponse { MediaContainer: { Account: PlexUserAccount[] }; } + +export interface PlexDevice { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: Date; + lastSeenAt: Date; + provides: string[]; + owned: boolean; + accessToken?: string; + publicAddress?: string; + httpsRequired?: boolean; + synced?: boolean; + relay?: boolean; + dnsRebindingProtection?: boolean; + natLoopbackSupported?: boolean; + publicAddressMatches?: boolean; + presence?: boolean; + ownerID?: string; + home?: boolean; + sourceTitle?: string; + connection: PlexConnection[]; +} + +export interface PlexConnection { + protocol: string; + address: string; + port: number; + uri: string; + local: boolean; + status?: number; + message?: string; +} diff --git a/server/src/modules/api/plex-api/plex-api.service.ts b/server/src/modules/api/plex-api/plex-api.service.ts index 52826825..30b41503 100644 --- a/server/src/modules/api/plex-api/plex-api.service.ts +++ b/server/src/modules/api/plex-api/plex-api.service.ts @@ -24,6 +24,7 @@ import { } from './interfaces/media.interface'; import { PlexAccountsResponse, + PlexDevice, PlexStatusResponse, } from './interfaces/server.interface'; import { EPlexDataType } from './enums/plex-data-type-enum'; @@ -31,6 +32,7 @@ import axios from 'axios'; import PlexApi from '../lib/plexApi'; import PlexTvApi from '../lib/plextvApi'; import cacheManager from '../../api/lib/cache'; +import { Settings } from '../../settings/entities/settings.entities'; @Injectable() export class PlexApiService { @@ -45,6 +47,13 @@ export class PlexApiService { this.initialize({}); } + maintainerrClientOptions = { + identifier: '695b47f5-3c61-4cbd-8eb3-bcc3d6d06ac5', + product: 'Maintainerr', + deviceName: 'Maintainerr', + platform: 'Maintainerr', + }; + private async getDbSettings(): Promise { return { name: this.settings.plex_name, @@ -89,12 +98,7 @@ export class PlexApiService { // requestOptions: { // includeChildren: 1, // }, - options: { - identifier: '695b47f5-3c61-4cbd-8eb3-bcc3d6d06ac5', // this.settings.clientId - product: 'Maintainerr', // this.settings.applicationTitle - deviceName: 'Maintainerr', // this.settings.applicationTitle - platform: 'Maintainerr', // this.settings.applicationTitle - }, + options: this.maintainerrClientOptions, }); this.plexTvClient = new PlexTvApi(plexToken); @@ -604,6 +608,76 @@ export class PlexApiService { } } + public async getAvailableServers(): Promise { + try { + // reload requirements, auth token might have changed + const settings = (await this.settings.getSettings()) as Settings; + this.plexTvClient = new PlexTvApi(settings.plex_auth_token); + + const devices = (await this.plexTvClient?.getDevices())?.filter( + (device) => { + return device.provides.includes('server') && device.owned; + }, + ); + + if (devices) { + await Promise.all( + devices.map(async (device) => { + device.connection.map((connection) => { + const url = new URL(connection.uri); + if (url.hostname !== connection.address) { + const plexDirectConnection = { + ...connection, + address: url.hostname, + }; + device.connection.push(plexDirectConnection); + connection.protocol = 'http'; + } + }); + + const filteredConnectionPromises = device.connection.map( + async (connection) => { + const newClient = new PlexApi({ + hostname: connection.address, + port: connection.port, + https: connection.protocol === 'https', + timeout: 5000, + token: settings.plex_auth_token, + authenticator: { + authenticate: ( + _plexApi, + cb: (err?: string, token?: string) => void, + ) => { + if (!settings.plex_auth_token) { + return cb('Plex Token not found!'); + } + cb(undefined, settings.plex_auth_token); + }, + }, + options: this.maintainerrClientOptions, + }); + + // test connection + return (await newClient.getStatus()) ? connection : null; + }, + ); + + device.connection = ( + await Promise.all(filteredConnectionPromises) + ).filter(Boolean); + }), + ); + } + return devices; + } catch (e) { + this.logger.warn( + 'Plex api communication failure.. Is the application running?', + ); + this.logger.debug(e); + return []; + } + } + public async getAllIdsForContextAction( collectionType: EPlexDataType, context: { type: EPlexDataType; id: number }, diff --git a/server/src/modules/settings/settings.controller.ts b/server/src/modules/settings/settings.controller.ts index 02e4206c..72f0937b 100644 --- a/server/src/modules/settings/settings.controller.ts +++ b/server/src/modules/settings/settings.controller.ts @@ -29,6 +29,10 @@ export class SettingsController { updateSettings(@Body() payload: SettingDto) { return this.settingsService.updateSettings(payload); } + @Post('/plex/token') + updateAuthToken(@Body() payload: { plex_auth_token: string }) { + return this.settingsService.savePlexApiAuthToken(payload.plex_auth_token); + } @Get('/test/setup') testSetup() { return this.settingsService.testSetup(); @@ -50,6 +54,11 @@ export class SettingsController { return this.settingsService.testPlex(); } + @Get('/plex/devices/servers') + async getPlexServers() { + return await this.settingsService.getPlexServers(); + } + @Post('/cron/validate') validateSingleCron(@Body() payload: CronScheduleDto) { return this.settingsService.cronIsValid(payload.schedule) diff --git a/server/src/modules/settings/settings.service.ts b/server/src/modules/settings/settings.service.ts index 22cb7f02..927fab26 100644 --- a/server/src/modules/settings/settings.service.ts +++ b/server/src/modules/settings/settings.service.ts @@ -125,6 +125,22 @@ export class SettingsService implements SettingDto { } } + public async savePlexApiAuthToken(plex_auth_token: string) { + try { + const settingsDb = await this.settingsRepo.findOne({ where: {} }); + + await this.settingsRepo.save({ + ...settingsDb, + plex_auth_token: plex_auth_token, + }); + + return { status: 'OK', code: 1, message: 'Success' }; + } catch (e) { + this.logger.error('Error while updating Plex auth token: ', e); + return { status: 'NOK', code: 0, message: 'Failed' }; + } + } + public async updateSettings(settings: Settings): Promise { try { settings.plex_hostname = settings.plex_hostname?.toLowerCase(); @@ -136,12 +152,13 @@ export class SettingsService implements SettingDto { // Plex SSL specifics settings.plex_ssl = - settings.plex_hostname.includes('https://') || settings.plex_port == 443 + settings.plex_hostname?.includes('https://') || + settings.plex_port == 443 ? 1 : 0; settings.plex_hostname = settings.plex_hostname - .replace('https://', '') - .replace('http://', ''); + ?.replace('https://', '') + ?.replace('http://', ''); if ( this.cronIsValid(settings.collection_handler_job_cron) && @@ -331,4 +348,8 @@ export class SettingsService implements SettingDto { } return false; } + + public async getPlexServers() { + return await this.plexApi.getAvailableServers(); + } } diff --git a/ui/src/components/Settings/Plex/index.tsx b/ui/src/components/Settings/Plex/index.tsx index 18101736..42926d6f 100644 --- a/ui/src/components/Settings/Plex/index.tsx +++ b/ui/src/components/Settings/Plex/index.tsx @@ -1,13 +1,68 @@ import { SaveIcon } from '@heroicons/react/solid' -import React, { useContext, useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' import SettingsContext from '../../../contexts/settings-context' -import { DeleteApiHandler, PostApiHandler } from '../../../utils/ApiHandler' +import GetApiHandler, { + DeleteApiHandler, + PostApiHandler, +} from '../../../utils/ApiHandler' import Alert from '../../Common/Alert' import Button from '../../Common/Button' import PlexLoginButton from '../../Login/Plex' import axios from 'axios' import TestButton from '../../Common/TestButton' import DocsButton from '../../Common/DocsButton' +import { orderBy } from 'lodash' +import { RefreshIcon } from '@heroicons/react/outline' +import { useToasts } from 'react-toast-notifications' + +interface PresetServerDisplay { + name: string + ssl: boolean + uri: string + address: string + port: number + local: boolean + status?: boolean + message?: string +} + +interface PlexConnection { + protocol: string + ssl: boolean + uri: string + address: string + port: number + local: boolean + status: number + message: string +} + +export interface PlexDevice { + name: string + product: string + productVersion: string + platform: string + platformVersion: string + device: string + clientIdentifier: string + createdAt: Date + lastSeenAt: Date + provides: string[] + owned: boolean + accessToken?: string + publicAddress?: string + httpsRequired?: boolean + synced?: boolean + relay?: boolean + dnsRebindingProtection?: boolean + natLoopbackSupported?: boolean + publicAddressMatches?: boolean + presence?: boolean + ownerID?: string + home?: boolean + sourceTitle?: string + connection: PlexConnection[] +} const PlexSettings = () => { const settingsCtx = useContext(SettingsContext) @@ -15,6 +70,7 @@ const PlexSettings = () => { const nameRef = useRef(null) const portRef = useRef(null) const sslRef = useRef(null) + const serverPresetRef = useRef(null) const [error, setError] = useState() const [changed, setChanged] = useState() const [tokenValid, setTokenValid] = useState(false) @@ -23,11 +79,15 @@ const PlexSettings = () => { status: Boolean version: string }>({ status: false, version: '0' }) + const [availableServers, setAvailableServers] = useState() + const [isRefreshingPresets, setIsRefreshingPresets] = useState(false) useEffect(() => { document.title = 'Maintainerr - Settings - Plex' }, []) + const { addToast, removeToast } = useToasts() + const submit = async ( e: React.FormEvent | undefined, plex_token?: { plex_auth_token: string } | undefined, @@ -79,9 +139,44 @@ const PlexSettings = () => { } } + const submitPlexToken = async ( + plex_token?: { plex_auth_token: string } | undefined, + ) => { + if (plex_token) { + const resp: { code: 0 | 1; message: string } = await PostApiHandler( + '/settings/plex/token', + { + plex_auth_token: plex_token.plex_auth_token, + }, + ) + if (resp.code === 1) { + settingsCtx.settings.plex_auth_token = plex_token.plex_auth_token + } + } + } + + const availablePresets = useMemo(() => { + const finalPresets: PresetServerDisplay[] = [] + availableServers?.forEach((dev) => { + dev.connection.forEach((conn) => + finalPresets.push({ + name: dev.name, + ssl: conn.protocol === 'https', + uri: conn.uri, + address: conn.address, + port: conn.port, + local: conn.local, + status: conn.status === 200, + message: conn.message, + }), + ) + }) + return orderBy(finalPresets, ['status', 'ssl'], ['desc', 'desc']) + }, [availableServers]) + const authsuccess = (token: string) => { verifyToken(token) - submit(undefined, { plex_auth_token: token }) + submitPlexToken({ plex_auth_token: token }) } const authFailed = () => { @@ -134,6 +229,55 @@ const PlexSettings = () => { setTestbanner({ status: result.status, version: result.version }) } + function setFieldValue( + ref: React.MutableRefObject, + value: string, + ) { + if (ref.current) { + ref.current.value = value.toString() + } + } + + const refreshPresetServers = async () => { + setIsRefreshingPresets(true) + let toastId: string | undefined + try { + addToast( + 'Retrieving server list from Plex…', + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id + }, + ) + const response: PlexDevice[] = await GetApiHandler( + '/settings/plex/devices/servers', + ) + if (response) { + setAvailableServers(response) + } + if (toastId) { + removeToast(toastId) + } + addToast('Plex server list retrieved successfully!', { + autoDismiss: true, + appearance: 'success', + }) + } catch (e) { + if (toastId) { + removeToast(toastId) + } + addToast('Failed to retrieve Plex server list.', { + autoDismiss: true, + appearance: 'error', + }) + } finally { + setIsRefreshingPresets(false) + } + } + return (
@@ -172,6 +316,70 @@ const PlexSettings = () => {
+ {/* Load preset server list */} +
+ +
+
+ + +
+
+
+ {/* Name */}