Files
watchstate/src/Backends/Plex/PlexClient.php
2024-01-17 17:04:52 +03:00

683 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Backends\Plex;
use App\Backends\Common\Cache;
use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Plex\Action\Backup;
use App\Backends\Plex\Action\Export;
use App\Backends\Plex\Action\GetIdentifier;
use App\Backends\Plex\Action\GetInfo;
use App\Backends\Plex\Action\GetLibrariesList;
use App\Backends\Plex\Action\GetLibrary;
use App\Backends\Plex\Action\GetMetaData;
use App\Backends\Plex\Action\GetSessions;
use App\Backends\Plex\Action\GetUsersList;
use App\Backends\Plex\Action\GetUserToken;
use App\Backends\Plex\Action\GetVersion;
use App\Backends\Plex\Action\Import;
use App\Backends\Plex\Action\InspectRequest;
use App\Backends\Plex\Action\ParseWebhook;
use App\Backends\Plex\Action\Progress;
use App\Backends\Plex\Action\Push;
use App\Backends\Plex\Action\SearchId;
use App\Backends\Plex\Action\SearchQuery;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\Backends\RuntimeException;
use App\Libs\Exceptions\HttpException;
use App\Libs\Mappers\ImportInterface as iImport;
use App\Libs\Options;
use App\Libs\QueueRequests;
use App\Libs\Uri;
use DateTimeInterface as iDate;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface as iLogger;
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;
/**
* Class PlexClient
*
* This class is responsible for facilitating communication with Plex Server backend.
*
* @implements iClient
*/
class PlexClient implements iClient
{
public const NAME = 'PlexBackend';
public const CLIENT_NAME = 'Plex';
public const TYPE_SHOW = 'show';
public const TYPE_MOVIE = 'movie';
public const TYPE_EPISODE = 'episode';
/**
* @var array Map plex types to iState types.
*/
public const TYPE_MAPPER = [
PlexClient::TYPE_SHOW => iState::TYPE_SHOW,
PlexClient::TYPE_MOVIE => iState::TYPE_MOVIE,
PlexClient::TYPE_EPISODE => iState::TYPE_EPISODE,
];
/**
* @var array List of supported agents.
*/
public const SUPPORTED_AGENTS = [
'com.plexapp.agents.imdb',
'com.plexapp.agents.tmdb',
'com.plexapp.agents.themoviedb',
'com.plexapp.agents.xbmcnfo',
'com.plexapp.agents.xbmcnfotv',
'com.plexapp.agents.thetvdb',
'com.plexapp.agents.hama',
'com.plexapp.agents.ytinforeader',
'com.plexapp.agents.cmdb',
'tv.plex.agents.movie',
'tv.plex.agents.series',
];
/**
* @var mixed $context Backend context.
*/
private Context $context;
/**
* @var iLogger The logger object.
*/
private iLogger $logger;
/**
* @var iGuid GUID parser.
*/
private iGuid $guid;
/**
* @var Cache The Cache store.
*/
private Cache $cache;
/**
* Class constructor.
*
* @param iLogger $logger The logger instance.
* @param Cache $cache The cache instance.
* @param PlexGuid $guid The PlexGuid instance.
*/
public function __construct(iLogger $logger, Cache $cache, PlexGuid $guid)
{
$this->cache = $cache;
$this->logger = $logger;
$this->context = new Context(
clientName: static::CLIENT_NAME,
backendName: static::CLIENT_NAME,
backendUrl: new Uri('http://localhost'),
cache: $this->cache,
);
$this->guid = $guid->withContext($this->context);
}
/**
* @inheritdoc
*/
public function withContext(Context $context): self
{
$cloned = clone $this;
$cloned->context = new Context(
clientName: static::CLIENT_NAME,
backendName: $context->backendName,
backendUrl: $context->backendUrl,
cache: $this->cache->withData(static::CLIENT_NAME . '_' . $context->backendName, $context->options),
backendId: $context->backendId,
backendToken: $context->backendToken,
backendUser: $context->backendUser,
backendHeaders: array_replace_recursive([
'headers' => [
'Accept' => 'application/json',
'X-Plex-Token' => $context->backendToken,
'X-Plex-Container-Size' => 0,
],
], ag($context->options, 'client', [])),
trace: true === ag($context->options, Options::DEBUG_TRACE),
options: array_replace_recursive($context->options, [
Options::LIBRARY_SEGMENT => (int)ag(
$context->options,
Options::LIBRARY_SEGMENT,
Config::get('library.segment')
),
])
);
$cloned->guid = $cloned->guid->withContext($cloned->context);
return $cloned;
}
/**
* @inheritdoc
*/
public function getContext(): Context
{
return $this->context;
}
/**
* @inheritdoc
*/
public function getName(): string
{
return $this->context?->backendName ?? static::CLIENT_NAME;
}
/**
* @inheritdoc
*/
public function setLogger(iLogger $logger): self
{
$this->logger = $logger;
return $this;
}
/**
* @inheritdoc
*/
public function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface
{
$response = Container::get(InspectRequest::class)(context: $this->context, request: $request);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
return $response->isSuccessful() ? $response->response : $request;
}
/**
* @inheritdoc
*/
public function parseWebhook(ServerRequestInterface $request): iState
{
$response = Container::get(ParseWebhook::class)(
context: $this->context,
guid: $this->guid,
request: $request
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response, HttpException::class, ag($response->extra, 'http_code', 400));
}
return $response->response;
}
/**
* @inheritdoc
*/
public function pull(iImport $mapper, iDate|null $after = null): array
{
$response = Container::get(Import::class)(
context: $this->context,
guid: $this->guid,
mapper: $mapper,
after: $after,
opts: [
Options::DISABLE_GUID => (bool)Config::get('episodes.disable.guid'),
]
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function backup(iImport $mapper, StreamInterface|null $writer = null, array $opts = []): array
{
$response = Container::get(Backup::class)(
context: $this->context,
guid: $this->guid,
mapper: $mapper,
opts: $opts + [
'writer' => $writer,
Options::DISABLE_GUID => (bool)Config::get('episodes.disable.guid'),
]
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function export(iImport $mapper, QueueRequests $queue, iDate|null $after = null): array
{
$response = Container::get(Export::class)(
context: $this->context,
guid: $this->guid,
mapper: $mapper,
after: $after,
opts: [
'queue' => $queue,
Options::DISABLE_GUID => (bool)Config::get('episodes.disable.guid'),
],
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function push(array $entities, QueueRequests $queue, iDate|null $after = null): array
{
$response = Container::get(Push::class)(
context: $this->context,
entities: $entities,
queue: $queue,
after: $after
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return [];
}
/**
* @inheritdoc
*/
public function progress(array $entities, QueueRequests $queue, iDate|null $after = null): array
{
$response = Container::get(Progress::class)(
context: $this->context,
guid: $this->guid,
entities: $entities,
queue: $queue,
after: $after
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return [];
}
/**
* @inheritdoc
*/
public function search(string $query, int $limit = 25, array $opts = []): array
{
$response = Container::get(SearchQuery::class)(
context: $this->context,
query: $query,
limit: $limit,
opts: $opts
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function searchId(string|int $id, array $opts = []): array
{
$response = Container::get(SearchId::class)(context: $this->context, id: $id, opts: $opts);
if ($response->hasError() && false === (bool)ag($opts, Options::NO_LOGGING, false)) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getMetadata(string|int $id, array $opts = []): array
{
$response = Container::get(GetMetaData::class)(context: $this->context, id: $id, opts: $opts);
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getLibrary(string|int $id, array $opts = []): array
{
$response = Container::get(GetLibrary::class)(context: $this->context, guid: $this->guid, id: $id, opts: $opts);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getIdentifier(bool $forceRefresh = false): int|string|null
{
if (false === $forceRefresh && null !== $this->context->backendId) {
return $this->context->backendId;
}
$response = Container::get(GetIdentifier::class)(context: $this->context);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
return $response->isSuccessful() ? $response->response : null;
}
/**
* @inheritdoc
*/
public function getUsersList(array $opts = []): array
{
$response = Container::get(GetUsersList::class)($this->context, $opts);
if (false === $response->isSuccessful()) {
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getSessions(array $opts = []): array
{
$response = Container::get(GetSessions::class)($this->context, $opts);
if (false === $response->isSuccessful()) {
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getUserToken(int|string $userId, string $username): string|bool
{
$response = Container::get(GetUserToken::class)($this->context, $userId, $username);
if (false === $response->isSuccessful()) {
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function listLibraries(array $opts = []): array
{
$response = Container::get(GetLibrariesList::class)(context: $this->context, opts: $opts);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getInfo(array $opts = []): array
{
$response = Container::get(GetInfo::class)(context: $this->context, opts: $opts);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public function getVersion(array $opts = []): string
{
$response = Container::get(GetVersion::class)(context: $this->context, opts: $opts);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/
public static function manage(array $backend, array $opts = []): array
{
return Container::get(PlexManage::class)->manage(backend: $backend, opts: $opts);
}
/**
* Retrieves a list of Plex servers using the Plex.tv API.
*
* @param HttpClientInterface $http The HTTP client used to send the request.
* @param string $token The Plex authentication token.
* @param array $opts (Optional) options.
*
* @return array The list of Plex servers.
*
* @throws RuntimeException When an unexpected status code is returned or a network-related exception occurs.
* @throws ClientExceptionInterface When a client error is encountered.
* @throws RedirectionExceptionInterface When a redirection error is encountered.
* @throws ServerExceptionInterface When a server error is encountered.
*/
public static function discover(HttpClientInterface $http, string $token, array $opts = []): array
{
try {
$response = $http->request('GET', 'https://plex.tv/api/resources?includeHttps=1&includeRelay=0', [
'headers' => [
'X-Plex-Token' => $token,
],
]);
$payload = $response->getContent(false);
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
r(
text: 'PlexClient: Request for servers list returned with unexpected [{status_code}] status code. {context}',
context: [
'status_code' => $response->getStatusCode(),
'context' => arrayToString(['payload' => $payload]),
]
)
);
}
} catch (TransportExceptionInterface $e) {
throw new RuntimeException(
r(
text: 'PlexClient: Exception [{kind}] was thrown unhandled during request for plex servers list, likely network related error. [{error} @ {file}:{line}]',
context: [
'kind' => $e::class,
'error' => $e->getMessage(),
'line' => $e->getLine(),
'file' => after($e->getFile(), ROOT_PATH),
]
)
);
}
$xml = simplexml_load_string($payload);
$list = [];
if (false === $xml->Device) {
throw new RuntimeException('PlexClient: No backends were associated with the given token.');
}
foreach ($xml->Device as $device) {
if (null === ($attr = $device->attributes())) {
continue;
}
$attr = ag((array)$attr, '@attributes');
if ('server' !== ag($attr, 'provides')) {
continue;
}
if (!property_exists($device, 'Connection') || false === $device->Connection) {
continue;
}
foreach ($device->Connection as $uri) {
if (null === ($cAttr = $uri->attributes())) {
continue;
}
$cAttr = ag((array)$cAttr, '@attributes');
$arr = [
'name' => ag($attr, 'name'),
'identifier' => ag($attr, 'clientIdentifier'),
'proto' => ag($cAttr, 'protocol'),
'address' => ag($cAttr, 'address'),
'port' => (int)ag($cAttr, 'port'),
'uri' => ag($cAttr, 'uri'),
'online' => 1 === (int)ag($attr, 'presence') ? 'Yes' : 'No',
];
if (true === ag_exists($opts, 'with-tokens')) {
$arr['AccessToken'] = ag($attr, 'accessToken');
}
$list['list'][] = $arr;
}
}
if (true === ag_exists($opts, Options::RAW_RESPONSE)) {
$list[Options::RAW_RESPONSE] = $xml;
}
return $list;
}
/**
* Throws an exception with the specified message and previous exception.
*
* @template T
* @param Response $response The response object containing the error details.
* @param class-string<T> $className The exception class name.
* @param int $code The exception code.
*/
private function throwError(Response $response, string $className = RuntimeException::class, int $code = 0): void
{
throw new $className(
message: ag($response->extra, 'message', fn() => $response->error->format()),
code: $code,
previous: $response->error->previous
);
}
}