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 $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 ); } }