From 3a44b5c5d8ff99d5d2ff714e774ed210fec9d874 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Mon, 9 May 2022 07:10:35 +0300 Subject: [PATCH] Updated backends webhook handling to support new database schema, And updated Emby webhook handler to include parent External ids to enable Relative external id support. --- src/Libs/Servers/EmbyServer.php | 243 ++++++++++++++------- src/Libs/Servers/JellyfinServer.php | 327 ++++++++++++++-------------- src/Libs/Servers/PlexServer.php | 257 ++++++++++------------ src/Libs/helpers.php | 19 +- 4 files changed, 463 insertions(+), 383 deletions(-) diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index 6c44dd41..33d7a6db 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -7,10 +7,14 @@ namespace App\Libs\Servers; use App\Libs\Config; use App\Libs\Container; use App\Libs\Entity\StateInterface; +use App\Libs\Guid; use App\Libs\HttpException; use DateTimeInterface; +use JsonException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Throwable; class EmbyServer extends JellyfinServer @@ -51,30 +55,39 @@ class EmbyServer extends JellyfinServer public static function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + try { + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Emby Server/')) { - return $request; - } + if (false === str_starts_with($userAgent, 'Emby Server/')) { + return $request; + } - $payload = ag($request->getParsedBody() ?? [], 'data', null); + $payload = ag($request->getParsedBody() ?? [], 'data', null); - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { - return $request; - } + if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + return $request; + } - $attributes = [ - 'SERVER_ID' => ag($json, 'Server.Id', ''), - 'SERVER_NAME' => ag($json, 'Server.Name', ''), - 'SERVER_VERSION' => afterLast($userAgent, '/'), - 'USER_ID' => ag($json, 'User.Id', ''), - 'USER_NAME' => ag($json, 'User.Name', ''), - 'WH_EVENT' => ag($json, 'Event', 'not_set'), - 'WH_TYPE' => ag($json, 'Item.Type', 'not_set'), - ]; + $request = $request->withParsedBody($json); - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + $attributes = [ + 'SERVER_ID' => ag($json, 'Server.Id', ''), + 'SERVER_NAME' => ag($json, 'Server.Name', ''), + 'SERVER_VERSION' => afterLast($userAgent, '/'), + 'USER_ID' => ag($json, 'User.Id', ''), + 'USER_NAME' => ag($json, 'User.Name', ''), + 'WH_EVENT' => ag($json, 'Event', 'not_set'), + 'WH_TYPE' => ag($json, 'Item.Type', 'not_set'), + ]; + + foreach ($attributes as $key => $val) { + $request = $request->withAttribute($key, $val); + } + } catch (Throwable $e) { + Container::get(LoggerInterface::class)->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); } return $request; @@ -82,9 +95,7 @@ class EmbyServer extends JellyfinServer public function parseWebhook(ServerRequestInterface $request): StateInterface { - $payload = ag($request->getParsedBody() ?? [], 'data', null); - - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = $request->getParsedBody())) { throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); } @@ -101,47 +112,16 @@ class EmbyServer extends JellyfinServer throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); } - $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); + $event = strtolower($event); - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')), - 'year' => ag($json, 'Item.ProductionYear', 0000), - 'date' => makeDate( - ag( - $json, - 'Item.PremiereDate', - ag($json, 'Item.ProductionYear', ag($json, 'Item.DateCreated', 'now')) - ) - )->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($json, 'Item.SeriesName', '??'), - 'year' => ag($json, 'Item.ProductionYear', 0000), - 'season' => ag($json, 'Item.ParentIndexNumber', 0), - 'episode' => ag($json, 'Item.IndexNumber', 0), - 'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')), - 'date' => makeDate(ag($json, 'Item.PremiereDate', ag($json, 'Item.ProductionYear', 'now')))->format( - 'Y-m-d' - ), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; + $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); if ('item.markplayed' === $event || 'playback.scrobble' === $event) { $isWatched = 1; } elseif ('item.markunplayed' === $event) { $isWatched = 0; } else { - $isWatched = (int)(bool)ag($json, 'Item.Played', ag($json, 'Item.PlayedToCompletion', 0)); + $isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], 0); } $providersId = ag($json, 'Item.ProviderIds', []); @@ -150,35 +130,59 @@ class EmbyServer extends JellyfinServer 'type' => $type, 'updated' => time(), 'watched' => $isWatched, - 'meta' => $meta, - ...$this->getGuids($providersId) + 'via' => $this->name, + 'title' => '??', + 'year' => ag($json, 'Item.ProductionYear', 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($providersId), + 'extra' => [ + 'date' => makeDate( + ag($json, ['Item.PremiereDate', 'Item.ProductionYear', 'Item.DateCreated'], 'now') + )->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($json, 'Item.SeriesName', '??'); + $row['season'] = ag($json, 'Item.ParentIndexNumber', 0); + $row['episode'] = ag($json, 'Item.IndexNumber', 0); + $row['extra']['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'); + + if (null !== ag($json, 'Item.SeriesId')) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'Item.SeriesId')); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); - if (!$entity->hasGuids()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($providersId) ? $providersId : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($providersId)) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => !empty($providersId) ? $providersId : 'None'])); + + throw new HttpException($message, 400); } - foreach ($entity->getPointers() as $guid) { - $this->cacheData[$guid] = ag($json, 'item.Id'); + foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { + $this->cacheData[$guid] = ag($json, 'Item.Id'); } - if (false === $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -188,6 +192,12 @@ class EmbyServer extends JellyfinServer return $entity; } + /** + * @param array $entities + * @param DateTimeInterface|null $after + * @return array + * @TODO need to be updated to support cached items. + */ public function push(array $entities, DateTimeInterface|null $after = null): array { $requests = []; @@ -214,14 +224,12 @@ class EmbyServer extends JellyfinServer try { $guids = []; - foreach ($entity->getPointers() as $pointer) { - if (str_starts_with($pointer, 'guid_plex://')) { + foreach ($entity->guids ?? [] as $key => $val) { + if ('guid_plex' === $key) { continue; } - if (false === preg_match('#guid_(.+?)://\w+?/(.+)#s', $pointer, $matches)) { - continue; - } - $guids[] = sprintf('%s.%s', $matches[1], $matches[2]); + + $guids[] = sprintf('%s.%s', afterLast($key, 'guid_'), $val); } if (empty($guids)) { @@ -292,7 +300,6 @@ class EmbyServer extends JellyfinServer $isWatched = (int)(bool)ag($json, 'UserData.Played', false); - if ($state->watched === $isWatched) { $this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName)); continue; @@ -341,4 +348,72 @@ class EmbyServer extends JellyfinServer return $stateRequests; } + private function getEpisodeParent(int|string $id): array + { + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $id, $this->user) + ), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + if (null === ($itemType = ag($json, 'Type')) || 'Series' !== $itemType) { + return []; + } + + $providersId = (array)ag($json, 'ProviderIds', []); + + if (!$this->hasSupportedIds($providersId)) { + $this->cacheShow[$id] = []; + return $this->cacheShow[$id]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { + [$type, $guid] = explode('://', $guid); + $guids[$type] = $guid; + } + + $this->cacheShow[$id] = $guids; + + return $this->cacheShow[$id]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 665510c5..c1067a9b 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -16,7 +16,6 @@ use App\Libs\Mappers\ImportInterface; use Closure; use DateInterval; use DateTimeInterface; -use Exception; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -233,32 +232,39 @@ class JellyfinServer implements ServerInterface public static function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); + try { + $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'Jellyfin-Server/')) { - return $request; - } + if (false === str_starts_with($userAgent, 'Jellyfin-Server/')) { + return $request; + } - $body = (string)$request->getBody(); + $body = (string)$request->getBody(); - if (null === ($json = json_decode($body, true))) { - return $request; - } + if (null === ($json = json_decode($body, true))) { + return $request; + } - $request = $request->withParsedBody($json); + $request = $request->withParsedBody($json); - $attributes = [ - 'SERVER_ID' => ag($json, 'ServerId', ''), - 'SERVER_NAME' => ag($json, 'ServerName', ''), - 'SERVER_VERSION' => afterLast($userAgent, '/'), - 'USER_ID' => ag($json, 'UserId', ''), - 'USER_NAME' => ag($json, 'NotificationUsername', ''), - 'WH_EVENT' => ag($json, 'NotificationType', 'not_set'), - 'WH_TYPE' => ag($json, 'ItemType', 'not_set'), - ]; + $attributes = [ + 'SERVER_ID' => ag($json, 'ServerId', ''), + 'SERVER_NAME' => ag($json, 'ServerName', ''), + 'SERVER_VERSION' => afterLast($userAgent, '/'), + 'USER_ID' => ag($json, 'UserId', ''), + 'USER_NAME' => ag($json, 'NotificationUsername', ''), + 'WH_EVENT' => ag($json, 'NotificationType', 'not_set'), + 'WH_TYPE' => ag($json, 'ItemType', 'not_set'), + ]; - foreach ($attributes as $key => $val) { - $request = $request->withAttribute($key, $val); + foreach ($attributes as $key => $val) { + $request = $request->withAttribute($key, $val); + } + } catch (Throwable $e) { + Container::get(LoggerInterface::class)->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); } return $request; @@ -285,29 +291,6 @@ class JellyfinServer implements ServerInterface $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($json, 'Name', '??'), - 'year' => ag($json, 'Year', 0000), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($json, 'SeriesName', '??'), - 'year' => ag($json, 'Year', 0000), - 'season' => ag($json, 'SeasonNumber', 0), - 'episode' => ag($json, 'EpisodeNumber', 0), - 'title' => ag($json, 'Name', '??'), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; - $providersId = []; foreach ($json as $key => $val) { @@ -317,44 +300,66 @@ class JellyfinServer implements ServerInterface $providersId[self::afterString($key, 'Provider_')] = $val; } - // We use SeriesName to overcome jellyfin webhook limitation, it does not send series id. - if (StateInterface::TYPE_EPISODE === $type && null !== ag($json, 'SeriesName')) { - $meta['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), ag($json, 'SeriesName')); - } - $row = [ 'type' => $type, 'updated' => time(), - 'watched' => (int)(bool)ag($json, 'Played', ag($json, 'PlayedToCompletion', 0)), - 'meta' => $meta, - ...$this->getGuids($providersId) + 'watched' => (int)(bool)ag($json, ['Played', 'PlayedToCompletion'], 0), + 'via' => $this->name, + 'title' => '??', + 'year' => ag($json, 'Year', 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids($providersId), + 'extra' => [ + 'date' => makeDate($item->PremiereDate ?? $item->ProductionYear ?? 'now')->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($json, ['Name', 'OriginalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($json, 'SeriesName', '??'); + $row['season'] = ag($json, 'ParentIndexNumber', 0); + $row['episode'] = ag($json, 'IndexNumber', 0); + + if (null !== ($epTitle = ag($json, ['Name', 'OriginalTitle'], null))) { + $row['extra']['title'] = $epTitle; + } + + // -- We use SeriesName to overcome jellyfin webhook limitation, it does not send series id. + // -- it might lead to incorrect result if there is a show with duplicate name. + if (null !== ag($json, 'SeriesName')) { + $row['parent'] = $this->getEpisodeParent(ag($json, 'ItemId'), ag($json, 'SeriesName')); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($providersId) ? $providersId : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($providersId)) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => !empty($providersId) ? $providersId : 'None'])); + + throw new HttpException($message, 400); } foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { $this->cacheData[$guid] = ag($json, 'Item.ItemId'); } - if (false === $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false === $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -364,99 +369,6 @@ class JellyfinServer implements ServerInterface return $entity; } - protected function getEpisodeParent(mixed $id, string|null $series): array - { - if (null !== $series && array_key_exists($series, $this->cacheShow)) { - return $this->cacheShow[$series]; - } - - try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath( - sprintf('/Users/%s/items/' . $id, $this->user) - ), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - if (null === ($type = ag($json, 'Type'))) { - return []; - } - - if (StateInterface::TYPE_EPISODE !== strtolower($type)) { - return []; - } - - if (null === ($seriesId = ag($json, 'SeriesId'))) { - return []; - } - - $response = $this->http->request( - 'GET', - (string)$this->url->withPath( - sprintf('/Users/%s/items/' . $seriesId, $this->user) - )->withQuery(http_build_query(['Fields' => 'ProviderIds'])), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $series = $json['Name'] ?? $json['OriginalTitle'] ?? $json['Id'] ?? random_int(1, PHP_INT_MAX); - - $providersId = (array)ag($json, 'ProviderIds', []); - - if (!$this->hasSupportedIds($providersId)) { - $this->cacheShow[$series] = []; - return $this->cacheShow[$series]; - } - - $guids = []; - - foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$series] = $guids; - - return $this->cacheShow[$series]; - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } - } - protected function getHeaders(): array { $opts = [ @@ -1683,4 +1595,97 @@ class JellyfinServer implements ServerInterface return $entity; } + + private function getEpisodeParent(mixed $id, string|null $series): array + { + if (null !== $series && array_key_exists($series, $this->cacheShow)) { + return $this->cacheShow[$series]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $id, $this->user) + ), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + if (null === ($type = ag($json, 'Type'))) { + return []; + } + + if (StateInterface::TYPE_EPISODE !== strtolower($type)) { + return []; + } + + if (null === ($seriesId = ag($json, 'SeriesId'))) { + return []; + } + + $response = $this->http->request( + 'GET', + (string)$this->url->withPath( + sprintf('/Users/%s/items/' . $seriesId, $this->user) + )->withQuery(http_build_query(['Fields' => 'ProviderIds'])), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $series = $json['Name'] ?? $json['OriginalTitle'] ?? $json['Id'] ?? random_int(1, PHP_INT_MAX); + + $providersId = (array)ag($json, 'ProviderIds', []); + + if (!$this->hasSupportedIds($providersId)) { + $this->cacheShow[$series] = []; + return $this->cacheShow[$series]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($providersId))->getPointers() as $guid) { + [$type, $id] = explode('://', $guid); + $guids[$type] = $id; + } + + $this->cacheShow[$series] = $guids; + + return $this->cacheShow[$series]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 9c6a75a1..046b29b1 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -16,7 +16,6 @@ use App\Libs\Mappers\ImportInterface; use Closure; use DateInterval; use DateTimeInterface; -use Exception; use JsonException; use JsonMachine\Exception\PathNotFoundException; use JsonMachine\Items; @@ -263,7 +262,7 @@ class PlexServer implements ServerInterface try { $userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', ''); - if (false === Config::get('webhook.debug', false) && !str_starts_with($userAgent, 'PlexMediaServer/')) { + if (false === str_starts_with($userAgent, 'PlexMediaServer/')) { return $request; } @@ -273,6 +272,8 @@ class PlexServer implements ServerInterface return $request; } + $request = $request->withParsedBody($json); + $attributes = [ 'SERVER_ID' => ag($json, 'Server.uuid', ''), 'SERVER_NAME' => ag($json, 'Server.title', ''), @@ -298,9 +299,7 @@ class PlexServer implements ServerInterface public function parseWebhook(ServerRequestInterface $request): StateInterface { - $payload = ag($request->getParsedBody() ?? [], 'payload', null); - - if (null === $payload || null === ($json = json_decode((string)$payload, true))) { + if (null === ($json = $request->getParsedBody())) { throw new HttpException(sprintf('%s: No payload.', afterLast(__CLASS__, '\\')), 400); } @@ -316,48 +315,21 @@ class PlexServer implements ServerInterface throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200); } - $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); - - $ignoreIds = null; - - if (null !== ($this->options['ignore'] ?? null)) { - $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$this->options['ignore'])); + if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$ignoreIds)); } if (null !== $ignoreIds && in_array(ag($item, 'librarySectionID', '???'), $ignoreIds)) { throw new HttpException( sprintf( - '%s: Library id \'%s\' is ignored.', + '%s: Library id \'%s\' is ignored by user server config.', afterLast(__CLASS__, '\\'), ag($item, 'librarySectionID', '???') ), 200 ); } - $meta = match ($type) { - StateInterface::TYPE_MOVIE => [ - 'via' => $this->name, - 'title' => ag($item, 'title', ag($item, 'originalTitle', '??')), - 'year' => ag($item, 'year', 0000), - 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - StateInterface::TYPE_EPISODE => [ - 'via' => $this->name, - 'series' => ag($item, 'grandparentTitle', '??'), - 'year' => ag($item, 'year', 0000), - 'season' => ag($item, 'parentIndex', 0), - 'episode' => ag($item, 'index', 0), - 'title' => ag($item, 'title', ag($item, 'originalTitle', '??')), - 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), - 'webhook' => [ - 'event' => $event, - ], - ], - default => throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400), - }; + $isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS); if (null === ag($item, 'Guid', null)) { $item['Guid'] = [['id' => ag($item, 'guid')]]; @@ -365,44 +337,61 @@ class PlexServer implements ServerInterface $item['Guid'][] = ['id' => ag($item, 'guid')]; } - if (StateInterface::TYPE_EPISODE === $type) { - $parentId = ag($item, 'grandparentRatingKey', fn() => ag($item, 'parentRatingKey')); - $meta['parent'] = null !== $parentId ? $this->getEpisodeParent($parentId) : []; - } - $row = [ 'type' => $type, 'updated' => time(), 'watched' => (int)(bool)ag($item, 'viewCount', 0), - 'meta' => $meta, - ...$this->getGuids(ag($item, 'Guid', []), isParent: false) + 'via' => $this->name, + 'title' => '??', + 'year' => (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0000), + 'season' => null, + 'episode' => null, + 'parent' => [], + 'guids' => $this->getGuids(ag($item, 'Guid', []), isParent: false), + 'extra' => [ + 'date' => makeDate(ag($item, 'originallyAvailableAt', 'now'))->format('Y-m-d'), + 'webhook' => [ + 'event' => $event, + ], + ], ]; + if (StateInterface::TYPE_MOVIE === $type) { + $row['title'] = ag($item, ['title', 'originalTitle'], '??'); + } elseif (StateInterface::TYPE_EPISODE === $type) { + $row['title'] = ag($item, 'grandparentTitle', '??'); + $row['season'] = ag($item, 'parentIndex', 0); + $row['episode'] = ag($item, 'index', 0); + $row['extra']['title'] = ag($item, ['title', 'originalTitle'], '??'); + + if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey'], null))) { + $row['parent'] = $this->getEpisodeParent($parentId); + } + } else { + throw new HttpException(sprintf('%s: Invalid content type.', afterLast(__CLASS__, '\\')), 400); + } + $entity = Container::get(StateInterface::class)::fromArray($row)->setIsTainted($isTainted); if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { - throw new HttpException( - sprintf( - '%s: No supported GUID was given. [%s]', - afterLast(__CLASS__, '\\'), - arrayToString( - [ - 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None', - 'rGuids' => $entity->hasRelativeGuid() ? $entity->getRelativeGuids() : 'None', - ] - ) - ), 400 - ); + $message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\')); + + if (empty($item['Guid'])) { + $message .= ' Most likely unmatched movie/episode or show.'; + } + + $message .= sprintf(' [%s].', arrayToString(['guids' => ag($item, 'Guid', 'None')])); + + throw new HttpException($message, 400); } foreach ([...$entity->getRelativePointers(), ...$entity->getPointers()] as $guid) { $this->cacheData[$guid] = ag($item, 'guid'); } - if (false !== $isTainted && (true === Config::get('webhook.debug') || null !== ag( - $request->getQueryParams(), - 'debug' - ))) { + $savePayload = true === Config::get('webhook.debug') || null !== ag($request->getQueryParams(), 'debug'); + + if (false !== $isTainted && $savePayload) { saveWebhookPayload($this->name . '.' . $event, $request, [ 'entity' => $entity->getAll(), 'payload' => $json, @@ -412,83 +401,6 @@ class PlexServer implements ServerInterface return $entity; } - protected function getEpisodeParent(int|string $id): array - { - if (array_key_exists($id, $this->cacheShow)) { - return $this->cacheShow[$id]; - } - - try { - $response = $this->http->request( - 'GET', - (string)$this->url->withPath('/library/metadata/' . $id), - $this->getHeaders() - ); - - if (200 !== $response->getStatusCode()) { - return []; - } - - $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); - - $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; - - if (null === ($type = ag($json, 'type'))) { - return []; - } - - if ('show' !== strtolower($type)) { - return []; - } - - if (null === ($json['Guid'] ?? null)) { - $json['Guid'] = [['id' => $json['guid']]]; - } else { - $json['Guid'][] = ['id' => $json['guid']]; - } - - if (!$this->hasSupportedGuids($json['Guid'], true)) { - $this->cacheShow[$id] = []; - return $this->cacheShow[$id]; - } - - $guids = []; - - foreach (Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getPointers() as $guid) { - [$type, $id] = explode('://', $guid); - $guids[$type] = $id; - } - - $this->cacheShow[$id] = $guids; - - return $this->cacheShow[$id]; - } catch (ExceptionInterface $e) { - $this->logger->error($e->getMessage(), [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ]); - return []; - } catch (JsonException $e) { - $this->logger->error( - sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } catch (Exception $e) { - $this->logger->error( - sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), - [ - 'file' => $e->getFile(), - 'line' => $e->getLine() - ] - ); - return []; - } - } - private function getHeaders(): array { $opts = [ @@ -1897,4 +1809,77 @@ class PlexServer implements ServerInterface return $entity; } + + private function getEpisodeParent(int|string $id): array + { + if (array_key_exists($id, $this->cacheShow)) { + return $this->cacheShow[$id]; + } + + try { + $response = $this->http->request( + 'GET', + (string)$this->url->withPath('/library/metadata/' . $id), + $this->getHeaders() + ); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $json = ag($json, 'MediaContainer.Metadata')[0] ?? []; + + if (null === ($type = ag($json, 'type')) || 'show' !== $type) { + return []; + } + + if (null === ($json['Guid'] ?? null)) { + $json['Guid'] = [['id' => $json['guid']]]; + } else { + $json['Guid'][] = ['id' => $json['guid']]; + } + + if (!$this->hasSupportedGuids($json['Guid'], true)) { + $this->cacheShow[$id] = []; + return $this->cacheShow[$id]; + } + + $guids = []; + + foreach (Guid::fromArray($this->getGuids($json['Guid'], isParent: true))->getPointers() as $guid) { + [$type, $id] = explode('://', $guid); + $guids[$type] = $id; + } + + $this->cacheShow[$id] = $guids; + + return $this->cacheShow[$id]; + } catch (ExceptionInterface $e) { + $this->logger->error($e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return []; + } catch (JsonException $e) { + $this->logger->error( + sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } catch (Throwable $e) { + $this->logger->error( + sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()), + [ + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + return []; + } + } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index c26ffe36..46dba6be 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -68,12 +68,27 @@ if (!function_exists('makeDate')) { } if (!function_exists('ag')) { - function ag(array $array, string|null $path, mixed $default = null, string $separator = '.'): mixed + function ag(array|object $array, string|array|null $path, mixed $default = null, string $separator = '.'): mixed { - if (null === $path) { + if (empty($path)) { return $array; } + if (!is_array($array)) { + $array = get_object_vars($array); + } + + if (is_array($path)) { + foreach ($path as $key) { + $val = ag($array, $key, '_not_set'); + if ('_not_set' === $val) { + continue; + } + return $val; + } + return getValue($default); + } + if (array_key_exists($path, $array)) { return $array[$path]; }