From b1075e2efc2b3ba1eb017562eed3c19c8ac50bc7 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sat, 18 Jun 2022 14:35:16 +0300 Subject: [PATCH 1/4] Migrated backends push method into separate action. --- src/Backends/Emby/Action/Push.php | 9 + src/Backends/Jellyfin/Action/GetMetaData.php | 2 +- src/Backends/Jellyfin/Action/Push.php | 300 ++++++++++++++++++ src/Backends/Plex/Action/Push.php | 310 +++++++++++++++++++ src/Commands/State/PushCommand.php | 4 + src/Libs/Servers/JellyfinServer.php | 261 +--------------- src/Libs/Servers/PlexServer.php | 256 +-------------- 7 files changed, 646 insertions(+), 496 deletions(-) create mode 100644 src/Backends/Emby/Action/Push.php create mode 100644 src/Backends/Jellyfin/Action/Push.php create mode 100644 src/Backends/Plex/Action/Push.php diff --git a/src/Backends/Emby/Action/Push.php b/src/Backends/Emby/Action/Push.php new file mode 100644 index 00000000..09d7cc5d --- /dev/null +++ b/src/Backends/Emby/Action/Push.php @@ -0,0 +1,9 @@ +backendUrl - ->withPath(sprintf('/Users/%s/items/' . $id, $context->backendUser)) + ->withPath(sprintf('/Users/%s/items/%s', $context->backendUser, $id)) ->withQuery( http_build_query( array_merge_recursive( diff --git a/src/Backends/Jellyfin/Action/Push.php b/src/Backends/Jellyfin/Action/Push.php new file mode 100644 index 00000000..db26f095 --- /dev/null +++ b/src/Backends/Jellyfin/Action/Push.php @@ -0,0 +1,300 @@ + $entities + * @param QueueRequests $queue + * @param DateTimeInterface|null $after + * @return Response + */ + public function __invoke( + Context $context, + array $entities, + QueueRequests $queue, + DateTimeInterface|null $after = null + ): Response { + return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $entities, $queue, $after)); + } + + private function action( + Context $context, + array $entities, + QueueRequests $queue, + DateTimeInterface|null $after = null + ): Response { + $requests = []; + + foreach ($entities as $key => $entity) { + if (true !== ($entity instanceof iFace)) { + continue; + } + + if (null !== $after && false === (bool)ag($context->options, Options::IGNORE_DATE, false)) { + if ($after->getTimestamp() > $entity->updated) { + continue; + } + } + + $metadata = $entity->getMetadata($context->backendName); + + $logContext = [ + 'item' => [ + 'id' => $entity->id, + 'type' => $entity->type, + 'title' => $entity->getName(), + ], + ]; + + if (null === ag($metadata, iFace::COLUMN_ID, null)) { + $this->logger->warning( + 'Ignoring [%(item.title)] for [%(backend)]. No metadata was found.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + $logContext['remote']['id'] = ag($metadata, iFace::COLUMN_ID); + + try { + $url = $context->backendUrl->withPath( + sprintf('/Users/%s/items/%s', $context->backendUser, ag($metadata, iFace::COLUMN_ID)) + )->withQuery( + http_build_query( + [ + 'fields' => implode(',', JellyfinClient::EXTRA_FIELDS), + 'enableUserData' => 'true', + 'enableImages' => 'false', + ] + ) + ); + + $logContext['remote']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] metadata.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + $requests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, [ + 'user_data' => [ + 'id' => $key, + 'context' => $logContext, + ] + ]) + ); + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } + + $logContext = null; + + foreach ($requests as $response) { + $logContext = ag($response->getInfo('user_data'), 'context', []); + + try { + if (null === ($id = ag($response->getInfo('user_data'), 'id'))) { + $this->logger->error('Unable to get entity object id.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + continue; + } + + $entity = $entities[$id]; + + assert($entity instanceof iFace); + + if (200 !== $response->getStatusCode()) { + if (404 === $response->getStatusCode()) { + $this->logger->warning( + 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with (Not Found) status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext + ] + ); + } else { + $this->logger->error( + 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext + ] + ); + } + + continue; + } + + $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)] %(item.type) [%(item.title)] payload.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'trace' => $json, + ] + ); + } + + $isWatched = (int)(bool)ag($json, 'UserData.Played', false); + + if ($entity->watched === $isWatched) { + $this->logger->info( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + if (false === (bool)ag($context->options, Options::IGNORE_DATE, false)) { + $dateKey = 1 === $isWatched ? 'UserData.LastPlayedDate' : 'DateCreated'; + $date = ag($json, $dateKey); + + if (null === $date) { + $this->logger->error( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.', + [ + 'backend' => $context->backendName, + 'date_key' => $dateKey, + ...$logContext, + 'response' => [ + 'body' => $json, + ], + ] + ); + continue; + } + + $date = makeDate($date); + + $timeExtra = (int)(ag($context->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10)); + + if ($date->getTimestamp() >= ($timeExtra + $entity->updated)) { + $this->logger->notice( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'storage' => makeDate($entity->updated), + 'backend' => $date, + 'difference' => $date->getTimestamp() - $entity->updated, + 'extra_margin' => [ + Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra, + ], + ], + ] + ); + continue; + } + } + + $url = $context->backendUrl->withPath( + sprintf('/Users/%s/PlayedItems/%s', $context->backendUser, ag($json, 'Id')) + ); + + $logContext['remote']['url'] = $url; + + $this->logger->debug( + 'Queuing request to change [%(backend)] %(item.type) [%(item.title)] play state to [%(play_state)].', + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ...$logContext, + ] + ); + + if (false === (bool)ag($context->options, Options::DRY_RUN, false)) { + $queue->add( + $this->http->request( + $entity->isWatched() ? 'POST' : 'DELETE', + (string)$url, + array_replace_recursive($context->backendHeaders, [ + 'user_data' => [ + 'context' => $logContext + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ], + ], + ]) + ) + ); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] %(item.type) [%(item.title)].', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } + + return new Response(status: true, response: $queue); + } +} diff --git a/src/Backends/Plex/Action/Push.php b/src/Backends/Plex/Action/Push.php new file mode 100644 index 00000000..a2859bc2 --- /dev/null +++ b/src/Backends/Plex/Action/Push.php @@ -0,0 +1,310 @@ + $entities + * @param QueueRequests $queue + * @param DateTimeInterface|null $after + * @return Response + */ + public function __invoke( + Context $context, + array $entities, + QueueRequests $queue, + DateTimeInterface|null $after = null + ): Response { + return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $entities, $queue, $after)); + } + + private function action( + Context $context, + array $entities, + QueueRequests $queue, + DateTimeInterface|null $after = null + ): Response { + $requests = []; + + foreach ($entities as $key => $entity) { + if (true !== ($entity instanceof iState)) { + continue; + } + + if (null !== $after && false === (bool)ag($context->options, Options::IGNORE_DATE, false)) { + if ($after->getTimestamp() > $entity->updated) { + continue; + } + } + + $metadata = $entity->getMetadata($context->backendName); + + $logContext = [ + 'item' => [ + 'id' => $entity->id, + 'type' => $entity->type, + 'title' => $entity->getName(), + ], + ]; + + if (null === ag($metadata, iState::COLUMN_ID)) { + $this->logger->warning( + 'Ignoring [%(item.title)] for [%(backend)]. No metadata was found.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + $logContext['remote']['id'] = ag($metadata, iState::COLUMN_ID); + + try { + $url = $context->backendUrl->withPath('/library/metadata/' . ag($metadata, iState::COLUMN_ID)); + + $logContext['remote']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] metadata.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + $requests[] = $this->http->request( + 'GET', + (string)$url, + $context->backendHeaders + [ + 'user_data' => [ + 'id' => $key, + 'context' => $logContext, + ] + ] + ); + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => after($e->getFile(), ROOT_PATH), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } + + $logContext = null; + + foreach ($requests as $response) { + $logContext = ag($response->getInfo('user_data'), 'context', []); + + try { + if (null === ($id = ag($response->getInfo('user_data'), 'id'))) { + $this->logger->error('Unable to get entity object id.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + continue; + } + + $entity = $entities[$id]; + + assert($entity instanceof iState); + + if (200 !== $response->getStatusCode()) { + if (404 === $response->getStatusCode()) { + $this->logger->warning( + 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with (Not Found) status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext + ] + ); + } else { + $this->logger->error( + 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext + ] + ); + } + + continue; + } + + $body = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if ($context->trace) { + $this->logger->debug( + 'Parsing [%(backend)] %(item.type) [%(item.title)] payload.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'trace' => $body, + ] + ); + } + + $json = ag($body, 'MediaContainer.Metadata.0', []); + + if (empty($json)) { + $this->logger->error( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Returned with unexpected body.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'response' => [ + 'body' => $body, + ], + ] + ); + continue; + } + + $isWatched = 0 === (int)ag($json, 'viewCount', 0) ? 0 : 1; + + if ($entity->watched === $isWatched) { + $this->logger->info( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + if (false === (bool)ag($context->options, Options::IGNORE_DATE, false)) { + $dateKey = 1 === $isWatched ? 'lastViewedAt' : 'addedAt'; + $date = ag($json, $dateKey); + + if (null === $date) { + $this->logger->error( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.', + [ + 'backend' => $context->backendName, + 'date_key' => $dateKey, + ...$logContext, + 'response' => [ + 'body' => $body, + ], + ] + ); + continue; + } + + $date = makeDate($date); + + $timeExtra = (int)(ag($context->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10)); + + if ($date->getTimestamp() >= ($entity->updated + $timeExtra)) { + $this->logger->notice( + 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'storage' => makeDate($entity->updated), + 'backend' => $date, + 'difference' => $date->getTimestamp() - $entity->updated, + 'extra_margin' => [ + Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra, + ], + ], + ] + ); + continue; + } + } + + $url = $context->backendUrl + ->withPath($entity->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery( + http_build_query( + [ + 'identifier' => 'com.plexapp.plugins.library', + 'key' => ag($json, 'ratingKey'), + ] + ) + ); + + $logContext['remote']['url'] = $url; + + $this->logger->debug( + 'Queuing request to change [%(backend)] %(item.type) [%(item.title)] play state to [%(play_state)].', + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ...$logContext, + ] + ); + + if (false === (bool)ag($context->options, Options::DRY_RUN)) { + $queue->add( + $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, [ + 'user_data' => [ + 'context' => $logContext + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ], + ] + ]) + ) + ); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] %(item.type) [%(item.title)].', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } + + return new Response(status: true, response: $queue); + } +} diff --git a/src/Commands/State/PushCommand.php b/src/Commands/State/PushCommand.php index 8c9a3828..6ca9baef 100644 --- a/src/Commands/State/PushCommand.php +++ b/src/Commands/State/PushCommand.php @@ -152,6 +152,10 @@ class PushCommand extends Command $opts[Options::DRY_RUN] = true; } + if ($input->getOption('trace')) { + $opts[Options::DEBUG_TRACE] = true; + } + $server['options'] = $opts; $server['class'] = makeServer(server: $server, name: $name); diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 01d2d090..92a572f5 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -10,6 +10,7 @@ use App\Backends\Jellyfin\Action\GetUsersList; use App\Backends\Jellyfin\Action\InspectRequest; use App\Backends\Jellyfin\Action\GetIdentifier; use App\Backends\Jellyfin\Action\ParseWebhook; +use App\Backends\Jellyfin\Action\Push; use App\Backends\Jellyfin\Action\SearchId; use App\Backends\Jellyfin\Action\SearchQuery; use App\Backends\Jellyfin\JellyfinActionTrait; @@ -243,9 +244,6 @@ class JellyfinServer implements ServerInterface return $response->response; } - /** - * @throws InvalidArgumentException - */ public function getMetadata(string|int $id, array $opts = []): array { return $this->getItemDetails(context: $this->context, id: $id, opts: $opts); @@ -580,258 +578,21 @@ class JellyfinServer implements ServerInterface public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array { - $this->checkConfig(true); + $response = Container::get(Push::class)( + context: $this->context, + entities: $entities, + queue: $queue, + after: $after + ); - $requests = []; - - foreach ($entities as $key => $entity) { - if (true !== ($entity instanceof iFace)) { - continue; - } - - if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) { - if (null !== $after && $after->getTimestamp() > $entity->updated) { - continue; - } - } - - $metadata = $entity->getMetadata($this->context->backendName); - - $context = [ - 'item' => [ - 'id' => $entity->id, - 'type' => $entity->type, - 'title' => $entity->getName(), - ], - ]; - - if (null === ag($metadata, iFace::COLUMN_ID, null)) { - $this->logger->warning( - 'Ignoring [%(item.title)] for [%(backend)] no backend metadata was found.', - [ - 'backend' => $this->context->backendName, - ...$context, - ] - ); - continue; - } - - $context['remote']['id'] = ag($metadata, iFace::COLUMN_ID); - - try { - $url = $this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery( - http_build_query( - [ - 'ids' => ag($metadata, iFace::COLUMN_ID), - 'fields' => implode(',', self::FIELDS), - 'enableUserData' => 'true', - 'enableImages' => 'false', - ] - ) - ); - - $context['remote']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] play state.', [ - 'backend' => $this->context->backendName, - ...$context, - ]); - - $requests[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'id' => $key, - 'context' => $context, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during requesting of [%(backend)] %(item.type) [%(item.title)].', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $context = null; - - foreach ($requests as $response) { - $context = ag($response->getInfo('user_data'), 'context', []); - - try { - if (null === ($id = ag($response->getInfo('user_data'), 'id'))) { - $this->logger->error('Unable to get entity object id.', [ - 'backend' => $this->context->backendName, - ...$context, - ]); - continue; - } - - $entity = $entities[$id]; - - assert($entity instanceof iFace); - - switch ($response->getStatusCode()) { - case 200: - break; - case 404: - $this->logger->warning( - 'Request for [%(backend)] %(item.type) [%(item.title)] returned with 404 (Not Found) status code.', - [ - 'backend' => $this->context->backendName, - ...$context - ] - ); - continue 2; - default: - $this->logger->error( - 'Request for [%(backend)] %(item.type) [%(item.title)] returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->context->backendName, - 'status_code' => $response->getStatusCode(), - ...$context - ] - ); - continue 2; - } - - $body = json_decode( - json: $response->getContent(false), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $json = ag($body, 'Items', [])[0] ?? []; - - if (empty($json)) { - $this->logger->error( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. responded with empty metadata.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'response' => [ - 'body' => $body, - ], - ] - ); - continue; - } - - $isWatched = (int)(bool)ag($json, 'UserData.Played', false); - - if ($entity->watched === $isWatched) { - $this->logger->info( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.', - [ - 'backend' => $this->context->backendName, - ...$context, - ] - ); - continue; - } - - if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) { - $dateKey = 1 === $isWatched ? 'UserData.LastPlayedDate' : 'DateCreated'; - $date = ag($json, $dateKey); - - if (null === $date) { - $this->logger->error( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.', - [ - 'backend' => $this->context->backendName, - 'date_key' => $dateKey, - ...$context, - 'response' => [ - 'body' => $body, - ], - ] - ); - continue; - } - - $date = makeDate($date); - - $timeExtra = (int)(ag($this->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10)); - - if ($date->getTimestamp() >= ($timeExtra + $entity->updated)) { - $this->logger->notice( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'comparison' => [ - 'storage' => makeDate($entity->updated), - 'backend' => $date, - 'difference' => $date->getTimestamp() - $entity->updated, - 'extra_margin' => [ - Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra, - ], - ], - ] - ); - continue; - } - } - - $url = $this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id'))); - - $context['remote']['url'] = $url; - - $this->logger->debug( - 'Queuing request to change [%(backend)] %(item.type) [%(item.title)] play state to [%(play_state)].', - [ - 'backend' => $this->context->backendName, - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ...$context, - ] - ); - - if (false === (bool)ag($this->options, Options::DRY_RUN, false)) { - $queue->add( - $this->http->request( - $entity->isWatched() ? 'POST' : 'DELETE', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'context' => $context + [ - 'backend' => $this->context->backendName, - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ], - ], - ]) - ) - ); - } - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] %(item.type) [%(item.title)].', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); } - unset($requests); - return []; } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index be8cadee..13ea58d3 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -10,6 +10,7 @@ use App\Backends\Plex\Action\GetIdentifier; use App\Backends\Plex\Action\GetUsersList; use App\Backends\Plex\Action\InspectRequest; use App\Backends\Plex\Action\ParseWebhook; +use App\Backends\Plex\Action\Push; use App\Backends\Plex\Action\SearchId; use App\Backends\Plex\Action\SearchQuery; use App\Backends\Plex\PlexActionTrait; @@ -642,256 +643,21 @@ class PlexServer implements ServerInterface public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array { - $this->checkConfig(); + $response = Container::get(Push::class)( + context: $this->context, + entities: $entities, + queue: $queue, + after: $after + ); - $requests = []; - - foreach ($entities as $key => $entity) { - if (true !== ($entity instanceof iFace)) { - continue; - } - - if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) { - if (null !== $after && $after->getTimestamp() > $entity->updated) { - continue; - } - } - - $metadata = $entity->getMetadata($this->getName()); - - $context = [ - 'item' => [ - 'id' => $entity->id, - 'type' => $entity->type, - 'title' => $entity->getName(), - ], - ]; - - if (null === ag($metadata, iFace::COLUMN_ID)) { - $this->logger->warning( - 'Ignoring [%(item.title)] for [%(backend)] no backend metadata was found.', - [ - 'backend' => $this->getName(), - ...$context, - ] - ); - continue; - } - - $context['remote']['id'] = ag($metadata, iFace::COLUMN_ID); - - try { - $url = $this->url->withPath('/library/metadata/' . ag($metadata, iFace::COLUMN_ID)); - - $context['remote']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] play state.', [ - 'backend' => $this->getName(), - ...$context, - ]); - - $requests[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'id' => $key, - 'context' => $context, - ] - ]) - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during requesting of [%(backend)] %(item.type) [%(item.title)].', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $context = null; - - foreach ($requests as $response) { - $context = ag($response->getInfo('user_data'), 'context', []); - - try { - if (null === ($id = ag($response->getInfo('user_data'), 'id'))) { - $this->logger->error('Unable to get entity object id.', [ - 'backend' => $this->getName(), - ...$context, - ]); - continue; - } - - $entity = $entities[$id]; - - assert($entity instanceof iFace); - - switch ($response->getStatusCode()) { - case 200: - break; - case 404: - $this->logger->warning( - 'Request for [%(backend)] %(item.type) [%(item.title)] returned with 404 (Not Found) status code.', - [ - 'backend' => $this->getName(), - ...$context - ] - ); - continue 2; - default: - $this->logger->error( - 'Request for [%(backend)] %(item.type) [%(item.title)] returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ...$context - ] - ); - continue 2; - } - - $body = json_decode( - json: $response->getContent(false), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $json = ag($body, 'MediaContainer.Metadata.0', []); - - if (empty($json)) { - $this->logger->error( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. responded with empty metadata.', - [ - 'backend' => $this->getName(), - ...$context, - 'response' => [ - 'body' => $body, - ], - ] - ); - continue; - } - - $isWatched = 0 === (int)ag($json, 'viewCount', 0) ? 0 : 1; - - if ($entity->watched === $isWatched) { - $this->logger->info( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.', - [ - 'backend' => $this->getName(), - ...$context, - ] - ); - continue; - } - - if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) { - $dateKey = 1 === $isWatched ? 'lastViewedAt' : 'addedAt'; - $date = ag($json, $dateKey); - - if (null === $date) { - $this->logger->error( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.', - [ - 'backend' => $this->getName(), - 'date_key' => $dateKey, - ...$context, - 'response' => [ - 'body' => $body, - ], - ] - ); - continue; - } - - $date = makeDate($date); - - $timeExtra = (int)(ag($this->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10)); - - if ($date->getTimestamp() >= ($entity->updated + $timeExtra)) { - $this->logger->notice( - 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.', - [ - 'backend' => $this->getName(), - ...$context, - 'comparison' => [ - 'storage' => makeDate($entity->updated), - 'backend' => $date, - 'difference' => $date->getTimestamp() - $entity->updated, - 'extra_margin' => [ - Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra, - ], - ], - ] - ); - continue; - } - } - - $url = $this->url->withPath($entity->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery( - http_build_query( - [ - 'identifier' => 'com.plexapp.plugins.library', - 'key' => ag($json, 'ratingKey'), - ] - ) - ); - - $context['remote']['url'] = $url; - - $this->logger->debug( - 'Queuing request to change [%(backend)] %(item.type) [%(item.title)] play state to [%(play_state)].', - [ - 'backend' => $this->getName(), - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ...$context, - ] - ); - - if (false === (bool)ag($this->options, Options::DRY_RUN)) { - $queue->add( - $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'context' => $context + [ - 'backend' => $this->getName(), - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ], - ] - ]) - ) - ); - } - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] %(item.type) [%(item.title)].', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); } - unset($requests); - return []; } From c0b0ce242aed1542c219552ad27a24b0dbc2d4bc Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sat, 18 Jun 2022 16:00:25 +0300 Subject: [PATCH 2/4] migrated backends get libraries list into separate action. --- src/Backends/Emby/Action/GetLibrariesList.php | 9 ++ src/Backends/Emby/EmbyClient.php | 9 +- .../Jellyfin/Action/GetLibrariesList.php | 133 ++++++++++++++++++ src/Backends/Jellyfin/JellyfinClient.php | 3 + src/Backends/Plex/Action/GetLibrariesList.php | 132 +++++++++++++++++ src/Commands/Backend/Library/ListCommand.php | 8 +- src/Libs/Servers/JellyfinServer.php | 112 ++------------- src/Libs/Servers/PlexServer.php | 100 ++----------- 8 files changed, 312 insertions(+), 194 deletions(-) create mode 100644 src/Backends/Emby/Action/GetLibrariesList.php create mode 100644 src/Backends/Jellyfin/Action/GetLibrariesList.php create mode 100644 src/Backends/Plex/Action/GetLibrariesList.php diff --git a/src/Backends/Emby/Action/GetLibrariesList.php b/src/Backends/Emby/Action/GetLibrariesList.php new file mode 100644 index 00000000..7dfb7494 --- /dev/null +++ b/src/Backends/Emby/Action/GetLibrariesList.php @@ -0,0 +1,9 @@ +tryResponse(context: $context, fn: fn() => $this->action($context, $opts)); + } + + /** + * @throws ExceptionInterface + * @throws JsonException + */ + private function action(Context $context, array $opts = []): Response + { + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser)); + + $this->logger->debug('Requesting [%(backend)] libraries list.', [ + 'backend' => $context->backendName, + 'url' => (string)$url + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] libraries 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)] libraries payload.', + [ + 'backend' => $context->backendName, + 'trace' => $json, + ] + ); + } + + $listDirs = ag($json, 'Items', []); + + if (empty($listDirs)) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] libraries returned empty list.', + context: [ + 'backend' => $context->backendName, + 'response' => [ + 'body' => $json + ], + ], + level: Levels::WARNING + ), + ); + } + + if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + } + + $list = []; + + foreach ($listDirs as $section) { + $key = (string)ag($section, 'Id'); + $type = ag($section, 'CollectionType', 'unknown'); + + $builder = [ + 'id' => $key, + 'title' => ag($section, 'Name', '???'), + 'type' => ucfirst($type), + 'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds), + 'supported' => in_array( + $type, + [JellyfinClient::COLLECTION_TYPE_MOVIES, JellyfinClient::COLLECTION_TYPE_SHOWS] + ), + ]; + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $section; + } + + $list[] = $builder; + } + + return new Response(status: true, response: $list); + } +} diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index bb02e33b..2f6c5bb8 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -19,6 +19,9 @@ class JellyfinClient public const TYPE_SHOW = 'Series'; public const TYPE_EPISODE = 'Episode'; + public const COLLECTION_TYPE_SHOWS = 'tvshows'; + public const COLLECTION_TYPE_MOVIES = 'movies'; + public const EXTRA_FIELDS = [ 'ProviderIds', 'DateCreated', diff --git a/src/Backends/Plex/Action/GetLibrariesList.php b/src/Backends/Plex/Action/GetLibrariesList.php new file mode 100644 index 00000000..a9e739f5 --- /dev/null +++ b/src/Backends/Plex/Action/GetLibrariesList.php @@ -0,0 +1,132 @@ +tryResponse(context: $context, fn: fn() => $this->action($context, $opts)); + } + + /** + * @throws ExceptionInterface + * @throws JsonException + */ + private function action(Context $context, array $opts = []): Response + { + $url = $context->backendUrl->withPath('/library/sections'); + + $this->logger->debug('Requesting [%(backend)] libraries list.', [ + 'backend' => $context->backendName, + 'url' => (string)$url + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] libraries 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)] libraries payload.', + [ + 'backend' => $context->backendName, + 'trace' => $json, + ] + ); + } + + $listDirs = ag($json, 'MediaContainer.Directory', []); + + if (empty($listDirs)) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] libraries returned empty list.', + context: [ + 'backend' => $context->backendName, + 'response' => [ + 'body' => $json + ], + ], + level: Levels::WARNING + ), + ); + } + + if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + } + + $list = []; + + foreach ($listDirs as $section) { + $key = (int)ag($section, 'key'); + $type = ag($section, 'type', 'unknown'); + + $builder = [ + 'id' => $key, + 'title' => ag($section, 'title', '???'), + 'type' => ucfirst($type), + 'ignored' => true === in_array($key, $ignoreIds ?? []), + 'supported' => PlexClient::TYPE_MOVIE === $type || PlexClient::TYPE_SHOW === $type, + 'agent' => ag($section, 'agent'), + 'scanner' => ag($section, 'scanner'), + ]; + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $section; + } + + $list[] = $builder; + } + + return new Response(status: true, response: $list); + } +} diff --git a/src/Commands/Backend/Library/ListCommand.php b/src/Commands/Backend/Library/ListCommand.php index 304e7fa9..c363dd40 100644 --- a/src/Commands/Backend/Library/ListCommand.php +++ b/src/Commands/Backend/Library/ListCommand.php @@ -44,13 +44,17 @@ final class ListCommand extends Command } try { - $opts = []; + $opts = $backendOpts = []; if ($input->getOption('include-raw-response')) { $opts[Options::RAW_RESPONSE] = true; } - $libraries = $this->getBackend($backend)->listLibraries(opts: $opts); + if ($input->getOption('trace')) { + $backendOpts = ag_set($backendOpts, 'options.' . Options::DEBUG_TRACE, true); + } + + $libraries = $this->getBackend($backend, $backendOpts)->listLibraries(opts: $opts); if (count($libraries) < 1) { $arr = [ diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 92a572f5..e853b243 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -6,6 +6,7 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; +use App\Backends\Jellyfin\Action\GetLibrariesList; use App\Backends\Jellyfin\Action\GetUsersList; use App\Backends\Jellyfin\Action\InspectRequest; use App\Backends\Jellyfin\Action\GetIdentifier; @@ -50,9 +51,6 @@ class JellyfinServer implements ServerInterface public const NAME = 'JellyfinBackend'; - protected const COLLECTION_TYPE_SHOWS = 'tvshows'; - protected const COLLECTION_TYPE_MOVIES = 'movies'; - public const FIELDS = JellyfinClient::EXTRA_FIELDS; protected UriInterface|null $url = null; @@ -116,7 +114,7 @@ class JellyfinServer implements ServerInterface backendUser: $userId, backendHeaders: $cloned->getHeaders(), trace: true === ag($options, Options::DEBUG_TRACE), - options: $this->options + options: $cloned->options ); $cloned->guid = $this->guid->withContext($cloned->context); @@ -216,7 +214,7 @@ class JellyfinServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return $response->response; @@ -238,7 +236,7 @@ class JellyfinServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return $response->response; @@ -477,103 +475,17 @@ class JellyfinServer implements ServerInterface public function listLibraries(array $opts = []): array { - $this->checkConfig(true); + $response = Container::get(GetLibrariesList::class)(context: $this->context, opts: $opts); - try { - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'recursive' => 'false', - 'fields' => implode(',', self::FIELDS), - 'enableUserData' => 'true', - 'enableImages' => 'false', - ] - ) - ); - - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->context->backendName, - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->context->backendName, - 'status_code' => $response->getStatusCode(), - ] - ); - return []; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $listDirs = ag($json, 'Items', []); - - if (empty($listDirs)) { - $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->context->backendName, - 'context' => [ - 'body' => $json, - ] - ]); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->context->backendName, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - return []; - } catch (JsonException $e) { - $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - ], - ]); - return []; + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } - $list = []; - - foreach ($listDirs as $section) { - $key = (string)ag($section, 'Id'); - $type = ag($section, 'CollectionType', 'unknown'); - - $builder = [ - 'id' => $key, - 'title' => ag($section, 'Name', '???'), - 'type' => $type, - 'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds), - 'supported' => in_array($type, [self::COLLECTION_TYPE_MOVIES, self::COLLECTION_TYPE_SHOWS]), - ]; - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $section; - } - - $list[] = $builder; - } - - return $list; + return $response->response; } public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array @@ -590,7 +502,7 @@ class JellyfinServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return []; @@ -951,7 +863,7 @@ class JellyfinServer implements ServerInterface ], ]; - if (self::COLLECTION_TYPE_SHOWS !== ag($context, 'library.type')) { + if (JellyfinClient::COLLECTION_TYPE_SHOWS !== ag($context, 'library.type')) { continue; } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 13ea58d3..cf8656d5 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -7,6 +7,7 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; use App\Backends\Plex\Action\GetIdentifier; +use App\Backends\Plex\Action\GetLibrariesList; use App\Backends\Plex\Action\GetUsersList; use App\Backends\Plex\Action\InspectRequest; use App\Backends\Plex\Action\ParseWebhook; @@ -96,7 +97,7 @@ class PlexServer implements ServerInterface backendUser: $userId, backendHeaders: $cloned->getHeaders(), trace: true === ag($options, Options::DEBUG_TRACE), - options: $this->options + options: $cloned->options ); $cloned->guid = $this->guid->withContext($cloned->context); @@ -204,7 +205,7 @@ class PlexServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return $response->response; @@ -219,7 +220,7 @@ class PlexServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return $response->response; @@ -549,96 +550,17 @@ class PlexServer implements ServerInterface public function listLibraries(array $opts = []): array { - $this->checkConfig(); + $response = Container::get(GetLibrariesList::class)(context: $this->context, opts: $opts); - try { - $url = $this->url->withPath('/library/sections'); - - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ] - ); - return []; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $listDirs = ag($json, 'MediaContainer.Directory', []); - - if (empty($listDirs)) { - $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->getName(), - 'context' => [ - 'body' => $json, - ] - ]); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->getName(), - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - return []; - } catch (JsonException $e) { - $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - ], - ]); - return []; + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } - $list = []; - - foreach ($listDirs as $section) { - $key = (int)ag($section, 'key'); - $type = ag($section, 'type', 'unknown'); - - $builder = [ - 'id' => $key, - 'title' => ag($section, 'title', '???'), - 'type' => $type, - 'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds), - 'supported' => 'movie' === $type || 'show' === $type, - 'agent' => ag($section, 'agent'), - 'scanner' => ag($section, 'scanner'), - ]; - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $section; - } - - $list[] = $builder; - } - - return $list; + return $response->response; } public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array @@ -655,7 +577,7 @@ class PlexServer implements ServerInterface } if (false === $response->isSuccessful()) { - throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } return []; From baf3c59aabe5c6f90d4e34f4efcea34462fd09ae Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sat, 18 Jun 2022 18:12:37 +0300 Subject: [PATCH 3/4] added isLocal method to GuidInterface to check whether the given guid local or not. --- src/Backends/Common/GuidInterface.php | 9 +++++++++ src/Backends/Jellyfin/JellyfinGuid.php | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/Backends/Common/GuidInterface.php b/src/Backends/Common/GuidInterface.php index 5923c4d7..d001bf89 100644 --- a/src/Backends/Common/GuidInterface.php +++ b/src/Backends/Common/GuidInterface.php @@ -43,4 +43,13 @@ interface GuidInterface * @return bool */ public function has(array $guids, array $context = []): bool; + + /** + * Is the given identifier a local id? + * + * @param string $guid + * + * @return bool + */ + public function isLocal(string $guid): bool; } diff --git a/src/Backends/Jellyfin/JellyfinGuid.php b/src/Backends/Jellyfin/JellyfinGuid.php index 7bf2257f..5ae13118 100644 --- a/src/Backends/Jellyfin/JellyfinGuid.php +++ b/src/Backends/Jellyfin/JellyfinGuid.php @@ -55,6 +55,11 @@ class JellyfinGuid implements iGuid return count($this->ListExternalIds(guids: $guids, context: $context, log: false)) >= 1; } + public function isLocal(string $guid): bool + { + return false; + } + /** * Get All Supported external ids. * From 393cd72c3e58f4e28b4ac9607ad9180f8feea59d Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Sat, 18 Jun 2022 19:02:42 +0300 Subject: [PATCH 4/4] Migrated backends get library to separate action. --- src/Backends/Jellyfin/Action/GetLibrary.php | 253 ++++++++++++ src/Backends/Jellyfin/JellyfinActionTrait.php | 24 ++ src/Backends/Plex/Action/GetLibrary.php | 364 ++++++++++++++++++ src/Backends/Plex/PlexActionTrait.php | 24 ++ .../Backend/Library/MismatchCommand.php | 6 +- src/Libs/Options.php | 1 + src/Libs/Servers/JellyfinServer.php | 244 +----------- src/Libs/Servers/PlexServer.php | 331 +--------------- src/Libs/Servers/ServerInterface.php | 5 +- 9 files changed, 693 insertions(+), 559 deletions(-) create mode 100644 src/Backends/Jellyfin/Action/GetLibrary.php create mode 100644 src/Backends/Plex/Action/GetLibrary.php diff --git a/src/Backends/Jellyfin/Action/GetLibrary.php b/src/Backends/Jellyfin/Action/GetLibrary.php new file mode 100644 index 00000000..fd158855 --- /dev/null +++ b/src/Backends/Jellyfin/Action/GetLibrary.php @@ -0,0 +1,253 @@ +tryResponse(context: $context, fn: fn() => $this->action($context, $id, $opts)); + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\ExceptionInterface + * @throws \JsonMachine\Exception\InvalidArgumentException + */ + private function action(Context $context, string|int $id, array $opts = []): Response + { + $libraries = $this->getBackendLibraries($context); + + if (null === ($section = ag($libraries, $id))) { + return new Response( + status: false, + error: new Error( + message: 'No Library with id [%(id)] found in [%(backend)] response.', + context: [ + 'id' => $id, + 'backend' => $context->backendName, + 'response' => [ + 'body' => $libraries + ], + ], + level: Levels::WARNING + ), + ); + } + + unset($libraries); + + $logContext = [ + 'library' => [ + 'id' => $id, + 'type' => ag($section, 'CollectionType', 'unknown'), + 'title' => ag($section, 'Name', '??'), + ], + ]; + + if (true !== in_array( + ag($logContext, 'library.type'), + [JellyfinClient::COLLECTION_TYPE_MOVIES, JellyfinClient::COLLECTION_TYPE_SHOWS] + )) { + return new Response( + status: false, + error: new Error( + message: 'The Requested [%(backend)] Library [%(library.id): %(library.title)] returned with unsupported type [%(library.type)].', + context: [ + 'backend' => $context->backendName, + ...$logContext, + ], + level: Levels::WARNING + ), + ); + } + + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery( + http_build_query( + [ + 'parentId' => $id, + 'enableUserData' => 'false', + 'enableImages' => 'false', + 'excludeLocationTypes' => 'Virtual', + 'include' => implode(',', [JellyfinClient::TYPE_SHOW, JellyfinClient::TYPE_MOVIE]), + 'fields' => implode(',', JellyfinClient::EXTRA_FIELDS) + ] + ) + ); + + $logContext['library']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] library [%(library.title)] returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext, + ], + level: Levels::ERROR + ), + ); + } + + $it = Items::fromIterable( + iterable: httpClientChunks($this->http->stream($response)), + options: [ + 'pointer' => '/Items', + 'decoder' => new ErrorWrappingDecoder( + new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE) + ) + ] + ); + + $list = []; + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->warning( + 'Failed to decode one item of [%(backend)] library [%(library.title)] content.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'error' => [ + 'message' => $entity->getErrorMessage(), + 'body' => $entity->getMalformedJson(), + ], + ] + ); + continue; + } + + $url = $context->backendUrl->withPath( + sprintf('/Users/%s/items/%s', $context->backendUser, ag($entity, 'Id')) + ); + + $logContext['item'] = [ + 'id' => ag($entity, 'Id'), + 'title' => ag($entity, ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'], '??'), + 'year' => ag($entity, 'ProductionYear', '0000'), + 'type' => ag($entity, 'Type'), + 'url' => (string)$url, + ]; + + $list[] = $this->process($context, $entity, $logContext, $opts); + } + + return new Response(status: true, response: $list); + } + + private function process(Context $context, array $item, array $log = [], array $opts = []): array + { + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/%s', $context->backendUser, ag($item, 'Id'))); + $possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName']; + + $data = [ + 'backend' => $context->backendName, + ...$log, + ]; + + if ($context->trace) { + $data['trace'] = $item; + } + + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data); + + $metadata = [ + 'id' => ag($item, 'Id'), + 'type' => ucfirst(ag($item, 'Type', 'unknown')), + 'url' => (string)$url, + 'title' => ag($item, $possibleTitlesList, '??'), + 'year' => ag($item, 'ProductionYear'), + 'guids' => [], + 'match' => [ + 'titles' => [], + 'paths' => [], + ], + ]; + + foreach ($possibleTitlesList as $title) { + if (null === ($title = ag($item, $title))) { + continue; + } + + $isASCII = mb_detect_encoding($title, 'ASCII', true); + $title = trim($isASCII ? strtolower($title) : mb_strtolower($title)); + + if (true === in_array($title, $metadata['match']['titles'])) { + continue; + } + + $metadata['match']['titles'][] = $title; + } + + if (null !== ($path = ag($item, 'Path'))) { + $metadata['match']['paths'][] = [ + 'full' => $path, + 'short' => basename($path), + ]; + + if (ag($item, 'Type') === 'Movie') { + if (false === str_starts_with(basename($path), basename(dirname($path)))) { + $metadata['match']['paths'][] = [ + 'full' => $path, + 'short' => basename($path), + ]; + } + } + } + + if (null !== ($providerIds = ag($item, 'ProviderIds'))) { + foreach ($providerIds as $key => $val) { + $metadata['guids'][] = $key . '://' . $val; + } + } + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $metadata['raw'] = $item; + } + + return $metadata; + } +} diff --git a/src/Backends/Jellyfin/JellyfinActionTrait.php b/src/Backends/Jellyfin/JellyfinActionTrait.php index 61b6bf2d..370228dc 100644 --- a/src/Backends/Jellyfin/JellyfinActionTrait.php +++ b/src/Backends/Jellyfin/JellyfinActionTrait.php @@ -6,11 +6,13 @@ namespace App\Backends\Jellyfin; use App\Backends\Common\Context; use App\Backends\Common\GuidInterface as iGuid; +use App\Backends\Jellyfin\Action\GetLibrariesList; use App\Backends\Jellyfin\Action\GetMetaData; use App\Libs\Container; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; use App\Libs\Guid; +use App\Libs\Options; use RuntimeException; trait JellyfinActionTrait @@ -212,4 +214,26 @@ trait JellyfinActionTrait return $context->cache->get($cacheKey); } + + /** + * Get Backend Libraries details. + */ + protected function getBackendLibraries(Context $context, array $opts = []): array + { + $opts = ag_set($opts, Options::RAW_RESPONSE, true); + + $response = Container::get(GetLibrariesList::class)(context: $context, opts: $opts); + + if (!$response->isSuccessful()) { + throw new RuntimeException(message: $response->error->format(), previous: $response->error->previous); + } + + $arr = []; + + foreach ($response->response as $item) { + $arr[$item['id']] = $item['raw']; + } + + return $arr; + } } diff --git a/src/Backends/Plex/Action/GetLibrary.php b/src/Backends/Plex/Action/GetLibrary.php new file mode 100644 index 00000000..58090779 --- /dev/null +++ b/src/Backends/Plex/Action/GetLibrary.php @@ -0,0 +1,364 @@ +tryResponse(context: $context, fn: fn() => $this->action($context, $guid, $id, $opts)); + } + + /** + * @throws \Symfony\Contracts\HttpClient\Exception\ExceptionInterface + * @throws \JsonMachine\Exception\InvalidArgumentException + */ + private function action(Context $context, iGuid $guid, string|int $id, array $opts = []): Response + { + $libraries = $this->getBackendLibraries($context); + + $deepScan = true === (bool)ag($opts, Options::MISMATCH_DEEP_SCAN); + + if (null === ($section = ag($libraries, $id))) { + return new Response( + status: false, + error: new Error( + message: 'No Library with id [%(id)] found in [%(backend)] response.', + context: [ + 'id' => $id, + 'backend' => $context->backendName, + 'response' => [ + 'body' => $libraries + ], + ], + level: Levels::WARNING + ), + ); + } + + unset($libraries); + + $logContext = [ + 'library' => [ + 'id' => $id, + 'type' => ag($section, 'type', 'unknown'), + 'title' => ag($section, 'title', '??'), + ], + ]; + + if (true !== in_array(ag($logContext, 'library.type'), [PlexClient::TYPE_MOVIE, PlexClient::TYPE_SHOW])) { + return new Response( + status: false, + error: new Error( + message: 'The Requested [%(backend)] Library [%(library.title)] returned with unsupported type [%(library.type)].', + context: [ + 'backend' => $context->backendName, + ...$logContext, + ], + level: Levels::WARNING + ), + ); + } + + $url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $id))->withQuery( + http_build_query( + [ + 'type' => PlexClient::TYPE_MOVIE === ag($logContext, 'library.type') ? 1 : 2, + 'includeGuids' => 1, + ] + ) + ); + + $logContext['library']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] library [%(library.title)] returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext, + ], + level: Levels::ERROR + ), + ); + } + + $it = Items::fromIterable( + iterable: httpClientChunks( + stream: $this->http->stream($response) + ), + options: [ + 'pointer' => '/MediaContainer/Metadata', + 'decoder' => new ErrorWrappingDecoder( + innerDecoder: new ExtJsonDecoder( + assoc: true, + options: JSON_INVALID_UTF8_IGNORE + ) + ) + ] + ); + + $list = $requests = []; + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->warning( + 'Failed to decode one item of [%(backend)] library [%(library.title)] content.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'error' => [ + 'message' => $entity->getErrorMessage(), + 'body' => $entity->getMalformedJson(), + ], + ] + ); + continue; + } + + $year = (int)ag($entity, 'year', 0); + + if (0 === $year && null !== ($airDate = ag($entity, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $logContext['item'] = [ + 'id' => ag($entity, 'ratingKey'), + 'title' => ag($entity, ['title', 'originalTitle'], '??'), + 'year' => $year, + 'type' => ag($entity, 'type'), + ]; + + if (false === $deepScan || PlexClient::TYPE_MOVIE === ag($logContext, 'item.type')) { + $list[] = $this->process($context, $guid, $entity, $logContext, $opts); + } else { + $requests[] = $this->http->request( + 'GET', + (string)$context->backendUrl->withPath(sprintf('/library/metadata/%d', ag($logContext, 'item.id'))), + $context->backendHeaders + [ + 'user_data' => [ + 'context' => $logContext + ] + ] + ); + } + } + + if (!empty($requests)) { + $this->logger->info( + 'Requesting [%(total)] items metadata from [%(backend)] library [%(library.title)].', + [ + 'total' => number_format(count($requests)), + 'backend' => $context->backendName, + ...$logContext + ] + ); + } + + foreach ($requests as $response) { + $requestContext = ag($response->getInfo('user_data'), 'context', []); + + try { + if (200 !== $response->getStatusCode()) { + $this->logger->warning( + 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$requestContext + ] + ); + + continue; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $list[] = $this->process( + $context, + $guid, + ag($json, 'MediaContainer.Metadata.0', []), + $requestContext, + $opts + ); + } catch (JsonException|HttpExceptionInterface $e) { + return new Response( + status: false, + error: new Error( + message: 'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.', + context: [ + 'backend' => $context->backendName, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ...$requestContext, + ], + level: Levels::WARNING, + previous: $e + ) + ); + } + } + + return new Response(status: true, response: $list); + } + + private function process(Context $context, iGuid $guid, array $item, array $log = [], array $opts = []): array + { + $url = $context->backendUrl->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey'))); + $possibleTitlesList = ['title', 'originalTitle', 'titleSort']; + + $data = [ + 'backend' => $context->backendName, + ...$log, + ]; + + $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + if ($context->trace) { + $data['trace'] = $item; + } + + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data); + + $metadata = [ + 'id' => (int)ag($item, 'ratingKey'), + 'type' => ucfirst(ag($item, 'type', 'unknown')), + 'url' => (string)$url, + 'title' => ag($item, $possibleTitlesList, '??'), + 'year' => $year, + 'guids' => [], + 'match' => [ + 'titles' => [], + 'paths' => [], + ], + ]; + + foreach ($possibleTitlesList as $title) { + if (null === ($title = ag($item, $title))) { + continue; + } + + $isASCII = mb_detect_encoding($title, 'ASCII', true); + $title = trim($isASCII ? strtolower($title) : mb_strtolower($title)); + + if (true === in_array($title, $metadata['match']['titles'])) { + continue; + } + + $metadata['match']['titles'][] = $title; + } + + switch (ag($item, 'type')) { + case PlexClient::TYPE_SHOW: + foreach (ag($item, 'Location', []) as $path) { + $path = ag($path, 'path'); + $metadata['match']['paths'][] = [ + 'full' => $path, + 'short' => basename($path), + ]; + } + break; + case PlexClient::TYPE_MOVIE: + foreach (ag($item, 'Media', []) as $leaf) { + foreach (ag($leaf, 'Part', []) as $path) { + $path = ag($path, 'file'); + $dir = dirname($path); + + $metadata['match']['paths'][] = [ + 'full' => $path, + 'short' => basename($path), + ]; + + if (false === str_starts_with(basename($path), basename($dir))) { + $metadata['match']['paths'][] = [ + 'full' => $path, + 'short' => basename($dir), + ]; + } + } + } + break; + default: + throw new RuntimeException( + sprintf( + 'While parsing [%s] library [%s] items, we encountered unexpected item type [%s].', + $context->backendName, + ag($log, 'library.title', '??'), + ag($item, 'type') + ) + ); + } + + if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) { + $metadata['guids'][] = $itemGuid; + } + + foreach (array_column(ag($item, 'Guid', []), 'id') as $externalId) { + $metadata['guids'][] = $externalId; + } + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $metadata['raw'] = $item; + } + + return $metadata; + } +} diff --git a/src/Backends/Plex/PlexActionTrait.php b/src/Backends/Plex/PlexActionTrait.php index f99590b1..b541e116 100644 --- a/src/Backends/Plex/PlexActionTrait.php +++ b/src/Backends/Plex/PlexActionTrait.php @@ -6,11 +6,13 @@ namespace App\Backends\Plex; use App\Backends\Common\Context; use App\Backends\Common\GuidInterface as iGuid; +use App\Backends\Plex\Action\GetLibrariesList; use App\Backends\Plex\Action\GetMetaData; use App\Libs\Container; use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; use App\Libs\Guid; +use App\Libs\Options; use RuntimeException; trait PlexActionTrait @@ -246,4 +248,26 @@ trait PlexActionTrait return $context->cache->get($cacheKey); } + + /** + * Get Backend Libraries details. + */ + protected function getBackendLibraries(Context $context, array $opts = []): array + { + $opts = ag_set($opts, Options::RAW_RESPONSE, true); + + $response = Container::get(GetLibrariesList::class)(context: $context, opts: $opts); + + if (!$response->isSuccessful()) { + throw new RuntimeException(message: $response->error->format(), previous: $response->error->previous); + } + + $arr = []; + + foreach ($response->response as $item) { + $arr[(int)$item['id']] = $item['raw']; + } + + return $arr; + } } diff --git a/src/Commands/Backend/Library/MismatchCommand.php b/src/Commands/Backend/Library/MismatchCommand.php index 744c0e76..6f1fa78a 100644 --- a/src/Commands/Backend/Library/MismatchCommand.php +++ b/src/Commands/Backend/Library/MismatchCommand.php @@ -30,6 +30,7 @@ final class MismatchCommand extends Command ->setDescription( 'Find possible mis-identified item in a library. This only works for Media that follow Plex naming format.' ) + ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all items regardless of status.') ->addOption('percentage', 'p', InputOption::VALUE_OPTIONAL, 'Acceptable percentage.', 50.0) ->addOption( 'method', @@ -54,6 +55,7 @@ final class MismatchCommand extends Command protected function runCommand(InputInterface $input, OutputInterface $output): int { $mode = $input->getOption('output'); + $showAll = $input->getOption('show-all'); $percentage = $input->getOption('percentage'); $backend = $input->getArgument('backend'); $id = $input->getArgument('id'); @@ -86,10 +88,12 @@ final class MismatchCommand extends Command $opts[Options::RAW_RESPONSE] = true; } + $opts[Options::MISMATCH_DEEP_SCAN] = true; + foreach ($this->getBackend($backend, $backendOpts)->getLibrary(id: $id, opts: $opts) as $item) { $processed = $this->compare(item: $item, method: $input->getOption('method')); - if (empty($processed) || $processed['percent'] >= (float)$percentage) { + if (!$showAll && (empty($processed) || $processed['percent'] >= (float)$percentage)) { continue; } diff --git a/src/Libs/Options.php b/src/Libs/Options.php index 51e0e2a0..5ea08812 100644 --- a/src/Libs/Options.php +++ b/src/Libs/Options.php @@ -17,6 +17,7 @@ final class Options public const MAPPER_ALWAYS_UPDATE_META = 'ALWAYS_UPDATE_META'; public const MAPPER_DISABLE_AUTOCOMMIT = 'DISABLE_AUTOCOMMIT'; public const IMPORT_METADATA_ONLY = 'IMPORT_METADATA_ONLY'; + public const MISMATCH_DEEP_SCAN = 'MISMATCH_DEEP_SCAN'; private function __construct() { diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index e853b243..09a4f1db 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -7,6 +7,7 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; use App\Backends\Jellyfin\Action\GetLibrariesList; +use App\Backends\Jellyfin\Action\GetLibrary; use App\Backends\Jellyfin\Action\GetUsersList; use App\Backends\Jellyfin\Action\InspectRequest; use App\Backends\Jellyfin\Action\GetIdentifier; @@ -28,7 +29,6 @@ use App\Libs\Options; use App\Libs\QueueRequests; use Closure; use DateTimeInterface; -use Generator; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -250,227 +250,24 @@ class JellyfinServer implements ServerInterface /** * @throws Throwable */ - public function getLibrary(string|int $id, array $opts = []): Generator + public function getLibrary(string|int $id, array $opts = []): array { - $this->checkConfig(); - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'recursive' => 'false', - 'enableUserData' => 'false', - 'enableImages' => 'false', - 'fields' => implode(',', self::FIELDS), - ] - ) + $response = Container::get(GetLibrary::class)( + context: $this->context, + guid: $this->guid, + id: $id, + opts: $opts ); - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->context->backendName, - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Request for [%s] libraries returned with unexpected [%s] status code.', - $this->context->backendName, - $response->getStatusCode(), - ) - ); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $context = []; - $found = false; - - foreach (ag($json, 'Items', []) as $section) { - if ((string)ag($section, 'Id') !== (string)$id) { - continue; - } - $found = true; - $context = [ - 'library' => [ - 'id' => ag($section, 'Id'), - 'type' => ag($section, 'CollectionType', 'unknown'), - 'title' => ag($section, 'Name', '??'), - ], - ]; - break; + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } - if (false === $found) { - throw new RuntimeException( - sprintf( - 'The response from [%s] does not contain library with id of [%s].', - $this->context->backendName, - $id - ) - ); - } - - if (true !== in_array(ag($context, 'library.type'), ['tvshows', 'movies'])) { - throw new RuntimeException( - sprintf( - 'The requested [%s] library [%s] is of [%s] type. Which is not supported type.', - $this->context->backendName, - ag($context, 'library.title', $id), - ag($context, 'library.type') - ) - ); - } - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'parentId' => $id, - 'enableUserData' => 'false', - 'enableImages' => 'false', - 'excludeLocationTypes' => 'Virtual', - 'include' => 'Series,Movie', - 'fields' => implode(',', self::FIELDS) - ] - ) - ); - - $context['library']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [ - 'backend' => $this->context->backendName, - ...$context, - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Request for [%s] library [%s] content returned with unexpected [%s] status code.', - $this->context->backendName, - ag($context, 'library.title', $id), - $response->getStatusCode(), - ) - ); - } - - $handleRequest = $opts['handler'] ?? function (array $item, array $context = []) use ($opts): array { - $url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($item, 'Id'))); - $possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName']; - - $data = [ - 'backend' => $this->context->backendName, - ...$context, - ]; - - if (true === ag($this->options, Options::DEBUG_TRACE)) { - $data['trace'] = $item; - } - - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data); - - $metadata = [ - 'id' => ag($item, 'Id'), - 'type' => ucfirst(ag($item, 'Type', 'unknown')), - 'url' => [(string)$url], - 'title' => ag($item, $possibleTitlesList, '??'), - 'year' => ag($item, 'ProductionYear'), - 'guids' => [], - 'match' => [ - 'titles' => [], - 'paths' => [], - ], - ]; - - foreach ($possibleTitlesList as $title) { - if (null === ($title = ag($item, $title))) { - continue; - } - - $isASCII = mb_detect_encoding($title, 'ASCII', true); - $title = trim($isASCII ? strtolower($title) : mb_strtolower($title)); - - if (true === in_array($title, $metadata['match']['titles'])) { - continue; - } - - $metadata['match']['titles'][] = $title; - } - - if (null !== ($path = ag($item, 'Path'))) { - $metadata['match']['paths'][] = [ - 'full' => $path, - 'short' => basename($path), - ]; - - if (ag($item, 'Type') === 'Movie') { - if (false === str_starts_with(basename($path), basename(dirname($path)))) { - $metadata['match']['paths'][] = [ - 'full' => $path, - 'short' => basename($path), - ]; - } - } - } - - if (null !== ($providerIds = ag($item, 'ProviderIds'))) { - foreach ($providerIds as $key => $val) { - $metadata['guids'][] = $key . '://' . $val; - } - } - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $metadata['raw'] = $item; - } - - return $metadata; - }; - - $it = Items::fromIterable( - iterable: httpClientChunks($this->http->stream($response)), - options: [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] library [%(library.title)] content.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - - $url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($entity, 'Id'))); - - $context['item'] = [ - 'id' => ag($entity, 'Id'), - 'title' => ag($entity, ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'], '??'), - 'year' => ag($entity, 'ProductionYear', '0000'), - 'type' => ag($entity, 'Type'), - 'url' => (string)$url, - ]; - - yield $handleRequest(item: $entity, context: $context); - } + return $response->response; } public function listLibraries(array $opts = []): array @@ -771,8 +568,6 @@ class JellyfinServer implements ServerInterface protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array { - $this->checkConfig(true); - try { $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( http_build_query( @@ -1403,19 +1198,4 @@ class JellyfinServer implements ServerInterface ])->getAll() ); } - - protected function checkConfig(bool $checkUrl = true, bool $checkToken = true, bool $checkUser = true): void - { - if (true === $checkUrl && !($this->url instanceof UriInterface)) { - throw new RuntimeException(static::NAME . ': No host was set.'); - } - - if (true === $checkToken && null === $this->token) { - throw new RuntimeException(static::NAME . ': No token was set.'); - } - - if (true === $checkUser && null === $this->user) { - throw new RuntimeException(static::NAME . ': No User was set.'); - } - } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index cf8656d5..ef0b9ffa 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -8,6 +8,7 @@ use App\Backends\Common\Cache; use App\Backends\Common\Context; use App\Backends\Plex\Action\GetIdentifier; use App\Backends\Plex\Action\GetLibrariesList; +use App\Backends\Plex\Action\GetLibrary; use App\Backends\Plex\Action\GetUsersList; use App\Backends\Plex\Action\InspectRequest; use App\Backends\Plex\Action\ParseWebhook; @@ -28,7 +29,6 @@ use App\Libs\Options; use App\Libs\QueueRequests; use Closure; use DateTimeInterface; -use Generator; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -231,321 +231,19 @@ class PlexServer implements ServerInterface return $this->getItemDetails(context: $this->context, id: $id, opts: $opts); } - /** - * @throws Throwable - */ - public function getLibrary(string|int $id, array $opts = []): Generator + public function getLibrary(string|int $id, array $opts = []): array { - $this->checkConfig(); + $response = Container::get(GetLibrary::class)(context: $this->context, guid: $this->guid, id: $id, opts: $opts); - $url = $this->url->withPath('/library/sections/'); - - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Request for [%s] libraries returned with unexpected [%s] status code.', - $this->getName(), - $response->getStatusCode(), - ) - ); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $context = []; - $found = false; - - foreach (ag($json, 'MediaContainer.Directory', []) as $section) { - if ((int)ag($section, 'key') !== (int)$id) { - continue; - } - $found = true; - $context = [ - 'library' => [ - 'id' => ag($section, 'key'), - 'type' => ag($section, 'type', 'unknown'), - 'title' => ag($section, 'title', '??'), - ], - ]; - break; + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } - if (false === $found) { - throw new RuntimeException( - sprintf('The response from [%s] does not contain library with id of [%s].', $this->getName(), $id) - ); - } - - if (true !== in_array(ag($context, 'library.type'), [iFace::TYPE_MOVIE, 'show'])) { - throw new RuntimeException( - sprintf( - 'The requested [%s] library [%s] is of [%s] type. Which is not supported type.', - $this->getName(), - ag($context, 'library.title', $id), - ag($context, 'library.type') - ) - ); - } - - $query = [ - 'sort' => 'addedAt:asc', - 'includeGuids' => 1, - ]; - - if (iFace::TYPE_MOVIE === ag($context, 'library.type')) { - $query['type'] = 1; - } - - $url = $this->url->withPath(sprintf('/library/sections/%d/all', $id))->withQuery(http_build_query($query)); - - $context['library']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [ - 'backend' => $this->getName(), - ...$context, - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Request for [%s] library [%s] content returned with unexpected [%s] status code.', - $this->getName(), - ag($context, 'library.title', $id), - $response->getStatusCode(), - ) - ); - } - - $handleRequest = $opts['handler'] ?? function (array $item, array $context = []) use ($opts): array { - $url = $this->url->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey'))); - $possibleTitlesList = ['title', 'originalTitle', 'titleSort']; - - $data = [ - 'backend' => $this->getName(), - ...$context, - ]; - - $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - if (true === ag($this->options, Options::DEBUG_TRACE)) { - $data['trace'] = $item; - } - - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data); - - $metadata = [ - 'id' => (int)ag($item, 'ratingKey'), - 'type' => ucfirst(ag($item, 'type', 'unknown')), - 'url' => (string)$url, - 'title' => ag($item, $possibleTitlesList, '??'), - 'year' => $year, - 'guids' => [], - 'match' => [ - 'titles' => [], - 'paths' => [], - ], - ]; - - foreach ($possibleTitlesList as $title) { - if (null === ($title = ag($item, $title))) { - continue; - } - - $isASCII = mb_detect_encoding($title, 'ASCII', true); - $title = trim($isASCII ? strtolower($title) : mb_strtolower($title)); - - if (true === in_array($title, $metadata['match']['titles'])) { - continue; - } - - $metadata['match']['titles'][] = $title; - } - - switch (ag($item, 'type')) { - case 'show': - foreach (ag($item, 'Location', []) as $path) { - $path = ag($path, 'path'); - $metadata['match']['paths'][] = [ - 'full' => $path, - 'short' => basename($path), - ]; - } - break; - case iFace::TYPE_MOVIE: - foreach (ag($item, 'Media', []) as $leaf) { - foreach (ag($leaf, 'Part', []) as $path) { - $path = ag($path, 'file'); - $dir = dirname($path); - - $metadata['match']['paths'][] = [ - 'full' => $path, - 'short' => basename($path), - ]; - - if (false === str_starts_with(basename($path), basename($dir))) { - $metadata['match']['paths'][] = [ - 'full' => $path, - 'short' => basename($dir), - ]; - } - } - } - break; - default: - throw new RuntimeException( - sprintf( - 'While parsing [%s] library [%s] items, we encountered unexpected item [%s] type.', - $this->getName(), - ag($context, 'library.title', '??'), - ag($item, 'type') - ) - ); - } - - $itemGuid = ag($item, 'guid'); - - if (null !== $itemGuid && false === $this->guid->isLocal($itemGuid)) { - $metadata['guids'][] = $itemGuid; - } - - foreach (ag($item, 'Guid', []) as $guid) { - $metadata['guids'][] = ag($guid, 'id'); - } - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $metadata['raw'] = $item; - } - - return $metadata; - }; - - $it = Items::fromIterable( - iterable: httpClientChunks(stream: $this->http->stream($response)), - options: [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - innerDecoder: new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE) - ) - ] - ); - - $requests = []; - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] library id [%(library.title)] content.', - [ - 'backend' => $this->getName(), - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - $year = (int)ag($entity, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($entity, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $context['item'] = [ - 'id' => ag($entity, 'ratingKey'), - 'title' => ag($entity, ['title', 'originalTitle'], '??'), - 'year' => $year, - 'type' => ag($entity, 'type'), - 'url' => (string)$url, - ]; - - if (iFace::TYPE_MOVIE === ag($context, 'item.type')) { - yield $handleRequest(item: $entity, context: $context); - } else { - $url = $this->url->withPath(sprintf('/library/metadata/%d', ag($entity, 'ratingKey'))); - - $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title) (%(item.year))] metadata.', [ - 'backend' => $this->getName(), - ...$context, - ]); - - $requests[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'context' => $context - ] - ]) - ); - } - } - - if (empty($requests) && iFace::TYPE_MOVIE !== ag($context, 'library.type')) { - throw new RuntimeException( - sprintf( - 'No requests were made [%s] library [%s] is empty.', - $this->getName(), - ag($context, 'library.title', $id) - ) - ); - } - - if (!empty($requests)) { - $this->logger->info( - 'Requesting [%(total)] items metadata from [%(backend)] library [%(library.title)].', - [ - 'backend' => $this->getName(), - 'total' => number_format(count($requests)), - 'library' => ag($context, 'library', []), - ] - ); - } - - foreach ($requests as $response) { - $requestContext = ag($response->getInfo('user_data'), 'context', []); - - if (200 !== $response->getStatusCode()) { - $this->logger->warning( - 'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ...$requestContext - ] - ); - - continue; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - yield $handleRequest( - item: ag($json, 'MediaContainer.Metadata.0', []), - context: $requestContext - ); - } + return $response->response; } public function listLibraries(array $opts = []): array @@ -846,8 +544,6 @@ class PlexServer implements ServerInterface protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array { - $this->checkConfig(); - try { $url = $this->url->withPath('/library/sections'); @@ -1510,15 +1206,4 @@ class PlexServer implements ServerInterface )->getAll() ); } - - protected function checkConfig(bool $checkUrl = true, bool $checkToken = true): void - { - if (true === $checkUrl && !($this->url instanceof UriInterface)) { - throw new RuntimeException(static::NAME . ': No host was set.'); - } - - if (true === $checkToken && null === $this->token) { - throw new RuntimeException(static::NAME . ': No token was set.'); - } - } } diff --git a/src/Libs/Servers/ServerInterface.php b/src/Libs/Servers/ServerInterface.php index 186f901d..b32b5e78 100644 --- a/src/Libs/Servers/ServerInterface.php +++ b/src/Libs/Servers/ServerInterface.php @@ -8,7 +8,6 @@ use App\Libs\Entity\StateInterface; use App\Libs\Mappers\ImportInterface; use App\Libs\QueueRequests; use DateTimeInterface; -use Generator; use JsonException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; @@ -144,9 +143,9 @@ interface ServerInterface * @param string|int $id * @param array $opts * - * @return Generator + * @return array */ - public function getLibrary(string|int $id, array $opts = []): Generator; + public function getLibrary(string|int $id, array $opts = []): array; /** * Get all persistent data.