From a9552a4bb696fda242395668c29c7b4f2e79bae3 Mon Sep 17 00:00:00 2001 From: abdulmohsen Date: Wed, 17 Jan 2024 18:16:12 +0300 Subject: [PATCH] Added GetSessions for Emby & Jellyfin. --- src/Backends/Emby/Action/GetSessions.php | 10 ++ src/Backends/Emby/EmbyClient.php | 19 +++ src/Backends/Jellyfin/Action/GetSessions.php | 151 ++++++++++++++++++ src/Backends/Jellyfin/JellyfinClient.php | 19 +++ src/Backends/Plex/Action/GetSessions.php | 9 +- src/Command.php | 5 +- .../Backend/Users/SessionsCommand.php | 87 +++++++--- 7 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 src/Backends/Emby/Action/GetSessions.php create mode 100644 src/Backends/Jellyfin/Action/GetSessions.php diff --git a/src/Backends/Emby/Action/GetSessions.php b/src/Backends/Emby/Action/GetSessions.php new file mode 100644 index 00000000..aefad197 --- /dev/null +++ b/src/Backends/Emby/Action/GetSessions.php @@ -0,0 +1,10 @@ +response; } + /** + * @inheritdoc + */ + public function getSessions(array $opts = []): array + { + $response = Container::get(GetSessions::class)($this->context, $opts); + + if (false === $response->isSuccessful()) { + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + + $this->throwError($response); + } + + return $response->response; + } + /** * For Emby we do not generate api access token, thus we simply return * the given the access token. diff --git a/src/Backends/Jellyfin/Action/GetSessions.php b/src/Backends/Jellyfin/Action/GetSessions.php new file mode 100644 index 00000000..99210006 --- /dev/null +++ b/src/Backends/Jellyfin/Action/GetSessions.php @@ -0,0 +1,151 @@ +tryResponse( + context: $context, + fn: function () use ($context, $opts) { + $url = $context->backendUrl->withPath('/Sessions'); + + $this->logger->debug('Requesting [{client}: {backend}] play sessions.', [ + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'url' => $url + ]); + + $response = $this->http->request( + 'GET', + (string)$url, + array_replace_recursive($context->backendHeaders, $opts['headers'] ?? []) + ); + + $content = $response->getContent(false); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [{backend}] {action} returned with unexpected [{status_code}] status code.', + context: [ + 'action' => $this->action, + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + 'url' => (string)$url, + 'response' => $content, + ], + level: Levels::WARNING + ) + ); + } + + if (empty($content)) { + return new Response( + status: false, + error: new Error( + message: 'Request for [{backend}] {action} returned with empty response. Please make sure the container can communicate with the backend.', + context: [ + 'action' => $this->action, + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'url' => (string)$url, + 'response' => $content, + ], + level: Levels::ERROR + ) + ); + } + + $items = json_decode( + json: $content, + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if (true === $context->trace) { + $this->logger->debug('Processing [{client}: {backend}] {action} payload.', [ + 'action' => $this->action, + 'client' => $context->clientName, + 'backend' => $context->backendName, + 'trace' => $items, + ]); + } + + $filtered = []; + + foreach ($items as $item) { + if (null === ag($item, 'NowPlayingItem')) { + continue; + } + + $filtered[] = $item; + } + + $ret = [ + 'sessions' => [], + ]; + + foreach ($filtered as $item) { + $ret['sessions'][] = [ + 'user_uid' => ag($item, 'UserId'), + 'user_name' => ag($item, 'UserName'), + 'item_id' => ag($item, 'NowPlayingItem.Id'), + 'item_title' => ag($item, 'NowPlayingItem.Name'), + 'item_type' => ag($item, 'NowPlayingItem.Type'), + 'item_offset_at' => ag($item, 'PlayState.PositionTicks') / 1_00_00, + 'session_state' => (bool)ag($item, 'PlayState.IsPaused', false) === true ? 'paused' : 'playing', + 'session_updated_at' => makeDate(ag($item, 'LastActivityDate')), + 'session_id' => ag($item, 'Id'), + ]; + } + + if (true === ag_exists($opts, Options::RAW_RESPONSE)) { + $ret[Options::RAW_RESPONSE] = $items; + } + + return new Response(status: true, response: $ret); + }, + action: $this->action, + ); + } +} diff --git a/src/Backends/Jellyfin/JellyfinClient.php b/src/Backends/Jellyfin/JellyfinClient.php index 4bdbdc37..b4eda639 100644 --- a/src/Backends/Jellyfin/JellyfinClient.php +++ b/src/Backends/Jellyfin/JellyfinClient.php @@ -18,6 +18,7 @@ use App\Backends\Jellyfin\Action\GetInfo; use App\Backends\Jellyfin\Action\GetLibrariesList; use App\Backends\Jellyfin\Action\GetLibrary; use App\Backends\Jellyfin\Action\GetMetaData; +use App\Backends\Jellyfin\Action\GetSessions; use App\Backends\Jellyfin\Action\GetUsersList; use App\Backends\Jellyfin\Action\GetVersion; use App\Backends\Jellyfin\Action\Import; @@ -488,6 +489,24 @@ class JellyfinClient implements iClient return $response->response; } + /** + * @inheritdoc + */ + public function getSessions(array $opts = []): array + { + $response = Container::get(GetSessions::class)($this->context, $opts); + + if (false === $response->isSuccessful()) { + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } + + $this->throwError($response); + } + + return $response->response; + } + /** * @inheritdoc */ diff --git a/src/Backends/Plex/Action/GetSessions.php b/src/Backends/Plex/Action/GetSessions.php index 96b47147..7f08a2ae 100644 --- a/src/Backends/Plex/Action/GetSessions.php +++ b/src/Backends/Plex/Action/GetSessions.php @@ -121,8 +121,9 @@ final class GetSessions $matches ) ? $matches[1] : null; - $ret['sessions'][] = [ + $item = [ 'user_uid' => (int)ag($session, 'User.id'), + 'user_name' => ag($session, 'User.title'), 'user_uuid' => $uuid, 'item_id' => (int)ag($session, 'ratingKey'), 'item_title' => ag($session, 'title'), @@ -131,6 +132,12 @@ final class GetSessions 'session_state' => ag($session, 'Player.state'), 'session_id' => ag($session, 'Session.id', ag($session, 'sessionKey')), ]; + + if ('playing' === $item['session_state']) { + $item['session_updated_at'] = makeDate(date: time()); + } + + $ret['sessions'][] = $item; } return new Response(status: true, response: $ret); diff --git a/src/Command.php b/src/Command.php index f8b68f4a..cb8c03f3 100644 --- a/src/Command.php +++ b/src/Command.php @@ -297,7 +297,10 @@ class Command extends BaseCommand $suggestions->suggestValues($suggest); } - if ($input->mustSuggestOptionValuesFor('select-backends') || $input->mustSuggestArgumentValuesFor('backend')) { + if ( + $input->mustSuggestOptionValuesFor('select-backends') || + $input->mustSuggestOptionValuesFor('select-backend') || + $input->mustSuggestArgumentValuesFor('backend')) { $currentValue = $input->getCompletionValue(); $suggest = []; diff --git a/src/Commands/Backend/Users/SessionsCommand.php b/src/Commands/Backend/Users/SessionsCommand.php index fc19ba65..bf9d244f 100644 --- a/src/Commands/Backend/Users/SessionsCommand.php +++ b/src/Commands/Backend/Users/SessionsCommand.php @@ -10,15 +10,14 @@ use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Entity\StateInterface as iState; use App\Libs\Options; use App\Libs\Routable; +use DateTimeInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Yaml\Yaml; -use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; /** - * Class ListCommand - * + * Get backend active sessions. */ #[Routable(command: self::ROUTE)] final class SessionsCommand extends Command @@ -26,15 +25,12 @@ final class SessionsCommand extends Command public const ROUTE = 'backend:users:sessions'; private const REMAP_FIELDS = [ - 'user_uid' => 'User ID', - 'user_uuid' => 'User UUID', - 'item_id' => 'Item ID', + 'user_name' => 'User', 'item_title' => 'Title', 'item_type' => 'Type', 'item_offset_at' => 'Progress', - 'session_id' => 'Session ID', - 'session_updated_at' => 'Session Activity', - 'session_state' => 'Play State', + 'session_updated_at' => 'Last Activity', + 'session_state' => 'State', ]; public function __construct(private iDB $db) @@ -51,7 +47,7 @@ final class SessionsCommand extends Command ->setDescription('Get backend active sessions.') ->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.') - ->addOption('select-backends', 's', InputOption::VALUE_REQUIRED, 'Select backends.') + ->addOption('select-backend', 's', InputOption::VALUE_REQUIRED, 'Select backend.') ->setHelp( r( <<getOption('output'); - $backend = $input->getOption('select-backends'); - - if (null === $backend) { - $output->writeln(r('ERROR: Backend not specified. Please use [-s, --select-backends].')); - return self::FAILURE; - } - - $backend = explode(',', $backend, 1)[0]; - // -- Use Custom servers.yaml file. if (($config = $input->getOption('config'))) { try { @@ -107,6 +91,15 @@ final class SessionsCommand extends Command } } + $mode = $input->getOption('output'); + + if (null === ($backend = $input->getOption('select-backend'))) { + $output->writeln(r('ERROR: Backend not specified. Please use [-s, --select-backends].')); + return self::FAILURE; + } + + $backend = explode(',', $backend, 2)[0]; + if (null === ag(Config::get('servers', []), $backend, null)) { $output->writeln(r("ERROR: Backend '{backend}' not found.", ['backend' => $backend])); return self::FAILURE; @@ -151,7 +144,15 @@ final class SessionsCommand extends Command $i = []; foreach ($item as $key => $val) { - $i[self::REMAP_FIELDS[$key] ?? $key] = $val; + if (!array_key_exists($key, self::REMAP_FIELDS)) { + continue; + } + + if ('session_updated_at' === $key) { + $val = $this->format_date($val); + } + + $i[self::REMAP_FIELDS[$key]] = $val; } $items[] = $i; @@ -165,4 +166,44 @@ final class SessionsCommand extends Command return self::SUCCESS; } + private function format_date(DateTimeInterface $date): string + { + $seconds = time() - $date->getTimestamp(); + + if ($seconds < 1) { + return '0s'; + } + + $string = ""; + + $years = (int)($seconds / 31536000); + $months = (int)($seconds / 2678400); + $days = (int)($seconds / (3600 * 24)); + $hours = (int)($seconds / 3600) % 24; + $minutes = (int)($seconds / 60) % 60; + $seconds = $seconds % 60; + + if ($years > 0) { + $string .= "{$days}y "; + } + + if ($months > 0) { + $string .= "{$days}d "; + } + + if ($hours > 0) { + $string .= "{$hours}h "; + } + + if ($days < 1 && $minutes > 0) { + $string .= "{$minutes}m "; + } + + if ($minutes < 1 && $seconds > 0) { + $string .= "{$seconds}s"; + } + + return $string; + } + }