Fully migrated jellyfin/emby to use new backend store.
This commit is contained in:
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -19,6 +18,8 @@ use Throwable;
|
||||
|
||||
class EmbyServer extends JellyfinServer
|
||||
{
|
||||
public const NAME = 'EmbyBackend';
|
||||
|
||||
protected const WEBHOOK_ALLOWED_TYPES = [
|
||||
'Movie',
|
||||
'Episode',
|
||||
@@ -53,18 +54,22 @@ class EmbyServer extends JellyfinServer
|
||||
return parent::setUp($name, $url, $token, $userId, $uuid, $persist, $options);
|
||||
}
|
||||
|
||||
public static function processRequest(ServerRequestInterface $request): ServerRequestInterface
|
||||
public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface
|
||||
{
|
||||
$logger = null;
|
||||
|
||||
try {
|
||||
$logger = $opts[LoggerInterface::class] ?? Container::get(LoggerInterface::class);
|
||||
|
||||
$userAgent = ag($request->getServerParams(), 'HTTP_USER_AGENT', '');
|
||||
|
||||
if (false === str_starts_with($userAgent, 'Emby Server/')) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
$payload = ag($request->getParsedBody() ?? [], 'data', null);
|
||||
$payload = (string)ag($request->getParsedBody() ?? [], 'data', null);
|
||||
|
||||
if (null === $payload || null === ($json = json_decode((string)$payload, true))) {
|
||||
if (null === ($json = json_decode(json: $payload, associative: true, flags: JSON_INVALID_UTF8_IGNORE))) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
@@ -84,9 +89,10 @@ class EmbyServer extends JellyfinServer
|
||||
$request = $request->withAttribute($key, $val);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Container::get(LoggerInterface::class)->error($e->getMessage(), [
|
||||
$logger?->error($e->getMessage(), [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -103,17 +109,15 @@ class EmbyServer extends JellyfinServer
|
||||
$type = ag($json, 'Item.Type', 'not_found');
|
||||
|
||||
if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
|
||||
throw new HttpException(sprintf('%s: Not allowed type [%s]', afterLast(__CLASS__, '\\'), $type), 200);
|
||||
throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200);
|
||||
}
|
||||
|
||||
$type = strtolower($type);
|
||||
|
||||
if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
|
||||
throw new HttpException(sprintf('%s: Not allowed event [%s]', afterLast(__CLASS__, '\\'), $event), 200);
|
||||
throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200);
|
||||
}
|
||||
|
||||
$event = strtolower($event);
|
||||
|
||||
$isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS);
|
||||
|
||||
if ('item.markplayed' === $event || 'playback.scrobble' === $event) {
|
||||
@@ -121,7 +125,7 @@ class EmbyServer extends JellyfinServer
|
||||
} elseif ('item.markunplayed' === $event) {
|
||||
$isWatched = 0;
|
||||
} else {
|
||||
$isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], 0);
|
||||
$isWatched = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], false);
|
||||
}
|
||||
|
||||
$providersId = ag($json, 'Item.ProviderIds', []);
|
||||
@@ -131,7 +135,7 @@ class EmbyServer extends JellyfinServer
|
||||
'updated' => time(),
|
||||
'watched' => $isWatched,
|
||||
'via' => $this->name,
|
||||
'title' => '??',
|
||||
'title' => ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'),
|
||||
'year' => ag($json, 'Item.ProductionYear', 0000),
|
||||
'season' => null,
|
||||
'episode' => null,
|
||||
@@ -147,25 +151,24 @@ class EmbyServer extends JellyfinServer
|
||||
],
|
||||
];
|
||||
|
||||
if (StateInterface::TYPE_MOVIE === $type) {
|
||||
$row['title'] = ag($json, ['Item.Name', 'Item.OriginalTitle'], '??');
|
||||
} elseif (StateInterface::TYPE_EPISODE === $type) {
|
||||
if (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 !== ($epTitle = ag($json, ['Name', 'OriginalTitle'], null))) {
|
||||
$row['extra']['title'] = $epTitle;
|
||||
}
|
||||
|
||||
if (null !== ag($json, 'Item.SeriesId')) {
|
||||
$row['parent'] = $this->getEpisodeParent(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() && !$entity->hasRelativeGuid()) {
|
||||
$message = sprintf('%s: No valid/supported External ids.', afterLast(__CLASS__, '\\'));
|
||||
$message = sprintf('%s: No valid/supported External ids.', self::NAME);
|
||||
|
||||
if (empty($providersId)) {
|
||||
$message .= ' Most likely unmatched movie/episode or show.';
|
||||
@@ -183,172 +186,13 @@ class EmbyServer extends JellyfinServer
|
||||
$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,
|
||||
]);
|
||||
saveWebhookPayload($this->name . '.' . $event, $request, $entity);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
|
||||
foreach ($entities as &$entity) {
|
||||
if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) {
|
||||
if (null !== $after && $after->getTimestamp() > $entity->updated) {
|
||||
$entity = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$entity->plex_guid = null;
|
||||
}
|
||||
|
||||
unset($entity);
|
||||
|
||||
/** @var StateInterface $entity */
|
||||
foreach ($entities as $entity) {
|
||||
if (null === $entity || false === $entity->hasGuids()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$guids = [];
|
||||
|
||||
foreach ($entity->guids ?? [] as $key => $val) {
|
||||
if ('guid_plex' === $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$guids[] = sprintf('%s.%s', afterLast($key, 'guid_'), $val);
|
||||
}
|
||||
|
||||
if (empty($guids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$requests[] = $this->http->request(
|
||||
'GET',
|
||||
(string)$this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery(
|
||||
http_build_query(
|
||||
[
|
||||
'Recursive' => 'true',
|
||||
'Fields' => 'ProviderIds,DateCreated',
|
||||
'enableUserData' => 'true',
|
||||
'enableImages' => 'false',
|
||||
'AnyProviderIdEquals' => implode(',', $guids),
|
||||
]
|
||||
)
|
||||
),
|
||||
array_replace_recursive($this->getHeaders(), [
|
||||
'user_data' => [
|
||||
'state' => &$entity,
|
||||
]
|
||||
])
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$stateRequests = [];
|
||||
|
||||
foreach ($requests as $response) {
|
||||
try {
|
||||
$json = ag(
|
||||
json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR),
|
||||
'Items',
|
||||
[]
|
||||
)[0] ?? [];
|
||||
|
||||
$state = $response->getInfo('user_data')['state'];
|
||||
assert($state instanceof StateInterface);
|
||||
|
||||
if (StateInterface::TYPE_MOVIE === $state->type) {
|
||||
$iName = sprintf(
|
||||
'%s - [%s (%d)]',
|
||||
$this->name,
|
||||
$state->meta['title'] ?? '??',
|
||||
$state->meta['year'] ?? 0000,
|
||||
);
|
||||
} else {
|
||||
$iName = trim(
|
||||
sprintf(
|
||||
'%s - [%s - (%dx%d) - %s]',
|
||||
$this->name,
|
||||
$state->meta['series'] ?? '??',
|
||||
$state->meta['season'] ?? 0,
|
||||
$state->meta['episode'] ?? 0,
|
||||
$state->meta['title'] ?? '??',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($json)) {
|
||||
$this->logger->notice(sprintf('Ignoring %s. does not exists.', $iName));
|
||||
continue;
|
||||
}
|
||||
|
||||
$isWatched = (int)(bool)ag($json, 'UserData.Played', false);
|
||||
|
||||
if ($state->watched === $isWatched) {
|
||||
$this->logger->debug(sprintf('Ignoring %s. State is unchanged.', $iName));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === ($this->options[ServerInterface::OPT_EXPORT_IGNORE_DATE] ?? false)) {
|
||||
$date = ag(
|
||||
$json,
|
||||
'UserData.LastPlayedDate',
|
||||
ag($json, 'DateCreated', ag($json, 'PremiereDate', null))
|
||||
);
|
||||
|
||||
if (null === $date) {
|
||||
$this->logger->notice(sprintf('Ignoring %s. No date is set.', $iName));
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = strtotime($date);
|
||||
|
||||
if ($date >= $state->updated) {
|
||||
$this->logger->debug(sprintf('Ignoring %s. Date is newer then what in db.', $iName));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$stateRequests[] = $this->http->request(
|
||||
1 === $state->watched ? 'POST' : 'DELETE',
|
||||
(string)$this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id'))),
|
||||
array_replace_recursive(
|
||||
$this->getHeaders(),
|
||||
[
|
||||
'user_data' => [
|
||||
'state' => 1 === $state->watched ? 'Watched' : 'Unwatched',
|
||||
'itemName' => $iName,
|
||||
],
|
||||
]
|
||||
)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]);
|
||||
}
|
||||
}
|
||||
|
||||
unset($requests);
|
||||
|
||||
return $stateRequests;
|
||||
}
|
||||
|
||||
private function getEpisodeParent(int|string $id): array
|
||||
protected function getEpisodeParent(mixed $id, string $cacheName): array
|
||||
{
|
||||
if (array_key_exists($id, $this->cacheShow)) {
|
||||
return $this->cacheShow[$id];
|
||||
@@ -367,7 +211,11 @@ class EmbyServer extends JellyfinServer
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
if (null === ($itemType = ag($json, 'Type')) || 'Series' !== $itemType) {
|
||||
return [];
|
||||
@@ -380,37 +228,32 @@ class EmbyServer extends JellyfinServer
|
||||
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;
|
||||
$this->cacheShow[$id] = Guid::fromArray($this->getGuids($providersId))->getAll();
|
||||
|
||||
return $this->cacheShow[$id];
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->logger->error($e->getMessage(), [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
]);
|
||||
return [];
|
||||
} catch (JsonException $e) {
|
||||
$this->logger->error(
|
||||
sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage()),
|
||||
sprintf('%s: Unable to decode \'%s\' JSON response. %s', $this->name, $cacheName, $e->getMessage()),
|
||||
[
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
'line' => $e->getLine(),
|
||||
]
|
||||
);
|
||||
return [];
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
sprintf('ERROR: %s response. Reason: \'%s\'.', $this->name, $e->getMessage()),
|
||||
sprintf('%s: Failed to handle \'%s\' response. %s', $this->name, $cacheName, $e->getMessage()),
|
||||
[
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
]
|
||||
);
|
||||
return [];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -55,9 +55,11 @@ interface ServerInterface
|
||||
* Process The request For attributes extraction.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param array $opts
|
||||
*
|
||||
* @return ServerRequestInterface
|
||||
*/
|
||||
public static function processRequest(ServerRequestInterface $request): ServerRequestInterface;
|
||||
public static function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Parse server specific webhook event. for play/un-played event.
|
||||
|
||||
@@ -241,24 +241,27 @@ if (!function_exists('fsize')) {
|
||||
}
|
||||
|
||||
if (!function_exists('saveWebhookPayload')) {
|
||||
function saveWebhookPayload(string $name, ServerRequestInterface $request, array $parsed = []): void
|
||||
function saveWebhookPayload(string $name, ServerRequestInterface $request, StateInterface $state): void
|
||||
{
|
||||
$content = [
|
||||
'query' => $request->getQueryParams(),
|
||||
'request' => [
|
||||
'server' => $request->getServerParams(),
|
||||
'body' => (string)$request->getBody(),
|
||||
'query' => $request->getQueryParams(),
|
||||
],
|
||||
'parsed' => $request->getParsedBody(),
|
||||
'server' => $request->getServerParams(),
|
||||
'body' => (string)$request->getBody(),
|
||||
'attributes' => $request->getAttributes(),
|
||||
'cParsed' => $parsed,
|
||||
'entity' => $state->getAll(),
|
||||
];
|
||||
|
||||
@file_put_contents(
|
||||
Config::get('tmpDir') . '/webhooks/' . sprintf(
|
||||
'webhook.%s.%s.json',
|
||||
'webhook.%s.%s.%s.json',
|
||||
$name,
|
||||
(string)ag($request->getServerParams(), 'X_REQUEST_ID', time())
|
||||
ag($state->extra, 'webhook.event', 'unknown'),
|
||||
ag($request->getServerParams(), 'X_REQUEST_ID', time())
|
||||
),
|
||||
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
json_encode(value: $content, flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -374,6 +377,13 @@ if (!function_exists('before')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('after')) {
|
||||
function after(string $subject, string $search): string
|
||||
{
|
||||
return empty($search) ? $subject : array_reverse(explode($search, $subject, 2))[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('makeServer')) {
|
||||
/**
|
||||
* @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, persist:array, options:array} $server
|
||||
|
||||
Reference in New Issue
Block a user