diff --git a/src/Backends/Common/CommonTrait.php b/src/Backends/Common/CommonTrait.php index 0bc49ff2..a178d614 100644 --- a/src/Backends/Common/CommonTrait.php +++ b/src/Backends/Common/CommonTrait.php @@ -18,7 +18,13 @@ trait CommonTrait protected function tryResponse(Context $context, callable $fn, string|null $action = null): Response { try { - return $fn(); + $response = $fn(); + + if (false === ($response instanceof Response)) { + return new Response(status: true, response: $response); + } + + return $response; } catch (\Throwable $e) { return new Response( status: false, diff --git a/src/Backends/Common/Context.php b/src/Backends/Common/Context.php index e020646f..801a4250 100644 --- a/src/Backends/Common/Context.php +++ b/src/Backends/Common/Context.php @@ -8,6 +8,20 @@ use Psr\Http\Message\UriInterface; class Context { + /** + * Make Context for classes to work with. + * + * @param string $clientName Backend Client Name + * @param string $backendName Backend Name + * @param UriInterface $backendUrl Backend Url + * @param Cache $cache A Global Cache for backend. + * @param string|int|null $backendId Backend Id. + * @param string|int|null $backendToken Backend access token + * @param string|int|null $backendUser Backend user id. + * @param array $backendHeaders Headers to pass for backend. + * @param bool $trace Enable debug tracing mode. + * @param array $options optional options. + */ public function __construct( public readonly string $clientName, public readonly string $backendName, diff --git a/src/Backends/Emby/Action/GetUsersList.php b/src/Backends/Emby/Action/GetUsersList.php new file mode 100644 index 00000000..08ea763a --- /dev/null +++ b/src/Backends/Emby/Action/GetUsersList.php @@ -0,0 +1,9 @@ +tryResponse( context: $context, - fn: fn() => $this->parse($context, $guid, $request, $opts), + fn: fn() => $this->parse($context, $guid, $request), ); } @@ -95,16 +96,26 @@ final class ParseWebhook } try { - $lastPlayedAt = null; - if ('item.markplayed' === $event || 'playback.scrobble' === $event) { - $lastPlayedAt = time(); $isPlayed = 1; + $lastPlayedAt = time(); } elseif ('item.markunplayed' === $event) { $isPlayed = 0; + $lastPlayedAt = makeDate(ag($json, 'Item.DateCreated'))->getTimestamp(); } else { - $isPlayed = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], false); + $isPlayed = (int)(bool)ag( + $json, + [ + 'Item.Played', + 'Item.PlayedToCompletion', + 'PlaybackInfo.PlayedToCompletion', + ], + false + ); + + $lastPlayedAt = (0 === $isPlayed) ? makeDate(ag($json, 'Item.DateCreated'))->getTimestamp() : time(); } + $fields = [ iFace::COLUMN_EXTRA => [ $context->backendName => [ @@ -114,13 +125,13 @@ final class ParseWebhook ], ]; - if (null !== $lastPlayedAt && 1 === $isPlayed) { + if (false === in_array($event, self::WEBHOOK_TAINTED_EVENTS)) { $fields += [ - iFace::COLUMN_UPDATED => $lastPlayedAt, iFace::COLUMN_WATCHED => $isPlayed, + iFace::COLUMN_UPDATED => $lastPlayedAt, iFace::COLUMN_META_DATA => [ $context->backendName => [ - iFace::COLUMN_WATCHED => (string)(int)(bool)$isPlayed, + iFace::COLUMN_WATCHED => (string)$isPlayed, iFace::COLUMN_META_DATA_PLAYED_AT => (string)$lastPlayedAt, ] ], diff --git a/src/Backends/Jellyfin/Action/GetUsersList.php b/src/Backends/Jellyfin/Action/GetUsersList.php new file mode 100644 index 00000000..4be0e0a0 --- /dev/null +++ b/src/Backends/Jellyfin/Action/GetUsersList.php @@ -0,0 +1,111 @@ +tryResponse(context: $context, fn: fn() => $this->getUsers($context, $opts)); + } + + /** + * Get Users list. + * + * @throws ExceptionInterface + * @throws JsonException + */ + private function getUsers(Context $context, array $opts = []): Response + { + $url = $context->backendUrl->withPath('/Users/'); + + $this->logger->debug('Requesting [%(backend)] Users list.', [ + 'backend' => $context->backendName, + 'url' => (string)$url, + ]); + + $response = $this->http->request('GET', (string)$url, $context->backendHeaders); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] users list returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ], + level: Levels::ERROR + ), + ); + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if ($context->trace) { + $this->logger->debug('Parsing [%(backend)] user list payload.', [ + 'backend' => $context->backendName, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + $list = []; + + foreach ($json ?? [] as $user) { + $date = ag($user, ['LastActivityDate', 'LastLoginDate'], null); + + $data = [ + 'id' => ag($user, 'Id'), + 'name' => ag($user, 'Name'), + 'admin' => (bool)ag($user, 'Policy.IsAdministrator'), + 'Hidden' => (bool)ag($user, 'Policy.IsHidden'), + 'disabled' => (bool)ag($user, 'Policy.IsDisabled'), + 'updatedAt' => null !== $date ? makeDate($date) : 'Never', + ]; + + if (true === (bool)ag($opts, 'tokens')) { + $data['token'] = $context->backendToken; + } + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $data['raw'] = $user; + } + + $list[] = $data; + } + + return new Response(status: true, response: $list); + } +} diff --git a/src/Backends/Plex/Action/GetUsersList.php b/src/Backends/Plex/Action/GetUsersList.php new file mode 100644 index 00000000..de498dfd --- /dev/null +++ b/src/Backends/Plex/Action/GetUsersList.php @@ -0,0 +1,291 @@ +tryResponse(context: $context, fn: fn() => $this->getUsers($context, $opts)); + } + + /** + * Get Users list. + * + * @throws ExceptionInterface + * @throws JsonException + */ + private function getUsers(Context $context, array $opts = []): Response + { + $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') + ->withPath('/api/v2/home/users/'); + + $response = $this->http->request('GET', (string)$url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-Plex-Token' => $context->backendToken, + 'X-Plex-Client-Identifier' => $context->backendId, + ], + ]); + + $this->logger->debug('Requesting [%(backend)] Users list.', [ + 'backend' => $context->backendName, + 'url' => (string)$url, + ]); + + if (200 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] users list returned with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'status_code' => $response->getStatusCode(), + ], + level: Levels::ERROR + ), + ); + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if ($context->trace) { + $this->logger->debug('Parsing [%(backend)] user list payload.', [ + 'backend' => $context->backendName, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + $list = []; + + $adminsCount = 0; + + $users = ag($json, 'users', []); + + foreach ($users as $user) { + if (true === (bool)ag($user, 'admin')) { + $adminsCount++; + } + } + + foreach ($users as $user) { + $data = [ + 'id' => ag($user, 'admin') && $adminsCount <= 1 ? 1 : ag($user, 'id'), + 'name' => ag($user, ['friendlyName', 'username', 'title', 'email'], '??'), + 'admin' => (bool)ag($user, 'admin'), + 'guest' => (bool)ag($user, 'guest'), + 'restricted' => (bool)ag($user, 'restricted'), + 'updatedAt' => isset($user['updatedAt']) ? makeDate($user['updatedAt']) : 'Never', + ]; + + if (true === (bool)ag($opts, 'tokens')) { + $tokenRequest = $this->getUserToken( + context: $context, + userId: ag($user, 'uuid'), + username: ag($data, 'name'), + ); + + if ($tokenRequest->hasError()) { + $this->logger->log( + $tokenRequest->error->level(), + $tokenRequest->error->message, + $tokenRequest->error->context + ); + } + + $data['token'] = $tokenRequest->isSuccessful() ? $tokenRequest->response : 'Not found'; + } + + if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { + $data['raw'] = $user; + } + + $list[] = $data; + } + + return new Response(status: true, response: $list); + } + + /** + * Request tokens from plex.tv api. + * + * @param Context $context + * @param int|string $userId + * @param string $username + * + * @return Response + */ + private function getUserToken(Context $context, int|string $userId, string $username): Response + { + try { + $url = Container::getNew(UriInterface::class) + ->withPort(443)->withScheme('https')->withHost('plex.tv') + ->withPath(sprintf('/api/v2/home/users/%s/switch', $userId)); + + $this->logger->debug('Requesting temporary access token for [%(backend)] user [%(username)].', [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'url' => (string)$url, + ]); + + $response = $this->http->request('POST', (string)$url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-Plex-Token' => $context->backendToken, + 'X-Plex-Client-Identifier' => $context->backendId, + ], + ]); + + if (201 !== $response->getStatusCode()) { + return new Response( + status: false, + error: new Error( + message: 'Request for [%(backend)] user [%(username)] temporary access token responded with unexpected [%(status_code)] status code.', + context: [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'status_code' => $response->getStatusCode(), + ], + level: Levels::ERROR + ), + ); + } + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if ($context->trace) { + $this->logger->debug('Parsing temporary access token for [%(backend)] user [%(username)] payload.', [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + $tempToken = ag($json, 'authToken', null); + + $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') + ->withPath('/api/v2/resources')->withQuery( + http_build_query( + [ + 'includeIPv6' => 1, + 'includeHttps' => 1, + 'includeRelay' => 1 + ] + ) + ); + + $this->logger->debug('Requesting permanent access token for [%(backend)] user [%(username)].', [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'url' => (string)$url, + ]); + + $response = $this->http->request('GET', (string)$url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'X-Plex-Token' => $tempToken, + 'X-Plex-Client-Identifier' => $context->backendId, + ], + ]); + + $json = json_decode( + json: $response->getContent(), + associative: true, + flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE + ); + + if ($context->trace) { + $this->logger->debug('Parsing permanent access token for [%(backend)] user [%(username)] payload.', [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'url' => (string)$url, + 'trace' => $json, + ]); + } + + foreach ($json ?? [] as $server) { + if (ag($server, 'clientIdentifier') !== $context->backendId) { + continue; + } + return new Response(status: true, response: ag($server, 'accessToken')); + } + + return new Response( + status: false, + error: new Error( + message: 'No permanent access token found in [%(backend)] user [%(username)] response.', + context: [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + ], + level: Levels::ERROR + ), + ); + } catch (Throwable $e) { + return new Response( + status: false, + error: new Error( + message: 'Unhandled exception was thrown during request for [%(backend)] [%(username)] access token.', + context: [ + 'backend' => $context->backendName, + 'username' => $username, + 'user_id' => $userId, + 'exception' => [ + 'file' => after($e->getFile(), ROOT_PATH), + 'line' => $e->getLine(), + 'kind' => get_class($e), + 'message' => $e->getMessage(), + ], + ], + level: Levels::ERROR + ), + ); + } + } +} diff --git a/src/Commands/Backend/Users/ListCommand.php b/src/Commands/Backend/Users/ListCommand.php index fd34d058..cd8d183c 100644 --- a/src/Commands/Backend/Users/ListCommand.php +++ b/src/Commands/Backend/Users/ListCommand.php @@ -46,7 +46,7 @@ final class ListCommand extends Command } try { - $opts = []; + $opts = $backendOpts = []; if ($input->getOption('with-tokens')) { $opts['tokens'] = true; @@ -56,7 +56,11 @@ final class ListCommand extends Command $opts[Options::RAW_RESPONSE] = true; } - $libraries = $this->getBackend($backend)->getUsersList(opts: $opts); + if ($input->getOption('trace')) { + $backendOpts = ag_set($opts, 'options.' . Options::DEBUG_TRACE, true); + } + + $libraries = $this->getBackend($backend, $backendOpts)->getUsersList(opts: $opts); if (count($libraries) < 1) { $arr = [ diff --git a/src/Libs/Extends/ConsoleHandler.php b/src/Libs/Extends/ConsoleHandler.php index c68ce9e4..ec58bd9a 100644 --- a/src/Libs/Extends/ConsoleHandler.php +++ b/src/Libs/Extends/ConsoleHandler.php @@ -6,6 +6,7 @@ use App\Libs\Config; use DateTimeInterface; use Monolog\Handler\AbstractProcessingHandler; use Monolog\Logger; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; class ConsoleHandler extends AbstractProcessingHandler @@ -70,7 +71,9 @@ class ConsoleHandler extends AbstractProcessingHandler $message .= ' { ' . arrayToString($record['context']) . ' }'; } - $this->output->writeln($message, $this->output->getVerbosity()); + $errOutput = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + + $errOutput?->writeln($message, $this->output->getVerbosity()); } /** diff --git a/src/Libs/Initializer.php b/src/Libs/Initializer.php index 7f5fc82b..9c5a50d8 100644 --- a/src/Libs/Initializer.php +++ b/src/Libs/Initializer.php @@ -386,7 +386,7 @@ final class Initializer status: 200, body: $local->getAll(), headers: $responseHeaders + [ - 'X-Status' => sprintf('%s Marked as unplayed.', ucfirst($entity->type)) + 'X-Status' => sprintf('%s Marked as [Unplayed].', ucfirst($entity->type)) ] ); } diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index d31c1a18..a37497ab 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -6,6 +6,7 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; +use App\Backends\Jellyfin\Action\GetUsersList; use App\Backends\Jellyfin\Action\InspectRequest; use App\Backends\Jellyfin\Action\GetIdentifier; use App\Backends\Jellyfin\Action\ParseWebhook; @@ -144,54 +145,19 @@ class JellyfinServer implements ServerInterface public function getUsersList(array $opts = []): array { - $this->checkConfig(checkUser: false); + $response = Container::get(GetUsersList::class)($this->context, $opts); - $url = $this->url->withPath('/Users/'); + if (false === $response->isSuccessful()) { + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } - $response = $this->http->request('GET', (string)$url, $this->getHeaders()); - - if (200 !== $response->getStatusCode()) { throw new RuntimeException( - sprintf( - 'Request for [%s] users list returned with unexpected [%s] status code.', - $this->context->backendName, - $response->getStatusCode(), - ) + ag($response->extra, 'message', fn() => $response->error->format()) ); } - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $list = []; - - foreach ($json ?? [] as $user) { - $date = ag($user, ['LastActivityDate', 'LastLoginDate'], null); - - $data = [ - 'id' => ag($user, 'Id'), - 'name' => ag($user, 'Name'), - 'admin' => (bool)ag($user, 'Policy.IsAdministrator'), - 'Hidden' => (bool)ag($user, 'Policy.IsHidden'), - 'disabled' => (bool)ag($user, 'Policy.IsDisabled'), - 'updatedAt' => null !== $date ? makeDate($date) : 'Never', - ]; - - if (true === ag($opts, 'tokens', false)) { - $data['token'] = $this->token; - } - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $data['raw'] = $user; - } - - $list[] = $data; - } - - return $list; + return $response->response; } public function getPersist(): array diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 78efe5e0..ccd6e936 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -7,6 +7,7 @@ namespace App\Libs\Servers; use App\Backends\Common\Cache; use App\Backends\Common\Context; use App\Backends\Plex\Action\GetIdentifier; +use App\Backends\Plex\Action\GetUsersList; use App\Backends\Plex\Action\InspectRequest; use App\Backends\Plex\Action\ParseWebhook; use App\Backends\Plex\PlexActionTrait; @@ -115,71 +116,19 @@ class PlexServer implements ServerInterface public function getUsersList(array $opts = []): array { - $this->checkConfig(checkUrl: false); + $response = Container::get(GetUsersList::class)($this->context, $opts); - $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') - ->withPath('/api/v2/home/users/'); + if (false === $response->isSuccessful()) { + if ($response->hasError()) { + $this->logger->log($response->error->level(), $response->error->message, $response->error->context); + } - $response = $this->http->request('GET', (string)$url, [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-Plex-Token' => $this->token, - 'X-Plex-Client-Identifier' => $this->getServerUUID(), - ], - ]); - - if (200 !== $response->getStatusCode()) { throw new RuntimeException( - sprintf( - 'Request for [%s] users list returned with unexpected [%s] status code.', - $this->getName(), - $response->getStatusCode(), - ) + ag($response->extra, 'message', fn() => $response->error->format()) ); } - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $list = []; - - $adminsCount = 0; - - $users = ag($json, 'users', []); - - foreach ($users as $user) { - if (true === (bool)ag($user, 'admin')) { - $adminsCount++; - } - } - - foreach ($users as $user) { - $data = [ - 'id' => ag($user, 'admin') && $adminsCount <= 1 ? 1 : ag($user, 'id'), - 'name' => $user['username'] ?? $user['title'] ?? $user['friendlyName'] ?? $user['email'] ?? '??', - 'admin' => (bool)ag($user, 'admin'), - 'guest' => (bool)ag($user, 'guest'), - 'restricted' => (bool)ag($user, 'restricted'), - 'updatedAt' => isset($user['updatedAt']) ? makeDate($user['updatedAt']) : 'Never', - ]; - - if (true === ($opts['tokens'] ?? false)) { - $data['token'] = $this->getUserToken($user['uuid']); - } - - if (true === (bool)ag($opts, Options::RAW_RESPONSE)) { - $data['raw'] = $user; - } - - $list[] = $data; - } - - unset($json, $users); - - return $list; + return $response->response; } public function getPersist(): array @@ -1978,105 +1927,4 @@ class PlexServer implements ServerInterface throw new RuntimeException(static::NAME . ': No token was set.'); } } - - protected function getUserToken(int|string $userId): int|string|null - { - try { - $uuid = $this->getServerUUID(); - - $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost( - 'plex.tv' - )->withPath(sprintf('/api/v2/home/users/%s/switch', $userId)); - - $this->logger->debug('Requesting temporary token for [%(backend)] user id [%(user_id)].', [ - 'backend' => $this->getName(), - 'user_id' => $userId, - 'url' => (string)$url, - ]); - - $response = $this->http->request('POST', (string)$url, [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-Plex-Token' => $this->token, - 'X-Plex-Client-Identifier' => $uuid, - ], - ]); - - if (201 !== $response->getStatusCode()) { - $this->logger->error( - 'Request for [%(backend)] [%(user_id)] temporary token responded with unexpected [%(status_code)] status code.', - [ - 'backend' => $this->getName(), - 'user_id' => $userId, - 'status_code' => $response->getStatusCode(), - ] - ); - return null; - } - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - $tempToken = ag($json, 'authToken', null); - - $url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv') - ->withPath('/api/v2/resources')->withQuery( - http_build_query( - [ - 'includeIPv6' => 1, - 'includeHttps' => 1, - 'includeRelay' => 1 - ] - ) - ); - - $this->logger->debug('Requesting permanent token for [%(backend)] user id [%(user_id)].', [ - 'backend' => $this->getName(), - 'user_id' => $userId, - 'url' => (string)$url, - ]); - - $response = $this->http->request('GET', (string)$url, [ - 'headers' => [ - 'Accept' => 'application/json', - 'X-Plex-Token' => $tempToken, - 'X-Plex-Client-Identifier' => $uuid, - ], - ]); - - $json = json_decode( - json: $response->getContent(), - associative: true, - flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE - ); - - foreach ($json ?? [] as $server) { - if (ag($server, 'clientIdentifier') !== $uuid) { - continue; - } - return ag($server, 'accessToken'); - } - - return null; - } catch (Throwable $e) { - $this->logger->error( - 'Unhandled exception was thrown during request for [%(backend)] [%(user_id)] access token.', - [ - 'backend' => $this->getName(), - 'user_id' => $userId, - 'exception' => [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'kind' => get_class($e), - 'message' => $e->getMessage(), - ], - ] - ); - - return null; - } - } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 9a58c1ff..7f7c8bd2 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -93,7 +93,7 @@ if (!function_exists('ag')) { return getValue($default); } - if (array_key_exists($path, $array)) { + if (null !== ($array[$path] ?? null)) { return $array[$path]; }