From f0dc3acadf001bcdfc7b4fa24e6174fec669e013 Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Wed, 15 Jun 2022 19:28:36 +0300 Subject: [PATCH] Moved Backends support methods to a trait to ease the code migration. --- src/Backends/Emby/EmbyClient.php | 4 + src/Backends/Emby/EmbyGuid.php | 11 + src/Backends/Jellyfin/JellyfinActionTrait.php | 138 +++++ src/Backends/Jellyfin/JellyfinClient.php | 4 + src/Backends/Jellyfin/JellyfinGuid.php | 153 ++++++ src/Backends/Plex/PlexActionTrait.php | 126 +++++ src/Backends/Plex/PlexGuid.php | 278 ++++++++++ src/Libs/Servers/EmbyServer.php | 1 - src/Libs/Servers/JellyfinServer.php | 429 ++++++---------- src/Libs/Servers/PlexServer.php | 481 ++---------------- 10 files changed, 904 insertions(+), 721 deletions(-) create mode 100644 src/Backends/Emby/EmbyGuid.php create mode 100644 src/Backends/Jellyfin/JellyfinGuid.php create mode 100644 src/Backends/Plex/PlexGuid.php diff --git a/src/Backends/Emby/EmbyClient.php b/src/Backends/Emby/EmbyClient.php index 37aed5f7..184befa6 100644 --- a/src/Backends/Emby/EmbyClient.php +++ b/src/Backends/Emby/EmbyClient.php @@ -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( diff --git a/src/Backends/Emby/EmbyGuid.php b/src/Backends/Emby/EmbyGuid.php new file mode 100644 index 00000000..556088e4 --- /dev/null +++ b/src/Backends/Emby/EmbyGuid.php @@ -0,0 +1,11 @@ + 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); + } } diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index 01d7c862..dd8a4249 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -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( diff --git a/src/Backends/Jellyfin/JellyfinGuid.php b/src/Backends/Jellyfin/JellyfinGuid.php new file mode 100644 index 00000000..af82bdba --- /dev/null +++ b/src/Backends/Jellyfin/JellyfinGuid.php @@ -0,0 +1,153 @@ + 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; + } +} diff --git a/src/Backends/Plex/PlexActionTrait.php b/src/Backends/Plex/PlexActionTrait.php index 4c641641..a616814c 100644 --- a/src/Backends/Plex/PlexActionTrait.php +++ b/src/Backends/Plex/PlexActionTrait.php @@ -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); + } } diff --git a/src/Backends/Plex/PlexGuid.php b/src/Backends/Plex/PlexGuid.php new file mode 100644 index 00000000..b52da102 --- /dev/null +++ b/src/Backends/Plex/PlexGuid.php @@ -0,0 +1,278 @@ + 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(anidb|tvdb|tmdb|tsdb|imdb))\d?-(?P[^\[\]]*)/'; + + 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; + } + } +} diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index f5945101..abfa5ea1 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -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) { diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index bd22144c..1407624f 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -8,10 +8,12 @@ use App\Backends\Common\Context; use App\Backends\Jellyfin\Action\GetMetaData; use App\Backends\Jellyfin\Action\InspectRequest; use App\Backends\Jellyfin\Action\GetIdentifier; +use App\Backends\Jellyfin\JellyfinActionTrait; +use App\Backends\Jellyfin\JellyfinClient; +use App\Backends\Jellyfin\JellyfinGuid; 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,30 +43,13 @@ use Throwable; class JellyfinServer implements ServerInterface { + use JellyfinActionTrait; + public const NAME = 'JellyfinBackend'; - protected const TYPE_MOVIE = 'Movie'; - protected const TYPE_SHOW = 'Series'; - protected const TYPE_EPISODE = 'Episode'; - - protected const TYPE_MAPPER = [ - self::TYPE_SHOW => iFace::TYPE_SHOW, - self::TYPE_MOVIE => iFace::TYPE_MOVIE, - self::TYPE_EPISODE => iFace::TYPE_EPISODE, - ]; - protected const COLLECTION_TYPE_SHOWS = 'tvshows'; protected const COLLECTION_TYPE_MOVIES = 'movies'; - 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 WEBHOOK_ALLOWED_TYPES = [ 'Movie', 'Episode', @@ -112,7 +97,8 @@ class JellyfinServer implements ServerInterface public function __construct( protected HttpClientInterface $http, protected LoggerInterface $logger, - protected CacheInterface $cacheIO + protected CacheInterface $cacheIO, + protected JellyfinGuid $guid, ) { } @@ -168,6 +154,8 @@ class JellyfinServer implements ServerInterface options: $this->options ); + $cloned->guid = $this->guid->withContext($cloned->context); + return $cloned; } @@ -196,7 +184,7 @@ class JellyfinServer implements ServerInterface throw new RuntimeException( sprintf( 'Request for [%s] users list returned with unexpected [%s] status code.', - $this->getName(), + $this->context->backendName, $response->getStatusCode(), ) ); @@ -279,22 +267,23 @@ class JellyfinServer implements ServerInterface if (null === $type || false === in_array($type, self::WEBHOOK_ALLOWED_TYPES)) { throw new HttpException( - sprintf('%s: Webhook content type [%s] is not supported.', $this->getName(), $type), 200 + sprintf('%s: Webhook content type [%s] is not supported.', $this->context->backendName, $type), 200 ); } if (null === $event || false === in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) { throw new HttpException( - sprintf('%s: Webhook event type [%s] is not supported.', $this->getName(), $event), 200 + sprintf('%s: Webhook event type [%s] is not supported.', $this->context->backendName, $event), 200 ); } if (null === $id) { - throw new HttpException(sprintf('%s: No item id was found in webhook body.', $this->getName()), 400); + throw new HttpException( + sprintf('%s: No item id was found in webhook body.', $this->context->backendName), + 400 + ); } - $type = strtolower($type); - try { $isPlayed = (bool)ag($json, 'Played'); $lastPlayedAt = true === $isPlayed ? ag($json, 'LastPlayedDate') : null; @@ -302,12 +291,12 @@ class JellyfinServer implements ServerInterface $fields = [ iFace::COLUMN_WATCHED => (int)$isPlayed, iFace::COLUMN_META_DATA => [ - $this->getName() => [ + $this->context->backendName => [ iFace::COLUMN_WATCHED => true === $isPlayed ? '1' : '0', ] ], iFace::COLUMN_EXTRA => [ - $this->getName() => [ + $this->context->backendName => [ iFace::COLUMN_EXTRA_EVENT => $event, iFace::COLUMN_EXTRA_DATE => makeDate('now'), ], @@ -319,7 +308,7 @@ class JellyfinServer implements ServerInterface $fields = array_replace_recursive($fields, [ iFace::COLUMN_UPDATED => $lastPlayedAt, iFace::COLUMN_META_DATA => [ - $this->getName() => [ + $this->context->backendName => [ iFace::COLUMN_META_DATA_PLAYED_AT => (string)$lastPlayedAt, ] ], @@ -335,20 +324,21 @@ class JellyfinServer implements ServerInterface $providersId[after($key, 'Provider_')] = $val; } - if (null !== ($guids = $this->getGuids($providersId)) && false === empty($guids)) { - $guids += Guid::makeVirtualGuid($this->getName(), (string)$id); + if (null !== ($guids = $this->guid->get($providersId)) && false === empty($guids)) { + $guids += Guid::makeVirtualGuid($this->context->backendName, (string)$id); $fields[iFace::COLUMN_GUIDS] = $guids; - $fields[iFace::COLUMN_META_DATA][$this->getName()][iFace::COLUMN_GUIDS] = $fields[iFace::COLUMN_GUIDS]; + $fields[iFace::COLUMN_META_DATA][$this->context->backendName][iFace::COLUMN_GUIDS] = $fields[iFace::COLUMN_GUIDS]; } $entity = $this->createEntity( - item: $this->getMetadata(id: $id), - type: $type, - opts: ['override' => $fields], + context: $this->context, + guid: $this->guid, + item: $this->getMetadata(id: $id), + 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.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'exception' => [ 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -362,7 +352,7 @@ class JellyfinServer implements ServerInterface ]); throw new HttpException( - sprintf('%s: Failed to handle webhook payload check logs.', $this->getName()), 200 + sprintf('%s: Failed to handle webhook payload check logs.', $this->context->backendName), 200 ); } @@ -379,7 +369,7 @@ class JellyfinServer implements ServerInterface ]); throw new HttpException( - sprintf('%s: Import ignored. No valid/supported external ids.', $this->getName()), + sprintf('%s: Import ignored. No valid/supported external ids.', $this->context->backendName), 200 ); } @@ -410,7 +400,7 @@ class JellyfinServer implements ServerInterface ); $this->logger->debug('Searching for [%(query)] in [%(backend)].', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'query' => $query, 'url' => $url ]); @@ -426,7 +416,7 @@ class JellyfinServer implements ServerInterface sprintf( 'Search request for [%s] in [%s] responded with unexpected [%s] status code.', $query, - $this->getName(), + $this->context->backendName, $response->getStatusCode(), ) ); @@ -571,7 +561,7 @@ class JellyfinServer implements ServerInterface ); $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'url' => $url ]); @@ -581,7 +571,7 @@ class JellyfinServer implements ServerInterface throw new RuntimeException( sprintf( 'Request for [%s] libraries returned with unexpected [%s] status code.', - $this->getName(), + $this->context->backendName, $response->getStatusCode(), ) ); @@ -613,7 +603,11 @@ class JellyfinServer implements ServerInterface if (false === $found) { throw new RuntimeException( - sprintf('The response from [%s] does not contain library with id of [%s].', $this->getName(), $id) + sprintf( + 'The response from [%s] does not contain library with id of [%s].', + $this->context->backendName, + $id + ) ); } @@ -621,7 +615,7 @@ class JellyfinServer implements ServerInterface throw new RuntimeException( sprintf( 'The requested [%s] library [%s] is of [%s] type. Which is not supported type.', - $this->getName(), + $this->context->backendName, ag($context, 'library.title', $id), ag($context, 'library.type') ) @@ -644,7 +638,7 @@ class JellyfinServer implements ServerInterface $context['library']['url'] = (string)$url; $this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); @@ -654,7 +648,7 @@ class JellyfinServer implements ServerInterface throw new RuntimeException( sprintf( 'Request for [%s] library [%s] content returned with unexpected [%s] status code.', - $this->getName(), + $this->context->backendName, ag($context, 'library.title', $id), $response->getStatusCode(), ) @@ -666,7 +660,7 @@ class JellyfinServer implements ServerInterface $possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName']; $data = [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]; @@ -748,7 +742,7 @@ class JellyfinServer implements ServerInterface $this->logger->warning( 'Failed to decode one item of [%(backend)] library [%(library.title)] content.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'error' => [ 'message' => $entity->getErrorMessage(), @@ -791,7 +785,7 @@ class JellyfinServer implements ServerInterface ); $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'url' => $url ]); @@ -801,7 +795,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'status_code' => $response->getStatusCode(), ] ); @@ -818,7 +812,7 @@ class JellyfinServer implements ServerInterface if (empty($listDirs)) { $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'context' => [ 'body' => $json, ] @@ -827,7 +821,7 @@ class JellyfinServer implements ServerInterface } } catch (ExceptionInterface $e) { $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'exception' => [ 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -892,7 +886,7 @@ class JellyfinServer implements ServerInterface } } - $metadata = $entity->getMetadata($this->getName()); + $metadata = $entity->getMetadata($this->context->backendName); $context = [ 'item' => [ @@ -906,7 +900,7 @@ class JellyfinServer implements ServerInterface $this->logger->warning( 'Ignoring [%(item.title)] for [%(backend)] no backend metadata was found.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ] ); @@ -930,7 +924,7 @@ class JellyfinServer implements ServerInterface $context['remote']['url'] = (string)$url; $this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] play state.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); @@ -948,7 +942,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown during requesting of [%(backend)] %(item.type) [%(item.title)].', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -969,7 +963,7 @@ class JellyfinServer implements ServerInterface try { if (null === ($id = ag($response->getInfo('user_data'), 'id'))) { $this->logger->error('Unable to get entity object id.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); continue; @@ -986,7 +980,7 @@ class JellyfinServer implements ServerInterface $this->logger->warning( 'Request for [%(backend)] %(item.type) [%(item.title)] returned with 404 (Not Found) status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context ] ); @@ -995,7 +989,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] %(item.type) [%(item.title)] returned with unexpected [%(status_code)] status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'status_code' => $response->getStatusCode(), ...$context ] @@ -1015,7 +1009,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Ignoring [%(backend)] %(item.type) [%(item.title)]. responded with empty metadata.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'response' => [ 'body' => $body, @@ -1031,7 +1025,7 @@ class JellyfinServer implements ServerInterface $this->logger->info( 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ] ); @@ -1046,7 +1040,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'date_key' => $dateKey, ...$context, 'response' => [ @@ -1065,7 +1059,7 @@ class JellyfinServer implements ServerInterface $this->logger->notice( 'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'comparison' => [ 'storage' => makeDate($entity->updated), @@ -1088,7 +1082,7 @@ class JellyfinServer implements ServerInterface $this->logger->debug( 'Queuing request to change [%(backend)] %(item.type) [%(item.title)] play state to [%(play_state)].', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', ...$context, ] @@ -1102,7 +1096,7 @@ class JellyfinServer implements ServerInterface array_replace_recursive($this->getHeaders(), [ 'user_data' => [ 'context' => $context + [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', ], ], @@ -1114,7 +1108,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown during handling of [%(backend)] %(item.type) [%(item.title)].', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1141,7 +1135,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'status_code' => $response->getStatusCode(), ...$context, ] @@ -1151,7 +1145,7 @@ class JellyfinServer implements ServerInterface $start = makeDate(); $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'time' => [ 'start' => $start, @@ -1177,7 +1171,7 @@ class JellyfinServer implements ServerInterface $this->logger->warning( 'Failed to decode one item of [%(backend)] [%(library.title)] content.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'error' => [ 'message' => $entity->getErrorMessage(), @@ -1199,7 +1193,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'No Items were found in [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1213,7 +1207,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1227,7 +1221,7 @@ class JellyfinServer implements ServerInterface $end = makeDate(); $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'time' => [ 'start' => $start, @@ -1241,7 +1235,7 @@ class JellyfinServer implements ServerInterface return fn(Throwable $e) => $this->logger->error( 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1265,7 +1259,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] [%(library.title)] content responded with unexpected [%(status_code)] status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'status_code' => $response->getStatusCode(), ...$context, ] @@ -1275,7 +1269,7 @@ class JellyfinServer implements ServerInterface $start = makeDate(); $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'time' => [ 'start' => $start, @@ -1301,7 +1295,7 @@ class JellyfinServer implements ServerInterface $this->logger->warning( 'Failed to decode one item of [%(backend)] [%(library.title)] content.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'error' => [ 'message' => $entity->getErrorMessage(), @@ -1324,7 +1318,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'No Items were found in [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1338,7 +1332,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1352,7 +1346,7 @@ class JellyfinServer implements ServerInterface $end = makeDate(); $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'time' => [ 'start' => $start, @@ -1366,7 +1360,7 @@ class JellyfinServer implements ServerInterface return fn(Throwable $e) => $this->logger->error( 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1424,7 +1418,7 @@ class JellyfinServer implements ServerInterface ); $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'url' => $url ]); @@ -1434,11 +1428,11 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'status_code' => $response->getStatusCode(), ] ); - Data::add($this->getName(), 'no_import_update', true); + Data::add($this->context->backendName, 'no_import_update', true); return []; } @@ -1452,17 +1446,17 @@ class JellyfinServer implements ServerInterface if (empty($listDirs)) { $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'context' => [ 'body' => $json, ] ]); - Data::add($this->getName(), 'no_import_update', true); + Data::add($this->context->backendName, 'no_import_update', true); return []; } } catch (ExceptionInterface $e) { $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'exception' => [ 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -1470,7 +1464,7 @@ class JellyfinServer implements ServerInterface 'message' => $e->getMessage(), ], ]); - Data::add($this->getName(), 'no_import_update', true); + Data::add($this->context->backendName, 'no_import_update', true); return []; } catch (JsonException $e) { $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ @@ -1480,7 +1474,7 @@ class JellyfinServer implements ServerInterface 'message' => $e->getMessage(), ], ]); - Data::add($this->getName(), 'no_import_update', true); + Data::add($this->context->backendName, 'no_import_update', true); return []; } @@ -1525,7 +1519,7 @@ class JellyfinServer implements ServerInterface $context['library']['url'] = (string)$url; $this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); @@ -1544,7 +1538,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Request for [%(backend)] [%(library.title)] series external ids has failed.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1571,7 +1565,7 @@ class JellyfinServer implements ServerInterface if (null !== $ignoreIds && true === in_array(ag($context, 'library.id'), $ignoreIds)) { $ignored++; $this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); continue; @@ -1582,7 +1576,7 @@ class JellyfinServer implements ServerInterface $this->logger->info( 'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ] ); @@ -1606,7 +1600,7 @@ class JellyfinServer implements ServerInterface $context['library']['url'] = (string)$url; $this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); @@ -1623,7 +1617,7 @@ class JellyfinServer implements ServerInterface ); } catch (ExceptionInterface $e) { $this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1638,14 +1632,14 @@ class JellyfinServer implements ServerInterface if (0 === count($promises)) { $this->logger->warning('No requests for [%(backend)] libraries were queued.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'context' => [ 'total' => count($listDirs), 'ignored' => $ignored, 'unsupported' => $unsupported, ], ]); - Data::add($this->getName(), 'no_import_update', true); + Data::add($this->context->backendName, 'no_import_update', true); return []; } @@ -1655,14 +1649,14 @@ class JellyfinServer implements ServerInterface protected function processImport(ImportInterface $mapper, array $item, array $context = [], array $opts = []): void { try { - if (self::TYPE_SHOW === ($type = ag($item, 'Type'))) { + if (JellyfinClient::TYPE_SHOW === ($type = ag($item, 'Type'))) { $this->processShow(item: $item, context: $context); return; } - $type = self::TYPE_MAPPER[$type]; + $type = $this->typeMapper[$type]; - Data::increment($this->getName(), $type . '_total'); + Data::increment($this->context->backendName, $type . '_total'); $context['item'] = [ 'id' => ag($item, 'Id'), @@ -1686,7 +1680,7 @@ class JellyfinServer implements ServerInterface if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'response' => [ 'body' => $item @@ -1699,7 +1693,7 @@ class JellyfinServer implements ServerInterface if (null === ag($item, $dateKey)) { $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'date_key' => $dateKey, ...$context, 'response' => [ @@ -1707,29 +1701,33 @@ class JellyfinServer implements ServerInterface ], ]); - Data::increment($this->getName(), $type . '_ignored_no_date_is_set'); + Data::increment($this->context->backendName, $type . '_ignored_no_date_is_set'); return; } $entity = $this->createEntity( - item: $item, - type: $type, - opts: $opts + [ - 'library' => ag($context, 'library.id'), - '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 + [ + 'library' => ag($context, 'library.id'), + 'override' => [ + iFace::COLUMN_EXTRA => [ + $this->context->backendName => [ + iFace::COLUMN_EXTRA_EVENT => 'task.import', + iFace::COLUMN_EXTRA_DATE => makeDate('now'), + ], + ], + ] + ], ); if (false === $entity->hasGuids() && false === $entity->hasRelativeGuid()) { if (true === (bool)Config::get('debug.import')) { - $name = Config::get('tmpDir') . '/debug/' . $this->getName() . '.' . ag($item, 'Id') . '.json'; + $name = Config::get('tmpDir') . '/debug/' . $this->context->backendName . '.' . ag( + $item, + 'Id' + ) . '.json'; if (!file_exists($name)) { file_put_contents( @@ -1751,14 +1749,14 @@ class JellyfinServer implements ServerInterface } $this->logger->info($message, [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'context' => [ 'guids' => !empty($providerIds) ? $providerIds : 'None' ], ]); - Data::increment($this->getName(), $type . '_ignored_no_supported_guid'); + Data::increment($this->context->backendName, $type . '_ignored_no_supported_guid'); return; } @@ -1770,7 +1768,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1791,15 +1789,15 @@ class JellyfinServer implements ServerInterface array $opts = [], ): void { try { - if (self::TYPE_SHOW === ($type = ag($item, 'Type'))) { + if (JellyfinClient::TYPE_SHOW === ($type = ag($item, 'Type'))) { $this->processShow(item: $item, context: $context); return; } $after = ag($opts, 'after'); - $type = self::TYPE_MAPPER[$type]; + $type = $this->typeMapper[$type]; - Data::increment($this->getName(), $type . '_total'); + Data::increment($this->context->backendName, $type . '_total'); $context['item'] = [ 'id' => ag($item, 'Id'), @@ -1823,7 +1821,7 @@ class JellyfinServer implements ServerInterface if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'response' => [ 'body' => $item @@ -1836,7 +1834,7 @@ class JellyfinServer implements ServerInterface if (null === ag($item, $dateKey)) { $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'date_key' => $dateKey, ...$context, 'response' => [ @@ -1844,14 +1842,15 @@ class JellyfinServer implements ServerInterface ], ]); - Data::increment($this->getName(), $type . '_ignored_no_date_is_set'); + Data::increment($this->context->backendName, $type . '_ignored_no_date_is_set'); return; } $rItem = $this->createEntity( - item: $item, - type: $type, - opts: array_replace_recursive($opts, ['library' => ag($context, 'library.id')]) + context: $this->context, + guid: $this->guid, + item: $item, + opts: array_replace_recursive($opts, ['library' => ag($context, 'library.id')]) ); if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { @@ -1864,14 +1863,14 @@ class JellyfinServer implements ServerInterface } $this->logger->info($message, [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'context' => [ 'guids' => !empty($providerIds) ? $providerIds : 'None' ], ]); - Data::increment($this->getName(), $type . '_ignored_no_supported_guid'); + Data::increment($this->context->backendName, $type . '_ignored_no_supported_guid'); return; } @@ -1880,7 +1879,7 @@ class JellyfinServer implements ServerInterface $this->logger->debug( 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'comparison' => [ 'lastSync' => makeDate($after), @@ -1889,17 +1888,17 @@ class JellyfinServer implements ServerInterface ] ); - Data::increment($this->getName(), $type . '_ignored_date_is_equal_or_higher'); + Data::increment($this->context->backendName, $type . '_ignored_date_is_equal_or_higher'); return; } } if (null === ($entity = $mapper->get($rItem))) { $this->logger->warning('Ignoring [%(backend)] [%(item.title)]. %(item.type) Is not imported yet.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, ]); - Data::increment($this->getName(), $type . '_ignored_not_found_in_db'); + Data::increment($this->context->backendName, $type . '_ignored_not_found_in_db'); return; } @@ -1908,7 +1907,7 @@ class JellyfinServer implements ServerInterface $this->logger->debug( 'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'comparison' => [ 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', @@ -1918,7 +1917,7 @@ class JellyfinServer implements ServerInterface ); } - Data::increment($this->getName(), $type . '_ignored_state_unchanged'); + Data::increment($this->context->backendName, $type . '_ignored_state_unchanged'); return; } @@ -1926,7 +1925,7 @@ class JellyfinServer implements ServerInterface $this->logger->debug( 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'comparison' => [ 'storage' => makeDate($entity->updated), @@ -1935,7 +1934,7 @@ class JellyfinServer implements ServerInterface ] ); - Data::increment($this->getName(), $type . '_ignored_date_is_newer'); + Data::increment($this->context->backendName, $type . '_ignored_date_is_newer'); return; } @@ -1946,7 +1945,7 @@ class JellyfinServer implements ServerInterface $this->logger->debug( 'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', ...$context, ] @@ -1960,7 +1959,7 @@ class JellyfinServer implements ServerInterface array_replace_recursive($this->getHeaders(), [ 'user_data' => [ 'context' => $context + [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', ], ], @@ -1972,7 +1971,7 @@ class JellyfinServer implements ServerInterface $this->logger->error( 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), @@ -1996,7 +1995,7 @@ class JellyfinServer implements ServerInterface if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))] payload.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'response' => [ 'body' => $item, @@ -2006,7 +2005,7 @@ class JellyfinServer implements ServerInterface $providersId = (array)ag($item, 'ProviderIds', []); - if (!$this->hasSupportedIds($providersId)) { + if (!$this->guid->has($providersId)) { $message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.'; if (empty($providersId)) { @@ -2014,7 +2013,7 @@ class JellyfinServer implements ServerInterface } $this->logger->info($message, [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'data' => [ 'guids' => !empty($providersId) ? $providersId : 'None' @@ -2024,46 +2023,12 @@ class JellyfinServer implements ServerInterface return; } - $this->cache['shows'][ag($context, 'item.id')] = Guid::fromArray($this->getGuids($providersId), context: [ - 'backend' => $this->getName(), + $this->cache['shows'][ag($context, 'item.id')] = Guid::fromArray($this->guid->get($providersId), context: [ + 'backend' => $this->context->backendName, ...$context, ])->getAll(); } - protected function getGuids(array $ids): array - { - $guid = []; - - foreach (array_change_key_case($ids, CASE_LOWER) as $key => $value) { - if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) { - continue; - } - - if (null !== ($guid[self::GUID_MAPPER[$key]] ?? null) && ctype_digit($value)) { - if ((int)$guid[self::GUID_MAPPER[$key]] > (int)$value) { - continue; - } - } - - $guid[self::GUID_MAPPER[$key]] = $value; - } - - ksort($guid); - - return $guid; - } - - protected function hasSupportedIds(array $ids): bool - { - foreach (array_change_key_case($ids, CASE_LOWER) as $key => $value) { - if (null !== (self::GUID_MAPPER[$key] ?? null) && !empty($value)) { - return true; - } - } - - return false; - } - protected function checkConfig(bool $checkUrl = true, bool $checkToken = true, bool $checkUser = true): void { if (true === $checkUrl && !($this->url instanceof UriInterface)) { @@ -2079,100 +2044,6 @@ class JellyfinServer 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, '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.'); - } - - $guids = $this->getGuids(ag($item, 'ProviderIds', [])); - $guids += Guid::makeVirtualGuid($this->getName(), (string)ag($item, 'Id')); - - $builder = [ - iFace::COLUMN_TYPE => $type, - iFace::COLUMN_UPDATED => makeDate($date)->getTimestamp(), - iFace::COLUMN_WATCHED => (int)$isPlayed, - iFace::COLUMN_VIA => $this->getName(), - iFace::COLUMN_TITLE => ag($item, ['Name', 'OriginalTitle'], '??'), - iFace::COLUMN_GUIDS => $guids, - iFace::COLUMN_META_DATA => [ - $this->getName() => [ - iFace::COLUMN_ID => (string)ag($item, 'Id'), - iFace::COLUMN_TYPE => $type, - iFace::COLUMN_WATCHED => true === $isPlayed ? '1' : '0', - iFace::COLUMN_VIA => $this->getName(), - 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][$this->getName()]; - $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); - } - protected function getEpisodeParent(int|string $id, array $context = []): array { if (array_key_exists($id, $this->cache['shows'] ?? [])) { @@ -2215,20 +2086,20 @@ class JellyfinServer implements ServerInterface $providersId = (array)ag($json, 'ProviderIds', []); - if (!$this->hasSupportedIds($providersId)) { + if (!$this->guid->has($providersId)) { $this->cache['shows'][$id] = []; return []; } - $this->cache['shows'][$id] = Guid::fromArray($this->getGuids($providersId), context: [ - 'backend' => $this->getName(), + $this->cache['shows'][$id] = Guid::fromArray($this->guid->get($providersId), context: [ + 'backend' => $this->context->backendName, ...$context, ])->getAll(); return $this->cache['shows'][$id]; } catch (Throwable $e) { $this->logger->error('Unhandled exception was thrown during getEpisodeParent.', [ - 'backend' => $this->getName(), + 'backend' => $this->context->backendName, ...$context, 'exception' => [ 'file' => $e->getFile(), diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 462578bc..2a423be9 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -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(anidb|tvdb|tmdb|tsdb|imdb))\d?-(?P[^\[\]]*)/'; - 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 {