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:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user