Files
watchstate/src/Commands/Database/ListCommand.php

422 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Commands\Database;
use App\Command;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use App\Libs\Storage\StorageInterface;
use Exception;
use PDO;
use RuntimeException;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Yaml\Yaml;
final class ListCommand extends Command
{
private const COLUMNS_CHANGEABLE = [
iFace::COLUMN_WATCHED,
iFace::COLUMN_VIA,
iFace::COLUMN_TITLE,
iFace::COLUMN_YEAR,
iFace::COLUMN_SEASON,
iFace::COLUMN_EPISODE,
iFace::COLUMN_UPDATED,
];
private const COLUMNS_SORTABLE = [
iFace::COLUMN_ID,
iFace::COLUMN_TYPE,
iFace::COLUMN_UPDATED,
iFace::COLUMN_WATCHED,
iFace::COLUMN_VIA,
iFace::COLUMN_TITLE,
iFace::COLUMN_YEAR,
iFace::COLUMN_SEASON,
iFace::COLUMN_EPISODE,
];
private PDO $pdo;
public function __construct(private StorageInterface $storage)
{
$this->pdo = $this->storage->getPdo();
parent::__construct();
}
protected function configure(): void
{
$this->setName('db:list')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit results to this number', 20)
->addOption(
'via',
null,
InputOption::VALUE_REQUIRED,
'Limit results to this specified backend. This filter is not reliable. and changes based on last backend query.'
)
->addOption(
'type',
null,
InputOption::VALUE_REQUIRED,
'Limit results to this specified type can be [movie or episode].'
)
->addOption('title', null, InputOption::VALUE_REQUIRED, 'Limit results to this specified tv show.')
->addOption('season', null, InputOption::VALUE_REQUIRED, 'Select season number')
->addOption('episode', null, InputOption::VALUE_REQUIRED, 'Select episode number')
->addOption('year', null, InputOption::VALUE_REQUIRED, 'Select year.')
->addOption('id', null, InputOption::VALUE_REQUIRED, 'Select db record number')
->addOption(
'sort',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Set sort by columns. for example, <comment>--sort season:asc --sort episode:desc</comment>',
)
->addOption(
'metadata-as',
null,
InputOption::VALUE_REQUIRED,
'Display metadata from this backend instead of latest.'
)
->setDescription('List Database entries.');
foreach (array_keys(Guid::getSupported(includeVirtual: false)) as $guid) {
$guid = afterLast($guid, 'guid_');
$this->addOption(
$guid,
null,
InputOption::VALUE_REQUIRED,
'Search Using ' . ucfirst($guid) . ' external id.'
);
}
$this->addOption('parent', null, InputOption::VALUE_NONE, 'If set it will search parent external ids instead.')
->addOption('key', null, InputOption::VALUE_REQUIRED, 'For JSON Fields key selection.')
->addOption('value', null, InputOption::VALUE_REQUIRED, 'For JSON Fields value selection.')
->addOption(
'metadata',
null,
InputOption::VALUE_NONE,
'Search in (metadata) provided by backends JSON Field using (--key, --value) options.'
)
->addOption(
'extra',
null,
InputOption::VALUE_NONE,
'Search in (extra information) JSON Field using (--key, --value) options.'
)
->addOption(
'dump-query',
null,
InputOption::VALUE_NONE,
'Dump the generated query and exit.'
);
}
/**
* @throws Exception
*/
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$limit = (int)$input->getOption('limit');
$es = fn(string $val) => $this->storage->identifier($val);
$params = [
'limit' => $limit <= 0 ? 20 : $limit,
];
$sql = $where = [];
$sql[] = sprintf('SELECT * FROM %s', $es('state'));
if ($input->getOption('id')) {
$where[] = $es(iFace::COLUMN_ID) . ' = :id';
$params['id'] = $input->getOption('id');
}
if ($input->getOption('via')) {
$where[] = $es(iFace::COLUMN_VIA) . ' = :via';
$params['via'] = $input->getOption('via');
}
if ($input->getOption('year')) {
$where[] = $es(iFace::COLUMN_YEAR) . ' = :year';
$params['year'] = $input->getOption('year');
}
if ($input->getOption('type')) {
$where[] = $es(iFace::COLUMN_TYPE) . ' = :type';
$params['type'] = match ($input->getOption('type')) {
iFace::TYPE_MOVIE => iFace::TYPE_MOVIE,
default => iFace::TYPE_EPISODE,
};
}
if ($input->getOption('title')) {
$where[] = $es(iFace::COLUMN_TITLE) . ' LIKE "%" || :title || "%"';
$params['title'] = $input->getOption('title');
}
if (null !== $input->getOption('season')) {
$where[] = $es(iFace::COLUMN_SEASON) . ' = :season';
$params['season'] = $input->getOption('season');
}
if (null !== $input->getOption('episode')) {
$where[] = $es(iFace::COLUMN_EPISODE) . ' = :episode';
$params['episode'] = $input->getOption('episode');
}
if ($input->getOption('parent')) {
foreach (array_keys(Guid::getSupported(includeVirtual: false)) as $guid) {
if (null === ($val = $input->getOption(afterLast($guid, 'guid_')))) {
continue;
}
$where[] = "json_extract(" . iFace::COLUMN_PARENT . ",'$.{$guid}') = :{$guid}";
$params[$guid] = $val;
}
} else {
foreach (array_keys(Guid::getSupported(includeVirtual: false)) as $guid) {
if (null === ($val = $input->getOption(afterLast($guid, 'guid_')))) {
continue;
}
$where[] = "json_extract(" . iFace::COLUMN_GUIDS . ",'$.{$guid}') = :{$guid}";
$params[$guid] = $val;
}
}
if ($input->getOption('metadata')) {
$sField = $input->getOption('key');
$sValue = $input->getOption('value');
if (null === $sField || null === $sValue) {
throw new RuntimeException(
'When searching using JSON fields the option --key and --value must be set.'
);
}
$where[] = "json_extract(" . iFace::COLUMN_META_DATA . ",'$.{$sField}') = :jf_metadata_value";
$params['jf_metadata_value'] = $sValue;
}
if ($input->getOption('extra')) {
$sField = $input->getOption('key');
$sValue = $input->getOption('value');
if (null === $sField || null === $sValue) {
throw new RuntimeException(
'When searching using JSON fields the option --key and --value must be set.'
);
}
$where[] = "json_extract(" . iFace::COLUMN_EXTRA . ",'$.{$sField}') = :jf_extra_value";
$params['jf_extra_value'] = $sValue;
}
if (count($where) >= 1) {
$sql[] = 'WHERE ' . implode(' AND ', $where);
}
$sorts = [];
foreach ($input->getOption('sort') as $sort) {
if (1 !== preg_match('/(?P<field>\w+)(:(?P<dir>\w+))?/', $sort, $matches)) {
continue;
}
if (null === ($matches['field'] ?? null) || false === in_array($matches['field'], self::COLUMNS_SORTABLE)) {
continue;
}
$sorts[] = sprintf(
'%s %s',
$es($matches['field']),
match (strtolower($matches['dir'] ?? 'desc')) {
default => 'DESC',
'asc' => 'ASC',
}
);
}
if (count($sorts) < 1) {
$sorts[] = sprintf('%s DESC', $es('updated'));
}
$sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :limit';
$sql = implode(' ', $sql);
if ($input->getOption('dump-query')) {
$arr = [
'query' => $sql,
'parameters' => $params,
'raw' => $this->storage->getRawSQLString($sql, $params),
];
if ('table' === $input->getOption('output')) {
$arr['parameters'] = arrayToString($params);
unset($arr['raw']);
$arr = [$arr];
}
$this->displayContent($arr, $output, $input->getOption('output'));
return self::SUCCESS;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
$rowCount = count($rows);
if (0 === $rowCount) {
$arr = [
'Error' => 'No Results.',
'Filters' => $params
];
if (true === ($hasFilters = count($arr['Filters']) > 1)) {
$arr['Error'] .= ' Probably invalid filters values were used.';
}
if ($hasFilters && 'table' !== $input->getOption('output')) {
array_shift($arr['Filters']);
if ('json' === $input->getOption('output')) {
$output->writeln(
json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
} elseif ('yaml' === $input->getOption('output')) {
$output->writeln(Yaml::dump($arr, 8, 2));
}
} else {
$output->writeln('<error>' . $arr['Error'] . '</error>');
}
return self::FAILURE;
}
foreach ($rows as &$row) {
foreach (iFace::ENTITY_ARRAY_KEYS as $key) {
if (null === ($row[$key] ?? null)) {
continue;
}
$row[$key] = json_decode($row[$key], true);
}
if (null !== ($via = $input->getOption('metadata-as'))) {
$path = $row[iFace::COLUMN_META_DATA][$via] ?? [];
foreach (self::COLUMNS_CHANGEABLE as $column) {
if (null === ($path[$column] ?? null)) {
continue;
}
$row[$column] = 'int' === get_debug_type($row[$column]) ? (int)$path[$column] : $path[$column];
}
if (null !== ($dateFromBackend = $path[iFace::COLUMN_META_DATA_PLAYED_AT] ?? $path[iFace::COLUMN_META_DATA_ADDED_AT] ?? null)) {
$row[iFace::COLUMN_UPDATED] = $dateFromBackend;
}
}
$row[iFace::COLUMN_WATCHED] = (bool)$row[iFace::COLUMN_WATCHED];
$row[iFace::COLUMN_UPDATED] = makeDate($row[iFace::COLUMN_UPDATED]);
}
unset($row);
if ('table' === $input->getOption('output')) {
$list = [];
foreach ($rows as $row) {
$row[iFace::COLUMN_UPDATED] = $row[iFace::COLUMN_UPDATED]->getTimestamp();
$row[iFace::COLUMN_WATCHED] = (int)$row[iFace::COLUMN_WATCHED];
$entity = Container::get(iFace::class)->fromArray($row);
$item = [
'id' => $entity->id,
'Type' => ucfirst($entity->type),
'Title' => $entity->getName(),
'Via (Last)' => $entity->via ?? '??',
'Date' => makeDate($entity->updated)->format('Y-m-d H:i:s T'),
'Played' => $entity->isWatched() ? 'Yes' : 'No',
'Via (Event)' => ag($entity->extra[$entity->via] ?? [], iFace::COLUMN_EXTRA_EVENT, '-'),
];
$list[] = $item;
$list[] = new TableSeparator();
}
$rows = null;
if (count($list) >= 2) {
array_pop($list);
}
(new Table($output))->setHeaders(array_keys($list[0] ?? []))->setStyle('box')->setRows($list)->render();
} else {
$this->displayContent($rows, $output, $input->getOption('output'));
}
return self::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
if ($input->mustSuggestOptionValuesFor('via') || $input->mustSuggestOptionValuesFor('metadata-as')) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach (array_keys(Config::get('servers', [])) as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
if ($input->mustSuggestOptionValuesFor('type')) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ([iFace::TYPE_MOVIE, iFace::TYPE_EPISODE] as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
if ($input->mustSuggestOptionValuesFor('sort')) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach (self::COLUMNS_SORTABLE as $name) {
foreach ([$name . ':desc', $name . ':asc'] as $subName) {
if (empty($currentValue) || true === str_starts_with($subName, $currentValue)) {
$suggest[] = $subName;
}
}
}
$suggestions->suggestValues($suggest);
}
}
}