Allow adding plex backend via API.

This commit is contained in:
Abdulmhsen B. A. A
2024-04-20 20:18:47 +03:00
parent d573505dea
commit 8632e3044c
8 changed files with 312 additions and 27 deletions

View File

@@ -20,14 +20,24 @@ use App\Libs\Traits\APITraits;
use App\Libs\Uri; use App\Libs\Uri;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
use Random\RandomException;
final class Add final class Add
{ {
use APITraits; use APITraits;
/**
* @throws RandomException
*/
#[Post(Index::URL . '[/]', name: 'backends.add')] #[Post(Index::URL . '[/]', name: 'backends.add')]
public function BackendAdd(iRequest $request): iResponse public function BackendAdd(iRequest $request): iResponse
{ {
$requestData = $request->getParsedBody();
if (!is_array($requestData)) {
return api_error('Invalid request data.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = DataUtil::fromArray($request->getParsedBody()); $data = DataUtil::fromArray($request->getParsedBody());
if (null === ($type = $data->get('type'))) { if (null === ($type = $data->get('type'))) {
@@ -46,6 +56,18 @@ final class Add
]), HTTP_STATUS::HTTP_CONFLICT); ]), HTTP_STATUS::HTTP_CONFLICT);
} }
if (false === isValidName($name)) {
return api_error('Invalid name was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($url = $data->get('url'))) {
return api_error('No url was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === isValidUrl($url)) {
return api_error('Invalid url was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($class = Config::get("supported.{$type}", null))) { if (null === ($class = Config::get("supported.{$type}", null))) {
throw api_error(r("Unexpected client type '{type}' was given.", [ throw api_error(r("Unexpected client type '{type}' was given.", [
'type' => $type 'type' => $type
@@ -70,22 +92,29 @@ final class Add
); );
if (false === $instance->validateContext($context)) { if (false === $instance->validateContext($context)) {
return api_error('Invalid context information was given.', HTTP_STATUS::HTTP_BAD_REQUEST); return api_error('Context information validation failed.', HTTP_STATUS::HTTP_BAD_REQUEST);
} }
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml'); if (!$config->has('webhook.token')) {
$configFile->set($name, $config); $config = $config->with('webhook.token', bin2hex(random_bytes(Config::get('webhook.tokenLength'))));
$configFile->persist(); }
ConfigFile::open(Config::get('backends_file'), 'yaml')
->set($name, $config->getAll())
->persist();
} catch (InvalidContextException $e) { } catch (InvalidContextException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST); return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
} }
$response = [ $data = $this->getBackends(name: $name);
'backends' => [], $data = array_pop($data);
'links' => [],
];
return api_response(HTTP_STATUS::HTTP_OK, $response); return api_response(HTTP_STATUS::HTTP_CREATED, [
...$data,
'links' => [
'self' => parseConfigValue(Index::URL) . '/' . $name,
],
]);
} }
private function fromRequest(string $type, iRequest $request, ClientInterface|null $client = null): array private function fromRequest(string $type, iRequest $request, ClientInterface|null $client = null): array
@@ -123,7 +152,7 @@ final class Add
]; ];
foreach ($optionals as $key => $type) { foreach ($optionals as $key => $type) {
if (null !== ($value = $data->get($key))) { if (null !== ($value = $data->get('options.' . $key))) {
$val = $data->get($value, $type); $val = $data->get($value, $type);
settype($val, $type); settype($val, $type);
$config = ag_set($config, "options.{$key}", $val); $config = ag_set($config, "options.{$key}", $val);
@@ -131,7 +160,7 @@ final class Add
} }
if (null !== $client) { if (null !== $client) {
$config = ag_set($config, 'options', $client->fromRequest($request)); $config = $client->fromRequest($config, $request);
} }
return $config; return $config;

View File

@@ -202,12 +202,14 @@ interface ClientInterface
public function listLibraries(array $opts = []): array; public function listLibraries(array $opts = []): array;
/** /**
* Parse client specific options. * Parse client specific options from request.
* *
* @param array $config The already pre-filled config.
* @param ServerRequestInterface $request request to parse. * @param ServerRequestInterface $request request to parse.
* @return array parsed options. *
* @return array Return updated config.
*/ */
public function fromRequest(ServerRequestInterface $request): array; public function fromRequest(array $config, ServerRequestInterface $request): array;
/** /**
* Validate backend context. * Validate backend context.

View File

@@ -539,9 +539,9 @@ class EmbyClient implements iClient
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function fromRequest(ServerRequestInterface $request): array public function fromRequest(array $config, ServerRequestInterface $request): array
{ {
return []; return $config;
} }
/** /**

View File

@@ -572,9 +572,9 @@ class JellyfinClient implements iClient
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function fromRequest(ServerRequestInterface $request): array public function fromRequest(array $config, ServerRequestInterface $request): array
{ {
return []; return $config;
} }
/** /**

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Libs\Container;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use JsonException;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
final class GetUser
{
private int $maxRetry = 3;
private string $action = 'plex.getUser';
use CommonTrait;
public function __construct(protected iHttp $http, protected iLogger $logger)
{
}
/**
* Get Users list.
*
* @param Context $context
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, array $opts = []): Response
{
return $this->tryResponse(
context: $context,
fn: fn() => $this->getUser($context, $opts),
action: $this->action
);
}
/**
* Get User list.
*
* @throws ExceptionInterface
* @throws JsonException
*/
private function getUser(Context $context, array $opts = []): Response
{
$url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')
->withHost('clients.plex.tv')->withPath('/api/v2/user');
$response = $this->http->request('GET', (string)$url, [
'headers' => [
'Accept' => 'application/json',
'X-Plex-Token' => $context->backendToken,
'X-Plex-Client-Identifier' => $context->backendId,
],
]);
$this->logger->debug("Requesting '{backend}' user info.", [
'backend' => $context->backendName,
'url' => (string)$url,
]);
if (HTTP_STATUS::HTTP_OK->value !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: "Request for '{backend}' user info returned with unexpected '{status_code}' status code.",
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
],
level: Levels::ERROR
),
);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug("Parsing '{backend}' user info payload.", [
'backend' => $context->backendName,
'url' => (string)$url,
'trace' => $json,
]);
}
$name = '??';
$possibleName = ['friendlyName', 'username', 'title', 'email'];
foreach ($possibleName as $key) {
$val = ag($json, $key);
if (empty($val)) {
continue;
}
$name = $val;
break;
}
$data = [
'id' => ag($json, 'id'),
'uuid' => ag($json, 'uuid'),
'name' => $name,
'home' => (bool)ag($json, 'home'),
'guest' => (bool)ag($json, 'guest'),
'restricted' => (bool)ag($json, 'restricted'),
'joinedAt' => isset($json['joinedAt']) ? makeDate($json['joinedAt']) : 'Unknown',
];
if (true === (bool)ag($opts, 'tokens')) {
$data['token'] = ag($json, 'authToken');
}
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$data['raw'] = $json;
}
return new Response(status: true, response: $data);
}
}

View File

@@ -554,21 +554,23 @@ class PlexClient implements iClient
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function fromRequest(ServerRequestInterface $request): array public function fromRequest(array $config, ServerRequestInterface $request): array
{ {
$params = DataUtil::fromArray($request->getParsedBody()); $params = DataUtil::fromArray($request->getParsedBody());
$opts = []; if (null !== ($uuid = $params->get('options.' . Options::PLEX_USER_UUID))) {
$config = ag_set($config, 'options.' . Options::PLEX_USER_UUID, $uuid);
if (null !== ($uuid = $params->get('plex_user_uuid'))) {
$opts['plex_user_uuid'] = $uuid;
} }
if (null !== ($adminToken = $params->get(Options::ADMIN_TOKEN))) { if (null !== ($adminToken = $params->get('options.' . Options::ADMIN_TOKEN))) {
$opts[Options::ADMIN_TOKEN] = $adminToken; $config = ag_set($config, 'options.' . Options::ADMIN_TOKEN, $adminToken);
} }
return $opts; if (null !== ($userId = ag($config, 'user')) && !is_int($userId)) {
$config = ag_set($config, 'user', (int)$userId);
}
return $config;
} }
/** /**
@@ -576,7 +578,7 @@ class PlexClient implements iClient
*/ */
public function validateContext(Context $context): bool public function validateContext(Context $context): bool
{ {
return false; return Container::get(PlexValidateContext::class)($context);
} }
/** /**

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex;
use App\Backends\Common\Context;
use App\Backends\Plex\Action\GetUser;
use App\Libs\Container;
use App\Libs\Exceptions\Backends\InvalidContextException;
use App\Libs\HTTP_STATUS;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
final readonly class PlexValidateContext
{
public function __construct(private iHttp $http)
{
}
/**
* Validate backend context.
*
* @param Context $context Backend context.
* @throws InvalidContextException on failure.
*/
public function __invoke(Context $context): bool
{
$response = $this->validateUrl($context);
$data = ag(json_decode($response, true), 'MediaContainer', []);
$backendId = ag($data, 'machineIdentifier');
if (empty($backendId)) {
throw new InvalidContextException('Failed to get backend id.');
}
if ($backendId !== $context->backendId) {
throw new InvalidContextException(
r("Backend id mismatch. Expected '{expected}', server responded with '{actual}'.", [
'expected' => $context->backendId,
'actual' => $backendId,
])
);
}
$action = Container::get(GetUser::class)($context);
if ($action->hasError()) {
throw new InvalidContextException(r('Failed to get user info. {error}', [
'error' => $action->error->format()
]));
}
$userId = ag($action->response, 'id');
if (empty($userId)) {
throw new InvalidContextException('Failed to get user id.');
}
if ((string)$context->backendUser !== (string)$userId) {
throw new InvalidContextException(
r("User id mismatch. Expected '{expected}', server responded with '{actual}'.", [
'expected' => $context->backendUser,
'actual' => $userId,
])
);
}
return true;
}
/**
* Validate backend url.
*
* @param Context $context
*
* @return string
* @throws InvalidContextException
*/
private function validateUrl(Context $context): string
{
try {
$url = $context->backendUrl->withPath('/');
$request = $this->http->request('GET', (string)$url, [
'headers' => [
'Accept' => 'application/json',
'X-Plex-Token' => $context->backendToken,
],
]);
if (HTTP_STATUS::HTTP_UNAUTHORIZED->value === $request->getStatusCode()) {
throw new InvalidContextException('Backend responded with 401. Most likely means token is invalid.');
}
if (HTTP_STATUS::HTTP_NOT_FOUND->value === $request->getStatusCode()) {
throw new InvalidContextException('Backend responded with 404. Most likely means url is incorrect.');
}
return $request->getContent(true);
} catch (TransportExceptionInterface $e) {
throw new InvalidContextException(r('Failed to connect to backend. {error}', ['error' => $e->getMessage()]),
previous: $e);
} catch (ClientExceptionInterface $e) {
throw new InvalidContextException(r('Got non 200 response. {error}', ['error' => $e->getMessage()]),
previous: $e);
} catch (RedirectionExceptionInterface $e) {
throw new InvalidContextException(
r('Redirection recursion detected. {error}', ['error' => $e->getMessage()]),
previous: $e
);
} catch (ServerExceptionInterface $e) {
throw new InvalidContextException(
r('Backend responded with 5xx error. {error}', ['error' => $e->getMessage()]),
previous: $e
);
}
}
}

View File

@@ -26,6 +26,7 @@ final class Options
public const string STATE_UPDATE_EVENT = 'STATE_UPDATE_EVENT'; public const string STATE_UPDATE_EVENT = 'STATE_UPDATE_EVENT';
public const string DUMP_PAYLOAD = 'DUMP_PAYLOAD'; public const string DUMP_PAYLOAD = 'DUMP_PAYLOAD';
public const string ADMIN_TOKEN = 'ADMIN_TOKEN'; public const string ADMIN_TOKEN = 'ADMIN_TOKEN';
public const string PLEX_USER_UUID = 'plex_user_uuid';
public const string NO_THROW = 'NO_THROW'; public const string NO_THROW = 'NO_THROW';
public const string NO_LOGGING = 'NO_LOGGING'; public const string NO_LOGGING = 'NO_LOGGING';
public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE'; public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE';