Merge pull request #167 from ArabCoders/dev
Migrating backends methods into separate actions.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
9
src/Backends/Emby/Action/GetLibrariesList.php
Normal file
9
src/Backends/Emby/Action/GetLibrariesList.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Backends\Emby\Action;
|
||||
|
||||
class GetLibrariesList extends \App\Backends\Jellyfin\Action\GetLibrariesList
|
||||
{
|
||||
}
|
||||
9
src/Backends/Emby/Action/Push.php
Normal file
9
src/Backends/Emby/Action/Push.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Backends\Emby\Action;
|
||||
|
||||
class Push extends \App\Backends\Jellyfin\Action\Push
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
133
src/Backends/Jellyfin/Action/GetLibrariesList.php
Normal file
133
src/Backends/Jellyfin/Action/GetLibrariesList.php
Normal 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);
|
||||
}
|
||||
}
|
||||
253
src/Backends/Jellyfin/Action/GetLibrary.php
Normal file
253
src/Backends/Jellyfin/Action/GetLibrary.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
300
src/Backends/Jellyfin/Action/Push.php
Normal file
300
src/Backends/Jellyfin/Action/Push.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
132
src/Backends/Plex/Action/GetLibrariesList.php
Normal file
132
src/Backends/Plex/Action/GetLibrariesList.php
Normal 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);
|
||||
}
|
||||
}
|
||||
364
src/Backends/Plex/Action/GetLibrary.php
Normal file
364
src/Backends/Plex/Action/GetLibrary.php
Normal 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;
|
||||
}
|
||||
}
|
||||
310
src/Backends/Plex/Action/Push.php
Normal file
310
src/Backends/Plex/Action/Push.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user