Added backend:restore command.
This commit is contained in:
315
src/Commands/Backend/RestoreCommand.php
Normal file
315
src/Commands/Backend/RestoreCommand.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\Backend;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Mappers\Import\RestoreMapper;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\QueueRequests;
|
||||
use App\Libs\Routable;
|
||||
use DirectoryIterator;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Completion\CompletionInput;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Throwable;
|
||||
|
||||
#[Routable(command: self::ROUTE)]
|
||||
class RestoreCommand extends Command
|
||||
{
|
||||
public const ROUTE = 'backend:restore';
|
||||
|
||||
public const TASK_NAME = 'export';
|
||||
|
||||
public function __construct(private QueueRequests $queue, private iLogger $logger)
|
||||
{
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '-1');
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$cmdContext = trim(commandContext());
|
||||
$cmdRoute = self::ROUTE;
|
||||
$backupDir = after(Config::get('path') . '/backup/', ROOT_PATH);
|
||||
|
||||
$this->setName($cmdRoute)
|
||||
->setDescription('Restore backend play state from backup file.')
|
||||
->addOption('execute', null, InputOption::VALUE_NONE, 'Commit the changes to backend.')
|
||||
->addOption('assume-yes', null, InputOption::VALUE_NONE, 'Answer yes to understanding the risks.')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
|
||||
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Set request timeout in seconds.')
|
||||
->addArgument('backend', InputArgument::REQUIRED, 'Backend name to restore.')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'Backup file to restore from')
|
||||
->setHelp(
|
||||
<<<HELP
|
||||
This command allow you restore specfic backend play state from backup file
|
||||
generated via <info>state:backup</info> command.
|
||||
|
||||
This restore process only works on backends that has export enabled.
|
||||
|
||||
The restore process is exactly the same as the <info>state:export</info> with <info>[--ignore-date, --force-full]</info>
|
||||
flags enabled, the difference is instead of reading state from database we are reading it from backup file.
|
||||
|
||||
-------------------
|
||||
<comment>[ Risk Assessment ]</comment>
|
||||
-------------------
|
||||
|
||||
If you are trying to restore a backend that has import play state enabled, the changes from restoring from backup file
|
||||
will propagate back to your other backends. If you don't intend for that to happen, then <fg=white;bg=red;options=bold,underscore>DISABLE</> import from the backend.
|
||||
|
||||
--------------------------------
|
||||
<comment>[ Enable restore functionality ]</comment>
|
||||
--------------------------------
|
||||
|
||||
If you understand the risks and what might happen if you do restore from a backup file,
|
||||
then you can enable the command by adding <info>[--execute]</info> to the command.
|
||||
|
||||
For example,
|
||||
|
||||
{$cmdContext} {$cmdRoute} --execute -- my_plex {$backupDir}/my_plex.json
|
||||
|
||||
-------
|
||||
<comment>[ FAQ ]</comment>
|
||||
-------
|
||||
|
||||
<comment># Restore operation is cancelled.</comment>
|
||||
|
||||
If you encounter this error, it means either you didn't answer with yes for risk assessment confirmation,
|
||||
or the interaction is disabled, if you can't enable interaction, then you can add another flag <info>[--assume-yes]</info>
|
||||
to bypass the check. This confirms that you understand the risks of restoring backend that has import enabled.
|
||||
|
||||
<comment># Ignoring [backend_name] [item_title]. [Movie|Episode] Is not imported yet.</comment>
|
||||
|
||||
This is normal, this is likely becuase the backup is already outdated and some items in remote does not exist in backup file,
|
||||
or you are using backup from another source which likely does not have matching data.
|
||||
|
||||
<comment># Where are my backups stored?</comment>
|
||||
|
||||
By defualt we store backups at {$backupDir}
|
||||
|
||||
<comment># How to see what data will be changed?</comment>
|
||||
|
||||
if you do not add <comment>[--execute]</comment> flag to the comment, it will run in test mode by default,
|
||||
To see what data will be changed run the command with <info>[-v]</info> log level.
|
||||
|
||||
HELP
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
* @throws \JsonMachine\Exception\InvalidArgumentException
|
||||
*/
|
||||
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->single(fn(): int => $this->process($input, $output), $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \JsonMachine\Exception\InvalidArgumentException
|
||||
*/
|
||||
protected function process(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $input->getArgument('backend');
|
||||
$file = $input->getArgument('file');
|
||||
|
||||
if (false === file_exists($file) || false === is_readable($file)) {
|
||||
$newFile = Config::get('path') . '/backup/' . $file;
|
||||
|
||||
if (false === file_exists($newFile) || false === is_readable($newFile)) {
|
||||
$output->writeln(sprintf('<error>ERROR: Unable to find or read backup file \'%s\'.</error>', $file));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$file = $newFile;
|
||||
}
|
||||
|
||||
if (($config = $input->getOption('config'))) {
|
||||
try {
|
||||
Config::save('servers', Yaml::parseFile($this->checkCustomServersFile($config)));
|
||||
} catch (RuntimeException $e) {
|
||||
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === ($server = ag(Config::get('servers', []), $name, null))) {
|
||||
$output->writeln(sprintf('<error>ERROR: Backend \'%s\' not found.</error>', $name));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (false === (bool)ag($server, 'export.enabled')) {
|
||||
$output->writeln(sprintf('<error>ERROR: Export to \'%s\' are disabled.</error>', $name));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (true === (bool)ag($server, 'import.enabled') && false === $input->getOption('assume-yes')) {
|
||||
$helper = $this->getHelper('question');
|
||||
$text =
|
||||
<<<TEXT
|
||||
<options=bold,underscore>Are you sure?</> <comment>[Y|N] [Default: No]</comment>
|
||||
-----------------
|
||||
You are about to restore backend that has imports enabled.
|
||||
|
||||
<fg=white;bg=red;options=bold>The changes will propagate back to your backends.</>
|
||||
|
||||
<comment>If you understand the risks then answer with <info>[yes]</info>
|
||||
If you don't please run same command with <info>[--help]</info> flag.
|
||||
</comment>
|
||||
-----------------
|
||||
TEXT;
|
||||
|
||||
$question = new ConfirmationQuestion($text . PHP_EOL . '> ', false);
|
||||
|
||||
if (false === $helper->ask($input, $output, $question)) {
|
||||
$output->writeln(
|
||||
'<comment>Restore operation is cancelled, you answered no for risk assessment, or interaction is disabled.</comment>'
|
||||
);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->logger->notice('SYSTEM: Loading restore data.', [
|
||||
'memory' => [
|
||||
'now' => getMemoryUsage(),
|
||||
'peak' => getPeakMemoryUsage(),
|
||||
],
|
||||
]);
|
||||
|
||||
$mapper = (new RestoreMapper($this->logger, $file))->loadData();
|
||||
|
||||
$this->logger->notice('SYSTEM: Loading restore data is complete.', [
|
||||
'memory' => [
|
||||
'now' => getMemoryUsage(),
|
||||
'peak' => getPeakMemoryUsage(),
|
||||
],
|
||||
]);
|
||||
|
||||
if (false === $input->getOption('execute')) {
|
||||
$output->writeln('<info>No changes will be committed to backend.</info>');
|
||||
}
|
||||
|
||||
$opts = [
|
||||
'options' => [
|
||||
Options::IGNORE_DATE => true,
|
||||
Options::DEBUG_TRACE => true === $input->getOption('trace'),
|
||||
Options::DRY_RUN => false === $input->getOption('execute'),
|
||||
],
|
||||
];
|
||||
|
||||
if ($input->getOption('timeout')) {
|
||||
$opts = ag_set($opts, 'options.client.timeout', $input->getOption('timeout'));
|
||||
}
|
||||
|
||||
$backend = $this->getBackend($name, $opts);
|
||||
|
||||
$this->logger->notice('Starting Restore process');
|
||||
|
||||
$requests = $backend->export($mapper, $this->queue, null);
|
||||
|
||||
$this->logger->notice('SYSTEM: Sending [%(total)] play state comparison requests.', [
|
||||
'total' => count($requests),
|
||||
]);
|
||||
|
||||
foreach ($requests as $response) {
|
||||
$requestData = $response->getInfo('user_data');
|
||||
try {
|
||||
$requestData['ok']($response);
|
||||
} catch (Throwable $e) {
|
||||
$requestData['error']($e);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->notice('SYSTEM: Sent [%(total)] play state comparison requests.', [
|
||||
'total' => count($requests),
|
||||
]);
|
||||
|
||||
$total = count($this->queue->getQueue());
|
||||
|
||||
if ($total >= 1) {
|
||||
$this->logger->notice('SYSTEM: Sending [%(total)] change play state requests.', [
|
||||
'total' => $total
|
||||
]);
|
||||
} else {
|
||||
$this->logger->notice('SYSTEM: No difference detected between backup file and backend.');
|
||||
}
|
||||
|
||||
if ($total < 1 || false === $input->getOption('execute')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($this->queue->getQueue() as $response) {
|
||||
$context = ag($response->getInfo('user_data'), 'context', []);
|
||||
|
||||
try {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
'Request to change [%(backend)] [%(item.title)] play state returned with unexpected [%(status_code)] status code.',
|
||||
$context
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->notice('Marked [%(backend)] [%(item.title)] as [%(play_state)].', $context);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
'Unhandled exception thrown during request to change play state of [%(backend)] %(item.type) [%(item.title)].',
|
||||
[
|
||||
...$context,
|
||||
'exception' => [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'kind' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->notice('SYSTEM: Sent [%(total)] change play state requests.', [
|
||||
'total' => $total
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||
{
|
||||
parent::complete($input, $suggestions);
|
||||
|
||||
if ($input->mustSuggestArgumentValuesFor('file')) {
|
||||
$currentValue = $input->getCompletionValue();
|
||||
|
||||
$suggest = [];
|
||||
|
||||
foreach (new DirectoryIterator(Config::get('path') . '/backup/') as $name) {
|
||||
if (!$name->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($currentValue) || str_starts_with($name->getFilename(), $currentValue)) {
|
||||
$suggest[] = $name->getFilename();
|
||||
}
|
||||
}
|
||||
|
||||
$suggestions->suggestValues($suggest);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
189
src/Libs/Mappers/Import/RestoreMapper.php
Normal file
189
src/Libs/Mappers/Import/RestoreMapper.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Mappers\Import;
|
||||
|
||||
use App\Libs\Entity\StateEntity;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Guid;
|
||||
use App\Libs\Mappers\ImportInterface as iImport;
|
||||
use App\Libs\Storage\StorageInterface as iStorage;
|
||||
use DateTimeInterface;
|
||||
use JsonMachine\Items;
|
||||
use JsonMachine\JsonDecoder\DecodingError;
|
||||
use JsonMachine\JsonDecoder\ErrorWrappingDecoder;
|
||||
use JsonMachine\JsonDecoder\ExtJsonDecoder;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
|
||||
final class RestoreMapper implements iImport
|
||||
{
|
||||
/**
|
||||
* @var array<int,iState> Entities table.
|
||||
*/
|
||||
protected array $objects = [];
|
||||
|
||||
/**
|
||||
* @var array<string,int> Map GUIDs to entities.
|
||||
*/
|
||||
protected array $pointers = [];
|
||||
|
||||
protected array $options = [];
|
||||
|
||||
public function __construct(private iLogger $logger, private string $file)
|
||||
{
|
||||
}
|
||||
|
||||
public function setOptions(array $options = []): iImport
|
||||
{
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \JsonMachine\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function loadData(DateTimeInterface|null $date = null): self
|
||||
{
|
||||
$it = Items::fromFile($this->file, [
|
||||
'decoder' => new ErrorWrappingDecoder(new ExtJsonDecoder(true, JSON_INVALID_UTF8_IGNORE))
|
||||
]);
|
||||
|
||||
$state = new StateEntity([]);
|
||||
|
||||
foreach ($it as $entity) {
|
||||
if ($entity instanceof DecodingError) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity[iState::COLUMN_VIA] = 'backup_file';
|
||||
if (null !== ($entity[iState::COLUMN_GUIDS] ?? null)) {
|
||||
$entity[iState::COLUMN_GUIDS] = Guid::fromArray($entity[iState::COLUMN_GUIDS])->getAll();
|
||||
}
|
||||
if (null !== ($entity[iState::COLUMN_PARENT] ?? null)) {
|
||||
$entity[iState::COLUMN_PARENT] = Guid::fromArray($entity[iState::COLUMN_PARENT])->getAll();
|
||||
}
|
||||
|
||||
$item = $state::fromArray($entity);
|
||||
|
||||
$this->add($item);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function add(iState $entity, array $opts = []): self
|
||||
{
|
||||
if (false === $entity->hasGuids() && false === $entity->hasRelativeGuid()) {
|
||||
$this->logger->debug('MAPPER: Ignoring [%(title)] no valid/supported external ids.', [
|
||||
'title' => $entity->getName(),
|
||||
]);
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (false === $this->getPointer($entity)) {
|
||||
$this->objects[] = $entity;
|
||||
$pointer = array_key_last($this->objects);
|
||||
$this->addPointers($this->objects[$pointer], $pointer);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function get(iState $entity): null|iState
|
||||
{
|
||||
return false === ($pointer = $this->getPointer($entity)) ? null : $this->objects[$pointer];
|
||||
}
|
||||
|
||||
public function remove(iState $entity): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function commit(): mixed
|
||||
{
|
||||
$this->reset();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function has(iState $entity): bool
|
||||
{
|
||||
return null !== $this->get($entity);
|
||||
}
|
||||
|
||||
public function reset(): self
|
||||
{
|
||||
$this->objects = $this->pointers = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getObjects(array $opts = []): array
|
||||
{
|
||||
return $this->objects;
|
||||
}
|
||||
|
||||
public function getObjectsCount(): int
|
||||
{
|
||||
return count($this->objects);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function setLogger(iLogger $logger): self
|
||||
{
|
||||
$this->logger = $logger;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setStorage(iStorage $storage): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function inDryRunMode(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function inTraceMode(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function addPointers(iState $entity, string|int $pointer): iImport
|
||||
{
|
||||
foreach ($entity->getRelativePointers() as $key) {
|
||||
$this->pointers[$key] = $pointer;
|
||||
}
|
||||
|
||||
foreach ($entity->getPointers() as $key) {
|
||||
$this->pointers[$key . '/' . $entity->type] = $pointer;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function getPointer(iState $entity): int|string|bool
|
||||
{
|
||||
foreach ($entity->getRelativePointers() as $key) {
|
||||
if (null !== ($this->pointers[$key] ?? null)) {
|
||||
return $this->pointers[$key];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($entity->getPointers() as $key) {
|
||||
$lookup = $key . '/' . $entity->type;
|
||||
if (null !== ($this->pointers[$lookup] ?? null)) {
|
||||
return $this->pointers[$lookup];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user