Migrated Jellyfin/emby import/export to separate actions.
This commit is contained in:
276
src/Backends/Jellyfin/Action/Export.php
Normal file
276
src/Backends/Jellyfin/Action/Export.php
Normal 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(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
597
src/Backends/Jellyfin/Action/Import.php
Normal file
597
src/Backends/Jellyfin/Action/Import.php
Normal 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(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Libs\Mappers\ImportInterface;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\QueueRequests;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface as iResponse;
|
||||
use Throwable;
|
||||
|
||||
final class Export extends Import
|
||||
@@ -34,17 +34,15 @@ final class Export extends Import
|
||||
DateTimeInterface|null $after = null,
|
||||
array $opts = []
|
||||
): Response {
|
||||
$outerOpts = $opts;
|
||||
|
||||
return $this->tryResponse($context, fn() => $this->getLibraries(
|
||||
context: $context,
|
||||
handle: fn(array $logContext = []) => fn(ResponseInterface $response) => $this->handle(
|
||||
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: $outerOpts['queue'],
|
||||
queue: $opts['queue'],
|
||||
mapper: $mapper,
|
||||
item: $item,
|
||||
logContext: $logContext,
|
||||
|
||||
@@ -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
@@ -59,7 +59,6 @@ class PlexServer implements ServerInterface
|
||||
array $options = []
|
||||
): ServerInterface {
|
||||
$cloned = clone $this;
|
||||
$cloned->persist = $persist;
|
||||
$cloned->context = new Context(
|
||||
clientName: static::NAME,
|
||||
backendName: $name,
|
||||
@@ -287,9 +286,7 @@ class PlexServer implements ServerInterface
|
||||
guid: $this->guid,
|
||||
mapper: $mapper,
|
||||
after: $after,
|
||||
opts: [
|
||||
'queue' => $queue
|
||||
],
|
||||
opts: ['queue' => $queue],
|
||||
);
|
||||
|
||||
if ($response->hasError()) {
|
||||
|
||||
Reference in New Issue
Block a user