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.

This commit is contained in:
Abdulmhsen B. A. A
2022-05-09 07:10:35 +03:00
parent 96355ee32c
commit 3a44b5c5d8
4 changed files with 463 additions and 383 deletions

View File

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

View File

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

View File

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

View File

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