diff --git a/src/Backends/Jellyfin/Action/Export.php b/src/Backends/Jellyfin/Action/Export.php new file mode 100644 index 00000000..7072a7e5 --- /dev/null +++ b/src/Backends/Jellyfin/Action/Export.php @@ -0,0 +1,276 @@ +tryResponse($context, fn() => $this->getLibraries( + context: $context, + handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle( + context: $context, + response: $response, + callback: fn(array $item, array $logContext = []) => $this->export( + context: $context, + guid: $guid, + queue: $opts['queue'], + mapper: $mapper, + item: $item, + logContext: $logContext, + opts: ['after' => $after], + ), + logContext: $logContext + ), + error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error( + 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ), + )); + } + + private function export( + Context $context, + iGuid $guid, + QueueRequests $queue, + ImportInterface $mapper, + array $item, + array $logContext = [], + array $opts = [], + ): void { + if (JFC::TYPE_SHOW === ($type = ag($item, 'Type'))) { + $this->processShow(context: $context, guid: $guid, item: $item, logContext: $logContext); + return; + } + + try { + $after = ag($opts, 'after'); + $type = JFC::TYPE_MAPPER[$type]; + + Data::increment($context->backendName, $type . '_total'); + + $logContext['item'] = [ + 'id' => ag($item, 'Id'), + 'title' => match ($type) { + iFace::TYPE_MOVIE => sprintf( + '%s (%d)', + ag($item, ['Name', 'OriginalTitle'], '??'), + ag($item, 'ProductionYear', 0000) + ), + iFace::TYPE_EPISODE => trim( + sprintf( + '%s - (%sx%s)', + ag($item, 'SeriesName', '??'), + str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT), + str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT), + ) + ), + }, + 'type' => $type, + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ + 'backend' => $context->backendName, + ...$logContext, + 'response' => [ + 'body' => $item + ], + ]); + } + + $isPlayed = true === (bool)ag($item, 'UserData.Played'); + $dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated'; + + if (null === ag($item, $dateKey)) { + $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ + 'backend' => $context->backendName, + 'date_key' => $dateKey, + ...$logContext, + 'response' => [ + 'body' => $item, + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_date_is_set'); + return; + } + + $rItem = $this->createEntity( + context: $context, + guid: $guid, + item: $item, + opts: $opts + ['library' => ag($logContext, 'library.id')] + ); + + if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { + $providerIds = (array)ag($item, 'ProviderIds', []); + + $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; + + if (empty($providerIds)) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'context' => [ + 'guids' => !empty($providerIds) ? $providerIds : 'None' + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_supported_guid'); + return; + } + + if (false === ag($context->options, Options::IGNORE_DATE, false)) { + if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'lastSync' => makeDate($after), + 'backend' => makeDate($rItem->updated), + ], + ] + ); + + Data::increment($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' => $context->backendName, + ...$logContext, + ]); + Data::increment($context->backendName, $type . '_ignored_not_found_in_db'); + return; + } + + if ($rItem->watched === $entity->watched) { + if (true === (bool)ag($context->options, Options::DEBUG_TRACE)) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + ], + ] + ); + } + + Data::increment($context->backendName, $type . '_ignored_state_unchanged'); + return; + } + + if ($rItem->updated >= $entity->updated && false === ag($context->options, Options::IGNORE_DATE, false)) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'storage' => makeDate($entity->updated), + 'backend' => makeDate($rItem->updated), + ], + ] + ); + + Data::increment($context->backendName, $type . '_ignored_date_is_newer'); + return; + } + + $url = $context->backendUrl->withPath( + sprintf('/Users/%s/PlayedItems/%s', $context->backendUser, ag($item, 'Id')) + ); + + $logContext['item']['url'] = $url; + + $this->logger->debug( + 'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].', + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ...$logContext, + ] + ); + + if (false === (bool)ag($context->options, Options::DRY_RUN, false)) { + $queue->add( + $this->http->request( + $entity->isWatched() ? 'POST' : 'DELETE', + (string)$url, + $context->backendHeaders + [ + 'user_data' => [ + 'context' => $logContext + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ], + ], + ] + ) + ); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } +} diff --git a/src/Backends/Jellyfin/Action/Import.php b/src/Backends/Jellyfin/Action/Import.php new file mode 100644 index 00000000..89fa8ee7 --- /dev/null +++ b/src/Backends/Jellyfin/Action/Import.php @@ -0,0 +1,597 @@ +tryResponse($context, fn() => $this->getLibraries( + context: $context, + handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle( + context: $context, + response: $response, + callback: fn(array $item, array $logContext = []) => $this->import( + context: $context, + guid: $guid, + mapper: $mapper, + item: $item, + logContext: $logContext, + opts: ['after' => $after], + ), + logContext: $logContext + ), + error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error( + 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ), + )); + } + + protected function getLibraries(Context $context, Closure $handle, Closure $error): array + { + try { + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser)); + + $this->logger->debug('Requesting [%(backend)] libraries.', [ + 'backend' => $context->backendName, + 'url' => $url + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + $this->logger->error( + 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ] + ); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $listDirs = ag($json, 'Items', []); + + if (empty($listDirs)) { + $this->logger->warning('Request for [%(backend)] libraries returned with empty list.', [ + 'backend' => $context->backendName, + 'context' => [ + 'body' => $json, + ] + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + } catch (ExceptionInterface $e) { + $this->logger->error('Request for [%(backend)] libraries has failed.', [ + 'backend' => $context->backendName, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } catch (JsonException $e) { + $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + ], + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$ignoreIds)); + } + + $requests = []; + $ignored = $unsupported = 0; + + // -- Episodes Parent external ids. + foreach ($listDirs as $section) { + $logContext = [ + 'library' => [ + 'id' => (string)ag($section, 'Id'), + 'title' => ag($section, 'Name', '??'), + 'type' => ag($section, 'CollectionType', 'unknown'), + ], + ]; + + if (JFC::COLLECTION_TYPE_SHOWS !== ag($logContext, 'library.type')) { + continue; + } + + if (true === in_array(ag($logContext, 'library.id'), $ignoreIds ?? [])) { + continue; + } + + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery( + http_build_query( + [ + 'parentId' => ag($logContext, 'library.id'), + 'recursive' => 'false', + 'enableUserData' => 'false', + 'enableImages' => 'false', + 'fields' => implode(',', JFC::EXTRA_FIELDS), + 'excludeLocationTypes' => 'Virtual', + ] + ) + ); + + $logContext['library']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + try { + $requests[] = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, [ + 'user_data' => [ + 'ok' => $handle($logContext), + 'error' => $error($logContext), + ] + ]) + ); + } catch (ExceptionInterface $e) { + $this->logger->error( + 'Request for [%(backend)] [%(library.title)] series external ids has failed.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + continue; + } + } + + foreach ($listDirs as $section) { + $logContext = [ + 'library' => [ + 'id' => (string)ag($section, 'Id'), + 'title' => ag($section, 'Name', '??'), + 'type' => ag($section, 'CollectionType', 'unknown'), + ], + ]; + + if (true === in_array(ag($logContext, 'library.id'), $ignoreIds ?? [])) { + $ignored++; + $this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + continue; + } + + if (!in_array(ag($logContext, 'library.type'), [JFC::COLLECTION_TYPE_SHOWS, JFC::COLLECTION_TYPE_MOVIES])) { + $unsupported++; + $this->logger->info( + 'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + $url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery( + http_build_query( + [ + 'parentId' => ag($logContext, 'library.id'), + 'recursive' => 'true', + 'enableUserData' => 'true', + 'enableImages' => 'false', + 'excludeLocationTypes' => 'Virtual', + 'fields' => implode(',', JFC::EXTRA_FIELDS), + 'includeItemTypes' => implode(',', [JFC::TYPE_MOVIE, JFC::TYPE_EPISODE]), + ] + ) + ); + + $logContext['library']['url'] = (string)$url; + + $this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + try { + $requests[] = $this->http->request( + 'GET', + (string)$url, + $context->backendHeaders + [ + 'user_data' => [ + 'ok' => $handle($logContext), + 'error' => $error($logContext), + ] + ] + ); + } catch (ExceptionInterface $e) { + $this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ]); + continue; + } + } + + if (0 === count($requests)) { + $this->logger->warning('No requests for [%(backend)] libraries were queued.', [ + 'backend' => $context->backendName, + 'context' => [ + 'total' => count($listDirs), + 'ignored' => $ignored, + 'unsupported' => $unsupported, + ], + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + return $requests; + } + + /** + * @throws TransportExceptionInterface + */ + protected function handle(Context $context, iResponse $response, Closure $callback, array $logContext = []): void + { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + 'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext, + ] + ); + return; + } + + $start = makeDate(); + $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ + 'backend' => $context->backendName, + ...$logContext, + 'time' => [ + 'start' => $start, + ], + ]); + + try { + $it = Items::fromIterable( + iterable: httpClientChunks($this->http->stream($response)), + options: [ + 'pointer' => '/Items', + 'decoder' => new ErrorWrappingDecoder( + innerDecoder: new ExtJsonDecoder( + assoc: true, + options: JSON_INVALID_UTF8_IGNORE + ) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->warning( + 'Failed to decode one item of [%(backend)] [%(library.title)] content.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'error' => [ + 'message' => $entity->getErrorMessage(), + 'body' => $entity->getMalformedJson(), + ], + ] + ); + continue; + } + + $callback(item: $entity, logContext: $logContext); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during parsing of [%(backend)] library [%(library.title)] response.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + + $end = makeDate(); + $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ + 'backend' => $context->backendName, + ...$logContext, + 'time' => [ + 'start' => $start, + 'end' => $end, + 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), + ], + ]); + } + + protected function processShow(Context $context, iGuid $guid, array $item, array $logContext = []): void + { + $logContext['item'] = [ + 'id' => ag($item, 'Id'), + 'title' => sprintf( + '%s (%s)', + ag($item, ['Name', 'OriginalTitle'], '??'), + ag($item, 'ProductionYear', '0000') + ), + 'year' => ag($item, 'ProductionYear', null), + 'type' => ag($item, 'Type'), + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))] payload.', [ + 'backend' => $context->backendName, + ...$logContext, + 'trace' => $item, + ]); + } + + $providersId = (array)ag($item, 'ProviderIds', []); + + if (false === $guid->has($providersId)) { + $message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.'; + + if (empty($providersId)) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'data' => [ + 'guids' => !empty($providersId) ? $providersId : 'None' + ], + ]); + + return; + } + + $context->cache->set( + JFC::TYPE_SHOW . '.' . ag($logContext, 'item.id'), + Guid::fromArray($guid->get($providersId), context: [ + 'backend' => $context->backendName, + ...$logContext, + ])->getAll() + ); + } + + private function import( + Context $context, + iGuid $guid, + ImportInterface $mapper, + array $item, + array $logContext = [], + array $opts = [] + ): void { + if (JFC::TYPE_SHOW === ($type = ag($item, 'Type'))) { + $this->processShow(context: $context, guid: $guid, item: $item, logContext: $logContext); + return; + } + + try { + Data::increment($context->backendName, $type . '_total'); + + $logContext['item'] = [ + 'id' => ag($item, 'Id'), + 'title' => match ($type) { + JFC::TYPE_MOVIE => sprintf( + '%s (%d)', + ag($item, ['Name', 'OriginalTitle'], '??'), + ag($item, 'ProductionYear', 0000) + ), + JFC::TYPE_EPISODE => trim( + sprintf( + '%s - (%sx%s)', + ag($item, 'SeriesName', '??'), + str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT), + str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT), + ) + ), + default => throw new \InvalidArgumentException('Invalid Content type was given.') + }, + 'type' => ag($item, 'Type'), + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ + 'backend' => $context->backendName, + ...$logContext, + 'response' => [ + 'body' => $item + ], + ]); + } + + $isPlayed = true === (bool)ag($item, 'UserData.Played'); + $dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated'; + + if (null === ag($item, $dateKey)) { + $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ + 'backend' => $context->backendName, + 'date_key' => $dateKey, + ...$logContext, + 'response' => [ + 'body' => $item, + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_date_is_set'); + return; + } + + $entity = $this->createEntity( + context: $context, + guid: $guid, + item: $item, + opts: $opts + [ + 'library' => ag($logContext, 'library.id'), + 'override' => [ + iFace::COLUMN_EXTRA => [ + $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 = sprintf( + Config::get('tmpDir') . '/debug/%s.%s.json', + $context->backendName, + ag($item, 'Id') + ); + + if (!file_exists($name)) { + file_put_contents( + $name, + json_encode( + $item, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ) + ); + } + } + + $providerIds = (array)ag($item, 'ProviderIds', []); + + $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; + + if (empty($providerIds)) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'context' => [ + 'guids' => !empty($providerIds) ? $providerIds : 'None' + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_supported_guid'); + return; + } + + $mapper->add(entity: $entity, opts: [ + 'after' => ag($opts, 'after'), + Options::IMPORT_METADATA_ONLY => true === (bool)ag($context->options, Options::IMPORT_METADATA_ONLY), + ]); + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } +} diff --git a/src/Backends/Jellyfin/JellyfinActionTrait.php b/src/Backends/Jellyfin/JellyfinActionTrait.php index 370228dc..87fcff72 100644 --- a/src/Backends/Jellyfin/JellyfinActionTrait.php +++ b/src/Backends/Jellyfin/JellyfinActionTrait.php @@ -9,7 +9,6 @@ use App\Backends\Common\GuidInterface as iGuid; use App\Backends\Jellyfin\Action\GetLibrariesList; use App\Backends\Jellyfin\Action\GetMetaData; use App\Libs\Container; -use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateInterface as iState; use App\Libs\Guid; use App\Libs\Options; @@ -17,14 +16,8 @@ use RuntimeException; trait JellyfinActionTrait { - private array $typeMapper = [ - JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW, - JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE, - JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE, - ]; - /** - * Create {@see StateEntity} Object based on given data. + * Create {@see iState} Object based on given data. * * @param Context $context * @param iGuid $guid @@ -48,7 +41,7 @@ trait JellyfinActionTrait throw new RuntimeException('No date was set on object.'); } - $type = $this->typeMapper[ag($item, 'Type')] ?? ag($item, 'Type'); + $type = JellyfinClient::TYPE_MAPPER[ag($item, 'Type')] ?? ag($item, 'Type'); $guids = $guid->get(ag($item, 'ProviderIds', []), context: [ 'item' => [ diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index 2f6c5bb8..6e4f782e 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -8,6 +8,7 @@ use App\Backends\Common\Context; use App\Backends\Jellyfin\Action\GetIdentifier; use App\Backends\Jellyfin\Action\GetMetaData; use App\Libs\Container; +use App\Libs\Entity\StateInterface as iState; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use RuntimeException; @@ -33,6 +34,12 @@ class JellyfinClient 'Path', ]; + public const TYPE_MAPPER = [ + JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW, + JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE, + JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE, + ]; + private Context|null $context = null; public function __construct( diff --git a/src/Backends/Plex/Action/Export.php b/src/Backends/Plex/Action/Export.php new file mode 100644 index 00000000..451b9312 --- /dev/null +++ b/src/Backends/Plex/Action/Export.php @@ -0,0 +1,288 @@ +tryResponse($context, fn() => $this->getLibraries( + context: $context, + handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle( + context: $context, + response: $response, + callback: fn(array $item, array $logContext = []) => $this->export( + context: $context, + guid: $guid, + queue: $opts['queue'], + mapper: $mapper, + item: $item, + logContext: $logContext, + opts: ['after' => $after], + ), + logContext: $logContext, + ), + error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error( + 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ), + )); + } + + private function export( + Context $context, + iGuid $guid, + QueueRequests $queue, + ImportInterface $mapper, + array $item, + array $logContext = [], + array $opts = [], + ): void { + $after = ag($opts, 'after', null); + $library = ag($logContext, 'library.id'); + $type = ag($item, 'type'); + + if (PlexClient::TYPE_SHOW === $type) { + $this->processShow($context, $guid, $item, $logContext); + return; + } + + try { + Data::increment($context->backendName, $library . '_total'); + Data::increment($context->backendName, $type . '_total'); + + $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $logContext['item'] = [ + 'id' => ag($item, 'ratingKey'), + 'title' => match ($type) { + PlexClient::TYPE_MOVIE => sprintf( + '%s (%s)', + ag($item, ['title', 'originalTitle'], '??'), + 0 === $year ? '0000' : $year, + ), + PlexClient::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), + ), + }, + 'type' => $type, + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ + 'backend' => $context->backendName, + ...$logContext, + 'payload' => $item, + ]); + } + + if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) { + $this->logger->debug('Ignoring [%(backend)] [%(item.title)]. No Date is set on object.', [ + 'backend' => $context->backendName, + 'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt', + ...$logContext, + 'response' => [ + 'body' => $item, + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_date_is_set'); + return; + } + + $rItem = $this->createEntity( + context: $context, + guid: $guid, + item: $item, + opts: $opts + ); + + if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { + $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; + + if (null === ($item['Guid'] ?? null)) { + $item['Guid'] = []; + } + + if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) { + $item['Guid'][] = $itemGuid; + } + + if (empty($item['Guid'])) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'context' => [ + 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_supported_guid'); + return; + } + + if (false === ag($context->options, Options::IGNORE_DATE, false)) { + if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'lastSync' => makeDate($after), + 'backend' => makeDate($rItem->updated), + ], + ] + ); + + Data::increment($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' => $context->backendName, + ...$logContext, + ]); + Data::increment($context->backendName, $type . '_ignored_not_found_in_db'); + return; + } + + if ($rItem->watched === $entity->watched) { + if (true === (bool)ag($context->options, Options::DEBUG_TRACE)) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', + 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', + ], + ] + ); + } + + Data::increment($context->backendName, $type . '_ignored_state_unchanged'); + return; + } + + if ($rItem->updated >= $entity->updated && false === ag($context->options, Options::IGNORE_DATE, false)) { + $this->logger->debug( + 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'comparison' => [ + 'storage' => makeDate($entity->updated), + 'backend' => makeDate($rItem->updated), + ], + ] + ); + + Data::increment($context->backendName, $type . '_ignored_date_is_newer'); + return; + } + + $url = $context->backendUrl->withPath( + '/:' . ($entity->isWatched() ? '/scrobble' : '/unscrobble') + )->withQuery( + http_build_query( + [ + 'identifier' => 'com.plexapp.plugins.library', + 'key' => $item['ratingKey'], + ] + ) + ); + + $logContext['item']['url'] = $url; + + $this->logger->debug( + 'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].', + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ...$logContext, + ] + ); + + if (false === (bool)ag($context->options, Options::DRY_RUN, false)) { + $queue->add( + $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, [ + 'user_data' => [ + 'context' => $logContext + [ + 'backend' => $context->backendName, + 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', + ], + ] + ]) + ) + ); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } +} diff --git a/src/Backends/Plex/Action/Import.php b/src/Backends/Plex/Action/Import.php new file mode 100644 index 00000000..cb5ccfdb --- /dev/null +++ b/src/Backends/Plex/Action/Import.php @@ -0,0 +1,647 @@ +tryResponse($context, fn() => $this->getLibraries( + context: $context, + handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle( + context: $context, + response: $response, + callback: fn(array $item, array $logContext = []) => $this->import( + context: $context, + guid: $guid, + mapper: $mapper, + item: $item, + logContext: $logContext, + opts: ['after' => $after], + ), + logContext: $logContext + ), + error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error( + 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ), + )); + } + + protected function getLibraries(Context $context, Closure $handle, Closure $error): array + { + try { + $url = $context->backendUrl->withPath('/library/sections'); + + $this->logger->debug('Requesting [%(backend)] libraries.', [ + 'backend' => $context->backendName, + 'url' => $url + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + $this->logger->error( + 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ] + ); + + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + $listDirs = ag($json, 'MediaContainer.Directory', []); + + if (empty($listDirs)) { + $this->logger->warning('Request for [%(backend)] libraries returned with empty list.', [ + 'backend' => $context->backendName, + 'context' => [ + 'body' => $json, + ] + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + } catch (ExceptionInterface $e) { + $this->logger->error('Request for [%(backend)] libraries has failed.', [ + 'backend' => $context->backendName, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } catch (JsonException $e) { + $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + ], + ]); + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) { + $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + } + + $requests = []; + $ignored = $unsupported = 0; + + // -- Get TV shows metadata. + foreach ($listDirs as $section) { + $key = (int)ag($section, 'key'); + + if (PlexClient::TYPE_SHOW !== ag($section, 'type', 'unknown')) { + continue; + } + + if (true === in_array($key, $ignoreIds ?? [])) { + continue; + } + + $url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( + http_build_query(['type' => 2, 'includeGuids' => 1]) + ); + + $logContext = [ + 'library' => [ + 'id' => $key, + 'title' => ag($section, 'title', '??'), + 'type' => ag($section, 'type', 'unknown'), + 'url' => $url, + ], + ]; + + $this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + try { + $requests[] = $this->http->request( + 'GET', + (string)$url, + $context->backendHeaders + [ + 'user_data' => [ + 'ok' => $handle($logContext), + 'error' => $error($logContext), + ] + ] + ); + } catch (ExceptionInterface $e) { + $this->logger->error( + 'Request for [%(backend)] [%(library.title)] series external ids has failed.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + continue; + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during [%(backend)] [%(library.title)] series external ids request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + continue; + } + } + + // -- Get Movies/episodes. + foreach ($listDirs as $section) { + $key = (int)ag($section, 'key'); + + $logContext = [ + 'library' => [ + 'id' => ag($section, 'key'), + 'title' => ag($section, 'title', '??'), + 'type' => ag($section, 'type', 'unknown'), + ], + ]; + + if (true === in_array($key, $ignoreIds ?? [])) { + $ignored++; + $this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + continue; + } + + if (!in_array(ag($logContext, 'library.type'), [PlexClient::TYPE_MOVIE, PlexClient::TYPE_SHOW])) { + $unsupported++; + $this->logger->info( + 'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.', + [ + 'backend' => $context->backendName, + ...$logContext, + ] + ); + continue; + } + + $url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( + http_build_query( + [ + 'type' => PlexClient::TYPE_MOVIE === ag($logContext, 'library.type') ? 1 : 4, + 'includeGuids' => 1, + ] + ) + ); + + $logContext['library']['url'] = $url; + + $this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [ + 'backend' => $context->backendName, + ...$logContext, + ]); + + try { + $requests[] = $this->http->request( + 'GET', + (string)$url, + $context->backendHeaders + [ + 'user_data' => [ + 'ok' => $handle($logContext), + 'error' => $error($logContext), + ] + ], + ); + } catch (ExceptionInterface $e) { + $this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ]); + continue; + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during [%(backend)] [%(library.title)] content list request.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + continue; + } + } + + if (0 === count($requests)) { + $this->logger->warning('No requests for [%(backend)] libraries were queued.', [ + 'backend' => $context->backendName, + 'context' => [ + 'total' => count($listDirs), + 'ignored' => $ignored, + 'unsupported' => $unsupported, + ], + ]); + + Data::add($context->backendName, 'no_import_update', true); + return []; + } + + return $requests; + } + + /** + * @throws TransportExceptionInterface + */ + protected function handle(Context $context, iResponse $response, Closure $callback, array $logContext = []): void + { + if (200 !== $response->getStatusCode()) { + $this->logger->error( + 'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.', + [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ...$logContext, + ] + ); + return; + } + + $start = makeDate(); + $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ + 'backend' => $context->backendName, + ...$logContext, + 'time' => [ + 'start' => $start, + ], + ]); + + try { + $it = Items::fromIterable( + iterable: httpClientChunks($this->http->stream($response)), + options: [ + 'pointer' => '/MediaContainer/Metadata', + 'decoder' => new ErrorWrappingDecoder( + innerDecoder: new ExtJsonDecoder( + assoc: true, + options: JSON_INVALID_UTF8_IGNORE + ) + ) + ] + ); + + foreach ($it as $entity) { + if ($entity instanceof DecodingError) { + $this->logger->warning( + 'Failed to decode one item of [%(backend)] [%(library.title)] content.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'error' => [ + 'message' => $entity->getErrorMessage(), + 'body' => $entity->getMalformedJson(), + ], + ] + ); + continue; + } + + $callback(item: $entity, logContext: $logContext); + } + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during parsing of [%(backend)] library [%(library.title)] response.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + + $end = makeDate(); + $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ + 'backend' => $context->backendName, + ...$logContext, + 'time' => [ + 'start' => $start, + 'end' => $end, + 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), + ], + ]); + } + + protected function processShow(Context $context, iGuid $guid, array $item, array $logContext = []): void + { + if (null === ($item['Guid'] ?? null)) { + $item['Guid'] = [['id' => $item['guid']]]; + } else { + $item['Guid'][] = ['id' => $item['guid']]; + } + + $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $logContext['item'] = [ + 'id' => ag($item, 'ratingKey'), + 'title' => sprintf( + '%s (%s)', + ag($item, ['title', 'originalTitle'], '??'), + 0 === $year ? '0000' : $year, + ), + 'year' => 0 === $year ? '0000' : $year, + 'type' => ag($item, 'type', 'unknown'), + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', [ + 'backend' => $context->backendName, + ...$logContext, + 'trace' => $item, + ]); + } + + if (!$guid->has($item['Guid'])) { + if (null === ($item['Guid'] ?? null)) { + $item['Guid'] = []; + } + + if (null !== ($item['guid'] ?? null) && false === $guid->isLocal($item['guid'])) { + $item['Guid'][] = ['id' => $item['guid']]; + } + + $message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.'; + + if (empty($item['Guid'] ?? [])) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'data' => [ + 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' + ], + ]); + + return; + } + + $gContext = ag_set( + $logContext, + 'item.plex_id', + str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none' + ); + + $context->cache->set( + PlexClient::TYPE_SHOW . '.' . ag($logContext, 'item.id'), + Guid::fromArray( + payload: $guid->get($item['Guid'], context: [...$gContext]), + context: ['backend' => $context->backendName, ...$logContext,] + )->getAll() + ); + } + + private function import( + Context $context, + iGuid $guid, + ImportInterface $mapper, + array $item, + array $logContext = [], + array $opts = [] + ): void { + $after = ag($opts, 'after', null); + $library = ag($logContext, 'library.id'); + $type = ag($item, 'type'); + + try { + if (PlexClient::TYPE_SHOW === $type) { + $this->processShow($context, $guid, $item, $logContext); + return; + } + + Data::increment($context->backendName, $library . '_total'); + Data::increment($context->backendName, $type . '_total'); + + $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); + if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { + $year = (int)makeDate($airDate)->format('Y'); + } + + $logContext['item'] = [ + 'id' => ag($item, 'ratingKey'), + 'title' => match ($type) { + PlexClient::TYPE_MOVIE => sprintf( + '%s (%s)', + ag($item, ['title', 'originalTitle'], '??'), + 0 === $year ? '0000' : $year, + ), + PlexClient::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), + ), + }, + 'type' => ag($item, 'type', 'unknown'), + ]; + + if ($context->trace) { + $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)]', [ + 'backend' => $context->backendName, + ...$logContext, + 'payload' => $item, + ]); + } + + if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) { + $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ + 'backend' => $context->backendName, + 'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt', + ...$logContext, + 'response' => [ + 'body' => $item, + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_date_is_set'); + return; + } + + $entity = $this->createEntity( + context: $context, + guid: $guid, + item: $item, + opts: $opts + [ + 'override' => [ + iFace::COLUMN_EXTRA => [ + $context->backendName => [ + iFace::COLUMN_EXTRA_EVENT => 'task.import', + iFace::COLUMN_EXTRA_DATE => makeDate('now'), + ], + ], + ], + ] + ); + + if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) { + if (true === (bool)Config::get('debug.import')) { + $name = Config::get( + 'tmpDir' + ) . '/debug/' . $context->backendName . '.' . $item['ratingKey'] . '.json'; + + if (!file_exists($name)) { + file_put_contents( + $name, + json_encode( + $item, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ) + ); + } + } + + $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; + + if (null === ($item['Guid'] ?? null)) { + $item['Guid'] = []; + } + + if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) { + $item['Guid'][] = $itemGuid; + } + + if (empty($item['Guid'])) { + $message .= ' Most likely unmatched %(item.type).'; + } + + $this->logger->info($message, [ + 'backend' => $context->backendName, + ...$logContext, + 'context' => [ + 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' + ], + ]); + + Data::increment($context->backendName, $type . '_ignored_no_supported_guid'); + return; + } + + $mapper->add(entity: $entity, opts: [ + 'after' => $after, + Options::IMPORT_METADATA_ONLY => true === (bool)ag($context->options, Options::IMPORT_METADATA_ONLY), + ]); + } catch (Throwable $e) { + $this->logger->error( + 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.', + [ + 'backend' => $context->backendName, + ...$logContext, + 'exception' => [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ] + ); + } + } +} diff --git a/src/Commands/State/ExportCommand.php b/src/Commands/State/ExportCommand.php index 02163dd7..dca3f1ec 100644 --- a/src/Commands/State/ExportCommand.php +++ b/src/Commands/State/ExportCommand.php @@ -335,7 +335,6 @@ class ExportCommand extends Command ); } else { Config::save(sprintf('servers.%s.export.lastSync', $name), time()); - Config::save(sprintf('servers.%s.persist', $name), $backend['class']->getPersist()); } } diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php index cc4a16d5..5a6df87d 100644 --- a/src/Commands/State/ImportCommand.php +++ b/src/Commands/State/ImportCommand.php @@ -331,14 +331,6 @@ class ImportCommand extends Command (new Table($output))->setHeaders(array_keys($a[0]))->setStyle('box')->setRows(array_values($a))->render(); if (false === $input->getOption('dry-run')) { - foreach ($list as $server) { - if (null === ($name = ag($server, 'name'))) { - continue; - } - - Config::save(sprintf('servers.%s.persist', $name), $server['class']->getPersist()); - } - if (false === $custom && is_writable(dirname($config))) { copy($config, $config . '.bak'); } diff --git a/src/Commands/State/PushCommand.php b/src/Commands/State/PushCommand.php index 6ca9baef..6e21d160 100644 --- a/src/Commands/State/PushCommand.php +++ b/src/Commands/State/PushCommand.php @@ -221,14 +221,6 @@ class PushCommand extends Command } if (false === $input->getOption('dry-run')) { - foreach ($list as $server) { - if (null === ($name = ag($server, 'name'))) { - continue; - } - - Config::save(sprintf('servers.%s.persist', $name), $server['class']->getPersist()); - } - $config = Config::get('path') . '/config/servers.yaml'; if (is_writable(dirname($config))) { diff --git a/src/Libs/Servers/EmbyServer.php b/src/Libs/Servers/EmbyServer.php index a1f108f0..a9cc463f 100644 --- a/src/Libs/Servers/EmbyServer.php +++ b/src/Libs/Servers/EmbyServer.php @@ -10,26 +10,11 @@ use App\Libs\Container; use App\Libs\Entity\StateInterface as iFace; use App\Libs\HttpException; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UriInterface; class EmbyServer extends JellyfinServer { public const NAME = 'EmbyBackend'; - public function setUp( - string $name, - UriInterface $url, - string|int|null $token = null, - string|int|null $userId = null, - string|int|null $uuid = null, - array $persist = [], - array $options = [] - ): ServerInterface { - $options['emby'] = true; - - return parent::setUp($name, $url, $token, $userId, $uuid, $persist, $options); - } - public function parseWebhook(ServerRequestInterface $request): iFace { $response = Container::get(ParseWebhook::class)(context: $this->context, guid: $this->guid, request: $request); diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 09a4f1db..77855651 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -6,43 +6,32 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; +use App\Backends\Jellyfin\Action\Export; +use App\Backends\Jellyfin\Action\GetIdentifier; use App\Backends\Jellyfin\Action\GetLibrariesList; use App\Backends\Jellyfin\Action\GetLibrary; use App\Backends\Jellyfin\Action\GetUsersList; +use App\Backends\Jellyfin\Action\Import; use App\Backends\Jellyfin\Action\InspectRequest; -use App\Backends\Jellyfin\Action\GetIdentifier; use App\Backends\Jellyfin\Action\ParseWebhook; use App\Backends\Jellyfin\Action\Push; use App\Backends\Jellyfin\Action\SearchId; use App\Backends\Jellyfin\Action\SearchQuery; 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\StateInterface as iFace; -use App\Libs\Guid; use App\Libs\HttpException; use App\Libs\Mappers\ImportInterface; use App\Libs\Options; use App\Libs\QueueRequests; -use Closure; use DateTimeInterface; -use JsonException; -use JsonMachine\Exception\PathNotFoundException; -use JsonMachine\Items; -use JsonMachine\JsonDecoder\DecodingError; -use JsonMachine\JsonDecoder\ErrorWrappingDecoder; -use JsonMachine\JsonDecoder\ExtJsonDecoder; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\InvalidArgumentException; use RuntimeException; -use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; use Throwable; class JellyfinServer implements ServerInterface @@ -50,20 +39,6 @@ class JellyfinServer implements ServerInterface use JellyfinActionTrait; public const NAME = 'JellyfinBackend'; - - public const FIELDS = JellyfinClient::EXTRA_FIELDS; - - protected UriInterface|null $url = null; - protected string|null $token = null; - protected string|null $user = null; - protected array $options = []; - protected string $name = ''; - protected bool $initialized = false; - protected bool $isEmby = false; - protected array $persist = []; - - protected string|int|null $uuid = null; - protected Context|null $context = null; public function __construct( @@ -80,59 +55,44 @@ class JellyfinServer implements ServerInterface string|int|null $token = null, string|int|null $userId = null, string|int|null $uuid = null, - array $persist = [], array $options = [] ): ServerInterface { - if (null === $token) { - throw new RuntimeException(self::NAME . ': No token is set.'); - } - $cloned = clone $this; - - $cloned->name = $name; - $cloned->url = $url; - $cloned->token = $token; - $cloned->uuid = $uuid; - $cloned->user = $userId; - $cloned->persist = $persist; - $cloned->isEmby = (bool)($options['emby'] ?? false); - $cloned->initialized = true; - - if (null !== ($options['emby'] ?? null)) { - unset($options['emby']); - } - - $cloned->options = $options; - $cloned->context = new Context( clientName: static::NAME, backendName: $name, backendUrl: $url, - cache: $this->cache->withData($cloned::NAME . '_' . $name, $options), + cache: $this->cache->withData(static::NAME . '_' . $name, $options), backendId: $uuid, backendToken: $token, backendUser: $userId, - backendHeaders: $cloned->getHeaders(), + backendHeaders: array_replace_recursive( + [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-MediaBrowser-Token' => $token, + ], + ], + $options['client'] ?? [] + ), trace: true === ag($options, Options::DEBUG_TRACE), - options: $cloned->options + options: $options ); - $cloned->guid = $this->guid->withContext($cloned->context); + $cloned->guid = $cloned->guid->withContext($cloned->context); return $cloned; } public function getServerUUID(bool $forceRefresh = false): int|string|null { - if (false === $forceRefresh && null !== $this->uuid) { - return $this->uuid; + if (false === $forceRefresh && null !== $this->context->backendId) { + return $this->context->backendId; } $response = Container::get(GetIdentifier::class)(context: $this->context); - $this->uuid = $response->isSuccessful() ? $response->response : null; - - return $this->uuid; + return $response->isSuccessful() ? $response->response : null; } public function getUsersList(array $opts = []): array @@ -152,17 +112,6 @@ class JellyfinServer implements ServerInterface return $response->response; } - public function getPersist(): array - { - return $this->persist; - } - - public function addPersist(string $key, mixed $value): ServerInterface - { - $this->persist = ag_set($this->persist, $key, $value); - return $this; - } - public function setLogger(LoggerInterface $logger): ServerInterface { $this->logger = $logger; @@ -172,7 +121,7 @@ class JellyfinServer implements ServerInterface public function getName(): string { - return $this->name ?? static::NAME; + return $this->context->backendName ?? static::NAME; } public function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface @@ -307,895 +256,42 @@ class JellyfinServer implements ServerInterface public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array { - return $this->getLibraries( - ok: function (array $context = []) use ($after, $mapper) { - return function (ResponseInterface $response) use ($mapper, $after, $context) { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->context->backendName, - 'status_code' => $response->getStatusCode(), - ...$context, - ] - ); - return; - } - - $start = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->context->backendName, - ...$context, - 'time' => [ - 'start' => $start, - ], - ]); - - try { - $it = Items::fromIterable( - iterable: httpClientChunks(stream: $this->http->stream($response)), - options: [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - innerDecoder: new ExtJsonDecoder( - assoc: true, - options: JSON_INVALID_UTF8_IGNORE - ) - ) - ] - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] [%(library.title)] content.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - $this->processImport( - mapper: $mapper, - item: $entity, - context: $context, - opts: ['after' => $after], - ); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - 'No Items were found in [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - - $end = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->context->backendName, - ...$context, - 'time' => [ - 'start' => $start, - 'end' => $end, - 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), - ], - ]); - }; - }, - error: function (array $context = []) { - return fn(Throwable $e) => $this->logger->error( - 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - }, - includeParent: true + $response = Container::get(Import::class)( + context: $this->context, + guid: $this->guid, + mapper: $mapper, + after: $after ); + + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); + } + + return $response->response; } public function export(ImportInterface $mapper, QueueRequests $queue, DateTimeInterface|null $after = null): array { - return $this->getLibraries( - ok: function (array $context = []) use ($mapper, $queue, $after) { - return function (ResponseInterface $response) use ($mapper, $queue, $after, $context) { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] content responded with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->context->backendName, - 'status_code' => $response->getStatusCode(), - ...$context, - ] - ); - return; - } - - $start = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->context->backendName, - ...$context, - 'time' => [ - 'start' => $start, - ], - ]); - - try { - $it = Items::fromIterable( - iterable: httpClientChunks(stream: $this->http->stream($response)), - options: [ - 'pointer' => '/Items', - 'decoder' => new ErrorWrappingDecoder( - innerDecoder: new ExtJsonDecoder( - assoc: true, - options: JSON_INVALID_UTF8_IGNORE - ) - ) - ] - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] [%(library.title)] content.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - $this->processExport( - mapper: $mapper, - queue: $queue, - item: $entity, - context: $context, - opts: ['after' => $after], - ); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - 'No Items were found in [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - - $end = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->context->backendName, - ...$context, - 'time' => [ - 'start' => $start, - 'end' => $end, - 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), - ], - ]); - }; - }, - error: function (array $context = []) { - return fn(Throwable $e) => $this->logger->error( - 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - }, - includeParent: false === count($this->context->cache->get(JellyfinClient::TYPE_SHOW, [])) > 1, + $response = Container::get(Export::class)( + context: $this->context, + guid: $this->guid, + mapper: $mapper, + after: $after, + opts: ['queue' => $queue] ); - } - protected function getHeaders(): array - { - $opts = [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-MediaBrowser-Token' => $this->token, - ], - ]; - - return array_replace_recursive($this->options['client'] ?? [], $opts); - } - - protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array - { - try { - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'recursive' => 'false', - 'enableUserData' => 'false', - 'enableImages' => 'false', - 'fields' => implode(',', self::FIELDS), - ] - ) - ); - - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->context->backendName, - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->context->backendName, - 'status_code' => $response->getStatusCode(), - ] - ); - Data::add($this->context->backendName, 'no_import_update', true); - return []; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $listDirs = ag($json, 'Items', []); - - if (empty($listDirs)) { - $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->context->backendName, - 'context' => [ - 'body' => $json, - ] - ]); - Data::add($this->context->backendName, 'no_import_update', true); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->context->backendName, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - Data::add($this->context->backendName, 'no_import_update', true); - return []; - } catch (JsonException $e) { - $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - ], - ]); - Data::add($this->context->backendName, 'no_import_update', true); - return []; + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); } - if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); } - $promises = []; - $ignored = $unsupported = 0; - - if (true === $includeParent) { - foreach ($listDirs as $section) { - $context = [ - 'library' => [ - 'id' => (string)ag($section, 'Id'), - 'title' => ag($section, 'Name', '??'), - 'type' => ag($section, 'CollectionType', 'unknown'), - ], - ]; - - if (JellyfinClient::COLLECTION_TYPE_SHOWS !== ag($context, 'library.type')) { - continue; - } - - if (null !== $ignoreIds && in_array(ag($context, 'library.id'), $ignoreIds, true)) { - continue; - } - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'parentId' => ag($context, 'library.id'), - 'recursive' => 'false', - 'enableUserData' => 'false', - 'enableImages' => 'false', - 'fields' => implode(',', self::FIELDS), - 'excludeLocationTypes' => 'Virtual', - ] - ) - ); - - $context['library']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [ - 'backend' => $this->context->backendName, - ...$context, - ]); - - try { - $promises[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'ok' => $ok(context: $context), - 'error' => $error(context: $context), - ] - ]) - ); - } catch (ExceptionInterface $e) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] series external ids has failed.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - continue; - } - } - } - - foreach ($listDirs as $section) { - $context = [ - 'library' => [ - 'id' => (string)ag($section, 'Id'), - 'title' => ag($section, 'Name', '??'), - 'type' => ag($section, 'CollectionType', 'unknown'), - ], - ]; - - 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->context->backendName, - ...$context, - ]); - continue; - } - - if (false === in_array(ag($context, 'library.type'), ['movies', 'tvshows'])) { - $unsupported++; - $this->logger->info( - 'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.', - [ - 'backend' => $this->context->backendName, - ...$context, - ] - ); - continue; - } - - $url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery( - http_build_query( - [ - 'parentId' => ag($context, 'library.id'), - 'recursive' => 'true', - 'enableUserData' => 'true', - 'enableImages' => 'false', - 'includeItemTypes' => 'Movie,Episode', - 'fields' => implode(',', self::FIELDS), - 'excludeLocationTypes' => 'Virtual', - ] - ) - ); - - $context['library']['url'] = (string)$url; - - $this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [ - 'backend' => $this->context->backendName, - ...$context, - ]); - - try { - $promises[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'ok' => $ok(context: $context), - 'error' => $error(context: $context), - ] - ]) - ); - } catch (ExceptionInterface $e) { - $this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - continue; - } - } - - if (0 === count($promises)) { - $this->logger->warning('No requests for [%(backend)] libraries were queued.', [ - 'backend' => $this->context->backendName, - 'context' => [ - 'total' => count($listDirs), - 'ignored' => $ignored, - 'unsupported' => $unsupported, - ], - ]); - Data::add($this->context->backendName, 'no_import_update', true); - return []; - } - - return $promises; - } - - protected function processImport(ImportInterface $mapper, array $item, array $context = [], array $opts = []): void - { - try { - if (JellyfinClient::TYPE_SHOW === ($type = ag($item, 'Type'))) { - $this->processShow(item: $item, context: $context); - return; - } - - $type = $this->typeMapper[$type]; - - Data::increment($this->context->backendName, $type . '_total'); - - $context['item'] = [ - 'id' => ag($item, 'Id'), - 'title' => match ($type) { - iFace::TYPE_MOVIE => sprintf( - '%s (%d)', - ag($item, ['Name', 'OriginalTitle'], '??'), - ag($item, 'ProductionYear', 0000) - ), - iFace::TYPE_EPISODE => trim( - sprintf( - '%s - (%sx%s)', - ag($item, 'SeriesName', '??'), - str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT), - str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT), - ) - ), - }, - 'type' => ag($item, 'Type'), - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ - 'backend' => $this->context->backendName, - ...$context, - 'response' => [ - 'body' => $item - ], - ]); - } - - $isPlayed = true === (bool)ag($item, 'UserData.Played'); - $dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated'; - - if (null === ag($item, $dateKey)) { - $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->context->backendName, - 'date_key' => $dateKey, - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - - Data::increment($this->context->backendName, $type . '_ignored_no_date_is_set'); - return; - } - - $entity = $this->createEntity( - 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->context->backendName . '.' . ag( - $item, - 'Id' - ) . '.json'; - - if (!file_exists($name)) { - file_put_contents( - $name, - json_encode( - $item, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ) - ); - } - } - - $providerIds = (array)ag($item, 'ProviderIds', []); - - $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; - - if (empty($providerIds)) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->context->backendName, - ...$context, - 'context' => [ - 'guids' => !empty($providerIds) ? $providerIds : 'None' - ], - ]); - - Data::increment($this->context->backendName, $type . '_ignored_no_supported_guid'); - return; - } - - $mapper->add(entity: $entity, opts: [ - 'after' => ag($opts, 'after'), - Options::IMPORT_METADATA_ONLY => true === (bool)ag($this->options, Options::IMPORT_METADATA_ONLY), - ]); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - } - - protected function processExport( - ImportInterface $mapper, - QueueRequests $queue, - array $item, - array $context = [], - array $opts = [], - ): void { - try { - if (JellyfinClient::TYPE_SHOW === ($type = ag($item, 'Type'))) { - $this->processShow(item: $item, context: $context); - return; - } - - $after = ag($opts, 'after'); - $type = $this->typeMapper[$type]; - - Data::increment($this->context->backendName, $type . '_total'); - - $context['item'] = [ - 'id' => ag($item, 'Id'), - 'title' => match ($type) { - iFace::TYPE_MOVIE => sprintf( - '%s (%d)', - ag($item, ['Name', 'OriginalTitle'], '??'), - ag($item, 'ProductionYear', 0000) - ), - iFace::TYPE_EPISODE => trim( - sprintf( - '%s - (%sx%s)', - ag($item, 'SeriesName', '??'), - str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT), - str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT), - ) - ), - }, - 'type' => $type, - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ - 'backend' => $this->context->backendName, - ...$context, - 'response' => [ - 'body' => $item - ], - ]); - } - - $isPlayed = true === (bool)ag($item, 'UserData.Played'); - $dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated'; - - if (null === ag($item, $dateKey)) { - $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->context->backendName, - 'date_key' => $dateKey, - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - - Data::increment($this->context->backendName, $type . '_ignored_no_date_is_set'); - return; - } - - $rItem = $this->createEntity( - context: $this->context, - guid: $this->guid, - item: $item, - opts: array_replace_recursive($opts, ['library' => ag($context, 'library.id')]) - ); - - if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) { - $providerIds = (array)ag($item, 'ProviderIds', []); - - $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; - - if (empty($providerIds)) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->context->backendName, - ...$context, - 'context' => [ - 'guids' => !empty($providerIds) ? $providerIds : 'None' - ], - ]); - - Data::increment($this->context->backendName, $type . '_ignored_no_supported_guid'); - return; - } - - if (false === ag($this->options, Options::IGNORE_DATE, false)) { - if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'comparison' => [ - 'lastSync' => makeDate($after), - 'backend' => makeDate($rItem->updated), - ], - ] - ); - - 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->context->backendName, - ...$context, - ]); - Data::increment($this->context->backendName, $type . '_ignored_not_found_in_db'); - return; - } - - if ($rItem->watched === $entity->watched) { - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'comparison' => [ - 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', - 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', - ], - ] - ); - } - - Data::increment($this->context->backendName, $type . '_ignored_state_unchanged'); - return; - } - - if ($rItem->updated >= $entity->updated && false === ag($this->options, Options::IGNORE_DATE, false)) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'comparison' => [ - 'storage' => makeDate($entity->updated), - 'backend' => makeDate($rItem->updated), - ], - ] - ); - - Data::increment($this->context->backendName, $type . '_ignored_date_is_newer'); - return; - } - - $url = $this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($item, 'Id'))); - - $context['item']['url'] = $url; - - $this->logger->debug( - 'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].', - [ - 'backend' => $this->context->backendName, - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ...$context, - ] - ); - - if (false === (bool)ag($this->options, Options::DRY_RUN, false)) { - $queue->add( - $this->http->request( - $entity->isWatched() ? 'POST' : 'DELETE', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'context' => $context + [ - 'backend' => $this->context->backendName, - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ], - ], - ]) - ) - ); - } - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.', - [ - 'backend' => $this->context->backendName, - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - } - - protected function processShow(array $item, array $context = []): void - { - $context['item'] = [ - 'id' => ag($item, 'Id'), - 'title' => sprintf( - '%s (%s)', - ag($item, ['Name', 'OriginalTitle'], '??'), - ag($item, 'ProductionYear', '0000') - ), - 'year' => ag($item, 'ProductionYear', null), - 'type' => ag($item, 'Type'), - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))] payload.', [ - 'backend' => $this->context->backendName, - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - } - - $providersId = (array)ag($item, 'ProviderIds', []); - - if (!$this->guid->has($providersId)) { - $message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.'; - - if (empty($providersId)) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->context->backendName, - ...$context, - 'data' => [ - 'guids' => !empty($providersId) ? $providersId : 'None' - ], - ]); - - return; - } - - $this->context->cache->set( - JellyfinClient::TYPE_SHOW . '.' . ag($context, 'item.id'), - Guid::fromArray($this->guid->get($providersId), context: [ - 'backend' => $this->context->backendName, - ...$context, - ])->getAll() - ); + return $response->response; } } diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index ef0b9ffa..75ac8678 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -6,43 +6,31 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; +use App\Backends\Plex\Action\Export; use App\Backends\Plex\Action\GetIdentifier; use App\Backends\Plex\Action\GetLibrariesList; use App\Backends\Plex\Action\GetLibrary; use App\Backends\Plex\Action\GetUsersList; +use App\Backends\Plex\Action\Import; use App\Backends\Plex\Action\InspectRequest; use App\Backends\Plex\Action\ParseWebhook; use App\Backends\Plex\Action\Push; use App\Backends\Plex\Action\SearchId; use App\Backends\Plex\Action\SearchQuery; use App\Backends\Plex\PlexActionTrait; -use App\Backends\Plex\PlexClient; use App\Backends\Plex\PlexGuid; -use App\Libs\Config; use App\Libs\Container; -use App\Libs\Data; use App\Libs\Entity\StateInterface as iFace; -use App\Libs\Guid; use App\Libs\HttpException; use App\Libs\Mappers\ImportInterface; use App\Libs\Options; use App\Libs\QueueRequests; -use Closure; use DateTimeInterface; -use JsonException; -use JsonMachine\Exception\PathNotFoundException; -use JsonMachine\Items; -use JsonMachine\JsonDecoder\DecodingError; -use JsonMachine\JsonDecoder\ErrorWrappingDecoder; -use JsonMachine\JsonDecoder\ExtJsonDecoder; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; use RuntimeException; -use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; -use Throwable; class PlexServer implements ServerInterface { @@ -50,14 +38,6 @@ class PlexServer implements ServerInterface public const NAME = 'PlexBackend'; - protected UriInterface|null $url = null; - protected string|null $token = null; - protected array $options = []; - protected string $name = ''; - protected array $persist = []; - - protected string|int|null $uuid = null; - protected string|int|null $user = null; protected Context|null $context = null; public function __construct( @@ -74,30 +54,28 @@ class PlexServer implements ServerInterface string|int|null $token = null, string|int|null $userId = null, string|int|null $uuid = null, - array $persist = [], array $options = [] ): ServerInterface { $cloned = clone $this; - - $cloned->name = $name; - $cloned->url = $url; - $cloned->token = $token; - $cloned->user = $userId; - $cloned->uuid = $uuid; - $cloned->options = $options; - $cloned->persist = $persist; - $cloned->context = new Context( clientName: static::NAME, backendName: $name, backendUrl: $url, - cache: $this->cache->withData($cloned::NAME . '_' . $name, $this->options), + cache: $this->cache->withData(static::NAME . '_' . $name, $options), backendId: $uuid, backendToken: $token, backendUser: $userId, - backendHeaders: $cloned->getHeaders(), + backendHeaders: array_replace_recursive( + [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-Plex-Token' => $token, + ], + ], + $options['client'] ?? [] + ), trace: true === ag($options, Options::DEBUG_TRACE), - options: $cloned->options + options: $options ); $cloned->guid = $this->guid->withContext($cloned->context); @@ -107,15 +85,13 @@ class PlexServer implements ServerInterface public function getServerUUID(bool $forceRefresh = false): int|string|null { - if (false === $forceRefresh && null !== $this->uuid) { - return $this->uuid; + if (false === $forceRefresh && null !== $this->context->backendId) { + return $this->context->backendId; } $response = Container::get(GetIdentifier::class)(context: $this->context); - $this->uuid = $response->isSuccessful() ? $response->response : null; - - return $this->uuid; + return $response->isSuccessful() ? $response->response : null; } public function getUsersList(array $opts = []): array @@ -135,17 +111,6 @@ class PlexServer implements ServerInterface return $response->response; } - public function getPersist(): array - { - return $this->persist; - } - - public function addPersist(string $key, mixed $value): ServerInterface - { - $this->persist = ag_set($this->persist, $key, $value); - return $this; - } - public function setLogger(LoggerInterface $logger): ServerInterface { $this->logger = $logger; @@ -155,7 +120,7 @@ class PlexServer implements ServerInterface public function getName(): string { - return $this->name ?? static::NAME; + return $this->context->backendName ?? static::NAME; } public function processRequest(ServerRequestInterface $request, array $opts = []): ServerRequestInterface @@ -283,927 +248,42 @@ class PlexServer implements ServerInterface public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array { - return $this->getLibraries( - ok: function (array $context = []) use ($after, $mapper) { - return function (ResponseInterface $response) use ($mapper, $after, $context) { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ...$context, - ] - ); - return; - } - - $start = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), - ...$context, - 'time' => [ - 'start' => $start, - ], - ]); - - try { - $it = Items::fromIterable( - iterable: httpClientChunks($this->http->stream($response)), - options: [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - innerDecoder: new ExtJsonDecoder( - assoc: true, - options: JSON_INVALID_UTF8_IGNORE - ) - ) - ] - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] [%(library.title)] content.', - [ - 'backend' => $this->getName(), - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - $this->processImport( - mapper: $mapper, - item: $entity, - context: $context, - opts: ['after' => $after], - ); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - 'No Items were found in [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - - $end = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->getName(), - ...$context, - 'time' => [ - 'start' => $start, - 'end' => $end, - 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), - ], - ]); - }; - }, - error: function (array $context = []) { - return fn(Throwable $e) => $this->logger->error( - 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - }, - includeParent: true + $response = Container::get(Import::class)( + context: $this->context, + guid: $this->guid, + mapper: $mapper, + after: $after ); + + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); + } + + return $response->response; } public function export(ImportInterface $mapper, QueueRequests $queue, DateTimeInterface|null $after = null): array { - return $this->getLibraries( - ok: function (array $context = []) use ($mapper, $queue, $after) { - return function (ResponseInterface $response) use ($mapper, $queue, $after, $context) { - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] content responded with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ...$context, - ] - ); - return; - } - - $start = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [ - 'backend' => $this->getName(), - ...$context, - 'time' => [ - 'start' => $start, - ], - ]); - - try { - $it = Items::fromIterable( - iterable: httpClientChunks(stream: $this->http->stream($response)), - options: [ - 'pointer' => '/MediaContainer/Metadata', - 'decoder' => new ErrorWrappingDecoder( - innerDecoder: new ExtJsonDecoder( - assoc: true, - options: JSON_INVALID_UTF8_IGNORE - ) - ) - ] - ); - - foreach ($it as $entity) { - if ($entity instanceof DecodingError) { - $this->logger->warning( - 'Failed to decode one item of [%(backend)] [%(library.title)] content.', - [ - 'backend' => $this->getName(), - ...$context, - 'error' => [ - 'message' => $entity->getErrorMessage(), - 'body' => $entity->getMalformedJson(), - ], - ] - ); - continue; - } - - $this->processExport( - mapper: $mapper, - queue: $queue, - item: $entity, - context: $context, - opts: ['after' => $after] - ); - } - } catch (PathNotFoundException $e) { - $this->logger->error( - 'No Items were found in [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown in parsing [%(backend)] library [%(library.title)] response.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - - $end = makeDate(); - $this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [ - 'backend' => $this->getName(), - ...$context, - 'time' => [ - 'start' => $start, - 'end' => $end, - 'duration' => number_format($end->getTimestamp() - $start->getTimestamp()), - ], - ]); - }; - }, - error: function (array $context = []) { - return fn(Throwable $e) => $this->logger->error( - 'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - }, - includeParent: false === count($this->context->cache->get(PlexClient::TYPE_SHOW, [])) > 1, - ); - } - - protected function getHeaders(): array - { - $opts = [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-Plex-Token' => $this->token, - ], - ]; - - return array_replace_recursive($this->options['client'] ?? [], $opts); - } - - protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array - { - try { - $url = $this->url->withPath('/library/sections'); - - $this->logger->debug('Requesting [%(backend)] libraries.', [ - 'backend' => $this->getName(), - 'url' => $url - ]); - - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'status_code' => $response->getStatusCode(), - ] - ); - - Data::add($this->getName(), 'no_import_update', true); - return []; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $listDirs = ag($json, 'MediaContainer.Directory', []); - - if (empty($listDirs)) { - $this->logger->warning('Request for [%(backend)] libraries returned empty list.', [ - 'backend' => $this->getName(), - 'context' => [ - 'body' => $json, - ] - ]); - Data::add($this->getName(), 'no_import_update', true); - return []; - } - } catch (ExceptionInterface $e) { - $this->logger->error('Request for [%(backend)] libraries has failed.', [ - 'backend' => $this->getName(), - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - Data::add($this->getName(), 'no_import_update', true); - return []; - } catch (JsonException $e) { - $this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [ - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - ], - ]); - Data::add($this->getName(), 'no_import_update', true); - return []; - } - - if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) { - $ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds)); - } - - $promises = []; - $ignored = $unsupported = 0; - - if (true === $includeParent) { - foreach ($listDirs as $section) { - $key = (int)ag($section, 'key'); - - if ('show' !== ag($section, 'type', 'unknown')) { - continue; - } - - if (null !== $ignoreIds && in_array($key, $ignoreIds)) { - continue; - } - - $url = $this->url->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( - http_build_query( - [ - 'type' => 2, - 'sort' => 'addedAt:asc', - 'includeGuids' => 1, - ] - ) - ); - - $context = [ - 'library' => [ - 'id' => $key, - 'title' => ag($section, 'title', '??'), - 'type' => ag($section, 'type', 'unknown'), - 'url' => $url, - ], - ]; - - $this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [ - 'backend' => $this->getName(), - ...$context, - ]); - - try { - $promises[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'ok' => $ok(context: $context), - 'error' => $error(context: $context), - ] - ]) - ); - } catch (ExceptionInterface $e) { - $this->logger->error( - 'Request for [%(backend)] [%(library.title)] series external ids has failed.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - continue; - } - } - } - - foreach ($listDirs as $section) { - $key = (int)ag($section, 'key'); - $type = ag($section, 'type', 'unknown'); - - $context = [ - 'library' => [ - 'id' => ag($section, 'key'), - 'title' => ag($section, 'title', '??'), - 'type' => ag($section, 'type', 'unknown'), - ], - ]; - - if (null !== $ignoreIds && true === in_array($key, $ignoreIds)) { - $ignored++; - $this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [ - 'backend' => $this->getName(), - ...$context, - ]); - continue; - } - - if ('movie' !== $type && 'show' !== $type) { - $unsupported++; - $this->logger->info( - 'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.', - [ - 'backend' => $this->getName(), - ...$context, - ] - ); - continue; - } - - $type = $type === 'movie' ? iFace::TYPE_MOVIE : iFace::TYPE_EPISODE; - - $url = $this->url->withPath(sprintf('/library/sections/%d/all', $key))->withQuery( - http_build_query( - [ - 'type' => 'movie' === $type ? 1 : 4, - 'sort' => 'addedAt:asc', - 'includeGuids' => 1, - ] - ) - ); - - $context['library']['type'] = $type; - $context['library']['url'] = $url; - - $this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [ - 'backend' => $this->getName(), - ...$context, - ]); - - try { - $promises[] = $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'ok' => $ok(context: $context), - 'error' => $error(context: $context), - ] - ]) - ); - } catch (ExceptionInterface $e) { - $this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ]); - continue; - } - } - - if (0 === count($promises)) { - $this->logger->warning('No requests for [%(backend)] libraries were queued.', [ - 'backend' => $this->getName(), - 'context' => [ - 'total' => count($listDirs), - 'ignored' => $ignored, - 'unsupported' => $unsupported, - ], - ]); - - Data::add($this->getName(), 'no_import_update', true); - return []; - } - - return $promises; - } - - protected function processImport(ImportInterface $mapper, array $item, array $context = [], array $opts = []): void - { - $after = ag($opts, 'after', null); - $library = ag($context, 'library.id'); - $type = ag($item, 'type'); - - try { - if ('show' === $type) { - $this->processShow($item, $context); - return; - } - - Data::increment($this->getName(), $library . '_total'); - Data::increment($this->getName(), $type . '_total'); - - $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $context['item'] = [ - 'id' => ag($item, 'ratingKey'), - 'title' => match ($type) { - iFace::TYPE_MOVIE => sprintf( - '%s (%s)', - ag($item, ['title', 'originalTitle'], '??'), - 0 === $year ? '0000' : $year, - ), - 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), - ), - }, - 'type' => ag($item, 'type', 'unknown'), - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)]', [ - 'backend' => $this->getName(), - ...$context, - 'payload' => $item, - ]); - } - - if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) { - $this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->getName(), - 'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt', - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - - Data::increment($this->getName(), $type . '_ignored_no_date_is_set'); - return; - } - - $entity = $this->createEntity( - 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()) { - if (true === (bool)Config::get('debug.import')) { - $name = Config::get('tmpDir') . '/debug/' . $this->getName() . '.' . $item['ratingKey'] . '.json'; - - if (!file_exists($name)) { - file_put_contents( - $name, - json_encode( - $item, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ) - ); - } - } - - $message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.'; - - if (null === ($item['Guid'] ?? null)) { - $item['Guid'] = []; - } - - if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->guid->isLocal($itemGuid)) { - $item['Guid'][] = $itemGuid; - } - - if (empty($item['Guid'])) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->getName(), - ...$context, - 'context' => [ - 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' - ], - ]); - - Data::increment($this->getName(), $type . '_ignored_no_supported_guid'); - return; - } - - $mapper->add(entity: $entity, opts: [ - 'after' => $after, - Options::IMPORT_METADATA_ONLY => true === (bool)ag($this->options, Options::IMPORT_METADATA_ONLY), - ]); - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - } - - protected function processExport( - ImportInterface $mapper, - QueueRequests $queue, - array $item, - array $context = [], - array $opts = [], - ): void { - $after = ag($opts, 'after', null); - $library = ag($context, 'library.id'); - $type = ag($item, 'type'); - - try { - if ('show' === $type) { - $this->processShow($item, $context); - return; - } - - Data::increment($this->getName(), $library . '_total'); - Data::increment($this->getName(), $type . '_total'); - - $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $context['item'] = [ - 'id' => ag($item, 'ratingKey'), - 'title' => match ($type) { - iFace::TYPE_MOVIE => sprintf( - '%s (%s)', - ag($item, ['title', 'originalTitle'], '??'), - 0 === $year ? '0000' : $year, - ), - 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), - ), - }, - 'type' => $type, - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [ - 'backend' => $this->getName(), - ...$context, - 'payload' => $item, - ]); - } - - if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) { - $this->logger->debug('Ignoring [%(backend)] [%(item.title)]. No Date is set on object.', [ - 'backend' => $this->getName(), - 'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt', - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - - Data::increment($this->getName(), $type . '_ignored_no_date_is_set'); - return; - } - - $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.'; - - if (null === ($item['Guid'] ?? null)) { - $item['Guid'] = []; - } - - if (null !== ($itemGuid = ag($item, 'guid')) && false === $this->guid->isLocal($itemGuid)) { - $item['Guid'][] = $itemGuid; - } - - if (empty($item['Guid'])) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->getName(), - ...$context, - 'context' => [ - 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' - ], - ]); - - Data::increment($this->getName(), $type . '_ignored_no_supported_guid'); - return; - } - - if (false === ag($this->options, Options::IGNORE_DATE, false)) { - if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.', - [ - 'backend' => $this->getName(), - ...$context, - 'comparison' => [ - 'lastSync' => makeDate($after), - 'backend' => makeDate($rItem->updated), - ], - ] - ); - - Data::increment($this->getName(), $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(), - ...$context, - ]); - Data::increment($this->getName(), $type . '_ignored_not_found_in_db'); - return; - } - - if ($rItem->watched === $entity->watched) { - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.', - [ - 'backend' => $this->getName(), - ...$context, - 'comparison' => [ - 'backend' => $entity->isWatched() ? 'Played' : 'Unplayed', - 'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed', - ], - ] - ); - } - - Data::increment($this->getName(), $type . '_ignored_state_unchanged'); - return; - } - - if ($rItem->updated >= $entity->updated && false === ag($this->options, Options::IGNORE_DATE, false)) { - $this->logger->debug( - 'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.', - [ - 'backend' => $this->getName(), - ...$context, - 'comparison' => [ - 'storage' => makeDate($entity->updated), - 'backend' => makeDate($rItem->updated), - ], - ] - ); - - Data::increment($this->getName(), $type . '_ignored_date_is_newer'); - return; - } - - $url = $this->url->withPath('/:' . ($entity->isWatched() ? '/scrobble' : '/unscrobble'))->withQuery( - http_build_query( - [ - 'identifier' => 'com.plexapp.plugins.library', - 'key' => $item['ratingKey'], - ] - ) - ); - - $context['item']['url'] = $url; - - $this->logger->debug( - 'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].', - [ - 'backend' => $this->getName(), - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ...$context, - ] - ); - - if (false === (bool)ag($this->options, Options::DRY_RUN, false)) { - $queue->add( - $this->http->request( - 'GET', - (string)$url, - array_replace_recursive($this->getHeaders(), [ - 'user_data' => [ - 'context' => $context + [ - 'backend' => $this->getName(), - 'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed', - ], - ] - ]) - ) - ); - } - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.', - [ - 'backend' => $this->getName(), - ...$context, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - } - } - - protected function processShow(array $item, array $context): void - { - if (null === ($item['Guid'] ?? null)) { - $item['Guid'] = [['id' => $item['guid']]]; - } else { - $item['Guid'][] = ['id' => $item['guid']]; - } - - $year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0); - if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) { - $year = (int)makeDate($airDate)->format('Y'); - } - - $context['item'] = [ - 'id' => ag($item, 'ratingKey'), - 'title' => sprintf( - '%s (%s)', - ag($item, ['title', 'originalTitle'], '??'), - 0 === $year ? '0000' : $year, - ), - 'year' => 0 === $year ? '0000' : $year, - 'type' => ag($item, 'type', 'unknown'), - ]; - - if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) { - $this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', [ - 'backend' => $this->getName(), - ...$context, - 'response' => [ - 'body' => $item, - ], - ]); - } - - if (!$this->guid->has(guids: $item['Guid'])) { - if (null === ($item['Guid'] ?? null)) { - $item['Guid'] = []; - } - - if (null !== ($item['guid'] ?? null) && false === $this->guid->isLocal($item['guid'])) { - $item['Guid'][] = ['id' => $item['guid']]; - } - - $message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.'; - - if (empty($item['Guid'] ?? [])) { - $message .= ' Most likely unmatched %(item.type).'; - } - - $this->logger->info($message, [ - 'backend' => $this->getName(), - ...$context, - 'data' => [ - 'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None' - ], - ]); - - return; - } - - $gContext = ag_set( - $context, - 'item.plex_id', - str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none' + $response = Container::get(Export::class)( + context: $this->context, + guid: $this->guid, + mapper: $mapper, + after: $after, + opts: ['queue' => $queue], ); - $this->context->cache->set( - PlexClient::TYPE_SHOW . '.' . ag($context, 'item.id'), - Guid::fromArray( - payload: $this->guid->get($item['Guid'], context: [...$gContext]), - context: ['backend' => $this->getName(), ...$context,] - )->getAll() - ); + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + + if (false === $response->isSuccessful()) { + throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format())); + } + + return $response->response; } } diff --git a/src/Libs/Servers/ServerInterface.php b/src/Libs/Servers/ServerInterface.php index b32b5e78..022075e1 100644 --- a/src/Libs/Servers/ServerInterface.php +++ b/src/Libs/Servers/ServerInterface.php @@ -25,7 +25,6 @@ interface ServerInterface * @param null|int|string $token Server Token * @param null|int|string $userId Server user Id * @param string|int|null $uuid - * @param array $persist persistent data saved by server. * @param array $options array of options. * * @return self @@ -36,7 +35,6 @@ interface ServerInterface null|string|int $token = null, null|string|int $userId = null, null|string|int $uuid = null, - array $persist = [], array $options = [] ): self; @@ -147,22 +145,6 @@ interface ServerInterface */ public function getLibrary(string|int $id, array $opts = []): array; - /** - * Get all persistent data. - * - * @return array - */ - public function getPersist(): array; - - /** - * Add persistent data to config. - * - * @param string $key - * @param mixed $value - * @return ServerInterface - */ - public function addPersist(string $key, mixed $value): ServerInterface; - /** * Get Server Unique ID. * diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 7f7c8bd2..cc30ef50 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -382,7 +382,7 @@ if (!function_exists('after')) { if (!function_exists('makeServer')) { /** - * @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, persist:array, options:array} $server + * @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, options:array} $server * @param string|null $name server name. * @return ServerInterface * @@ -414,7 +414,6 @@ if (!function_exists('makeServer')) { token: ag($server, 'token', null), userId: ag($server, 'user', null), uuid: ag($server, 'uuid', null), - persist: ag($server, 'persist', []), options: ag($server, 'options', []), ); }