From e59759d706e8f289798edd2f045f4233ad7a9e24 Mon Sep 17 00:00:00 2001 From: abdulmohsen Date: Fri, 17 Jun 2022 21:24:08 +0300 Subject: [PATCH] Migrated backends search methods into separate actions. --- src/Backends/Emby/Action/ParseWebhook.php | 8 +- src/Backends/Emby/Action/SearchId.php | 9 + src/Backends/Emby/Action/SearchQuery.php | 9 + src/Backends/Emby/EmbyClient.php | 3 + src/Backends/Jellyfin/Action/GetMetaData.php | 4 +- src/Backends/Jellyfin/Action/ParseWebhook.php | 10 +- src/Backends/Jellyfin/Action/SearchId.php | 93 +++++++++ src/Backends/Jellyfin/Action/SearchQuery.php | 145 ++++++++++++++ src/Backends/Jellyfin/JellyfinClient.php | 11 ++ src/Backends/Plex/Action/ParseWebhook.php | 10 +- src/Backends/Plex/Action/SearchId.php | 74 +++++++ src/Backends/Plex/Action/SearchQuery.php | 154 +++++++++++++++ src/Commands/Backend/Search/QueryCommand.php | 10 +- src/Libs/Servers/EmbyServer.php | 15 +- src/Libs/Servers/JellyfinServer.php | 185 +++--------------- src/Libs/Servers/PlexServer.php | 156 +++------------ 16 files changed, 583 insertions(+), 313 deletions(-) create mode 100644 src/Backends/Emby/Action/SearchId.php create mode 100644 src/Backends/Emby/Action/SearchQuery.php create mode 100644 src/Backends/Jellyfin/Action/SearchId.php create mode 100644 src/Backends/Jellyfin/Action/SearchQuery.php create mode 100644 src/Backends/Plex/Action/SearchId.php create mode 100644 src/Backends/Plex/Action/SearchQuery.php diff --git a/src/Backends/Emby/Action/ParseWebhook.php b/src/Backends/Emby/Action/ParseWebhook.php index 3fe3d9fd..f84d9143 100644 --- a/src/Backends/Emby/Action/ParseWebhook.php +++ b/src/Backends/Emby/Action/ParseWebhook.php @@ -49,16 +49,12 @@ final class ParseWebhook * @param Context $context * @param iGuid $guid * @param iRequest $request - * @param array $opts optional options. * * @return Response */ - public function __invoke(Context $context, iGuid $guid, iRequest $request, array $opts = []): Response + public function __invoke(Context $context, iGuid $guid, iRequest $request): Response { - return $this->tryResponse( - context: $context, - fn: fn() => $this->parse($context, $guid, $request), - ); + return $this->tryResponse(context: $context, fn: fn() => $this->parse($context, $guid, $request)); } private function parse(Context $context, iGuid $guid, iRequest $request): Response diff --git a/src/Backends/Emby/Action/SearchId.php b/src/Backends/Emby/Action/SearchId.php new file mode 100644 index 00000000..47634c93 --- /dev/null +++ b/src/Backends/Emby/Action/SearchId.php @@ -0,0 +1,9 @@ + 'false', - 'fields' => implode(',', JellyfinServer::FIELDS), + 'fields' => implode(',', JellyfinClient::EXTRA_FIELDS), 'enableUserData' => 'true', 'enableImages' => 'false', 'includeItemTypes' => 'Episode,Movie,Series', diff --git a/src/Backends/Jellyfin/Action/ParseWebhook.php b/src/Backends/Jellyfin/Action/ParseWebhook.php index 6e310726..685e20e9 100644 --- a/src/Backends/Jellyfin/Action/ParseWebhook.php +++ b/src/Backends/Jellyfin/Action/ParseWebhook.php @@ -39,21 +39,17 @@ final class ParseWebhook ]; /** - * Parse Plex Webhook payload. + * Parse Webhook payload. * * @param Context $context * @param iGuid $guid * @param iRequest $request - * @param array $opts optional options. * * @return Response */ - public function __invoke(Context $context, iGuid $guid, iRequest $request, array $opts = []): Response + public function __invoke(Context $context, iGuid $guid, iRequest $request): Response { - return $this->tryResponse( - context: $context, - fn: fn() => $this->parse($context, $guid, $request, $opts), - ); + return $this->tryResponse(context: $context, fn: fn() => $this->parse($context, $guid, $request)); } private function parse(Context $context, iGuid $guid, iRequest $request): Response diff --git a/src/Backends/Jellyfin/Action/SearchId.php b/src/Backends/Jellyfin/Action/SearchId.php new file mode 100644 index 00000000..d74a923a --- /dev/null +++ b/src/Backends/Jellyfin/Action/SearchId.php @@ -0,0 +1,93 @@ +tryResponse(context: $context, fn: fn() => $this->search($context, $id, $opts)); + } + + private function search(Context $context, string|int $id, array $opts = []): Response + { + $item = $this->getItemDetails($context, $id, $opts); + + $year = (int)ag($item, 'Year', 0); + + if (0 === $year && null !== ($airDate = ag($item, 'PremiereDate'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $type = strtolower(ag($item, 'Type')); + + $episodeNumber = ('episode' === $type) ? sprintf( + '%sx%s - ', + str_pad((string)(ag($item, 'ParentIndexNumber', 0)), 2, '0', STR_PAD_LEFT), + str_pad((string)(ag($item, 'IndexNumber', 0)), 3, '0', STR_PAD_LEFT), + ) : null; + + $builder = [ + 'id' => ag($item, 'Id'), + 'type' => ucfirst($type), + 'title' => $episodeNumber . mb_substr(ag($item, ['Name', 'OriginalTitle'], '??'), 0, 50), + 'year' => $year, + 'addedAt' => makeDate(ag($item, 'DateCreated', 'now'))->format('Y-m-d H:i:s T'), + ]; + + if (null !== ($watchedAt = ag($item, 'UserData.LastPlayedDate'))) { + $builder['watchedAt'] = makeDate($watchedAt)->format('Y-m-d H:i:s T'); + } + + if (null !== ($endDate = ag($item, 'EndDate'))) { + $builder['EndedAt'] = makeDate($endDate)->format('Y-m-d H:i:s T'); + } + + if (('movie' === $type || 'series' === $type) && null !== ($premiereDate = ag($item, 'PremiereDate'))) { + $builder['premieredAt'] = makeDate($premiereDate)->format('Y-m-d H:i:s T'); + } + + if (null !== $watchedAt) { + $builder['watchedAt'] = makeDate($watchedAt)->format('Y-m-d H:i:s T'); + } + + if (('episode' === $type || 'movie' === $type) && null !== ($duration = ag($item, 'RunTimeTicks'))) { + $builder['duration'] = formatDuration($duration / 10000); + } + + if (null !== ($status = ag($item, 'Status'))) { + $builder['status'] = $status; + } + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $item; + } + + return new Response(status: true, response: $builder); + } +} diff --git a/src/Backends/Jellyfin/Action/SearchQuery.php b/src/Backends/Jellyfin/Action/SearchQuery.php new file mode 100644 index 00000000..bebc2943 --- /dev/null +++ b/src/Backends/Jellyfin/Action/SearchQuery.php @@ -0,0 +1,145 @@ +tryResponse(context: $context, fn: fn() => $this->search($context, $query, $limit, $opts)); + } + + /** + * Search Backend Titles. + * + * @throws ExceptionInterface + * @throws JsonException + */ + private function search(Context $context, string $query, int $limit = 25, array $opts = []): Response + { + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery( + http_build_query( + array_replace_recursive( + [ + 'searchTerm' => $query, + 'limit' => $limit, + 'recursive' => 'true', + 'fields' => implode(',', JellyfinClient::EXTRA_FIELDS), + 'enableUserData' => 'true', + 'enableImages' => 'false', + 'includeItemTypes' => 'Episode,Movie,Series', + ], + $opts['query'] ?? [] + ) + ) + ); + + $this->logger->debug('Searching [%(backend)] libraries for [%(query)].', [ + 'backend' => $context->backendName, + 'query' => $query, + 'url' => $url + ]); + + $response = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, $opts['headers'] ?? []) + ); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Search request for [%(query)] in [%(backend)] returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'query' => $query, + '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 Searching [%(backend)] libraries for [%(query)] payload.', [ + 'backend' => $context->backendName, + 'query' => $query, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + $list = []; + + foreach (ag($json, 'Items', []) as $item) { + $watchedAt = ag($item, 'UserData.LastPlayedDate'); + $year = (int)ag($item, 'Year', 0); + + if (0 === $year && null !== ($airDate = ag($item, 'PremiereDate'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $type = strtolower(ag($item, 'Type')); + + $episodeNumber = ('episode' === $type) ? sprintf( + '%sx%s - ', + str_pad((string)(ag($item, 'ParentIndexNumber', 0)), 2, '0', STR_PAD_LEFT), + str_pad((string)(ag($item, 'IndexNumber', 0)), 3, '0', STR_PAD_LEFT), + ) : null; + + $builder = [ + 'id' => ag($item, 'Id'), + 'type' => ucfirst($type), + 'title' => $episodeNumber . mb_substr(ag($item, ['Name', 'OriginalTitle'], '??'), 0, 50), + 'year' => $year, + 'addedAt' => makeDate(ag($item, 'DateCreated', 'now'))->format('Y-m-d H:i:s T'), + 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', + ]; + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $item; + } + + $list[] = $builder; + } + + return new Response(status: true, response: $list); + } +} diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index dd8a4249..bb02e33b 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -19,6 +19,17 @@ class JellyfinClient public const TYPE_SHOW = 'Series'; public const TYPE_EPISODE = 'Episode'; + public const EXTRA_FIELDS = [ + 'ProviderIds', + 'DateCreated', + 'OriginalTitle', + 'SeasonUserData', + 'DateLastSaved', + 'PremiereDate', + 'ProductionYear', + 'Path', + ]; + private Context|null $context = null; public function __construct( diff --git a/src/Backends/Plex/Action/ParseWebhook.php b/src/Backends/Plex/Action/ParseWebhook.php index ca698616..dffa9eec 100644 --- a/src/Backends/Plex/Action/ParseWebhook.php +++ b/src/Backends/Plex/Action/ParseWebhook.php @@ -44,21 +44,17 @@ final class ParseWebhook ]; /** - * Parse Plex Webhook payload. + * Parse Webhook payload. * * @param Context $context * @param iGuid $guid * @param iRequest $request - * @param array $opts optional options. * * @return Response */ - public function __invoke(Context $context, iGuid $guid, iRequest $request, array $opts = []): Response + public function __invoke(Context $context, iGuid $guid, iRequest $request): Response { - return $this->tryResponse( - context: $context, - fn: fn() => $this->parse($context, $guid, $request, $opts), - ); + return $this->tryResponse(context: $context, fn: fn() => $this->parse($context, $guid, $request)); } private function parse(Context $context, iGuid $guid, iRequest $request): Response diff --git a/src/Backends/Plex/Action/SearchId.php b/src/Backends/Plex/Action/SearchId.php new file mode 100644 index 00000000..f3153266 --- /dev/null +++ b/src/Backends/Plex/Action/SearchId.php @@ -0,0 +1,74 @@ +tryResponse(context: $context, fn: fn() => $this->search($context, $id, $opts)); + } + + private function search(Context $context, string|int $id, array $opts = []): Response + { + $item = $this->getItemDetails($context, $id, $opts); + + $metadata = ag($item, 'MediaContainer.Metadata.0', []); + + $type = ag($metadata, 'type'); + $watchedAt = ag($metadata, 'lastViewedAt'); + + $year = (int)ag($metadata, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($metadata, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $episodeNumber = ('episode' === $type) ? sprintf( + '%sx%s - ', + str_pad((string)(ag($metadata, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT), + str_pad((string)(ag($metadata, 'index', 0)), 3, '0', STR_PAD_LEFT), + ) : null; + + $builder = [ + 'id' => (int)ag($metadata, 'ratingKey'), + 'type' => ucfirst(ag($metadata, 'type', '??')), + 'library' => ag($metadata, 'librarySectionTitle', '??'), + 'title' => $episodeNumber . mb_substr(ag($metadata, ['title', 'originalTitle'], '??'), 0, 50), + 'year' => $year, + 'addedAt' => makeDate(ag($metadata, 'addedAt'))->format('Y-m-d H:i:s T'), + 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', + 'duration' => ag($metadata, 'duration') ? formatDuration(ag($metadata, 'duration')) : 'None', + ]; + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $item; + } + + return new Response(status: true, response: $builder); + } +} diff --git a/src/Backends/Plex/Action/SearchQuery.php b/src/Backends/Plex/Action/SearchQuery.php new file mode 100644 index 00000000..f2fb7638 --- /dev/null +++ b/src/Backends/Plex/Action/SearchQuery.php @@ -0,0 +1,154 @@ +tryResponse(context: $context, fn: fn() => $this->search($context, $query, $limit, $opts)); + } + + /** + * Search Backend Titles. + * + * @throws ExceptionInterface + * @throws JsonException + */ + private function search(Context $context, string $query, int $limit = 25, array $opts = []): Response + { + $url = $context->backendUrl->withPath('/hubs/search')->withQuery( + http_build_query( + array_replace_recursive( + [ + 'query' => $query, + 'limit' => $limit, + 'includeGuids' => 1, + 'includeExternalMedia' => 0, + 'includeCollections' => 0, + ], + $opts['query'] ?? [] + ) + ) + ); + + $this->logger->debug('Searching [%(backend)] libraries for [%(query)].', [ + 'backend' => $context->backendName, + 'query' => $query, + 'url' => $url + ]); + + $response = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, $opts['headers'] ?? []) + ); + + $this->logger->debug('Requesting [%(backend)] Users list.', [ + 'backend' => $context->backendName, + 'url' => (string)$url, + ]); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Search request for [%(query)] in [%(backend)] returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'query' => $query, + '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 Searching [%(backend)] libraries for [%(query)] payload.', [ + 'backend' => $context->backendName, + 'query' => $query, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + $list = []; + + foreach (ag($json, 'MediaContainer.Hub', []) as $leaf) { + $type = ag($leaf, 'type'); + + if ('show' !== $type && 'movie' !== $type && 'episode' !== $type) { + continue; + } + + foreach (ag($leaf, 'Metadata', []) as $item) { + $watchedAt = ag($item, 'lastViewedAt'); + + $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $episodeNumber = ('episode' === $type) ? sprintf( + '%sx%s - ', + str_pad((string)(ag($item, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT), + str_pad((string)(ag($item, 'index', 0)), 3, '0', STR_PAD_LEFT), + ) : null; + + $builder = [ + 'id' => (int)ag($item, 'ratingKey'), + 'type' => ucfirst(ag($item, 'type', '??')), + 'library' => ag($item, 'librarySectionTitle', '??'), + 'title' => $episodeNumber . mb_substr(ag($item, ['title', 'originalTitle'], '??'), 0, 50), + 'year' => $year, + 'addedAt' => makeDate(ag($item, 'addedAt'))->format('Y-m-d H:i:s T'), + 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', + ]; + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $builder['raw'] = $item; + } + + $list[] = $builder; + } + } + + return new Response(status: true, response: $list); + } +} diff --git a/src/Commands/Backend/Search/QueryCommand.php b/src/Commands/Backend/Search/QueryCommand.php index e161f7cb..a0cd16a8 100644 --- a/src/Commands/Backend/Search/QueryCommand.php +++ b/src/Commands/Backend/Search/QueryCommand.php @@ -46,14 +46,18 @@ final class QueryCommand extends Command } try { - $backend = $this->getBackend($input->getArgument('backend')); - - $opts = []; + $opts = $backendOpts = []; if ($input->getOption('include-raw-response')) { $opts[Options::RAW_RESPONSE] = true; } + if ($input->getOption('trace')) { + $backendOpts = ag_set($opts, 'options.' . Options::DEBUG_TRACE, true); + } + + $backend = $this->getBackend($input->getArgument('backend'), $backendOpts); + $results = $backend->search( query: $query, limit: (int)$input->getOption('limit'), diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 24235290..a1f108f0 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -32,18 +32,13 @@ class EmbyServer extends JellyfinServer public function parseWebhook(ServerRequestInterface $request): iFace { - $response = Container::get(ParseWebhook::class)( - context: $this->context, - guid: $this->guid, - request: $request, - opts: $this->options - ); + $response = Container::get(ParseWebhook::class)(context: $this->context, guid: $this->guid, request: $request); + + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } if (false === $response->isSuccessful()) { - if ($response->hasError()) { - $this->logger->log($response->error->level(), $response->error->message, $response->error->context); - } - throw new HttpException( ag($response->extra, 'message', fn() => $response->error->format()), ag($response->extra, 'http_code', 400), diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index a37497ab..01d2d090 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -10,6 +10,8 @@ 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\SearchId; +use App\Backends\Jellyfin\Action\SearchQuery; use App\Backends\Jellyfin\JellyfinActionTrait; use App\Backends\Jellyfin\JellyfinClient; use App\Backends\Jellyfin\JellyfinGuid; @@ -50,16 +52,7 @@ class JellyfinServer implements ServerInterface protected const COLLECTION_TYPE_SHOWS = 'tvshows'; protected const COLLECTION_TYPE_MOVIES = 'movies'; - public const FIELDS = [ - 'ProviderIds', - 'DateCreated', - 'OriginalTitle', - 'SeasonUserData', - 'DateLastSaved', - 'PremiereDate', - 'ProductionYear', - 'Path', - ]; + public const FIELDS = JellyfinClient::EXTRA_FIELDS; protected UriInterface|null $url = null; protected string|null $token = null; @@ -192,18 +185,13 @@ class JellyfinServer implements ServerInterface public function parseWebhook(ServerRequestInterface $request): iFace { - $response = Container::get(ParseWebhook::class)( - context: $this->context, - guid: $this->guid, - request: $request, - opts: $this->options - ); + $response = Container::get(ParseWebhook::class)(context: $this->context, guid: $this->guid, request: $request); + + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } if (false === $response->isSuccessful()) { - if ($response->hasError()) { - $this->logger->log($response->error->level(), $response->error->message, $response->error->context); - } - throw new HttpException( ag($response->extra, 'message', fn() => $response->error->format()), ag($response->extra, 'http_code', 400), @@ -215,93 +203,22 @@ class JellyfinServer implements ServerInterface public function search(string $query, int $limit = 25, array $opts = []): array { - $this->checkConfig(true); + $response = Container::get(SearchQuery::class)( + context: $this->context, + query: $query, + limit: $limit, + opts: $opts + ); - try { - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - array_replace_recursive( - [ - 'searchTerm' => $query, - 'limit' => $limit, - 'recursive' => 'true', - 'fields' => implode(',', self::FIELDS), - 'enableUserData' => 'true', - 'enableImages' => 'false', - 'includeItemTypes' => 'Episode,Movie,Series', - ], - $opts['query'] ?? [] - ) - ) - ); - - $this->logger->debug('Searching for [%(query)] in [%(backend)].', [ - 'backend' => $this->context->backendName, - 'query' => $query, - 'url' => $url - ]); - - $response = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), $opts['headers'] ?? []) - ); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Search request for [%s] in [%s] responded with unexpected [%s] status code.', - $query, - $this->context->backendName, - $response->getStatusCode(), - ) - ); - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $list = []; - - foreach (ag($json, 'Items', []) as $item) { - $watchedAt = ag($item, 'UserData.LastPlayedDate'); - $year = (int)ag($item, 'Year', 0); - - if (0 === $year && null !== ($airDate = ag($item, 'PremiereDate'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $type = strtolower(ag($item, 'Type')); - - $episodeNumber = ('episode' === $type) ? sprintf( - '%sx%s - ', - str_pad((string)(ag($item, 'ParentIndexNumber', 0)), 2, '0', STR_PAD_LEFT), - str_pad((string)(ag($item, 'IndexNumber', 0)), 3, '0', STR_PAD_LEFT), - ) : null; - - $builder = [ - 'id' => ag($item, 'Id'), - 'type' => ucfirst($type), - 'title' => $episodeNumber . mb_substr(ag($item, ['Name', 'OriginalTitle'], '??'), 0, 50), - 'year' => $year, - 'addedAt' => makeDate(ag($item, 'DateCreated', 'now'))->format('Y-m-d H:i:s T'), - 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', - ]; - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $item; - } - - $list[] = $builder; - } - - return $list; - } catch (ExceptionInterface|JsonException $e) { - throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } + + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + } + + return $response->response; } /** @@ -309,59 +226,21 @@ class JellyfinServer implements ServerInterface */ public function searchId(string|int $id, array $opts = []): array { - $item = $this->getMetadata($id, $opts); + $response = Container::get(SearchId::class)( + context: $this->context, + id: $id, + opts: $opts + ); - $year = (int)ag($item, 'Year', 0); - - if (0 === $year && null !== ($airDate = ag($item, 'PremiereDate'))) { - $year = (int)makeDate($airDate)->format('Y'); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $type = strtolower(ag($item, 'Type')); - - $episodeNumber = ('episode' === $type) ? sprintf( - '%sx%s - ', - str_pad((string)(ag($item, 'ParentIndexNumber', 0)), 2, '0', STR_PAD_LEFT), - str_pad((string)(ag($item, 'IndexNumber', 0)), 3, '0', STR_PAD_LEFT), - ) : null; - - $builder = [ - 'id' => ag($item, 'Id'), - 'type' => ucfirst($type), - 'title' => $episodeNumber . mb_substr(ag($item, ['Name', 'OriginalTitle'], '??'), 0, 50), - 'year' => $year, - 'addedAt' => makeDate(ag($item, 'DateCreated', 'now'))->format('Y-m-d H:i:s T'), - ]; - - if (null !== ($watchedAt = ag($item, 'UserData.LastPlayedDate'))) { - $builder['watchedAt'] = makeDate($watchedAt)->format('Y-m-d H:i:s T'); + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); } - if (null !== ($endDate = ag($item, 'EndDate'))) { - $builder['EndedAt'] = makeDate($endDate)->format('Y-m-d H:i:s T'); - } - - if (('movie' === $type || 'series' === $type) && null !== ($premiereDate = ag($item, 'PremiereDate'))) { - $builder['premieredAt'] = makeDate($premiereDate)->format('Y-m-d H:i:s T'); - } - - if (null !== $watchedAt) { - $builder['watchedAt'] = makeDate($watchedAt)->format('Y-m-d H:i:s T'); - } - - if (('episode' === $type || 'movie' === $type) && null !== ($duration = ag($item, 'RunTimeTicks'))) { - $builder['duration'] = formatDuration($duration / 10000); - } - - if (null !== ($status = ag($item, 'Status'))) { - $builder['status'] = $status; - } - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $item; - } - - return $builder; + return $response->response; } /** diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index ccd6e936..be8cadee 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -10,6 +10,8 @@ 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\SearchId; +use App\Backends\Plex\Action\SearchQuery; use App\Backends\Plex\PlexActionTrait; use App\Backends\Plex\PlexClient; use App\Backends\Plex\PlexGuid; @@ -158,6 +160,10 @@ class PlexServer implements ServerInterface { $response = Container::get(InspectRequest::class)(context: $this->context, request: $request); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + return $response->isSuccessful() ? $response->response : $request; } @@ -166,15 +172,14 @@ class PlexServer implements ServerInterface $response = Container::get(ParseWebhook::class)( context: $this->context, guid: $this->guid, - request: $request, - opts: $this->options + request: $request ); - if (false === $response->isSuccessful()) { - if ($response->hasError()) { - $this->logger->log($response->error->level(), $response->error->message, $response->error->context); - } + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + if (false === $response->isSuccessful()) { throw new HttpException( ag($response->extra, 'message', fn() => $response->error->format()), ag($response->extra, 'http_code', 400), @@ -186,136 +191,37 @@ class PlexServer implements ServerInterface public function search(string $query, int $limit = 25, array $opts = []): array { - $this->checkConfig(); + $response = Container::get(SearchQuery::class)( + context: $this->context, + query: $query, + limit: $limit, + opts: $opts + ); - try { - $url = $this->url->withPath('/hubs/search')->withQuery( - http_build_query( - array_replace_recursive( - [ - 'query' => $query, - 'limit' => $limit, - 'includeGuids' => 1, - 'includeExternalMedia' => 0, - 'includeCollections' => 0, - ], - $opts['query'] ?? [] - ) - ) - ); - - $this->logger->debug('Searching for [%(query)] in [%(backend)].', [ - 'backend' => $this->getName(), - 'query' => $query, - 'url' => $url - ]); - - $response = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), $opts['headers'] ?? []) - ); - - if (200 !== $response->getStatusCode()) { - throw new RuntimeException( - sprintf( - 'Search request for [%s] in [%s] responded with unexpected [%s] status code.', - $query, - $this->getName(), - $response->getStatusCode(), - ) - ); - } - - $list = []; - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - foreach (ag($json, 'MediaContainer.Hub', []) as $leaf) { - $type = ag($leaf, 'type'); - - if ('show' !== $type && 'movie' !== $type && 'episode' !== $type) { - continue; - } - - foreach (ag($leaf, 'Metadata', []) as $item) { - $watchedAt = ag($item, 'lastViewedAt'); - - $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $episodeNumber = ('episode' === $type) ? sprintf( - '%sx%s - ', - str_pad((string)(ag($item, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT), - str_pad((string)(ag($item, 'index', 0)), 3, '0', STR_PAD_LEFT), - ) : null; - - $builder = [ - 'id' => (int)ag($item, 'ratingKey'), - 'type' => ucfirst(ag($item, 'type', '??')), - 'library' => ag($item, 'librarySectionTitle', '??'), - 'title' => $episodeNumber . mb_substr(ag($item, ['title', 'originalTitle'], '??'), 0, 50), - 'year' => $year, - 'addedAt' => makeDate(ag($item, 'addedAt'))->format('Y-m-d H:i:s T'), - 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', - ]; - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $item; - } - - $list[] = $builder; - } - } - - return $list; - } catch (ExceptionInterface|JsonException $e) { - throw new RuntimeException(sprintf('%s: %s', $this->getName(), $e->getMessage()), previous: $e); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } + + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); + } + + return $response->response; } public function searchId(string|int $id, array $opts = []): array { - $item = $this->getMetadata($id, $opts); + $response = Container::get(SearchId::class)(context: $this->context, id: $id, opts: $opts); - $metadata = ag($item, 'MediaContainer.Metadata.0', []); - - $type = ag($metadata, 'type'); - $watchedAt = ag($metadata, 'lastViewedAt'); - - $year = (int)ag($metadata, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($metadata, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - $episodeNumber = ('episode' === $type) ? sprintf( - '%sx%s - ', - str_pad((string)(ag($metadata, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT), - str_pad((string)(ag($metadata, 'index', 0)), 3, '0', STR_PAD_LEFT), - ) : null; - - $builder = [ - 'id' => (int)ag($metadata, 'ratingKey'), - 'type' => ucfirst(ag($metadata, 'type', '??')), - 'library' => ag($metadata, 'librarySectionTitle', '??'), - 'title' => $episodeNumber . mb_substr(ag($metadata, ['title', 'originalTitle'], '??'), 0, 50), - 'year' => $year, - 'addedAt' => makeDate(ag($metadata, 'addedAt'))->format('Y-m-d H:i:s T'), - 'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'Never', - 'duration' => ag($metadata, 'duration') ? formatDuration(ag($metadata, 'duration')) : 'None', - ]; - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $builder['raw'] = $item; + if (false === $response->isSuccessful()) { + throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format())); } - return $builder; + return $response->response; } public function getMetadata(string|int $id, array $opts = []): array