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] 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 []; }