Merge pull request #168 from ArabCoders/dev

Fully migrated all backends action into separate action classes.
This commit is contained in:
Abdulmohsen
2022-06-19 17:39:00 +03:00
committed by GitHub
14 changed files with 1910 additions and 1977 deletions

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Jellyfin\JellyfinClient as JFC;
use App\Libs\Data;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Mappers\ImportInterface;
use App\Libs\Options;
use App\Libs\QueueRequests;
use DateTimeInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;
use Throwable;
class Export extends Import
{
/**
* @param Context $context
* @param iGuid $guid
* @param ImportInterface $mapper
* @param DateTimeInterface|null $after
* @param array $opts
*
* @return Response
*/
public function __invoke(
Context $context,
iGuid $guid,
ImportInterface $mapper,
DateTimeInterface|null $after = null,
array $opts = []
): Response {
return $this->tryResponse($context, fn() => $this->getLibraries(
context: $context,
handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle(
context: $context,
response: $response,
callback: fn(array $item, array $logContext = []) => $this->export(
context: $context,
guid: $guid,
queue: $opts['queue'],
mapper: $mapper,
item: $item,
logContext: $logContext,
opts: ['after' => $after],
),
logContext: $logContext
),
error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error(
'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
),
));
}
private function export(
Context $context,
iGuid $guid,
QueueRequests $queue,
ImportInterface $mapper,
array $item,
array $logContext = [],
array $opts = [],
): void {
if (JFC::TYPE_SHOW === ($type = ag($item, 'Type'))) {
$this->processShow(context: $context, guid: $guid, item: $item, logContext: $logContext);
return;
}
try {
$after = ag($opts, 'after');
$type = JFC::TYPE_MAPPER[$type];
Data::increment($context->backendName, $type . '_total');
$logContext['item'] = [
'id' => ag($item, 'Id'),
'title' => match ($type) {
iFace::TYPE_MOVIE => sprintf(
'%s (%d)',
ag($item, ['Name', 'OriginalTitle'], '??'),
ag($item, 'ProductionYear', 0000)
),
iFace::TYPE_EPISODE => trim(
sprintf(
'%s - (%sx%s)',
ag($item, 'SeriesName', '??'),
str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT),
)
),
},
'type' => $type,
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [
'backend' => $context->backendName,
...$logContext,
'response' => [
'body' => $item
],
]);
}
$isPlayed = true === (bool)ag($item, 'UserData.Played');
$dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated';
if (null === ag($item, $dateKey)) {
$this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [
'backend' => $context->backendName,
'date_key' => $dateKey,
...$logContext,
'response' => [
'body' => $item,
],
]);
Data::increment($context->backendName, $type . '_ignored_no_date_is_set');
return;
}
$rItem = $this->createEntity(
context: $context,
guid: $guid,
item: $item,
opts: $opts + ['library' => ag($logContext, 'library.id')]
);
if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) {
$providerIds = (array)ag($item, 'ProviderIds', []);
$message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.';
if (empty($providerIds)) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'context' => [
'guids' => !empty($providerIds) ? $providerIds : 'None'
],
]);
Data::increment($context->backendName, $type . '_ignored_no_supported_guid');
return;
}
if (false === ag($context->options, Options::IGNORE_DATE, false)) {
if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'lastSync' => makeDate($after),
'backend' => makeDate($rItem->updated),
],
]
);
Data::increment($context->backendName, $type . '_ignored_date_is_equal_or_higher');
return;
}
}
if (null === ($entity = $mapper->get($rItem))) {
$this->logger->warning('Ignoring [%(backend)] [%(item.title)]. %(item.type) Is not imported yet.', [
'backend' => $context->backendName,
...$logContext,
]);
Data::increment($context->backendName, $type . '_ignored_not_found_in_db');
return;
}
if ($rItem->watched === $entity->watched) {
if (true === (bool)ag($context->options, Options::DEBUG_TRACE)) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'backend' => $entity->isWatched() ? 'Played' : 'Unplayed',
'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed',
],
]
);
}
Data::increment($context->backendName, $type . '_ignored_state_unchanged');
return;
}
if ($rItem->updated >= $entity->updated && false === ag($context->options, Options::IGNORE_DATE, false)) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => makeDate($rItem->updated),
],
]
);
Data::increment($context->backendName, $type . '_ignored_date_is_newer');
return;
}
$url = $context->backendUrl->withPath(
sprintf('/Users/%s/PlayedItems/%s', $context->backendUser, ag($item, 'Id'))
);
$logContext['item']['url'] = $url;
$this->logger->debug(
'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].',
[
'backend' => $context->backendName,
'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed',
...$logContext,
]
);
if (false === (bool)ag($context->options, Options::DRY_RUN, false)) {
$queue->add(
$this->http->request(
$entity->isWatched() ? 'POST' : 'DELETE',
(string)$url,
$context->backendHeaders + [
'user_data' => [
'context' => $logContext + [
'backend' => $context->backendName,
'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed',
],
],
]
)
);
}
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
}

View File

@@ -0,0 +1,597 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Jellyfin\JellyfinActionTrait;
use App\Backends\Jellyfin\JellyfinClient as JFC;
use App\Libs\Config;
use App\Libs\Data;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use App\Libs\Mappers\ImportInterface;
use App\Libs\Options;
use Closure;
use DateTimeInterface;
use JsonException;
use JsonMachine\Items;
use JsonMachine\JsonDecoder\DecodingError;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;
use Throwable;
class Import
{
use CommonTrait, JellyfinActionTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* @param Context $context
* @param iGuid $guid
* @param ImportInterface $mapper
* @param DateTimeInterface|null $after
* @param array $opts
*
* @return Response
*/
public function __invoke(
Context $context,
iGuid $guid,
ImportInterface $mapper,
DateTimeInterface|null $after = null,
array $opts = []
): Response {
return $this->tryResponse($context, fn() => $this->getLibraries(
context: $context,
handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle(
context: $context,
response: $response,
callback: fn(array $item, array $logContext = []) => $this->import(
context: $context,
guid: $guid,
mapper: $mapper,
item: $item,
logContext: $logContext,
opts: ['after' => $after],
),
logContext: $logContext
),
error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error(
'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
),
));
}
protected function getLibraries(Context $context, Closure $handle, Closure $error): array
{
try {
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser));
$this->logger->debug('Requesting [%(backend)] libraries.', [
'backend' => $context->backendName,
'url' => $url
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
$this->logger->error(
'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
]
);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$listDirs = ag($json, 'Items', []);
if (empty($listDirs)) {
$this->logger->warning('Request for [%(backend)] libraries returned with empty list.', [
'backend' => $context->backendName,
'context' => [
'body' => $json,
]
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
} catch (ExceptionInterface $e) {
$this->logger->error('Request for [%(backend)] libraries has failed.', [
'backend' => $context->backendName,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
} catch (JsonException $e) {
$this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'message' => $e->getMessage(),
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) {
$ignoreIds = array_map(fn($v) => trim($v), explode(',', (string)$ignoreIds));
}
$requests = [];
$ignored = $unsupported = 0;
// -- Episodes Parent external ids.
foreach ($listDirs as $section) {
$logContext = [
'library' => [
'id' => (string)ag($section, 'Id'),
'title' => ag($section, 'Name', '??'),
'type' => ag($section, 'CollectionType', 'unknown'),
],
];
if (JFC::COLLECTION_TYPE_SHOWS !== ag($logContext, 'library.type')) {
continue;
}
if (true === in_array(ag($logContext, 'library.id'), $ignoreIds ?? [])) {
continue;
}
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery(
http_build_query(
[
'parentId' => ag($logContext, 'library.id'),
'recursive' => 'false',
'enableUserData' => 'false',
'enableImages' => 'false',
'fields' => implode(',', JFC::EXTRA_FIELDS),
'excludeLocationTypes' => 'Virtual',
]
)
);
$logContext['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [
'backend' => $context->backendName,
...$logContext,
]);
try {
$requests[] = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($context->backendHeaders, [
'user_data' => [
'ok' => $handle($logContext),
'error' => $error($logContext),
]
])
);
} catch (ExceptionInterface $e) {
$this->logger->error(
'Request for [%(backend)] [%(library.title)] series external ids has failed.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
}
}
foreach ($listDirs as $section) {
$logContext = [
'library' => [
'id' => (string)ag($section, 'Id'),
'title' => ag($section, 'Name', '??'),
'type' => ag($section, 'CollectionType', 'unknown'),
],
];
if (true === in_array(ag($logContext, 'library.id'), $ignoreIds ?? [])) {
$ignored++;
$this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [
'backend' => $context->backendName,
...$logContext,
]);
continue;
}
if (!in_array(ag($logContext, 'library.type'), [JFC::COLLECTION_TYPE_SHOWS, JFC::COLLECTION_TYPE_MOVIES])) {
$unsupported++;
$this->logger->info(
'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
$url = $context->backendUrl->withPath(sprintf('/Users/%s/items/', $context->backendUser))->withQuery(
http_build_query(
[
'parentId' => ag($logContext, 'library.id'),
'recursive' => 'true',
'enableUserData' => 'true',
'enableImages' => 'false',
'excludeLocationTypes' => 'Virtual',
'fields' => implode(',', JFC::EXTRA_FIELDS),
'includeItemTypes' => implode(',', [JFC::TYPE_MOVIE, JFC::TYPE_EPISODE]),
]
)
);
$logContext['library']['url'] = (string)$url;
$this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [
'backend' => $context->backendName,
...$logContext,
]);
try {
$requests[] = $this->http->request(
'GET',
(string)$url,
$context->backendHeaders + [
'user_data' => [
'ok' => $handle($logContext),
'error' => $error($logContext),
]
]
);
} catch (ExceptionInterface $e) {
$this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]);
continue;
}
}
if (0 === count($requests)) {
$this->logger->warning('No requests for [%(backend)] libraries were queued.', [
'backend' => $context->backendName,
'context' => [
'total' => count($listDirs),
'ignored' => $ignored,
'unsupported' => $unsupported,
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
return $requests;
}
/**
* @throws TransportExceptionInterface
*/
protected function handle(Context $context, iResponse $response, Closure $callback, array $logContext = []): void
{
if (200 !== $response->getStatusCode()) {
$this->logger->error(
'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext,
]
);
return;
}
$start = makeDate();
$this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [
'backend' => $context->backendName,
...$logContext,
'time' => [
'start' => $start,
],
]);
try {
$it = Items::fromIterable(
iterable: httpClientChunks($this->http->stream($response)),
options: [
'pointer' => '/Items',
'decoder' => new ErrorWrappingDecoder(
innerDecoder: new ExtJsonDecoder(
assoc: true,
options: JSON_INVALID_UTF8_IGNORE
)
)
]
);
foreach ($it as $entity) {
if ($entity instanceof DecodingError) {
$this->logger->warning(
'Failed to decode one item of [%(backend)] [%(library.title)] content.',
[
'backend' => $context->backendName,
...$logContext,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$callback(item: $entity, logContext: $logContext);
}
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during parsing of [%(backend)] library [%(library.title)] response.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
$end = makeDate();
$this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [
'backend' => $context->backendName,
...$logContext,
'time' => [
'start' => $start,
'end' => $end,
'duration' => number_format($end->getTimestamp() - $start->getTimestamp()),
],
]);
}
protected function processShow(Context $context, iGuid $guid, array $item, array $logContext = []): void
{
$logContext['item'] = [
'id' => ag($item, 'Id'),
'title' => sprintf(
'%s (%s)',
ag($item, ['Name', 'OriginalTitle'], '??'),
ag($item, 'ProductionYear', '0000')
),
'year' => ag($item, 'ProductionYear', null),
'type' => ag($item, 'Type'),
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))] payload.', [
'backend' => $context->backendName,
...$logContext,
'trace' => $item,
]);
}
$providersId = (array)ag($item, 'ProviderIds', []);
if (false === $guid->has($providersId)) {
$message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.';
if (empty($providersId)) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'data' => [
'guids' => !empty($providersId) ? $providersId : 'None'
],
]);
return;
}
$context->cache->set(
JFC::TYPE_SHOW . '.' . ag($logContext, 'item.id'),
Guid::fromArray($guid->get($providersId), context: [
'backend' => $context->backendName,
...$logContext,
])->getAll()
);
}
private function import(
Context $context,
iGuid $guid,
ImportInterface $mapper,
array $item,
array $logContext = [],
array $opts = []
): void {
if (JFC::TYPE_SHOW === ($type = ag($item, 'Type'))) {
$this->processShow(context: $context, guid: $guid, item: $item, logContext: $logContext);
return;
}
try {
Data::increment($context->backendName, $type . '_total');
$logContext['item'] = [
'id' => ag($item, 'Id'),
'title' => match ($type) {
JFC::TYPE_MOVIE => sprintf(
'%s (%d)',
ag($item, ['Name', 'OriginalTitle'], '??'),
ag($item, 'ProductionYear', 0000)
),
JFC::TYPE_EPISODE => trim(
sprintf(
'%s - (%sx%s)',
ag($item, 'SeriesName', '??'),
str_pad((string)ag($item, 'ParentIndexNumber', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'IndexNumber', 0), 3, '0', STR_PAD_LEFT),
)
),
default => throw new \InvalidArgumentException('Invalid Content type was given.')
},
'type' => ag($item, 'Type'),
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [
'backend' => $context->backendName,
...$logContext,
'response' => [
'body' => $item
],
]);
}
$isPlayed = true === (bool)ag($item, 'UserData.Played');
$dateKey = true === $isPlayed ? 'UserData.LastPlayedDate' : 'DateCreated';
if (null === ag($item, $dateKey)) {
$this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [
'backend' => $context->backendName,
'date_key' => $dateKey,
...$logContext,
'response' => [
'body' => $item,
],
]);
Data::increment($context->backendName, $type . '_ignored_no_date_is_set');
return;
}
$entity = $this->createEntity(
context: $context,
guid: $guid,
item: $item,
opts: $opts + [
'library' => ag($logContext, 'library.id'),
'override' => [
iFace::COLUMN_EXTRA => [
$context->backendName => [
iFace::COLUMN_EXTRA_EVENT => 'task.import',
iFace::COLUMN_EXTRA_DATE => makeDate('now'),
],
],
]
],
);
if (false === $entity->hasGuids() && false === $entity->hasRelativeGuid()) {
if (true === (bool)Config::get('debug.import')) {
$name = sprintf(
Config::get('tmpDir') . '/debug/%s.%s.json',
$context->backendName,
ag($item, 'Id')
);
if (!file_exists($name)) {
file_put_contents(
$name,
json_encode(
$item,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE
)
);
}
}
$providerIds = (array)ag($item, 'ProviderIds', []);
$message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.';
if (empty($providerIds)) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'context' => [
'guids' => !empty($providerIds) ? $providerIds : 'None'
],
]);
Data::increment($context->backendName, $type . '_ignored_no_supported_guid');
return;
}
$mapper->add(entity: $entity, opts: [
'after' => ag($opts, 'after'),
Options::IMPORT_METADATA_ONLY => true === (bool)ag($context->options, Options::IMPORT_METADATA_ONLY),
]);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
}

View File

@@ -9,7 +9,6 @@ use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Jellyfin\Action\GetLibrariesList;
use App\Backends\Jellyfin\Action\GetMetaData;
use App\Libs\Container;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Guid;
use App\Libs\Options;
@@ -17,14 +16,8 @@ use RuntimeException;
trait JellyfinActionTrait
{
private array $typeMapper = [
JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW,
JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE,
JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE,
];
/**
* Create {@see StateEntity} Object based on given data.
* Create {@see iState} Object based on given data.
*
* @param Context $context
* @param iGuid $guid
@@ -48,7 +41,7 @@ trait JellyfinActionTrait
throw new RuntimeException('No date was set on object.');
}
$type = $this->typeMapper[ag($item, 'Type')] ?? ag($item, 'Type');
$type = JellyfinClient::TYPE_MAPPER[ag($item, 'Type')] ?? ag($item, 'Type');
$guids = $guid->get(ag($item, 'ProviderIds', []), context: [
'item' => [

View File

@@ -8,6 +8,7 @@ use App\Backends\Common\Context;
use App\Backends\Jellyfin\Action\GetIdentifier;
use App\Backends\Jellyfin\Action\GetMetaData;
use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use RuntimeException;
@@ -33,6 +34,12 @@ class JellyfinClient
'Path',
];
public const TYPE_MAPPER = [
JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW,
JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE,
JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE,
];
private Context|null $context = null;
public function __construct(

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Plex\PlexClient;
use App\Libs\Data;
use App\Libs\Mappers\ImportInterface;
use App\Libs\Options;
use App\Libs\QueueRequests;
use DateTimeInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;
use Throwable;
final class Export extends Import
{
/**
* @param Context $context
* @param iGuid $guid
* @param ImportInterface $mapper
* @param DateTimeInterface|null $after
* @param array $opts
*
* @return Response
*/
public function __invoke(
Context $context,
iGuid $guid,
ImportInterface $mapper,
DateTimeInterface|null $after = null,
array $opts = []
): Response {
return $this->tryResponse($context, fn() => $this->getLibraries(
context: $context,
handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle(
context: $context,
response: $response,
callback: fn(array $item, array $logContext = []) => $this->export(
context: $context,
guid: $guid,
queue: $opts['queue'],
mapper: $mapper,
item: $item,
logContext: $logContext,
opts: ['after' => $after],
),
logContext: $logContext,
),
error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error(
'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
),
));
}
private function export(
Context $context,
iGuid $guid,
QueueRequests $queue,
ImportInterface $mapper,
array $item,
array $logContext = [],
array $opts = [],
): void {
$after = ag($opts, 'after', null);
$library = ag($logContext, 'library.id');
$type = ag($item, 'type');
if (PlexClient::TYPE_SHOW === $type) {
$this->processShow($context, $guid, $item, $logContext);
return;
}
try {
Data::increment($context->backendName, $library . '_total');
Data::increment($context->backendName, $type . '_total');
$year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$logContext['item'] = [
'id' => ag($item, 'ratingKey'),
'title' => match ($type) {
PlexClient::TYPE_MOVIE => sprintf(
'%s (%s)',
ag($item, ['title', 'originalTitle'], '??'),
0 === $year ? '0000' : $year,
),
PlexClient::TYPE_EPISODE => sprintf(
'%s - (%sx%s)',
ag($item, ['grandparentTitle', 'originalTitle', 'title'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
),
},
'type' => $type,
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)] payload.', [
'backend' => $context->backendName,
...$logContext,
'payload' => $item,
]);
}
if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) {
$this->logger->debug('Ignoring [%(backend)] [%(item.title)]. No Date is set on object.', [
'backend' => $context->backendName,
'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt',
...$logContext,
'response' => [
'body' => $item,
],
]);
Data::increment($context->backendName, $type . '_ignored_no_date_is_set');
return;
}
$rItem = $this->createEntity(
context: $context,
guid: $guid,
item: $item,
opts: $opts
);
if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) {
$message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.';
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [];
}
if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) {
$item['Guid'][] = $itemGuid;
}
if (empty($item['Guid'])) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'context' => [
'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None'
],
]);
Data::increment($context->backendName, $type . '_ignored_no_supported_guid');
return;
}
if (false === ag($context->options, Options::IGNORE_DATE, false)) {
if (true === ($after instanceof DateTimeInterface) && $rItem->updated >= $after->getTimestamp()) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than last sync date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'lastSync' => makeDate($after),
'backend' => makeDate($rItem->updated),
],
]
);
Data::increment($context->backendName, $type . '_ignored_date_is_equal_or_higher');
return;
}
}
if (null === ($entity = $mapper->get($rItem))) {
$this->logger->warning('Ignoring [%(backend)] [%(item.title)]. %(item.type) Is not imported yet.', [
'backend' => $context->backendName,
...$logContext,
]);
Data::increment($context->backendName, $type . '_ignored_not_found_in_db');
return;
}
if ($rItem->watched === $entity->watched) {
if (true === (bool)ag($context->options, Options::DEBUG_TRACE)) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. %(item.type) play state is identical.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'backend' => $entity->isWatched() ? 'Played' : 'Unplayed',
'remote' => $rItem->isWatched() ? 'Played' : 'Unplayed',
],
]
);
}
Data::increment($context->backendName, $type . '_ignored_state_unchanged');
return;
}
if ($rItem->updated >= $entity->updated && false === ag($context->options, Options::IGNORE_DATE, false)) {
$this->logger->debug(
'Ignoring [%(backend)] [%(item.title)]. Backend date is equal or newer than storage date.',
[
'backend' => $context->backendName,
...$logContext,
'comparison' => [
'storage' => makeDate($entity->updated),
'backend' => makeDate($rItem->updated),
],
]
);
Data::increment($context->backendName, $type . '_ignored_date_is_newer');
return;
}
$url = $context->backendUrl->withPath(
'/:' . ($entity->isWatched() ? '/scrobble' : '/unscrobble')
)->withQuery(
http_build_query(
[
'identifier' => 'com.plexapp.plugins.library',
'key' => $item['ratingKey'],
]
)
);
$logContext['item']['url'] = $url;
$this->logger->debug(
'Queuing Request to change [%(backend)] [%(item.title)] play state to [%(play_state)].',
[
'backend' => $context->backendName,
'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed',
...$logContext,
]
);
if (false === (bool)ag($context->options, Options::DRY_RUN, false)) {
$queue->add(
$this->http->request(
'GET',
(string)$url,
array_replace_recursive($context->backendHeaders, [
'user_data' => [
'context' => $logContext + [
'backend' => $context->backendName,
'play_state' => $entity->isWatched() ? 'Played' : 'Unplayed',
],
]
])
)
);
}
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] export.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
}

View File

@@ -0,0 +1,647 @@
<?php
declare(strict_types=1);
namespace App\Backends\Plex\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Plex\PlexActionTrait;
use App\Backends\Plex\PlexClient;
use App\Libs\Config;
use App\Libs\Data;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use App\Libs\Mappers\ImportInterface;
use App\Libs\Options;
use Closure;
use DateTimeInterface;
use JsonException;
use JsonMachine\Items;
use JsonMachine\JsonDecoder\DecodingError;
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
use JsonMachine\JsonDecoder\ExtJsonDecoder;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;
use Throwable;
class Import
{
use CommonTrait, PlexActionTrait;
public function __construct(protected HttpClientInterface $http, protected LoggerInterface $logger)
{
}
/**
* @param Context $context
* @param iGuid $guid
* @param ImportInterface $mapper
* @param DateTimeInterface|null $after
* @param array $opts
*
* @return Response
*/
public function __invoke(
Context $context,
iGuid $guid,
ImportInterface $mapper,
DateTimeInterface|null $after = null,
array $opts = []
): Response {
return $this->tryResponse($context, fn() => $this->getLibraries(
context: $context,
handle: fn(array $logContext = []) => fn(iResponse $response) => $this->handle(
context: $context,
response: $response,
callback: fn(array $item, array $logContext = []) => $this->import(
context: $context,
guid: $guid,
mapper: $mapper,
item: $item,
logContext: $logContext,
opts: ['after' => $after],
),
logContext: $logContext
),
error: fn(array $logContext = []) => fn(Throwable $e) => $this->logger->error(
'Unhandled Exception was thrown during [%(backend)] library [%(library.title)] request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
),
));
}
protected function getLibraries(Context $context, Closure $handle, Closure $error): array
{
try {
$url = $context->backendUrl->withPath('/library/sections');
$this->logger->debug('Requesting [%(backend)] libraries.', [
'backend' => $context->backendName,
'url' => $url
]);
$response = $this->http->request('GET', (string)$url, $context->backendHeaders);
if (200 !== $response->getStatusCode()) {
$this->logger->error(
'Request for [%(backend)] libraries returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
]
);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
$listDirs = ag($json, 'MediaContainer.Directory', []);
if (empty($listDirs)) {
$this->logger->warning('Request for [%(backend)] libraries returned with empty list.', [
'backend' => $context->backendName,
'context' => [
'body' => $json,
]
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
} catch (ExceptionInterface $e) {
$this->logger->error('Request for [%(backend)] libraries has failed.', [
'backend' => $context->backendName,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
} catch (JsonException $e) {
$this->logger->error('Request for [%(backend)] libraries returned with invalid body.', [
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'message' => $e->getMessage(),
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
if (null !== ($ignoreIds = ag($context->options, 'ignore', null))) {
$ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', (string)$ignoreIds));
}
$requests = [];
$ignored = $unsupported = 0;
// -- Get TV shows metadata.
foreach ($listDirs as $section) {
$key = (int)ag($section, 'key');
if (PlexClient::TYPE_SHOW !== ag($section, 'type', 'unknown')) {
continue;
}
if (true === in_array($key, $ignoreIds ?? [])) {
continue;
}
$url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $key))->withQuery(
http_build_query(['type' => 2, 'includeGuids' => 1])
);
$logContext = [
'library' => [
'id' => $key,
'title' => ag($section, 'title', '??'),
'type' => ag($section, 'type', 'unknown'),
'url' => $url,
],
];
$this->logger->debug('Requesting [%(backend)] [%(library.title)] series external ids.', [
'backend' => $context->backendName,
...$logContext,
]);
try {
$requests[] = $this->http->request(
'GET',
(string)$url,
$context->backendHeaders + [
'user_data' => [
'ok' => $handle($logContext),
'error' => $error($logContext),
]
]
);
} catch (ExceptionInterface $e) {
$this->logger->error(
'Request for [%(backend)] [%(library.title)] series external ids has failed.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during [%(backend)] [%(library.title)] series external ids request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
}
}
// -- Get Movies/episodes.
foreach ($listDirs as $section) {
$key = (int)ag($section, 'key');
$logContext = [
'library' => [
'id' => ag($section, 'key'),
'title' => ag($section, 'title', '??'),
'type' => ag($section, 'type', 'unknown'),
],
];
if (true === in_array($key, $ignoreIds ?? [])) {
$ignored++;
$this->logger->info('Ignoring [%(backend)] [%(library.title)]. Requested by user config.', [
'backend' => $context->backendName,
...$logContext,
]);
continue;
}
if (!in_array(ag($logContext, 'library.type'), [PlexClient::TYPE_MOVIE, PlexClient::TYPE_SHOW])) {
$unsupported++;
$this->logger->info(
'Ignoring [%(backend)] [%(library.title)]. Library type [%(library.type)] is not supported.',
[
'backend' => $context->backendName,
...$logContext,
]
);
continue;
}
$url = $context->backendUrl->withPath(sprintf('/library/sections/%d/all', $key))->withQuery(
http_build_query(
[
'type' => PlexClient::TYPE_MOVIE === ag($logContext, 'library.type') ? 1 : 4,
'includeGuids' => 1,
]
)
);
$logContext['library']['url'] = $url;
$this->logger->debug('Requesting [%(backend)] [%(library.title)] content list.', [
'backend' => $context->backendName,
...$logContext,
]);
try {
$requests[] = $this->http->request(
'GET',
(string)$url,
$context->backendHeaders + [
'user_data' => [
'ok' => $handle($logContext),
'error' => $error($logContext),
]
],
);
} catch (ExceptionInterface $e) {
$this->logger->error('Requesting for [%(backend)] [%(library.title)] content list has failed.', [
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]);
continue;
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during [%(backend)] [%(library.title)] content list request.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
continue;
}
}
if (0 === count($requests)) {
$this->logger->warning('No requests for [%(backend)] libraries were queued.', [
'backend' => $context->backendName,
'context' => [
'total' => count($listDirs),
'ignored' => $ignored,
'unsupported' => $unsupported,
],
]);
Data::add($context->backendName, 'no_import_update', true);
return [];
}
return $requests;
}
/**
* @throws TransportExceptionInterface
*/
protected function handle(Context $context, iResponse $response, Closure $callback, array $logContext = []): void
{
if (200 !== $response->getStatusCode()) {
$this->logger->error(
'Request for [%(backend)] [%(library.title)] content returned with unexpected [%(status_code)] status code.',
[
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
...$logContext,
]
);
return;
}
$start = makeDate();
$this->logger->info('Parsing [%(backend)] library [%(library.title)] response.', [
'backend' => $context->backendName,
...$logContext,
'time' => [
'start' => $start,
],
]);
try {
$it = Items::fromIterable(
iterable: httpClientChunks($this->http->stream($response)),
options: [
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
innerDecoder: new ExtJsonDecoder(
assoc: true,
options: JSON_INVALID_UTF8_IGNORE
)
)
]
);
foreach ($it as $entity) {
if ($entity instanceof DecodingError) {
$this->logger->warning(
'Failed to decode one item of [%(backend)] [%(library.title)] content.',
[
'backend' => $context->backendName,
...$logContext,
'error' => [
'message' => $entity->getErrorMessage(),
'body' => $entity->getMalformedJson(),
],
]
);
continue;
}
$callback(item: $entity, logContext: $logContext);
}
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during parsing of [%(backend)] library [%(library.title)] response.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
$end = makeDate();
$this->logger->info('Parsing [%(backend)] library [%(library.title)] response is complete.', [
'backend' => $context->backendName,
...$logContext,
'time' => [
'start' => $start,
'end' => $end,
'duration' => number_format($end->getTimestamp() - $start->getTimestamp()),
],
]);
}
protected function processShow(Context $context, iGuid $guid, array $item, array $logContext = []): void
{
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [['id' => $item['guid']]];
} else {
$item['Guid'][] = ['id' => $item['guid']];
}
$year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$logContext['item'] = [
'id' => ag($item, 'ratingKey'),
'title' => sprintf(
'%s (%s)',
ag($item, ['title', 'originalTitle'], '??'),
0 === $year ? '0000' : $year,
),
'year' => 0 === $year ? '0000' : $year,
'type' => ag($item, 'type', 'unknown'),
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title) (%(item.year))].', [
'backend' => $context->backendName,
...$logContext,
'trace' => $item,
]);
}
if (!$guid->has($item['Guid'])) {
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [];
}
if (null !== ($item['guid'] ?? null) && false === $guid->isLocal($item['guid'])) {
$item['Guid'][] = ['id' => $item['guid']];
}
$message = 'Ignoring [%(backend)] [%(item.title)]. %(item.type) has no valid/supported external ids.';
if (empty($item['Guid'] ?? [])) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'data' => [
'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None'
],
]);
return;
}
$gContext = ag_set(
$logContext,
'item.plex_id',
str_starts_with(ag($item, 'guid', ''), 'plex://') ? ag($item, 'guid') : 'none'
);
$context->cache->set(
PlexClient::TYPE_SHOW . '.' . ag($logContext, 'item.id'),
Guid::fromArray(
payload: $guid->get($item['Guid'], context: [...$gContext]),
context: ['backend' => $context->backendName, ...$logContext,]
)->getAll()
);
}
private function import(
Context $context,
iGuid $guid,
ImportInterface $mapper,
array $item,
array $logContext = [],
array $opts = []
): void {
$after = ag($opts, 'after', null);
$library = ag($logContext, 'library.id');
$type = ag($item, 'type');
try {
if (PlexClient::TYPE_SHOW === $type) {
$this->processShow($context, $guid, $item, $logContext);
return;
}
Data::increment($context->backendName, $library . '_total');
Data::increment($context->backendName, $type . '_total');
$year = (int)ag($item, ['grandParentYear', 'parentYear', 'year'], 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$logContext['item'] = [
'id' => ag($item, 'ratingKey'),
'title' => match ($type) {
PlexClient::TYPE_MOVIE => sprintf(
'%s (%s)',
ag($item, ['title', 'originalTitle'], '??'),
0 === $year ? '0000' : $year,
),
PlexClient::TYPE_EPISODE => sprintf(
'%s - (%sx%s)',
ag($item, ['grandparentTitle', 'originalTitle', 'title'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
),
},
'type' => ag($item, 'type', 'unknown'),
];
if ($context->trace) {
$this->logger->debug('Processing [%(backend)] %(item.type) [%(item.title)]', [
'backend' => $context->backendName,
...$logContext,
'payload' => $item,
]);
}
if (null === ag($item, true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt')) {
$this->logger->debug('Ignoring [%(backend)] %(item.type) [%(item.title)]. No Date is set on object.', [
'backend' => $context->backendName,
'date_key' => true === (bool)ag($item, 'viewCount', false) ? 'lastViewedAt' : 'addedAt',
...$logContext,
'response' => [
'body' => $item,
],
]);
Data::increment($context->backendName, $type . '_ignored_no_date_is_set');
return;
}
$entity = $this->createEntity(
context: $context,
guid: $guid,
item: $item,
opts: $opts + [
'override' => [
iFace::COLUMN_EXTRA => [
$context->backendName => [
iFace::COLUMN_EXTRA_EVENT => 'task.import',
iFace::COLUMN_EXTRA_DATE => makeDate('now'),
],
],
],
]
);
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
if (true === (bool)Config::get('debug.import')) {
$name = Config::get(
'tmpDir'
) . '/debug/' . $context->backendName . '.' . $item['ratingKey'] . '.json';
if (!file_exists($name)) {
file_put_contents(
$name,
json_encode(
$item,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE
)
);
}
}
$message = 'Ignoring [%(backend)] [%(item.title)]. No valid/supported external ids.';
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [];
}
if (null !== ($itemGuid = ag($item, 'guid')) && false === $guid->isLocal($itemGuid)) {
$item['Guid'][] = $itemGuid;
}
if (empty($item['Guid'])) {
$message .= ' Most likely unmatched %(item.type).';
}
$this->logger->info($message, [
'backend' => $context->backendName,
...$logContext,
'context' => [
'guids' => !empty($item['Guid']) ? $item['Guid'] : 'None'
],
]);
Data::increment($context->backendName, $type . '_ignored_no_supported_guid');
return;
}
$mapper->add(entity: $entity, opts: [
'after' => $after,
Options::IMPORT_METADATA_ONLY => true === (bool)ag($context->options, Options::IMPORT_METADATA_ONLY),
]);
} catch (Throwable $e) {
$this->logger->error(
'Unhandled exception was thrown during handling of [%(backend)] [%(library.title)] [%(item.title)] import.',
[
'backend' => $context->backendName,
...$logContext,
'exception' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
'message' => $e->getMessage(),
],
]
);
}
}
}

View File

@@ -335,7 +335,6 @@ class ExportCommand extends Command
);
} else {
Config::save(sprintf('servers.%s.export.lastSync', $name), time());
Config::save(sprintf('servers.%s.persist', $name), $backend['class']->getPersist());
}
}

View File

@@ -331,14 +331,6 @@ class ImportCommand extends Command
(new Table($output))->setHeaders(array_keys($a[0]))->setStyle('box')->setRows(array_values($a))->render();
if (false === $input->getOption('dry-run')) {
foreach ($list as $server) {
if (null === ($name = ag($server, 'name'))) {
continue;
}
Config::save(sprintf('servers.%s.persist', $name), $server['class']->getPersist());
}
if (false === $custom && is_writable(dirname($config))) {
copy($config, $config . '.bak');
}

View File

@@ -221,14 +221,6 @@ class PushCommand extends Command
}
if (false === $input->getOption('dry-run')) {
foreach ($list as $server) {
if (null === ($name = ag($server, 'name'))) {
continue;
}
Config::save(sprintf('servers.%s.persist', $name), $server['class']->getPersist());
}
$config = Config::get('path') . '/config/servers.yaml';
if (is_writable(dirname($config))) {

View File

@@ -10,26 +10,11 @@ use App\Libs\Container;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\HttpException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class EmbyServer extends JellyfinServer
{
public const NAME = 'EmbyBackend';
public function setUp(
string $name,
UriInterface $url,
string|int|null $token = null,
string|int|null $userId = null,
string|int|null $uuid = null,
array $persist = [],
array $options = []
): ServerInterface {
$options['emby'] = true;
return parent::setUp($name, $url, $token, $userId, $uuid, $persist, $options);
}
public function parseWebhook(ServerRequestInterface $request): iFace
{
$response = Container::get(ParseWebhook::class)(context: $this->context, guid: $this->guid, request: $request);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,6 @@ interface ServerInterface
* @param null|int|string $token Server Token
* @param null|int|string $userId Server user Id
* @param string|int|null $uuid
* @param array $persist persistent data saved by server.
* @param array $options array of options.
*
* @return self
@@ -36,7 +35,6 @@ interface ServerInterface
null|string|int $token = null,
null|string|int $userId = null,
null|string|int $uuid = null,
array $persist = [],
array $options = []
): self;
@@ -147,22 +145,6 @@ interface ServerInterface
*/
public function getLibrary(string|int $id, array $opts = []): array;
/**
* Get all persistent data.
*
* @return array
*/
public function getPersist(): array;
/**
* Add persistent data to config.
*
* @param string $key
* @param mixed $value
* @return ServerInterface
*/
public function addPersist(string $key, mixed $value): ServerInterface;
/**
* Get Server Unique ID.
*

View File

@@ -382,7 +382,7 @@ if (!function_exists('after')) {
if (!function_exists('makeServer')) {
/**
* @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, persist:array, options:array} $server
* @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, options:array} $server
* @param string|null $name server name.
* @return ServerInterface
*
@@ -414,7 +414,6 @@ if (!function_exists('makeServer')) {
token: ag($server, 'token', null),
userId: ag($server, 'user', null),
uuid: ag($server, 'uuid', null),
persist: ag($server, 'persist', []),
options: ag($server, 'options', []),
);
}