Moved Backends support methods to a trait to ease the code migration.

This commit is contained in:
Abdulmhsen B. A. A
2022-06-15 19:28:36 +03:00
parent d7a958d77d
commit f0dc3acadf
10 changed files with 904 additions and 721 deletions

View File

@@ -15,6 +15,10 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class EmbyClient
{
public const TYPE_MOVIE = 'Movie';
public const TYPE_SHOW = 'Series';
public const TYPE_EPISODE = 'Episode';
private Context|null $context = null;
public function __construct(

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Backends\Emby;
use App\Backends\Jellyfin\JellyfinGuid;
final class EmbyGuid extends JellyfinGuid
{
}

View File

@@ -4,6 +4,144 @@ declare(strict_types=1);
namespace App\Backends\Jellyfin;
use App\Backends\Common\Context;
use App\Libs\Container;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use RuntimeException;
trait JellyfinActionTrait
{
private array $typeMapper = [
JellyfinClient::TYPE_SHOW => iFace::TYPE_SHOW,
JellyfinClient::TYPE_MOVIE => iFace::TYPE_MOVIE,
JellyfinClient::TYPE_EPISODE => iFace::TYPE_EPISODE,
];
/**
* Create {@see StateEntity} Object based on given data.
*
* @param Context $context
* @param JellyfinGuid $guid
* @param array $item Jellyfin/emby API item.
* @param array $opts options
*
* @return StateEntity Return Object on successful creation.
*/
protected function createEntity(Context $context, JellyfinGuid $guid, array $item, array $opts = []): StateEntity
{
// -- Handle watched/updated column in a special way to support mark as unplayed.
if (null !== ($opts['override'][iFace::COLUMN_WATCHED] ?? null)) {
$isPlayed = (bool)$opts['override'][iFace::COLUMN_WATCHED];
$date = $opts['override'][iFace::COLUMN_UPDATED] ?? ag($item, 'DateCreated');
} else {
$isPlayed = (bool)ag($item, 'UserData.Played', false);
$date = ag($item, true === $isPlayed ? ['UserData.LastPlayedDate', 'DateCreated'] : 'DateCreated');
}
if (null === $date) {
throw new RuntimeException('No date was set on object.');
}
$type = $this->typeMapper[ag($item, 'Type')] ?? ag($item, 'Type');
$guids = $guid->get(ag($item, 'ProviderIds', []), context: [
'item' => [
'id' => (string)ag($item, 'Id'),
'type' => ag($item, 'Type'),
'title' => match ($type) {
iFace::TYPE_MOVIE => sprintf(
'%s (%s)',
ag($item, ['Name', 'OriginalTitle'], '??'),
ag($item, 'ProductionYear', '0000')
),
iFace::TYPE_EPISODE => sprintf(
'%s - (%sx%s)',
ag($item, ['Name', 'OriginalTitle'], '??'),
str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT),
),
},
'year' => (string)ag($item, 'ProductionYear', '0000'),
],
]);
$guids += Guid::makeVirtualGuid($context->backendName, (string)ag($item, 'Id'));
$builder = [
iFace::COLUMN_TYPE => strtolower(ag($item, 'Type')),
iFace::COLUMN_UPDATED => makeDate($date)->getTimestamp(),
iFace::COLUMN_WATCHED => (int)$isPlayed,
iFace::COLUMN_VIA => $context->backendName,
iFace::COLUMN_TITLE => ag($item, ['Name', 'OriginalTitle'], '??'),
iFace::COLUMN_GUIDS => $guids,
iFace::COLUMN_META_DATA => [
$context->backendName => [
iFace::COLUMN_ID => (string)ag($item, 'Id'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => true === $isPlayed ? '1' : '0',
iFace::COLUMN_VIA => $context->backendName,
iFace::COLUMN_TITLE => ag($item, ['Name', 'OriginalTitle'], '??'),
iFace::COLUMN_GUIDS => array_change_key_case((array)ag($item, 'ProviderIds', []), CASE_LOWER),
iFace::COLUMN_META_DATA_ADDED_AT => (string)makeDate(ag($item, 'DateCreated'))->getTimestamp(),
],
],
iFace::COLUMN_EXTRA => [],
];
$metadata = &$builder[iFace::COLUMN_META_DATA][$context->backendName];
$metadataExtra = &$metadata[iFace::COLUMN_META_DATA_EXTRA];
// -- jellyfin/emby API does not provide library ID.
if (null !== ($library = $opts['library'] ?? null)) {
$metadata[iFace::COLUMN_META_LIBRARY] = (string)$library;
}
if (iFace::TYPE_EPISODE === $type) {
$builder[iFace::COLUMN_SEASON] = ag($item, 'ParentIndexNumber', 0);
$builder[iFace::COLUMN_EPISODE] = ag($item, 'IndexNumber', 0);
if (null !== ($parentId = ag($item, 'SeriesId'))) {
$metadata[iFace::COLUMN_META_SHOW] = (string)$parentId;
}
$metadata[iFace::COLUMN_TITLE] = ag($item, 'SeriesName', '??');
$metadata[iFace::COLUMN_SEASON] = (string)$builder[iFace::COLUMN_SEASON];
$metadata[iFace::COLUMN_EPISODE] = (string)$builder[iFace::COLUMN_EPISODE];
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_TITLE] = $builder[iFace::COLUMN_TITLE];
$builder[iFace::COLUMN_TITLE] = $metadata[iFace::COLUMN_TITLE];
if (null !== $parentId) {
$builder[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$metadata[iFace::COLUMN_PARENT] = $builder[iFace::COLUMN_PARENT];
}
}
if (!empty($metadata) && null !== ($mediaYear = ag($item, 'ProductionYear'))) {
$builder[iFace::COLUMN_YEAR] = (int)$mediaYear;
$metadata[iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($mediaPath = ag($item, 'Path')) && !empty($mediaPath)) {
$metadata[iFace::COLUMN_META_PATH] = (string)$mediaPath;
}
if (null !== ($PremieredAt = ag($item, 'PremiereDate'))) {
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate($PremieredAt)->format('Y-m-d');
}
if (true === $isPlayed) {
$metadata[iFace::COLUMN_META_DATA_PLAYED_AT] = (string)makeDate($date)->getTimestamp();
}
unset($metadata, $metadataExtra);
if (null !== ($opts['override'] ?? null)) {
$builder = array_replace_recursive($builder, $opts['override'] ?? []);
}
return Container::get(iFace::class)::fromArray($builder);
}
}

View File

@@ -15,6 +15,10 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class JellyfinClient
{
public const TYPE_MOVIE = 'Movie';
public const TYPE_SHOW = 'Series';
public const TYPE_EPISODE = 'Episode';
private Context|null $context = null;
public function __construct(

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin;
use App\Backends\Common\Context;
use App\Libs\Guid;
use Psr\Log\LoggerInterface;
use Throwable;
class JellyfinGuid
{
private const GUID_MAPPER = [
'imdb' => Guid::GUID_IMDB,
'tmdb' => Guid::GUID_TMDB,
'tvdb' => Guid::GUID_TVDB,
'tvmaze' => Guid::GUID_TVMAZE,
'tvrage' => Guid::GUID_TVRAGE,
'anidb' => Guid::GUID_ANIDB,
];
private Context|null $context = null;
/**
* Class to handle Jellyfin external ids Parsing.
*
* @param LoggerInterface $logger
*/
public function __construct(protected LoggerInterface $logger)
{
}
/**
* Set working context.
*
* @param Context $context
* @return $this a cloned version of the class will be returned.
*/
public function withContext(Context $context): self
{
$cloned = clone $this;
$cloned->context = $context;
return $cloned;
}
/**
* Parse external ids from given list in safe way.
*
* *DO NOT THROW OR LOG ANYTHING.*
*
* @param array $guids
*
* @return array
*/
public function parse(array $guids): array
{
return $this->ListExternalIds(guids: $guids, log: false);
}
/**
* Parse supported external ids from given list.
*
* @param array $guids
* @param array $context
* @return array
*/
public function get(array $guids, array $context = []): array
{
return $this->ListExternalIds(guids: $guids, context: $context, log: true);
}
/**
* Does the given list contain supported external ids?
*
* @param array $guids
* @param array $context
* @return bool
*/
public function has(array $guids, array $context = []): bool
{
return count($this->ListExternalIds(guids: $guids, context: $context, log: false)) >= 1;
}
/**
* Get All Supported external ids.
*
* @param array $guids
* @param array $context
* @param bool $log
*
* @return array
*/
protected function ListExternalIds(array $guids, array $context = [], bool $log = true): array
{
$guid = [];
foreach (array_change_key_case($guids, CASE_LOWER) as $key => $value) {
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
continue;
}
try {
if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null)) {
if (true === $log) {
$this->logger->info(
'[%(backend)] reported multiple ids for same data source [%(key): %(ids)] for %(item.type) [%(item.title)].',
[
'backend' => $this->context->backendName,
'key' => $key,
'ids' => sprintf('%s, %s', $guid[self::GUID_MAPPER[$key]], $value),
...$context
]
);
}
if (false === ctype_digit($value)) {
continue;
}
if ((int)$guid[self::GUID_MAPPER[$key]] < (int)$value) {
continue;
}
}
$guid[self::GUID_MAPPER[$key]] = $value;
} catch (Throwable $e) {
if (true === $log) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->context->backendName,
'agent' => $value,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
...$context,
]
);
}
continue;
}
}
ksort($guid);
return $guid;
}
}

View File

@@ -4,6 +4,132 @@ declare(strict_types=1);
namespace App\Backends\Plex;
use App\Backends\Common\Context;
use App\Libs\Container;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use RuntimeException;
trait PlexActionTrait
{
protected function createEntity(Context $context, PlexGuid $guid, array $item, array $opts = []): StateEntity
{
// -- Handle watched/updated column in a special way to support mark as unplayed.
if (null !== ($opts['override'][iFace::COLUMN_WATCHED] ?? null)) {
$isPlayed = (bool)$opts['override'][iFace::COLUMN_WATCHED];
$date = $opts['override'][iFace::COLUMN_UPDATED] ?? ag($item, 'addedAt');
} else {
$isPlayed = (bool)ag($item, 'viewCount', false);
$date = ag($item, true === $isPlayed ? 'lastViewedAt' : 'addedAt');
}
if (null === $date) {
throw new RuntimeException('No date was set on object.');
}
if (null === ag($item, 'Guid')) {
$item['Guid'] = [['id' => ag($item, 'guid')]];
} else {
$item['Guid'][] = ['id' => ag($item, 'guid')];
}
$type = ag($item, 'type');
$guids = $guid->get(ag($item, 'Guid', []), context: [
'item' => [
'id' => ag($item, 'ratingKey'),
'type' => ag($item, 'type'),
'title' => match ($type) {
iFace::TYPE_MOVIE => sprintf(
'%s (%s)',
ag($item, ['title', 'originalTitle'], '??'),
ag($item, 'year', '0000')
),
iFace::TYPE_EPISODE => sprintf(
'%s - (%sx%s)',
ag($item, ['grandparentTitle', 'originalTitle', 'title'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
),
},
'year' => ag($item, ['grandParentYear', 'parentYear', 'year']),
'plex_id' => str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none',
],
]);
$guids += Guid::makeVirtualGuid($context->backendName, (string)ag($item, 'ratingKey'));
$builder = [
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_UPDATED => (int)$date,
iFace::COLUMN_WATCHED => (int)$isPlayed,
iFace::COLUMN_VIA => $context->backendName,
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $guids,
iFace::COLUMN_META_DATA => [
$context->backendName => [
iFace::COLUMN_ID => (string)ag($item, 'ratingKey'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => true === $isPlayed ? '1' : '0',
iFace::COLUMN_VIA => $context->backendName,
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $guid->parse(ag($item, 'Guid', [])),
iFace::COLUMN_META_DATA_ADDED_AT => (string)ag($item, 'addedAt'),
],
],
iFace::COLUMN_EXTRA => [],
];
$metadata = &$builder[iFace::COLUMN_META_DATA][$context->backendName];
$metadataExtra = &$metadata[iFace::COLUMN_META_DATA_EXTRA];
if (null !== ($library = ag($item, 'librarySectionID', $opts['library'] ?? null))) {
$metadata[iFace::COLUMN_META_LIBRARY] = (string)$library;
}
if (iFace::TYPE_EPISODE === $type) {
$builder[iFace::COLUMN_SEASON] = (int)ag($item, 'parentIndex', 0);
$builder[iFace::COLUMN_EPISODE] = (int)ag($item, 'index', 0);
$metadata[iFace::COLUMN_META_SHOW] = (string)ag($item, ['grandparentRatingKey', 'parentRatingKey'], '??');
$metadata[iFace::COLUMN_TITLE] = ag($item, 'grandparentTitle', '??');
$metadata[iFace::COLUMN_SEASON] = (string)$builder[iFace::COLUMN_SEASON];
$metadata[iFace::COLUMN_EPISODE] = (string)$builder[iFace::COLUMN_EPISODE];
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_TITLE] = $builder[iFace::COLUMN_TITLE];
$builder[iFace::COLUMN_TITLE] = $metadata[iFace::COLUMN_TITLE];
if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey']))) {
$builder[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$metadata[iFace::COLUMN_PARENT] = $builder[iFace::COLUMN_PARENT];
}
}
if (null !== ($mediaYear = ag($item, ['grandParentYear', 'parentYear', 'year'])) && !empty($mediaYear)) {
$builder[iFace::COLUMN_YEAR] = (int)$mediaYear;
$metadata[iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($mediaPath = ag($item, 'Media.0.Part.0.file')) && !empty($mediaPath)) {
$metadata[iFace::COLUMN_META_PATH] = (string)$mediaPath;
}
if (null !== ($PremieredAt = ag($item, 'originallyAvailableAt'))) {
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate($PremieredAt)->format('Y-m-d');
}
if (true === $isPlayed) {
$metadata[iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$date;
}
unset($metadata, $metadataExtra);
if (null !== ($opts['override'] ?? null)) {
$builder = array_replace_recursive($builder, $opts['override'] ?? []);
}
return Container::get(iFace::class)::fromArray($builder);
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex;
use App\Backends\Common\Context;
use App\Libs\Guid;
use Psr\Log\LoggerInterface;
use Throwable;
final class PlexGuid
{
private const GUID_MAPPER = [
'imdb' => Guid::GUID_IMDB,
'tmdb' => Guid::GUID_TMDB,
'tvdb' => Guid::GUID_TVDB,
'tvmaze' => Guid::GUID_TVMAZE,
'tvrage' => Guid::GUID_TVRAGE,
'anidb' => Guid::GUID_ANIDB,
];
private const GUID_LEGACY = [
'com.plexapp.agents.imdb',
'com.plexapp.agents.tmdb',
'com.plexapp.agents.themoviedb',
'com.plexapp.agents.xbmcnfo',
'com.plexapp.agents.xbmcnfotv',
'com.plexapp.agents.thetvdb',
'com.plexapp.agents.hama',
];
private const GUID_LOCAL = [
'plex',
'local',
'com.plexapp.agents.none',
];
private const GUID_LEGACY_REPLACER = [
'com.plexapp.agents.themoviedb://' => 'com.plexapp.agents.tmdb://',
'com.plexapp.agents.xbmcnfotv://' => 'com.plexapp.agents.tvdb://',
'com.plexapp.agents.thetvdb://' => 'com.plexapp.agents.tvdb://',
// -- imdb ids usually starts with tt(number)..
'com.plexapp.agents.xbmcnfo://tt' => 'com.plexapp.agents.imdb://tt',
// -- otherwise fallback to tmdb.
'com.plexapp.agents.xbmcnfo://' => 'com.plexapp.agents.tmdb://',
];
private Context|null $context = null;
/**
* Class to handle Plex external ids Parsing.
*
* @param LoggerInterface $logger
*/
public function __construct(protected LoggerInterface $logger)
{
}
/**
* Set working context.
*
* @param Context $context
* @return $this a cloned version of the class will be returned.
*/
public function withContext(Context $context): self
{
$cloned = clone $this;
$cloned->context = $context;
return $cloned;
}
/**
* Parse external ids from given list in safe way.
*
* *DO NOT THROW OR LOG ANYTHING.*
*
* @param array $guids
*
* @return array
*/
public function parse(array $guids): array
{
return $this->ListExternalIds(guids: $guids, log: false);
}
/**
* Parse supported external ids from given list.
*
* @param array $guids
* @param array $context
* @return array
*/
public function get(array $guids, array $context = []): array
{
return $this->ListExternalIds(guids: $guids, context: $context, log: true);
}
/**
* Does the given list contain supported external ids?
*
* @param array $guids
* @param array $context
* @return bool
*/
public function has(array $guids, array $context = []): bool
{
return count($this->ListExternalIds(guids: $guids, context: $context, log: false)) >= 1;
}
/**
* Is the given identifier a local plex id?
*
* @param string $guid
*
* @return bool
*/
public function isLocal(string $guid): bool
{
return in_array(before(strtolower($guid), '://'), self::GUID_LOCAL);
}
/**
* List Supported External Ids.
*
* @param array $guids
* @param array $context
* @param bool $log Log errors. default true.
* @return array
*/
private function ListExternalIds(array $guids, array $context = [], bool $log = true): array
{
$guid = [];
foreach (array_column($guids, 'id') as $val) {
try {
if (empty($val)) {
continue;
}
if (true === str_starts_with($val, 'com.plexapp.agents.')) {
// -- DO NOT accept plex relative unique ids, we generate our own.
if (substr_count($val, '/') >= 3) {
continue;
}
$val = $this->parseLegacyAgent(guid: $val, context: $context, log: $log);
}
if (false === str_contains($val, '://')) {
if (true === $log) {
$this->logger->info(
'Unable to parse [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->context->backendName,
'agent' => $val ?? null,
...$context
]
);
}
continue;
}
[$key, $value] = explode('://', $val);
$key = strtolower($key);
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
continue;
}
// -- Plex in their infinite wisdom, sometimes report two keys for same data source.
if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null)) {
if (true === $log) {
$this->logger->info(
'[%(backend)] reported multiple ids for same data source [%(key): %(ids)] for %(item.type) [%(item.title)].',
[
'backend' => $this->context->backendName,
'key' => $key,
'ids' => sprintf('%s, %s', $guid[self::GUID_MAPPER[$key]], $value),
...$context
]
);
}
if (false === ctype_digit($val)) {
continue;
}
if ((int)$guid[self::GUID_MAPPER[$key]] < (int)$val) {
continue;
}
}
$guid[self::GUID_MAPPER[$key]] = $value;
} catch (Throwable $e) {
if (true === $log) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->context->backendName,
'agent' => $val ?? null,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
...$context,
]
);
}
continue;
}
}
ksort($guid);
return $guid;
}
/**
* Parse legacy plex agents.
*
* @param string $guid
* @param array $context
* @param bool $log Log errors. default true.
*
* @return string
* @see https://github.com/ZeroQI/Hama.bundle/issues/510
*/
private function parseLegacyAgent(string $guid, array $context = [], bool $log = true): string
{
if (false === in_array(before($guid, '://'), self::GUID_LEGACY)) {
return $guid;
}
try {
// -- Handle hama plex agent. This is multi source agent.
if (true === str_starts_with($guid, 'com.plexapp.agents.hama')) {
$hamaRegex = '/(?P<source>(anidb|tvdb|tmdb|tsdb|imdb))\d?-(?P<id>[^\[\]]*)/';
if (1 !== preg_match($hamaRegex, after($guid, '://'), $matches)) {
return $guid;
}
if (null === ($source = ag($matches, 'source')) || null === ($sourceId = ag($matches, 'id'))) {
return $guid;
}
return str_replace('tsdb', 'tmdb', $source) . '://' . before($sourceId, '?');
}
$guid = strtr($guid, self::GUID_LEGACY_REPLACER);
$agentGuid = explode('://', after($guid, 'agents.'));
return $agentGuid[0] . '://' . before($agentGuid[1], '?');
} catch (Throwable $e) {
if (true === $log) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] legacy agent [%(agent)] identifier.',
[
'backend' => $this->context->backendName,
'agent' => $guid,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
...$context,
]
);
}
return $guid;
}
}
}

View File

@@ -55,7 +55,6 @@ class EmbyServer extends JellyfinServer
return $response->isSuccessful() ? $response->response : $request;
}
public function getServerUUID(bool $forceRefresh = false): int|string|null
{
if (false === $forceRefresh && null !== $this->uuid) {

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,11 @@ use App\Backends\Common\Context;
use App\Backends\Plex\Action\GetIdentifier;
use App\Backends\Plex\Action\GetMetaData;
use App\Backends\Plex\Action\InspectRequest;
use App\Backends\Plex\PlexActionTrait;
use App\Backends\Plex\PlexGuid;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Data;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use App\Libs\HttpException;
@@ -41,40 +42,10 @@ use Throwable;
class PlexServer implements ServerInterface
{
use PlexActionTrait;
public const NAME = 'PlexBackend';
protected const GUID_MAPPER = [
'imdb' => Guid::GUID_IMDB,
'tmdb' => Guid::GUID_TMDB,
'tvdb' => Guid::GUID_TVDB,
'tvmaze' => Guid::GUID_TVMAZE,
'tvrage' => Guid::GUID_TVRAGE,
'anidb' => Guid::GUID_ANIDB,
];
protected const SUPPORTED_LEGACY_AGENTS = [
'com.plexapp.agents.imdb',
'com.plexapp.agents.tmdb',
'com.plexapp.agents.themoviedb',
'com.plexapp.agents.xbmcnfo',
'com.plexapp.agents.xbmcnfotv',
'com.plexapp.agents.thetvdb',
'com.plexapp.agents.hama',
];
protected const GUID_PLEX_LOCAL = [
'plex://',
'local://',
'com.plexapp.agents.none://',
];
protected const GUID_AGENT_REPLACER = [
'com.plexapp.agents.themoviedb://' => 'com.plexapp.agents.tmdb://',
'com.plexapp.agents.xbmcnfo://' => 'com.plexapp.agents.imdb://',
'com.plexapp.agents.thetvdb://' => 'com.plexapp.agents.tvdb://',
'com.plexapp.agents.xbmcnfotv://' => 'com.plexapp.agents.tvdb://',
];
protected const WEBHOOK_ALLOWED_TYPES = [
'movie',
'episode',
@@ -97,11 +68,6 @@ class PlexServer implements ServerInterface
'media.pause',
];
/**
* Parse hama agent guid.
*/
private const HAMA_REGEX = '/(?P<source>(anidb|tvdb|tmdb|tsdb|imdb))\d?-(?P<id>[^\[\]]*)/';
protected bool $initialized = false;
protected UriInterface|null $url = null;
protected string|null $token = null;
@@ -118,7 +84,8 @@ class PlexServer implements ServerInterface
public function __construct(
protected HttpClientInterface $http,
protected LoggerInterface $logger,
protected CacheInterface $cacheIO
protected CacheInterface $cacheIO,
protected PlexGuid $guid,
) {
}
@@ -164,6 +131,8 @@ class PlexServer implements ServerInterface
options: $this->options
);
$cloned->guid = $this->guid->withContext($cloned->context);
return $cloned;
}
@@ -346,7 +315,7 @@ class PlexServer implements ServerInterface
$obj = ag($this->getMetadata(id: $id), 'MediaContainer.Metadata.0', []);
$guids = $this->getGuids(ag($item, 'Guid', []), context: [
$guids = $this->guid->get(ag($item, 'Guid', []), context: [
'item' => [
'id' => ag($item, 'ratingKey'),
'type' => ag($item, 'type'),
@@ -375,9 +344,10 @@ class PlexServer implements ServerInterface
}
$entity = $this->createEntity(
item: $obj,
type: $type,
opts: ['override' => $fields],
context: $this->context,
guid: $this->guid,
item: $obj,
opts: ['override' => $fields],
)->setIsTainted(isTainted: true === in_array($event, self::WEBHOOK_TAINTED_EVENTS));
} catch (Throwable $e) {
$this->logger->error('Unhandled exception was thrown during [%(backend)] webhook event parsing.', [
@@ -749,7 +719,7 @@ class PlexServer implements ServerInterface
$itemGuid = ag($item, 'guid');
if (null !== $itemGuid && false === $this->isLocalPlexId($itemGuid)) {
if (null !== $itemGuid && false === $this->guid->isLocal($itemGuid)) {
$metadata['guids'][] = $itemGuid;
}
@@ -1790,18 +1760,19 @@ class PlexServer implements ServerInterface
}
$entity = $this->createEntity(
item: $item,
type: $type,
opts: $opts + [
'override' => [
iFace::COLUMN_EXTRA => [
$this->getName() => [
iFace::COLUMN_EXTRA_EVENT => 'task.import',
iFace::COLUMN_EXTRA_DATE => makeDate('now'),
],
],
],
]
context: $this->context,
guid: $this->guid,
item: $item,
opts: $opts + [
'override' => [
iFace::COLUMN_EXTRA => [
$this->getName() => [
iFace::COLUMN_EXTRA_EVENT => 'task.import',
iFace::COLUMN_EXTRA_DATE => makeDate('now'),
],
],
],
]
);
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
@@ -1825,7 +1796,7 @@ class PlexServer implements ServerInterface
$item['Guid'] = [];
}
if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->isLocalPlexId($itemGuid)) {
if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->guid->isLocal($itemGuid)) {
$item['Guid'][] = $itemGuid;
}
@@ -1926,7 +1897,12 @@ class PlexServer implements ServerInterface
return;
}
$rItem = $this->createEntity(item: $item, type: $type, opts: $opts);
$rItem = $this->createEntity(
context: $this->context,
guid: $this->guid,
item: $item,
opts: $opts
);
if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) {
$message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.';
@@ -1935,7 +1911,7 @@ class PlexServer implements ServerInterface
$item['Guid'] = [];
}
if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->isLocalPlexId($itemGuid)) {
if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->guid->isLocal($itemGuid)) {
$item['Guid'][] = $itemGuid;
}
@@ -2097,12 +2073,12 @@ class PlexServer implements ServerInterface
]);
}
if (!$this->hasSupportedGuids(guids: $item['Guid'])) {
if (!$this->guid->has(guids: $item['Guid'])) {
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [];
}
if (null !== ($item['guid'] ?? null) && false === $this->isLocalPlexId($item['guid'])) {
if (null !== ($item['guid'] ?? null) && false === $this->guid->isLocal($item['guid'])) {
$item['Guid'][] = ['id' => $item['guid']];
}
@@ -2129,196 +2105,11 @@ class PlexServer implements ServerInterface
str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none'
);
$this->cache['shows'][ag($context, 'item.id')] = Guid::fromArray(
payload: $this->getGuids($item['Guid'], context: [...$gContext]),
payload: $this->guid->get($item['Guid'], context: [...$gContext]),
context: ['backend' => $this->getName(), ...$context,]
)->getAll();
}
protected function parseGuids(array $guids): array
{
$guid = [];
$ids = array_column($guids, 'id');
foreach ($ids as $val) {
try {
if (empty($val)) {
continue;
}
if (true === str_starts_with($val, 'com.plexapp.agents.')) {
// -- DO NOT accept plex relative unique ids, we generate our own.
if (substr_count($val, '/') >= 3) {
continue;
}
$val = $this->parseLegacyAgent($val);
}
if (false === str_contains($val, '://')) {
continue;
}
[$key, $value] = explode('://', $val);
$key = strtolower($key);
$guid[$key] = $value;
} catch (Throwable) {
continue;
}
}
ksort($guid);
return $guid;
}
protected function getGuids(array $guids, array $context = []): array
{
$guid = [];
$ids = array_column($guids, 'id');
foreach ($ids as $val) {
try {
if (empty($val)) {
continue;
}
if (true === str_starts_with($val, 'com.plexapp.agents.')) {
// -- DO NOT accept plex relative unique ids, we generate our own.
if (substr_count($val, '/') >= 3) {
if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) {
$this->logger->warning('Parsing [%(backend)] [%(agent)] identifier is not supported.', [
'backend' => $this->getName(),
'agent' => $val,
...$context
]);
}
continue;
}
$val = $this->parseLegacyAgent($val);
}
if (false === str_contains($val, '://')) {
$this->logger->info(
'Parsing [%(backend)] [%(agent)] identifier impossible. Probably alternative version of movie?',
[
'backend' => $this->getName(),
'agent' => $val ?? null,
...$context
]
);
continue;
}
[$key, $value] = explode('://', $val);
$key = strtolower($key);
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
continue;
}
// -- Plex in their infinite wisdom, sometimes report two keys for same data source.
if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null)) {
$this->logger->info(
'[%(backend)] reported multiple ids for same data source [%(key): %(ids)] for %(item.type) [%(item.title)].',
[
'key' => $key,
'backend' => $this->getName(),
'ids' => sprintf('%s, %s', $guid[self::GUID_MAPPER[$key]], $value),
...$context
]
);
if (false === ctype_digit($val)) {
continue;
}
if ((int)$guid[self::GUID_MAPPER[$key]] < (int)$val) {
continue;
}
}
$guid[self::GUID_MAPPER[$key]] = $value;
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->getName(),
'agent' => $val ?? null,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
}
}
ksort($guid);
return $guid;
}
protected function hasSupportedGuids(array $guids, array $context = []): bool
{
foreach ($guids as $_id) {
try {
$val = is_object($_id) ? $_id->id : $_id['id'];
if (empty($val)) {
continue;
}
if (true === str_starts_with($val, 'com.plexapp.agents.')) {
// -- DO NOT accept plex relative unique ids, we generate our own.
if (substr_count($val, '/') >= 3) {
if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) {
$this->logger->warning('Parsing [%(backend)] [%(agent)] identifier is not supported.', [
'backend' => $this->getName(),
'agent' => $val,
...$context
]);
}
continue;
}
$val = $this->parseLegacyAgent($val);
}
if (false === str_contains($val, '://')) {
continue;
}
[$key, $value] = explode('://', $val);
$key = strtolower($key);
if (null !== (self::GUID_MAPPER[$key] ?? null) && !empty($value)) {
return true;
}
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->getName(),
'agent' => $val ?? null,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
}
}
return false;
}
protected function checkConfig(bool $checkUrl = true, bool $checkToken = true): void
{
if (true === $checkUrl && !($this->url instanceof UriInterface)) {
@@ -2330,125 +2121,6 @@ class PlexServer implements ServerInterface
}
}
protected function createEntity(array $item, string $type, array $opts = []): StateEntity
{
// -- Handle watched/updated column in a special way to support mark as unplayed.
if (null !== ($opts['override'][iFace::COLUMN_WATCHED] ?? null)) {
$isPlayed = (bool)$opts['override'][iFace::COLUMN_WATCHED];
$date = $opts['override'][iFace::COLUMN_UPDATED] ?? ag($item, 'addedAt');
} else {
$isPlayed = (bool)ag($item, 'viewCount', false);
$date = ag($item, true === $isPlayed ? 'lastViewedAt' : 'addedAt');
}
if (null === $date) {
throw new RuntimeException('No date was set on object.');
}
if (null === ag($item, 'Guid')) {
$item['Guid'] = [['id' => ag($item, 'guid')]];
} else {
$item['Guid'][] = ['id' => ag($item, 'guid')];
}
$context = [
'item' => [
'id' => ag($item, 'ratingKey'),
'type' => ag($item, 'type'),
'title' => match ($type) {
iFace::TYPE_MOVIE, 'show' => sprintf(
'%s (%s)',
ag($item, ['title', 'originalTitle'], '??'),
ag($item, 'year', '0000')
),
iFace::TYPE_EPISODE => sprintf(
'%s - (%sx%s)',
ag($item, ['grandparentTitle', 'originalTitle', 'title'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
),
},
'year' => ag($item, ['grandParentYear', 'parentYear', 'year']),
'plex_id' => str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none',
],
];
$guids = $this->getGuids(ag($item, 'Guid', []), context: $context);
$guids += Guid::makeVirtualGuid($this->getName(), (string)ag($item, 'ratingKey'));
$builder = [
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_UPDATED => (int)$date,
iFace::COLUMN_WATCHED => (int)$isPlayed,
iFace::COLUMN_VIA => $this->getName(),
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $guids,
iFace::COLUMN_META_DATA => [
$this->getName() => [
iFace::COLUMN_ID => (string)ag($item, 'ratingKey'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => true === $isPlayed ? '1' : '0',
iFace::COLUMN_VIA => $this->getName(),
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $this->parseGuids(ag($item, 'Guid', [])),
iFace::COLUMN_META_DATA_ADDED_AT => (string)ag($item, 'addedAt'),
],
],
iFace::COLUMN_EXTRA => [],
];
$metadata = &$builder[iFace::COLUMN_META_DATA][$this->getName()];
$metadataExtra = &$metadata[iFace::COLUMN_META_DATA_EXTRA];
if (null !== ($library = ag($item, 'librarySectionID', $opts['library'] ?? null))) {
$metadata[iFace::COLUMN_META_LIBRARY] = (string)$library;
}
if (iFace::TYPE_EPISODE === $type) {
$builder[iFace::COLUMN_SEASON] = (int)ag($item, 'parentIndex', 0);
$builder[iFace::COLUMN_EPISODE] = (int)ag($item, 'index', 0);
$metadata[iFace::COLUMN_META_SHOW] = (string)ag($item, ['grandparentRatingKey', 'parentRatingKey'], '??');
$metadata[iFace::COLUMN_TITLE] = ag($item, 'grandparentTitle', '??');
$metadata[iFace::COLUMN_SEASON] = (string)$builder[iFace::COLUMN_SEASON];
$metadata[iFace::COLUMN_EPISODE] = (string)$builder[iFace::COLUMN_EPISODE];
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_TITLE] = $builder[iFace::COLUMN_TITLE];
$builder[iFace::COLUMN_TITLE] = $metadata[iFace::COLUMN_TITLE];
if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey']))) {
$builder[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$metadata[iFace::COLUMN_PARENT] = $builder[iFace::COLUMN_PARENT];
}
}
if (null !== ($mediaYear = ag($item, ['grandParentYear', 'parentYear', 'year'])) && !empty($mediaYear)) {
$builder[iFace::COLUMN_YEAR] = (int)$mediaYear;
$metadata[iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($mediaPath = ag($item, 'Media.0.Part.0.file')) && !empty($mediaPath)) {
$metadata[iFace::COLUMN_META_PATH] = (string)$mediaPath;
}
if (null !== ($PremieredAt = ag($item, 'originallyAvailableAt'))) {
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate($PremieredAt)->format('Y-m-d');
}
if (true === $isPlayed) {
$metadata[iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$date;
}
unset($metadata, $metadataExtra);
if (null !== ($opts['override'] ?? null)) {
$builder = array_replace_recursive($builder, $opts['override'] ?? []);
}
return Container::get(iFace::class)::fromArray($builder);
}
protected function getEpisodeParent(int|string $id, array $context = []): array
{
if (array_key_exists($id, $this->cache['shows'] ?? [])) {
@@ -2475,7 +2147,7 @@ class PlexServer implements ServerInterface
$json['Guid'][] = ['id' => $json['guid']];
}
if (!$this->hasSupportedGuids(guids: $json['Guid'])) {
if (!$this->guid->has(guids: $json['Guid'])) {
$this->cache['shows'][$id] = [];
return [];
}
@@ -2487,7 +2159,7 @@ class PlexServer implements ServerInterface
);
$this->cache['shows'][$id] = Guid::fromArray(
payload: $this->getGuids($json['Guid'], context: [...$gContext]),
payload: $this->guid->get($json['Guid'], context: [...$gContext]),
context: ['backend' => $this->getName(), ...$context]
)->getAll();
@@ -2507,79 +2179,6 @@ class PlexServer implements ServerInterface
}
}
/**
* Parse legacy plex agents.
*
* @param string $agent
*
* @return string
* @see https://github.com/ZeroQI/Hama.bundle/issues/510
*/
protected function parseLegacyAgent(string $agent): string
{
try {
if (false === in_array(before($agent, '://'), self::SUPPORTED_LEGACY_AGENTS)) {
return $agent;
}
// -- Handle hama plex agent. This is multi source agent.
if (true === str_starts_with($agent, 'com.plexapp.agents.hama')) {
$guid = after($agent, '://');
if (1 !== preg_match(self::HAMA_REGEX, $guid, $matches)) {
return $agent;
}
if (null === ($source = ag($matches, 'source')) || null === ($sourceId = ag($matches, 'id'))) {
return $agent;
}
return str_replace('tsdb', 'tmdb', $source) . '://' . before($sourceId, '?');
}
$agent = strtr($agent, self::GUID_AGENT_REPLACER);
$agentGuid = explode('://', after($agent, 'agents.'));
return $agentGuid[0] . '://' . before($agentGuid[1], '?');
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown in parsing of [%(backend)] [%(agent)] identifier.',
[
'backend' => $this->getName(),
'agent' => $agent,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
return $agent;
}
}
/**
* Is Given id a local plex id?
*
* @param string $id
*
* @return bool
*/
protected function isLocalPlexId(string $id): bool
{
$id = strtolower($id);
foreach (self::GUID_PLEX_LOCAL as $guid) {
if (true === str_starts_with($id, $guid)) {
return true;
}
}
return false;
}
protected function getUserToken(int|string $userId): int|string|null
{
try {