diff --git a/src/Commands/Backend/RestoreCommand.php b/src/Commands/Backend/RestoreCommand.php new file mode 100644 index 00000000..6c3e9af3 --- /dev/null +++ b/src/Commands/Backend/RestoreCommand.php @@ -0,0 +1,315 @@ +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( + <<state:backup command. + +This restore process only works on backends that has export enabled. + +The restore process is exactly the same as the state:export with [--ignore-date, --force-full] +flags enabled, the difference is instead of reading state from database we are reading it from backup file. + +------------------- +[ Risk Assessment ] +------------------- + +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 DISABLE import from the backend. + +-------------------------------- +[ Enable restore functionality ] +-------------------------------- + +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 [--execute] to the command. + +For example, + +{$cmdContext} {$cmdRoute} --execute -- my_plex {$backupDir}/my_plex.json + +------- +[ FAQ ] +------- + +# Restore operation is cancelled. + +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 [--assume-yes] +to bypass the check. This confirms that you understand the risks of restoring backend that has import enabled. + +# Ignoring [backend_name] [item_title]. [Movie|Episode] Is not imported yet. + +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. + +# Where are my backups stored? + +By defualt we store backups at {$backupDir} + +# How to see what data will be changed? + +if you do not add [--execute] flag to the comment, it will run in test mode by default, +To see what data will be changed run the command with [-v] 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: Unable to find or read backup file \'%s\'.', $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('%s', $e->getMessage())); + return self::FAILURE; + } + } + + if (null === ($server = ag(Config::get('servers', []), $name, null))) { + $output->writeln(sprintf('ERROR: Backend \'%s\' not found.', $name)); + return self::FAILURE; + } + + if (false === (bool)ag($server, 'export.enabled')) { + $output->writeln(sprintf('ERROR: Export to \'%s\' are disabled.', $name)); + return self::FAILURE; + } + + if (true === (bool)ag($server, 'import.enabled') && false === $input->getOption('assume-yes')) { + $helper = $this->getHelper('question'); + $text = + <<Are you sure? [Y|N] [Default: No] + ----------------- + You are about to restore backend that has imports enabled. + + The changes will propagate back to your backends. + + If you understand the risks then answer with [yes] + If you don't please run same command with [--help] flag. + + ----------------- + TEXT; + + $question = new ConfirmationQuestion($text . PHP_EOL . '> ', false); + + if (false === $helper->ask($input, $output, $question)) { + $output->writeln( + 'Restore operation is cancelled, you answered no for risk assessment, or interaction is disabled.' + ); + 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('No changes will be committed to backend.'); + } + + $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); + } + } + +} diff --git a/src/Libs/Mappers/Import/RestoreMapper.php b/src/Libs/Mappers/Import/RestoreMapper.php new file mode 100644 index 00000000..5c9642be --- /dev/null +++ b/src/Libs/Mappers/Import/RestoreMapper.php @@ -0,0 +1,189 @@ + Entities table. + */ + protected array $objects = []; + + /** + * @var array 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; + } +}