Merge pull request #167 from ArabCoders/dev

Migrating backends methods into separate actions.
This commit is contained in:
Abdulmohsen
2022-06-18 19:33:57 +03:00
committed by GitHub
22 changed files with 1663 additions and 1247 deletions

View File

@@ -43,4 +43,13 @@ interface GuidInterface
* @return bool
*/
public function has(array $guids, array $context = []): bool;
/**
* Is the given identifier a local id?
*
* @param string $guid
*
* @return bool
*/
public function isLocal(string $guid): bool;
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Backends\Emby\Action;
class GetLibrariesList extends \App\Backends\Jellyfin\Action\GetLibrariesList
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Backends\Emby\Action;
class Push extends \App\Backends\Jellyfin\Action\Push
{
}

View File

@@ -16,9 +16,12 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class EmbyClient
{
public const TYPE_MOVIE = 'Movie';
public const TYPE_SHOW = 'Series';
public const TYPE_EPISODE = 'Episode';
public const TYPE_MOVIE = JellyfinClient::TYPE_MOVIE;
public const TYPE_SHOW = JellyfinClient::TYPE_SHOW;
public const TYPE_EPISODE = JellyfinClient::TYPE_EPISODE;
public const COLLECTION_TYPE_SHOWS = JellyfinClient::COLLECTION_TYPE_SHOWS;
public const COLLECTION_TYPE_MOVIES = JellyfinClient::COLLECTION_TYPE_MOVIES;
public const EXTRA_FIELDS = JellyfinClient::EXTRA_FIELDS;

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\JellyfinClient;
use App\Libs\Options;
use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GetLibrariesList
{
use CommonTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Get Backend libraries list.
*
* @param Context $context
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, array $opts = []): Response
{
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $opts));
}
/**
* @throws ExceptionInterface
* @throws JsonException
*/
private function action(Context $context, array $opts = []): Response
{
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser));
$this->logger->debug('Requesting [%(backend)] libraries list.', [
'backend' => $context->backendName,
'url' => (string)$url
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.',
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
],
level: Levels::ERROR
),
);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug(
'Parsing [%(backend)] libraries payload.',
[
'backend' => $context->backendName,
'trace' => $json,
]
);
}
$listDirs = ag($json, 'Items', []);
if (empty($listDirs)) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] libraries returned empty list.',
context: [
'backend' => $context->backendName,
'response' => [
'body' => $json
],
],
level: Levels::WARNING
),
);
}
if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) {
$ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds));
}
$list = [];
foreach ($listDirs as $section) {
$key = (string)ag($section, 'Id');
$type = ag($section, 'CollectionType', 'unknown');
$builder = [
'id' => $key,
'title' => ag($section, 'Name', '???'),
'type' => ucfirst($type),
'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds),
'supported' => in_array(
$type,
[JellyfinClient::COLLECTION_TYPE_MOVIES, JellyfinClient::COLLECTION_TYPE_SHOWS]
),
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $section;
}
$list[] = $builder;
}
return new Response(status: true, response: $list);
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Error;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\JellyfinActionTrait;
use App\Backends\Jellyfin\JellyfinClient;
use App\Libs\Options;
use JsonMachine\Items;
use JsonMachine\JsonDecoder\DecodingError;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GetLibrary
{
use CommonTrait, JellyfinActionTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Get Library content.
*
* @param Context $context
* @param iGuid $guid
* @param string|int $id
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, iGuid $guid, string|int $id, array $opts = []): Response
{
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $id, $opts));
}
/**
* @throws \Symfony\Contracts\HttpClient\Exception\ExceptionInterface
* @throws \JsonMachine\Exception\InvalidArgumentException
*/
private function action(Context $context, string|int $id, array $opts = []): Response
{
$libraries = $this->getBackendLibraries($context);
if (null === ($section = ag($libraries, $id))) {
return new Response(
status: false,
error: new Error(
message: 'No Library with id [%(id)] found in [%(backend)] response.',
context: [
'id' => $id,
'backend' => $context->backendName,
'response' => [
'body' => $libraries
],
],
level: Levels::WARNING
),
);
}
unset($libraries);
$logContext = [
'library' => [
'id' => $id,
'type' => ag($section, 'CollectionType', 'unknown'),
'title' => ag($section, 'Name', '??'),
],
];
if (true !== in_array(
ag($logContext, 'library.type'),
[JellyfinClient::COLLECTION_TYPE_MOVIES, JellyfinClient::COLLECTION_TYPE_SHOWS]
)) {
return new Response(
status: false,
error: new Error(
message: 'The Requested [%(backend)] Library [%(library.id): %(library.title)] returned with unsupported type [%(library.type)].',
context: [
'backend' => $context->backendName,
...$logContext,
],
level: Levels::WARNING
),
);
}
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery(
http_build_query(
[
'parentId' => $id,
'enableUserData' => 'false',
'enableImages' => 'false',
'excludeLocationTypes' => 'Virtual',
'include' => implode(',', [JellyfinClient::TYPE_SHOW, JellyfinClient::TYPE_MOVIE]),
'fields' => implode(',', JellyfinClient::EXTRA_FIELDS)
]
)
);
$logContext['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [
'backend' => $context->backendName,
...$logContext,
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] library [%(library.title)] returned with unexpected [%(status_code)] status code.',
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext,
],
level: Levels::ERROR
),
);
}
$it = Items::fromIterable(
iterable: httpClientChunks($this->http->stream($response)),
options: [
'pointer' => '/Items',
'decoder' => new ErrorWrappingDecoder(
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
)
]
);
$list = [];
foreach ($it as $entity) {
if ($entity instanceof DecodingError) {
$this->logger->warning(
'Failed to decode one item of [%(backend)] library [%(library.title)] content.',
[
'backend' => $context->backendName,
...$logContext,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$url = $context->backendUrl->withPath(
sprintf('/Users/%s/items/%s', $context->backendUser, ag($entity, 'Id'))
);
$logContext['item'] = [
'id' => ag($entity, 'Id'),
'title' => ag($entity, ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'], '??'),
'year' => ag($entity, 'ProductionYear', '0000'),
'type' => ag($entity, 'Type'),
'url' => (string)$url,
];
$list[] = $this->process($context, $entity, $logContext, $opts);
}
return new Response(status: true, response: $list);
}
private function process(Context $context, array $item, array $log = [], array $opts = []): array
{
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/%s', $context->backendUser, ag($item, 'Id')));
$possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'];
$data = [
'backend' => $context->backendName,
...$log,
];
if ($context->trace) {
$data['trace'] = $item;
}
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data);
$metadata = [
'id' => ag($item, 'Id'),
'type' => ucfirst(ag($item, 'Type', 'unknown')),
'url' => (string)$url,
'title' => ag($item, $possibleTitlesList, '??'),
'year' => ag($item, 'ProductionYear'),
'guids' => [],
'match' => [
'titles' => [],
'paths' => [],
],
];
foreach ($possibleTitlesList as $title) {
if (null === ($title = ag($item, $title))) {
continue;
}
$isASCII = mb_detect_encoding($title, 'ASCII', true);
$title = trim($isASCII ? strtolower($title) : mb_strtolower($title));
if (true === in_array($title, $metadata['match']['titles'])) {
continue;
}
$metadata['match']['titles'][] = $title;
}
if (null !== ($path = ag($item, 'Path'))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
if (ag($item, 'Type') === 'Movie') {
if (false === str_starts_with(basename($path), basename(dirname($path)))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
}
}
}
if (null !== ($providerIds = ag($item, 'ProviderIds'))) {
foreach ($providerIds as $key => $val) {
$metadata['guids'][] = $key . '://' . $val;
}
}
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$metadata['raw'] = $item;
}
return $metadata;
}
}

View File

@@ -45,7 +45,7 @@ class GetMetaData
}
$url = $context->backendUrl
->withPath(sprintf('/Users/%s/items/' . $id, $context->backendUser))
->withPath(sprintf('/Users/%s/items/%s', $context->backendUser, $id))
->withQuery(
http_build_query(
array_merge_recursive(

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\JellyfinClient;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Options;
use App\Libs\QueueRequests;
use DateTimeInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
class Push
{
use CommonTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Push Play state.
*
* @param Context $context
* @param array<iState> $entities
* @param QueueRequests $queue
* @param DateTimeInterface|null $after
* @return Response
*/
public function __invoke(
Context $context,
array $entities,
QueueRequests $queue,
DateTimeInterface|null $after = null
): Response {
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $entities, $queue, $after));
}
private function action(
Context $context,
array $entities,
QueueRequests $queue,
DateTimeInterface|null $after = null
): Response {
$requests = [];
foreach ($entities as $key => $entity) {
if (true !== ($entity instanceof iFace)) {
continue;
}
if (null !== $after && false === (bool)ag($context->options, Options::IGNORE_DATE, false)) {
if ($after->getTimestamp() > $entity->updated) {
continue;
}
}
$metadata = $entity->getMetadata($context->backendName);
$logContext = [
'item' => [
'id' => $entity->id,
'type' => $entity->type,
'title' => $entity->getName(),
],
];
if (null === ag($metadata, iFace::COLUMN_ID, null)) {
$this->logger->warning(
'Ignoring [%(item.title)] for [%(backend)]. No metadata was found.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
$logContext['remote']['id'] = ag($metadata, iFace::COLUMN_ID);
try {
$url = $context->backendUrl->withPath(
sprintf('/Users/%s/items/%s', $context->backendUser, ag($metadata, iFace::COLUMN_ID))
)->withQuery(
http_build_query(
[
'fields' => implode(',', JellyfinClient::EXTRA_FIELDS),
'enableUserData' => 'true',
'enableImages' => 'false',
]
)
);
$logContext['remote']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] metadata.', [
'backend' => $context->backendName,
...$logContext,
]);
$requests[] = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($context->backendHeaders, [
'user_data' => [
'id' => $key,
'context' => $logContext,
]
])
);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
$logContext = null;
foreach ($requests as $response) {
$logContext = ag($response->getInfo('user_data'), 'context', []);
try {
if (null === ($id = ag($response->getInfo('user_data'), 'id'))) {
$this->logger->error('Unable to get entity object id.', [
'backend' => $context->backendName,
...$logContext,
]);
continue;
}
$entity = $entities[$id];
assert($entity instanceof iFace);
if (200 !== $response->getStatusCode()) {
if (404 === $response->getStatusCode()) {
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with (Not Found) status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext
]
);
} else {
$this->logger->error(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext
]
);
}
continue;
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug(
'Parsing [%(backend)] %(item.type) [%(item.title)] payload.',
[
'backend' => $context->backendName,
...$logContext,
'trace' => $json,
]
);
}
$isWatched = (int)(bool)ag($json, 'UserData.Played', false);
if ($entity->watched === $isWatched) {
$this->logger->info(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
if (false === (bool)ag($context->options, Options::IGNORE_DATE, false)) {
$dateKey = 1 === $isWatched ? 'UserData.LastPlayedDate' : 'DateCreated';
$date = ag($json, $dateKey);
if (null === $date) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.',
[
'backend' => $context->backendName,
'date_key' => $dateKey,
...$logContext,
'response' => [
'body' => $json,
],
]
);
continue;
}
$date = makeDate($date);
$timeExtra = (int)(ag($context->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10));
if ($date->getTimestamp() >= ($timeExtra + $entity->updated)) {
$this->logger->notice(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => $date,
'difference' => $date->getTimestamp() - $entity->updated,
'extra_margin' => [
Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra,
],
],
]
);
continue;
}
}
$url = $context->backendUrl->withPath(
sprintf('/Users/%s/PlayedItems/%s', $context->backendUser, ag($json, 'Id'))
);
$logContext['remote']['url'] = $url;
$this->logger->debug(
'Queuing request to change [%(backend)] %(item.type) [%(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,
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)] %(item.type) [%(item.title)].',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
return new Response(status: true, response: $queue);
}
}

View File

@@ -6,11 +6,13 @@ namespace App\Backends\Jellyfin;
use App\Backends\Common\Context;
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;
use RuntimeException;
trait JellyfinActionTrait
@@ -212,4 +214,26 @@ trait JellyfinActionTrait
return $context->cache->get($cacheKey);
}
/**
* Get Backend Libraries details.
*/
protected function getBackendLibraries(Context $context, array $opts = []): array
{
$opts = ag_set($opts, Options::RAW_RESPONSE, true);
$response = Container::get(GetLibrariesList::class)(context: $context, opts: $opts);
if (!$response->isSuccessful()) {
throw new RuntimeException(message: $response->error->format(), previous: $response->error->previous);
}
$arr = [];
foreach ($response->response as $item) {
$arr[$item['id']] = $item['raw'];
}
return $arr;
}
}

View File

@@ -19,6 +19,9 @@ class JellyfinClient
public const TYPE_SHOW = 'Series';
public const TYPE_EPISODE = 'Episode';
public const COLLECTION_TYPE_SHOWS = 'tvshows';
public const COLLECTION_TYPE_MOVIES = 'movies';
public const EXTRA_FIELDS = [
'ProviderIds',
'DateCreated',

View File

@@ -55,6 +55,11 @@ class JellyfinGuid implements iGuid
return count($this->ListExternalIds(guids: $guids, context: $context, log: false)) >= 1;
}
public function isLocal(string $guid): bool
{
return false;
}
/**
* Get All Supported external ids.
*

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Backends\Plex\PlexClient;
use App\Libs\Options;
use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class GetLibrariesList
{
use CommonTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Get Backend libraries list.
*
* @param Context $context
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, array $opts = []): Response
{
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $opts));
}
/**
* @throws ExceptionInterface
* @throws JsonException
*/
private function action(Context $context, array $opts = []): Response
{
$url = $context->backendUrl->withPath('/library/sections');
$this->logger->debug('Requesting [%(backend)] libraries list.', [
'backend' => $context->backendName,
'url' => (string)$url
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.',
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
],
level: Levels::ERROR
),
);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug(
'Parsing [%(backend)] libraries payload.',
[
'backend' => $context->backendName,
'trace' => $json,
]
);
}
$listDirs = ag($json, 'MediaContainer.Directory', []);
if (empty($listDirs)) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] libraries returned empty list.',
context: [
'backend' => $context->backendName,
'response' => [
'body' => $json
],
],
level: Levels::WARNING
),
);
}
if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) {
$ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds));
}
$list = [];
foreach ($listDirs as $section) {
$key = (int)ag($section, 'key');
$type = ag($section, 'type', 'unknown');
$builder = [
'id' => $key,
'title' => ag($section, 'title', '???'),
'type' => ucfirst($type),
'ignored' => true === in_array($key, $ignoreIds ?? []),
'supported' => PlexClient::TYPE_MOVIE === $type || PlexClient::TYPE_SHOW === $type,
'agent' => ag($section, 'agent'),
'scanner' => ag($section, 'scanner'),
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $section;
}
$list[] = $builder;
}
return new Response(status: true, response: $list);
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Error;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Backends\Plex\PlexActionTrait;
use App\Backends\Plex\PlexClient;
use App\Libs\Options;
use JsonException;
use JsonMachine\Items;
use JsonMachine\JsonDecoder\DecodingError;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class GetLibrary
{
use CommonTrait, PlexActionTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Get Library content.
*
* @param Context $context
* @param iGuid $guid
* @param string|int $id
* @param array $opts optional options.
*
* @return Response
*/
public function __invoke(Context $context, iGuid $guid, string|int $id, array $opts = []): Response
{
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $guid, $id, $opts));
}
/**
* @throws \Symfony\Contracts\HttpClient\Exception\ExceptionInterface
* @throws \JsonMachine\Exception\InvalidArgumentException
*/
private function action(Context $context, iGuid $guid, string|int $id, array $opts = []): Response
{
$libraries = $this->getBackendLibraries($context);
$deepScan = true === (bool)ag($opts, Options::MISMATCH_DEEP_SCAN);
if (null === ($section = ag($libraries, $id))) {
return new Response(
status: false,
error: new Error(
message: 'No Library with id [%(id)] found in [%(backend)] response.',
context: [
'id' => $id,
'backend' => $context->backendName,
'response' => [
'body' => $libraries
],
],
level: Levels::WARNING
),
);
}
unset($libraries);
$logContext = [
'library' => [
'id' => $id,
'type' => ag($section, 'type', 'unknown'),
'title' => ag($section, 'title', '??'),
],
];
if (true !== in_array(ag($logContext, 'library.type'), [PlexClient::TYPE_MOVIE, PlexClient::TYPE_SHOW])) {
return new Response(
status: false,
error: new Error(
message: 'The Requested [%(backend)] Library [%(library.title)] returned with unsupported type [%(library.type)].',
context: [
'backend' => $context->backendName,
...$logContext,
],
level: Levels::WARNING
),
);
}
$url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $id))->withQuery(
http_build_query(
[
'type' => PlexClient::TYPE_MOVIE === ag($logContext, 'library.type') ? 1 : 2,
'includeGuids' => 1,
]
)
);
$logContext['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [
'backend' => $context->backendName,
...$logContext,
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: 'Request for [%(backend)] library [%(library.title)] returned with unexpected [%(status_code)] status code.',
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext,
],
level: Levels::ERROR
),
);
}
$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
)
)
]
);
$list = $requests = [];
foreach ($it as $entity) {
if ($entity instanceof DecodingError) {
$this->logger->warning(
'Failed to decode one item of [%(backend)] library [%(library.title)] content.',
[
'backend' => $context->backendName,
...$logContext,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$year = (int)ag($entity, 'year', 0);
if (0 === $year && null !== ($airDate = ag($entity, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$logContext['item'] = [
'id' => ag($entity, 'ratingKey'),
'title' => ag($entity, ['title', 'originalTitle'], '??'),
'year' => $year,
'type' => ag($entity, 'type'),
];
if (false === $deepScan || PlexClient::TYPE_MOVIE === ag($logContext, 'item.type')) {
$list[] = $this->process($context, $guid, $entity, $logContext, $opts);
} else {
$requests[] = $this->http->request(
'GET',
(string)$context->backendUrl->withPath(sprintf('/library/metadata/%d', ag($logContext, 'item.id'))),
$context->backendHeaders + [
'user_data' => [
'context' => $logContext
]
]
);
}
}
if (!empty($requests)) {
$this->logger->info(
'Requesting [%(total)] items metadata from [%(backend)] library [%(library.title)].',
[
'total' => number_format(count($requests)),
'backend' => $context->backendName,
...$logContext
]
);
}
foreach ($requests as $response) {
$requestContext = ag($response->getInfo('user_data'), 'context', []);
try {
if (200 !== $response->getStatusCode()) {
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$requestContext
]
);
continue;
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$list[] = $this->process(
$context,
$guid,
ag($json, 'MediaContainer.Metadata.0', []),
$requestContext,
$opts
);
} catch (JsonException|HttpExceptionInterface $e) {
return new Response(
status: false,
error: new Error(
message: 'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.',
context: [
'backend' => $context->backendName,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
...$requestContext,
],
level: Levels::WARNING,
previous: $e
)
);
}
}
return new Response(status: true, response: $list);
}
private function process(Context $context, iGuid $guid, array $item, array $log = [], array $opts = []): array
{
$url = $context->backendUrl->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey')));
$possibleTitlesList = ['title', 'originalTitle', 'titleSort'];
$data = [
'backend' => $context->backendName,
...$log,
];
$year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
if ($context->trace) {
$data['trace'] = $item;
}
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data);
$metadata = [
'id' => (int)ag($item, 'ratingKey'),
'type' => ucfirst(ag($item, 'type', 'unknown')),
'url' => (string)$url,
'title' => ag($item, $possibleTitlesList, '??'),
'year' => $year,
'guids' => [],
'match' => [
'titles' => [],
'paths' => [],
],
];
foreach ($possibleTitlesList as $title) {
if (null === ($title = ag($item, $title))) {
continue;
}
$isASCII = mb_detect_encoding($title, 'ASCII', true);
$title = trim($isASCII ? strtolower($title) : mb_strtolower($title));
if (true === in_array($title, $metadata['match']['titles'])) {
continue;
}
$metadata['match']['titles'][] = $title;
}
switch (ag($item, 'type')) {
case PlexClient::TYPE_SHOW:
foreach (ag($item, 'Location', []) as $path) {
$path = ag($path, 'path');
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
}
break;
case PlexClient::TYPE_MOVIE:
foreach (ag($item, 'Media', []) as $leaf) {
foreach (ag($leaf, 'Part', []) as $path) {
$path = ag($path, 'file');
$dir = dirname($path);
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
if (false === str_starts_with(basename($path), basename($dir))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($dir),
];
}
}
}
break;
default:
throw new RuntimeException(
sprintf(
'While parsing [%s] library [%s] items, we encountered unexpected item type [%s].',
$context->backendName,
ag($log, 'library.title', '??'),
ag($item, 'type')
)
);
}
if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) {
$metadata['guids'][] = $itemGuid;
}
foreach (array_column(ag($item, 'Guid', []), 'id') as $externalId) {
$metadata['guids'][] = $externalId;
}
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$metadata['raw'] = $item;
}
return $metadata;
}
}

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Response;
use App\Backends\Common\Context;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Options;
use App\Libs\QueueRequests;
use DateTimeInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class Push
{
use CommonTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* Push Play state.
*
* @param Context $context
* @param array<iState> $entities
* @param QueueRequests $queue
* @param DateTimeInterface|null $after
* @return Response
*/
public function __invoke(
Context $context,
array $entities,
QueueRequests $queue,
DateTimeInterface|null $after = null
): Response {
return $this->tryResponse(context: $context, fn: fn() => $this->action($context, $entities, $queue, $after));
}
private function action(
Context $context,
array $entities,
QueueRequests $queue,
DateTimeInterface|null $after = null
): Response {
$requests = [];
foreach ($entities as $key => $entity) {
if (true !== ($entity instanceof iState)) {
continue;
}
if (null !== $after && false === (bool)ag($context->options, Options::IGNORE_DATE, false)) {
if ($after->getTimestamp() > $entity->updated) {
continue;
}
}
$metadata = $entity->getMetadata($context->backendName);
$logContext = [
'item' => [
'id' => $entity->id,
'type' => $entity->type,
'title' => $entity->getName(),
],
];
if (null === ag($metadata, iState::COLUMN_ID)) {
$this->logger->warning(
'Ignoring [%(item.title)] for [%(backend)]. No metadata was found.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
$logContext['remote']['id'] = ag($metadata, iState::COLUMN_ID);
try {
$url = $context->backendUrl->withPath('/library/metadata/' . ag($metadata, iState::COLUMN_ID));
$logContext['remote']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] metadata.', [
'backend' => $context->backendName,
...$logContext,
]);
$requests[] = $this->http->request(
'GET',
(string)$url,
$context->backendHeaders + [
'user_data' => [
'id' => $key,
'context' => $logContext,
]
]
);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during request for [%(backend)] %(item.type) [%(item.title)] metadata.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => after($e->getFile(), ROOT_PATH),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
$logContext = null;
foreach ($requests as $response) {
$logContext = ag($response->getInfo('user_data'), 'context', []);
try {
if (null === ($id = ag($response->getInfo('user_data'), 'id'))) {
$this->logger->error('Unable to get entity object id.', [
'backend' => $context->backendName,
...$logContext,
]);
continue;
}
$entity = $entities[$id];
assert($entity instanceof iState);
if (200 !== $response->getStatusCode()) {
if (404 === $response->getStatusCode()) {
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with (Not Found) status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext
]
);
} else {
$this->logger->error(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext
]
);
}
continue;
}
$body = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug(
'Parsing [%(backend)] %(item.type) [%(item.title)] payload.',
[
'backend' => $context->backendName,
...$logContext,
'trace' => $body,
]
);
}
$json = ag($body, 'MediaContainer.Metadata.0', []);
if (empty($json)) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Returned with unexpected body.',
[
'backend' => $context->backendName,
...$logContext,
'response' => [
'body' => $body,
],
]
);
continue;
}
$isWatched = 0 === (int)ag($json, 'viewCount', 0) ? 0 : 1;
if ($entity->watched === $isWatched) {
$this->logger->info(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
if (false === (bool)ag($context->options, Options::IGNORE_DATE, false)) {
$dateKey = 1 === $isWatched ? 'lastViewedAt' : 'addedAt';
$date = ag($json, $dateKey);
if (null === $date) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.',
[
'backend' => $context->backendName,
'date_key' => $dateKey,
...$logContext,
'response' => [
'body' => $body,
],
]
);
continue;
}
$date = makeDate($date);
$timeExtra = (int)(ag($context->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10));
if ($date->getTimestamp() >= ($entity->updated + $timeExtra)) {
$this->logger->notice(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => $date,
'difference' => $date->getTimestamp() - $entity->updated,
'extra_margin' => [
Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra,
],
],
]
);
continue;
}
}
$url = $context->backendUrl
->withPath($entity->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery(
http_build_query(
[
'identifier' => 'com.plexapp.plugins.library',
'key' => ag($json, 'ratingKey'),
]
)
);
$logContext['remote']['url'] = $url;
$this->logger->debug(
'Queuing request to change [%(backend)] %(item.type) [%(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)) {
$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)] %(item.type) [%(item.title)].',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
return new Response(status: true, response: $queue);
}
}

View File

@@ -6,11 +6,13 @@ namespace App\Backends\Plex;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Plex\Action\GetLibrariesList;
use App\Backends\Plex\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;
use RuntimeException;
trait PlexActionTrait
@@ -246,4 +248,26 @@ trait PlexActionTrait
return $context->cache->get($cacheKey);
}
/**
* Get Backend Libraries details.
*/
protected function getBackendLibraries(Context $context, array $opts = []): array
{
$opts = ag_set($opts, Options::RAW_RESPONSE, true);
$response = Container::get(GetLibrariesList::class)(context: $context, opts: $opts);
if (!$response->isSuccessful()) {
throw new RuntimeException(message: $response->error->format(), previous: $response->error->previous);
}
$arr = [];
foreach ($response->response as $item) {
$arr[(int)$item['id']] = $item['raw'];
}
return $arr;
}
}

View File

@@ -44,13 +44,17 @@ final class ListCommand extends Command
}
try {
$opts = [];
$opts = $backendOpts = [];
if ($input->getOption('include-raw-response')) {
$opts[Options::RAW_RESPONSE] = true;
}
$libraries = $this->getBackend($backend)->listLibraries(opts: $opts);
if ($input->getOption('trace')) {
$backendOpts = ag_set($backendOpts, 'options.' . Options::DEBUG_TRACE, true);
}
$libraries = $this->getBackend($backend, $backendOpts)->listLibraries(opts: $opts);
if (count($libraries) < 1) {
$arr = [

View File

@@ -30,6 +30,7 @@ final class MismatchCommand extends Command
->setDescription(
'Find possible mis-identified item in a library. This only works for Media that follow Plex naming format.'
)
->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all items regardless of status.')
->addOption('percentage', 'p', InputOption::VALUE_OPTIONAL, 'Acceptable percentage.', 50.0)
->addOption(
'method',
@@ -54,6 +55,7 @@ final class MismatchCommand extends Command
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$showAll = $input->getOption('show-all');
$percentage = $input->getOption('percentage');
$backend = $input->getArgument('backend');
$id = $input->getArgument('id');
@@ -86,10 +88,12 @@ final class MismatchCommand extends Command
$opts[Options::RAW_RESPONSE] = true;
}
$opts[Options::MISMATCH_DEEP_SCAN] = true;
foreach ($this->getBackend($backend, $backendOpts)->getLibrary(id: $id, opts: $opts) as $item) {
$processed = $this->compare(item: $item, method: $input->getOption('method'));
if (empty($processed) || $processed['percent'] >= (float)$percentage) {
if (!$showAll && (empty($processed) || $processed['percent'] >= (float)$percentage)) {
continue;
}

View File

@@ -152,6 +152,10 @@ class PushCommand extends Command
$opts[Options::DRY_RUN] = true;
}
if ($input->getOption('trace')) {
$opts[Options::DEBUG_TRACE] = true;
}
$server['options'] = $opts;
$server['class'] = makeServer(server: $server, name: $name);

View File

@@ -17,6 +17,7 @@ final class Options
public const MAPPER_ALWAYS_UPDATE_META = 'ALWAYS_UPDATE_META';
public const MAPPER_DISABLE_AUTOCOMMIT = 'DISABLE_AUTOCOMMIT';
public const IMPORT_METADATA_ONLY = 'IMPORT_METADATA_ONLY';
public const MISMATCH_DEEP_SCAN = 'MISMATCH_DEEP_SCAN';
private function __construct()
{

View File

@@ -6,10 +6,13 @@ namespace App\Libs\Servers;
use App\Backends\Common\Cache;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\Action\GetLibrariesList;
use App\Backends\Jellyfin\Action\GetLibrary;
use App\Backends\Jellyfin\Action\GetUsersList;
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;
@@ -26,7 +29,6 @@ use App\Libs\Options;
use App\Libs\QueueRequests;
use Closure;
use DateTimeInterface;
use Generator;
use JsonException;
use JsonMachine\Exception\PathNotFoundException;
use JsonMachine\Items;
@@ -49,9 +51,6 @@ class JellyfinServer implements ServerInterface
public const NAME = 'JellyfinBackend';
protected const COLLECTION_TYPE_SHOWS = 'tvshows';
protected const COLLECTION_TYPE_MOVIES = 'movies';
public const FIELDS = JellyfinClient::EXTRA_FIELDS;
protected UriInterface|null $url = null;
@@ -115,7 +114,7 @@ class JellyfinServer implements ServerInterface
backendUser: $userId,
backendHeaders: $cloned->getHeaders(),
trace: true === ag($options, Options::DEBUG_TRACE),
options: $this->options
options: $cloned->options
);
$cloned->guid = $this->guid->withContext($cloned->context);
@@ -215,7 +214,7 @@ class JellyfinServer implements ServerInterface
}
if (false === $response->isSuccessful()) {
throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format()));
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
return $response->response;
@@ -237,15 +236,12 @@ class JellyfinServer implements ServerInterface
}
if (false === $response->isSuccessful()) {
throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format()));
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
return $response->response;
}
/**
* @throws InvalidArgumentException
*/
public function getMetadata(string|int $id, array $opts = []): array
{
return $this->getItemDetails(context: $this->context, id: $id, opts: $opts);
@@ -254,584 +250,58 @@ class JellyfinServer implements ServerInterface
/**
* @throws Throwable
*/
public function getLibrary(string|int $id, array $opts = []): Generator
public function getLibrary(string|int $id, array $opts = []): array
{
$this->checkConfig();
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
http_build_query(
[
'recursive' => 'false',
'enableUserData' => 'false',
'enableImages' => 'false',
'fields' => implode(',', self::FIELDS),
]
)
$response = Container::get(GetLibrary::class)(
context: $this->context,
guid: $this->guid,
id: $id,
opts: $opts
);
$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()) {
throw new RuntimeException(
sprintf(
'Request for [%s] libraries returned with unexpected [%s] status code.',
$this->context->backendName,
$response->getStatusCode(),
)
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$context = [];
$found = false;
foreach (ag($json, 'Items', []) as $section) {
if ((string)ag($section, 'Id') !== (string)$id) {
continue;
}
$found = true;
$context = [
'library' => [
'id' => ag($section, 'Id'),
'type' => ag($section, 'CollectionType', 'unknown'),
'title' => ag($section, 'Name', '??'),
],
];
break;
if (false === $response->isSuccessful()) {
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
if (false === $found) {
throw new RuntimeException(
sprintf(
'The response from [%s] does not contain library with id of [%s].',
$this->context->backendName,
$id
)
);
}
if (true !== in_array(ag($context, 'library.type'), ['tvshows', 'movies'])) {
throw new RuntimeException(
sprintf(
'The requested [%s] library [%s] is of [%s] type. Which is not supported type.',
$this->context->backendName,
ag($context, 'library.title', $id),
ag($context, 'library.type')
)
);
}
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
http_build_query(
[
'parentId' => $id,
'enableUserData' => 'false',
'enableImages' => 'false',
'excludeLocationTypes' => 'Virtual',
'include' => 'Series,Movie',
'fields' => implode(',', self::FIELDS)
]
)
);
$context['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [
'backend' => $this->context->backendName,
...$context,
]);
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
sprintf(
'Request for [%s] library [%s] content returned with unexpected [%s] status code.',
$this->context->backendName,
ag($context, 'library.title', $id),
$response->getStatusCode(),
)
);
}
$handleRequest = $opts['handler'] ?? function (array $item, array $context = []) use ($opts): array {
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($item, 'Id')));
$possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'];
$data = [
'backend' => $this->context->backendName,
...$context,
];
if (true === ag($this->options, Options::DEBUG_TRACE)) {
$data['trace'] = $item;
}
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data);
$metadata = [
'id' => ag($item, 'Id'),
'type' => ucfirst(ag($item, 'Type', 'unknown')),
'url' => [(string)$url],
'title' => ag($item, $possibleTitlesList, '??'),
'year' => ag($item, 'ProductionYear'),
'guids' => [],
'match' => [
'titles' => [],
'paths' => [],
],
];
foreach ($possibleTitlesList as $title) {
if (null === ($title = ag($item, $title))) {
continue;
}
$isASCII = mb_detect_encoding($title, 'ASCII', true);
$title = trim($isASCII ? strtolower($title) : mb_strtolower($title));
if (true === in_array($title, $metadata['match']['titles'])) {
continue;
}
$metadata['match']['titles'][] = $title;
}
if (null !== ($path = ag($item, 'Path'))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
if (ag($item, 'Type') === 'Movie') {
if (false === str_starts_with(basename($path), basename(dirname($path)))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
}
}
}
if (null !== ($providerIds = ag($item, 'ProviderIds'))) {
foreach ($providerIds as $key => $val) {
$metadata['guids'][] = $key . '://' . $val;
}
}
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$metadata['raw'] = $item;
}
return $metadata;
};
$it = Items::fromIterable(
iterable: httpClientChunks($this->http->stream($response)),
options: [
'pointer' => '/Items',
'decoder' => new ErrorWrappingDecoder(
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 [%(library.title)] content.',
[
'backend' => $this->context->backendName,
...$context,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($entity, 'Id')));
$context['item'] = [
'id' => ag($entity, 'Id'),
'title' => ag($entity, ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'], '??'),
'year' => ag($entity, 'ProductionYear', '0000'),
'type' => ag($entity, 'Type'),
'url' => (string)$url,
];
yield $handleRequest(item: $entity, context: $context);
}
return $response->response;
}
public function listLibraries(array $opts = []): array
{
$this->checkConfig(true);
$response = Container::get(GetLibrariesList::class)(context: $this->context, opts: $opts);
try {
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
http_build_query(
[
'recursive' => 'false',
'fields' => implode(',', self::FIELDS),
'enableUserData' => 'true',
'enableImages' => 'false',
]
)
);
$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(),
]
);
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,
]
]);
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(),
],
]);
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(),
],
]);
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()));
}
$list = [];
foreach ($listDirs as $section) {
$key = (string)ag($section, 'Id');
$type = ag($section, 'CollectionType', 'unknown');
$builder = [
'id' => $key,
'title' => ag($section, 'Name', '???'),
'type' => $type,
'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds),
'supported' => in_array($type, [self::COLLECTION_TYPE_MOVIES, self::COLLECTION_TYPE_SHOWS]),
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $section;
}
$list[] = $builder;
}
return $list;
return $response->response;
}
public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array
{
$this->checkConfig(true);
$response = Container::get(Push::class)(
context: $this->context,
entities: $entities,
queue: $queue,
after: $after
);
$requests = [];
foreach ($entities as $key => $entity) {
if (true !== ($entity instanceof iFace)) {
continue;
}
if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) {
if (null !== $after && $after->getTimestamp() > $entity->updated) {
continue;
}
}
$metadata = $entity->getMetadata($this->context->backendName);
$context = [
'item' => [
'id' => $entity->id,
'type' => $entity->type,
'title' => $entity->getName(),
],
];
if (null === ag($metadata, iFace::COLUMN_ID, null)) {
$this->logger->warning(
'Ignoring [%(item.title)] for [%(backend)] no backend metadata was found.',
[
'backend' => $this->context->backendName,
...$context,
]
);
continue;
}
$context['remote']['id'] = ag($metadata, iFace::COLUMN_ID);
try {
$url = $this->url->withPath(sprintf('/Users/%s/items', $this->user))->withQuery(
http_build_query(
[
'ids' => ag($metadata, iFace::COLUMN_ID),
'fields' => implode(',', self::FIELDS),
'enableUserData' => 'true',
'enableImages' => 'false',
]
)
);
$context['remote']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] play state.', [
'backend' => $this->context->backendName,
...$context,
]);
$requests[] = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($this->getHeaders(), [
'user_data' => [
'id' => $key,
'context' => $context,
]
])
);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during requesting of [%(backend)] %(item.type) [%(item.title)].',
[
'backend' => $this->context->backendName,
...$context,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$context = null;
foreach ($requests as $response) {
$context = ag($response->getInfo('user_data'), 'context', []);
try {
if (null === ($id = ag($response->getInfo('user_data'), 'id'))) {
$this->logger->error('Unable to get entity object id.', [
'backend' => $this->context->backendName,
...$context,
]);
continue;
}
$entity = $entities[$id];
assert($entity instanceof iFace);
switch ($response->getStatusCode()) {
case 200:
break;
case 404:
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] returned with 404 (Not Found) status code.',
[
'backend' => $this->context->backendName,
...$context
]
);
continue 2;
default:
$this->logger->error(
'Request for [%(backend)] %(item.type) [%(item.title)] returned with unexpected [%(status_code)] status code.',
[
'backend' => $this->context->backendName,
'status_code' => $response->getStatusCode(),
...$context
]
);
continue 2;
}
$body = json_decode(
json: $response->getContent(false),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$json = ag($body, 'Items', [])[0] ?? [];
if (empty($json)) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. responded with empty metadata.',
[
'backend' => $this->context->backendName,
...$context,
'response' => [
'body' => $body,
],
]
);
continue;
}
$isWatched = (int)(bool)ag($json, 'UserData.Played', false);
if ($entity->watched === $isWatched) {
$this->logger->info(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.',
[
'backend' => $this->context->backendName,
...$context,
]
);
continue;
}
if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) {
$dateKey = 1 === $isWatched ? 'UserData.LastPlayedDate' : 'DateCreated';
$date = ag($json, $dateKey);
if (null === $date) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.',
[
'backend' => $this->context->backendName,
'date_key' => $dateKey,
...$context,
'response' => [
'body' => $body,
],
]
);
continue;
}
$date = makeDate($date);
$timeExtra = (int)(ag($this->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10));
if ($date->getTimestamp() >= ($timeExtra + $entity->updated)) {
$this->logger->notice(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.',
[
'backend' => $this->context->backendName,
...$context,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => $date,
'difference' => $date->getTimestamp() - $entity->updated,
'extra_margin' => [
Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra,
],
],
]
);
continue;
}
}
$url = $this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, ag($json, 'Id')));
$context['remote']['url'] = $url;
$this->logger->debug(
'Queuing request to change [%(backend)] %(item.type) [%(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)] %(item.type) [%(item.title)].',
[
'backend' => $this->context->backendName,
...$context,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
if (false === $response->isSuccessful()) {
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
unset($requests);
return [];
}
@@ -1098,8 +568,6 @@ class JellyfinServer implements ServerInterface
protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array
{
$this->checkConfig(true);
try {
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
http_build_query(
@@ -1190,7 +658,7 @@ class JellyfinServer implements ServerInterface
],
];
if (self::COLLECTION_TYPE_SHOWS !== ag($context, 'library.type')) {
if (JellyfinClient::COLLECTION_TYPE_SHOWS !== ag($context, 'library.type')) {
continue;
}
@@ -1730,19 +1198,4 @@ class JellyfinServer implements ServerInterface
])->getAll()
);
}
protected function checkConfig(bool $checkUrl = true, bool $checkToken = true, bool $checkUser = true): void
{
if (true === $checkUrl && !($this->url instanceof UriInterface)) {
throw new RuntimeException(static::NAME . ': No host was set.');
}
if (true === $checkToken && null === $this->token) {
throw new RuntimeException(static::NAME . ': No token was set.');
}
if (true === $checkUser && null === $this->user) {
throw new RuntimeException(static::NAME . ': No User was set.');
}
}
}

View File

@@ -7,9 +7,12 @@ namespace App\Libs\Servers;
use App\Backends\Common\Cache;
use App\Backends\Common\Context;
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\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;
@@ -26,7 +29,6 @@ use App\Libs\Options;
use App\Libs\QueueRequests;
use Closure;
use DateTimeInterface;
use Generator;
use JsonException;
use JsonMachine\Exception\PathNotFoundException;
use JsonMachine\Items;
@@ -95,7 +97,7 @@ class PlexServer implements ServerInterface
backendUser: $userId,
backendHeaders: $cloned->getHeaders(),
trace: true === ag($options, Options::DEBUG_TRACE),
options: $this->options
options: $cloned->options
);
$cloned->guid = $this->guid->withContext($cloned->context);
@@ -203,7 +205,7 @@ class PlexServer implements ServerInterface
}
if (false === $response->isSuccessful()) {
throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format()));
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
return $response->response;
@@ -218,7 +220,7 @@ class PlexServer implements ServerInterface
}
if (false === $response->isSuccessful()) {
throw new HttpException(ag($response->extra, 'message', fn() => $response->error->format()));
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
return $response->response;
@@ -229,669 +231,53 @@ class PlexServer implements ServerInterface
return $this->getItemDetails(context: $this->context, id: $id, opts: $opts);
}
/**
* @throws Throwable
*/
public function getLibrary(string|int $id, array $opts = []): Generator
public function getLibrary(string|int $id, array $opts = []): array
{
$this->checkConfig();
$response = Container::get(GetLibrary::class)(context: $this->context, guid: $this->guid, id: $id, opts: $opts);
$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()) {
throw new RuntimeException(
sprintf(
'Request for [%s] libraries returned with unexpected [%s] status code.',
$this->getName(),
$response->getStatusCode(),
)
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$context = [];
$found = false;
foreach (ag($json, 'MediaContainer.Directory', []) as $section) {
if ((int)ag($section, 'key') !== (int)$id) {
continue;
}
$found = true;
$context = [
'library' => [
'id' => ag($section, 'key'),
'type' => ag($section, 'type', 'unknown'),
'title' => ag($section, 'title', '??'),
],
];
break;
if (false === $response->isSuccessful()) {
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
if (false === $found) {
throw new RuntimeException(
sprintf('The response from [%s] does not contain library with id of [%s].', $this->getName(), $id)
);
}
if (true !== in_array(ag($context, 'library.type'), [iFace::TYPE_MOVIE, 'show'])) {
throw new RuntimeException(
sprintf(
'The requested [%s] library [%s] is of [%s] type. Which is not supported type.',
$this->getName(),
ag($context, 'library.title', $id),
ag($context, 'library.type')
)
);
}
$query = [
'sort' => 'addedAt:asc',
'includeGuids' => 1,
];
if (iFace::TYPE_MOVIE === ag($context, 'library.type')) {
$query['type'] = 1;
}
$url = $this->url->withPath(sprintf('/library/sections/%d/all', $id))->withQuery(http_build_query($query));
$context['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] library [%(library.title)] content.', [
'backend' => $this->getName(),
...$context,
]);
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
sprintf(
'Request for [%s] library [%s] content returned with unexpected [%s] status code.',
$this->getName(),
ag($context, 'library.title', $id),
$response->getStatusCode(),
)
);
}
$handleRequest = $opts['handler'] ?? function (array $item, array $context = []) use ($opts): array {
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey')));
$possibleTitlesList = ['title', 'originalTitle', 'titleSort'];
$data = [
'backend' => $this->getName(),
...$context,
];
$year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
if (true === ag($this->options, Options::DEBUG_TRACE)) {
$data['trace'] = $item;
}
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', $data);
$metadata = [
'id' => (int)ag($item, 'ratingKey'),
'type' => ucfirst(ag($item, 'type', 'unknown')),
'url' => (string)$url,
'title' => ag($item, $possibleTitlesList, '??'),
'year' => $year,
'guids' => [],
'match' => [
'titles' => [],
'paths' => [],
],
];
foreach ($possibleTitlesList as $title) {
if (null === ($title = ag($item, $title))) {
continue;
}
$isASCII = mb_detect_encoding($title, 'ASCII', true);
$title = trim($isASCII ? strtolower($title) : mb_strtolower($title));
if (true === in_array($title, $metadata['match']['titles'])) {
continue;
}
$metadata['match']['titles'][] = $title;
}
switch (ag($item, 'type')) {
case 'show':
foreach (ag($item, 'Location', []) as $path) {
$path = ag($path, 'path');
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
}
break;
case iFace::TYPE_MOVIE:
foreach (ag($item, 'Media', []) as $leaf) {
foreach (ag($leaf, 'Part', []) as $path) {
$path = ag($path, 'file');
$dir = dirname($path);
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($path),
];
if (false === str_starts_with(basename($path), basename($dir))) {
$metadata['match']['paths'][] = [
'full' => $path,
'short' => basename($dir),
];
}
}
}
break;
default:
throw new RuntimeException(
sprintf(
'While parsing [%s] library [%s] items, we encountered unexpected item [%s] type.',
$this->getName(),
ag($context, 'library.title', '??'),
ag($item, 'type')
)
);
}
$itemGuid = ag($item, 'guid');
if (null !== $itemGuid && false === $this->guid->isLocal($itemGuid)) {
$metadata['guids'][] = $itemGuid;
}
foreach (ag($item, 'Guid', []) as $guid) {
$metadata['guids'][] = ag($guid, 'id');
}
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$metadata['raw'] = $item;
}
return $metadata;
};
$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)
)
]
);
$requests = [];
foreach ($it as $entity) {
if ($entity instanceof DecodingError) {
$this->logger->warning(
'Failed to decode one item of [%(backend)] library id [%(library.title)] content.',
[
'backend' => $this->getName(),
...$context,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$year = (int)ag($entity, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($entity, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$context['item'] = [
'id' => ag($entity, 'ratingKey'),
'title' => ag($entity, ['title', 'originalTitle'], '??'),
'year' => $year,
'type' => ag($entity, 'type'),
'url' => (string)$url,
];
if (iFace::TYPE_MOVIE === ag($context, 'item.type')) {
yield $handleRequest(item: $entity, context: $context);
} else {
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($entity, 'ratingKey')));
$this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title) (%(item.year))] metadata.', [
'backend' => $this->getName(),
...$context,
]);
$requests[] = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($this->getHeaders(), [
'user_data' => [
'context' => $context
]
])
);
}
}
if (empty($requests) && iFace::TYPE_MOVIE !== ag($context, 'library.type')) {
throw new RuntimeException(
sprintf(
'No requests were made [%s] library [%s] is empty.',
$this->getName(),
ag($context, 'library.title', $id)
)
);
}
if (!empty($requests)) {
$this->logger->info(
'Requesting [%(total)] items metadata from [%(backend)] library [%(library.title)].',
[
'backend' => $this->getName(),
'total' => number_format(count($requests)),
'library' => ag($context, 'library', []),
]
);
}
foreach ($requests as $response) {
$requestContext = ag($response->getInfo('user_data'), 'context', []);
if (200 !== $response->getStatusCode()) {
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] metadata returned with unexpected [%(status_code)] status code.',
[
'backend' => $this->getName(),
'status_code' => $response->getStatusCode(),
...$requestContext
]
);
continue;
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
yield $handleRequest(
item: ag($json, 'MediaContainer.Metadata.0', []),
context: $requestContext
);
}
return $response->response;
}
public function listLibraries(array $opts = []): array
{
$this->checkConfig();
$response = Container::get(GetLibrariesList::class)(context: $this->context, opts: $opts);
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(),
]
);
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,
]
]);
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(),
],
]);
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(),
],
]);
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()));
}
$list = [];
foreach ($listDirs as $section) {
$key = (int)ag($section, 'key');
$type = ag($section, 'type', 'unknown');
$builder = [
'id' => $key,
'title' => ag($section, 'title', '???'),
'type' => $type,
'ignored' => null !== $ignoreIds && in_array($key, $ignoreIds),
'supported' => 'movie' === $type || 'show' === $type,
'agent' => ag($section, 'agent'),
'scanner' => ag($section, 'scanner'),
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $section;
}
$list[] = $builder;
}
return $list;
return $response->response;
}
public function push(array $entities, QueueRequests $queue, DateTimeInterface|null $after = null): array
{
$this->checkConfig();
$response = Container::get(Push::class)(
context: $this->context,
entities: $entities,
queue: $queue,
after: $after
);
$requests = [];
foreach ($entities as $key => $entity) {
if (true !== ($entity instanceof iFace)) {
continue;
}
if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) {
if (null !== $after && $after->getTimestamp() > $entity->updated) {
continue;
}
}
$metadata = $entity->getMetadata($this->getName());
$context = [
'item' => [
'id' => $entity->id,
'type' => $entity->type,
'title' => $entity->getName(),
],
];
if (null === ag($metadata, iFace::COLUMN_ID)) {
$this->logger->warning(
'Ignoring [%(item.title)] for [%(backend)] no backend metadata was found.',
[
'backend' => $this->getName(),
...$context,
]
);
continue;
}
$context['remote']['id'] = ag($metadata, iFace::COLUMN_ID);
try {
$url = $this->url->withPath('/library/metadata/' . ag($metadata, iFace::COLUMN_ID));
$context['remote']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] %(item.type) [%(item.title)] play state.', [
'backend' => $this->getName(),
...$context,
]);
$requests[] = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($this->getHeaders(), [
'user_data' => [
'id' => $key,
'context' => $context,
]
])
);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during requesting of [%(backend)] %(item.type) [%(item.title)].',
[
'backend' => $this->getName(),
...$context,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
$context = null;
foreach ($requests as $response) {
$context = ag($response->getInfo('user_data'), 'context', []);
try {
if (null === ($id = ag($response->getInfo('user_data'), 'id'))) {
$this->logger->error('Unable to get entity object id.', [
'backend' => $this->getName(),
...$context,
]);
continue;
}
$entity = $entities[$id];
assert($entity instanceof iFace);
switch ($response->getStatusCode()) {
case 200:
break;
case 404:
$this->logger->warning(
'Request for [%(backend)] %(item.type) [%(item.title)] returned with 404 (Not Found) status code.',
[
'backend' => $this->getName(),
...$context
]
);
continue 2;
default:
$this->logger->error(
'Request for [%(backend)] %(item.type) [%(item.title)] returned with unexpected [%(status_code)] status code.',
[
'backend' => $this->getName(),
'status_code' => $response->getStatusCode(),
...$context
]
);
continue 2;
}
$body = json_decode(
json: $response->getContent(false),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$json = ag($body, 'MediaContainer.Metadata.0', []);
if (empty($json)) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. responded with empty metadata.',
[
'backend' => $this->getName(),
...$context,
'response' => [
'body' => $body,
],
]
);
continue;
}
$isWatched = 0 === (int)ag($json, 'viewCount', 0) ? 0 : 1;
if ($entity->watched === $isWatched) {
$this->logger->info(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Play state is identical.',
[
'backend' => $this->getName(),
...$context,
]
);
continue;
}
if (false === (bool)ag($this->options, Options::IGNORE_DATE, false)) {
$dateKey = 1 === $isWatched ? 'lastViewedAt' : 'addedAt';
$date = ag($json, $dateKey);
if (null === $date) {
$this->logger->error(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. No %(date_key) is set on backend object.',
[
'backend' => $this->getName(),
'date_key' => $dateKey,
...$context,
'response' => [
'body' => $body,
],
]
);
continue;
}
$date = makeDate($date);
$timeExtra = (int)(ag($this->options, Options::EXPORT_ALLOWED_TIME_DIFF, 10));
if ($date->getTimestamp() >= ($entity->updated + $timeExtra)) {
$this->logger->notice(
'Ignoring [%(backend)] %(item.type) [%(item.title)]. Storage date is older than backend date.',
[
'backend' => $this->getName(),
...$context,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => $date,
'difference' => $date->getTimestamp() - $entity->updated,
'extra_margin' => [
Options::EXPORT_ALLOWED_TIME_DIFF => $timeExtra,
],
],
]
);
continue;
}
}
$url = $this->url->withPath($entity->isWatched() ? '/:/scrobble' : '/:/unscrobble')->withQuery(
http_build_query(
[
'identifier' => 'com.plexapp.plugins.library',
'key' => ag($json, 'ratingKey'),
]
)
);
$context['remote']['url'] = $url;
$this->logger->debug(
'Queuing request to change [%(backend)] %(item.type) [%(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)) {
$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)] %(item.type) [%(item.title)].',
[
'backend' => $this->getName(),
...$context,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
if (false === $response->isSuccessful()) {
throw new RuntimeException(ag($response->extra, 'message', fn() => $response->error->format()));
}
unset($requests);
return [];
}
@@ -1158,8 +544,6 @@ class PlexServer implements ServerInterface
protected function getLibraries(Closure $ok, Closure $error, bool $includeParent = false): array
{
$this->checkConfig();
try {
$url = $this->url->withPath('/library/sections');
@@ -1822,15 +1206,4 @@ class PlexServer implements ServerInterface
)->getAll()
);
}
protected function checkConfig(bool $checkUrl = true, bool $checkToken = true): void
{
if (true === $checkUrl && !($this->url instanceof UriInterface)) {
throw new RuntimeException(static::NAME . ': No host was set.');
}
if (true === $checkToken && null === $this->token) {
throw new RuntimeException(static::NAME . ': No token was set.');
}
}
}

View File

@@ -8,7 +8,6 @@ use App\Libs\Entity\StateInterface;
use App\Libs\Mappers\ImportInterface;
use App\Libs\QueueRequests;
use DateTimeInterface;
use Generator;
use JsonException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
@@ -144,9 +143,9 @@ interface ServerInterface
* @param string|int $id
* @param array $opts
*
* @return Generator
* @return array
*/
public function getLibrary(string|int $id, array $opts = []): Generator;
public function getLibrary(string|int $id, array $opts = []): array;
/**
* Get all persistent data.