Cleaned up backend code, and seperated and removed servers:remote command.

This commit is contained in:
Abdulmhsen B. A. A
2022-06-01 06:12:01 +03:00
parent 6793abdcc7
commit 76c0fdc52d
19 changed files with 1482 additions and 1165 deletions

219
FAQ.md
View File

@@ -2,9 +2,7 @@
### Q: How to update play state for newly added media backend without overwriting my current play state?
First, add the media backend and when asked, answer `n` for allow import from this server. when you finish, run the
following
command:
Add the backend and when asked, answer `no` for allow import. when you finish, then run the following command:
```bash
$ docker exec -ti watchstate console state:export -vv --ignore-date --force-full --servers-filter [SERVER_NAME]
@@ -19,17 +17,17 @@ successful you can then enable the import feature if you want.
No, The tool is designed to work for single user. However, It's possible to run container for each user.
Note: for Plex managed users run the following command to extract each managed user token.
Note: for Plex managed users run the following command to extract each managed user access token.
```bash
$ docker exec -ti console servers:remote --list-users-with-tokens -- my_plex_1
$ docker exec -ti console backend:users:list --with-tokens -- [BACKEND_NAME]
```
For jellyfin/emby, you can use same api-token and just replace the userId.
For Jellyfin/Emby, you can use same api token and just replace the user id.
---
### Q: Sometimes newly added episodes or movies don't make it to webhook server?
### Q: Sometimes newly added episodes or movies don't make it to webhook endpoint?
As stated in webhook limitation section sometimes media backends don't make it easy to receive those events, as such, to
complement webhooks, its good idea enable the scheduled tasks of import/export and let them run once in a while to
@@ -37,12 +35,32 @@ remap the data.
----
### Q: How to get library id?
Run the following command to get list of backend libraries.
```bash
$ docker exec -ti watchstate console backend:library:list [SERVER_NAME]
```
it should display something like
| Id | Title | Type | Ignored | Supported |
|-----|-------------|--------|---------|-----------|
| 2 | Movies | movie | No | Yes |
| 1 | shows | show | No | Yes |
| 17 | Audio Books | artist | Yes | No |
The id column refers to backend side id.
---
### Q: Can this tool run without docker?
Yes, if you have the required PHP version and the needed extensions. to run this tool you need the following `php8.1`,
and `php8.1-fpm` and the following extensions `php8.1-pdo`, `php8.1-mbstring`, `php8.1-ctype`, `php8.1-curl`,
`php8.1-sqlite3` and [composer](https://getcomposer.org/). once you have the required runtime dependencies, for first
time run:
`php8.1-fpm` and `redis-server` and the following extensions `php8.1-pdo`, `php8.1-mbstring`, `php8.1-ctype`
, `php8.1-curl`,`php8.1-sqlite3`, `php8.1-redis`, and [composer](https://getcomposer.org/). once you have the required
runtime dependencies, for first time run:
```bash
cd ~/watchstate
@@ -58,7 +76,7 @@ $ php console
The app should save your data into `./var` directory. If you want to change the directory you can export the environment
variable `WS_DATA_PATH` for console and browser. you can add a file called `.env` in main tool directory with the
environment variables. take look at the files inside `docker/files` directory to know how to run the scheduled tasks and
ofc if you want a webhook support you would need a frontend proxy for `php8.1-fpm` like nginx, caddy or apache.
if you want a webhook support you would need a frontend proxy for `php8.1-fpm` like nginx, caddy or apache.
---
@@ -87,23 +105,6 @@ of the following conditions has to be met:
---
### Q: I enabled strict user match to allow only my user to update the play state, and now webhook requests are failing to be processed?
#### For Jellyfin backend
If this relates to jellyfin backend, then please make sure you have selected "Send All Properties (ignores template)".
#### For Plex backend
if your account is the admin account then update the `user` to `1` by running the following command, just change
the `[SERVER_NAME]` to your server config name.
```bash
$ docker exec -ti watchstate console servers:edit --key user --set 1 -- [SERVER_NAME]
```
---
### Q: Does this tool require webhooks to work?
No, You can use the task scheduler or on demand sync if you want. However, we recommend the webhook method as it's the
@@ -118,14 +119,7 @@ Just reload the page make sure there is only one added watchstate endpoint.
---
### Q: I keep on seeing "..., entity state is tainted." what does that means?
Tainted events are events that are not used to update the play state, but are interesting enough for us to keep around
for other benefits like updating the external ids mapping for movies/episodes. It's normal do not worry about it.
---
### Q: How can I see my play state list?
### Q: How to see my data?
```bash
$ docker exec -ti watchstate console db:list
@@ -136,22 +130,16 @@ can run the same command with `[-h, --help]` to see more options to extend the l
---
### Q: Can I ignore specific libraries from being processed?
### Q: How to ignore specific libraries from being processed?
Yes, First run the following command
```bash
$ docker exec -ti watchstate console backend:library:list -- [SERVER_NAME]
```
it should show you list of given server libraries, you are mainly interested in the ID column. take note of the library
id, after that run the following command to ignore the libraries. The `options.ignore` accepts comma seperated list of
ids to ignore.
Run the following command:
```bash
$ docker exec -ti watchstate console servers:edit --key options.ignore --set 'id1,id2,id3' -- [SERVER_NAME]
```
where `id1,id2,id3` refers to backend library id
If you ignored a library by mistake you can run the same command again and omit the id, or you can just delete the key
entirely by running the following command
@@ -159,6 +147,11 @@ entirely by running the following command
$ docker exec -ti watchstate console servers:edit --delete --key options.ignore -- [SERVER_NAME]
```
##### Notice
While this feature works for manual/task scheduler for all supported backends, Jellyfin/Emby does not report library id
on webhook event. So, this feature will not work for them in webhook context and the items will be processed.
---
### Q: I get tired of writing the whole command everytime is there an easy way run the commands?
@@ -177,125 +170,99 @@ after that you can do `./ws command` for example, `./ws db:list`
### Q: I am using media backends hosted behind HTTPS, and see errors related to HTTP/2?
Sometimes there are problems related to HTTP/2 in the underlying library we use, so before reporting bug please try
running the following command
Sometimes there are problems related to HTTP/2, so before reporting bug please try running the following command:
```bash
$ docker exec -ti watchstate console servers:edit --key options.client.http_version --set 1.0 -- [SERVER_NAME]
```
if it does not fix your problem, please open issue about it.
This will force set the internal http client to use http v1 if it does not fix your problem, please open bug report
about it.
---
### Q: My sync operations are failing due to timeout can I increase that?
### Q: Sync operations are failing due to request timeout?
We use [symfony/httpClient](https://symfony.com/doc/current/http_client.html) internally, So any options available in [
configuration](https://symfony.com/doc/current/http_client.html#configuration) section, can be used
under `options.client.` key for example if you want to increase the timeout you can do
If you want to increase the timeout for specific backend you can run the following command:
```bash
$ docker exec -ti watchstate console servers:edit --key options.client.timeout --set 600 -- [SERVER_NAME]
```
---
### Q: Can I search my server remote libraries?
Yes, you can search your backend media servers. You can use `--search '[searchTerm]'`
or `--search-id [backend_media_id]`. For example, to search for specific title keyword run the following command:
```bash
$ docker exec -ti console server servers:remote --search 'Gundam' -- [SERVER_NAME]
```
or if you want to get a specific item metadata run the following command:
```bash
$ docker exec -ti console server servers:remote --search-id 2514 -- [SERVER_NAME]
```
### Optional flags that can be used with `--search` or `--search-id`
* `--search-raw` Return unfiltered response.
* `--search-limit` To limit returned results. Defaults to `25`.
* `--search-output` Set output style, it can be `yaml` or `json`. Defaults to `json`.
where `600` is the number of secs before the timeout handler kill the request.
---
### Q: Is it possible to look for mis-identified items?
### Q: How to perform search on backend libraries?
Yes, You can use the command `backend:library:mismatch`, For example
first get your library id by running the following command
Use the following command:
```bash
$ docker exec -ti watchstate console backend:library:list -- [SERVER_NAME]
$ docker exec -ti console backend:search:query [BACKEND_NAME] '[QUERY_STRING]'
```
it should display something like
| Id | Title | Type | Ignored | Supported |
|-----|-------------|--------|---------|-----------|
| 2 | Movies | movie | No | Yes |
| 1 | shows | show | No | Yes |
| 17 | Audio Books | artist | Yes | No |
Note the library id that you want to scan for possible mis-identified items, then run the following command:
```bash
$ docker exec -ti console server backend:library:mismatch --id [LIBRARY_ID] -- [BACKEND_NAME]
```
### Required flags
* `[-i, --id]` Library id.
where `[QUERY_STRING]` is the keyword that you want to search for
### Optional flags
* `[-p, --percentage]` How much in percentage the title has to be in path to be marked as matched item. Defaults
to `50.0%`.
* `[-o, --output]` Set output mode, it can be `yaml`, `json` or `table`. Defaults to `table`.
* `[-m, --method]` Which algorithm to use, it can be `similarity`, or `levenshtein`. Defaults to `similarity`.
* `[--timeout]` Set request timeout in seconds.
* `[-l, --limit]` To limit returned results. Default to `25`.
* `[-o, --output]` Set output style, it can be `yaml`, `json` or `table`. Default to `table`.
* `[--include-raw-response]` will include backend response in main response body with `raw` key.
---
### Q: How to the metadata about specific item?
Use the following command:
```bash
$ docker exec -ti console server backend:search:id [BACKEND_NAME] [ITEM_ID]
```
where `[ITEM_ID]` refers to backend item id
### Optional flags
* `[-o, --output]` Set output style, it can be `yaml`, `json` or `table`. Default to `table`.
* `[--include-raw-response]` will include backend response in main response body with `raw` key.
---
### Q: How to look for mis-identified items?
Use the `backend:library:mismatch` command. For example,
```bash
$ docker exec -ti console server backend:library:mismatch [BACKEND_NAME] [LIBRARY_ID]
```
where `[LIBRARY_ID]` refers to backend library id
### Optional flags
* `[-p, --percentage]` How much in percentage the title has to be in path to be marked as matched item. Default
to `50.0%`.
* `[-o, --output]` Set output mode, it can be `yaml`, `json` or `table`. Default to `table`.
* `[-m, --method]` Which algorithm to use, it can be `similarity`, or `levenshtein`. Default to `similarity`.
* `[--include-raw-response]` Will include backend response in main response body with `raw` key.
---
### Q: Is it possible to look for unmatched items?
Yes, You can use the command `backend:library:unmatched`, For example
first get your library id by running the following command
Use the `backend:library:unmatched` command. For example,
```bash
$ docker exec -ti watchstate console backend:library:list -- [SERVER_NAME]
$ docker exec -ti console server backend:library:unmatched [BACKEND_NAME] [LIBRARY_ID]
```
it should display something like
| Id | Title | Type | Ignored | Supported |
|-----|-------------|--------|---------|-----------|
| 2 | Movies | movie | No | Yes |
| 1 | shows | show | No | Yes |
| 17 | Audio Books | artist | Yes | No |
Note the library id that you want to scan for unmatched items, then run the following command:
```bash
$ docker exec -ti console server backend:library:unmatched --id [LIBRARY_ID] -- [BACKEND_NAME]
```
### Required flags
* `[-i, --id]` Library id.
where `[LIBRARY_ID]` refers to backend library id
### Optional flags
* `[-o, --output]` Set output mode, it can be `yaml`, `json` or `table`. Defaults to `table`.
* `[--timeout]` Set request timeout in seconds.
* `[--show-all]` will display all library items regardless if match or unmatched.
* `[--include-raw-response]` will include backend response in main response body with `raw` key.
* `[--show-all]` Will show all library items regardless of the match status.
* `[--include-raw-response]` Will include backend response in main response body with `raw` key.
---

View File

@@ -1,8 +1,7 @@
# WatchState
WatchState is a CLI based tool to sync your watch state between different media backends, without relying on 3rd parties
services, like trakt.tv, This tool support `Plex Media Server`, `Emby` and `Jellyfin` out of the box currently, with
plans for future expansion for other media backends.
services, like trakt.tv, This tool support `Plex Media Server`, `Emby` and `Jellyfin` out of the box.
# Install
@@ -52,20 +51,19 @@ After starting the container, you have to add your media backends, to do so run
$ docker exec -ti watchstate console servers:manage --add -- [SERVER_NAME]
```
This command will ask you for some questions to add your backend, you can run the command as many times as you want, if
you want to edit the config again or if you made mistake just run the same command without `--add` flag.
After adding your backends, You should import your current watch state by running the following command.
This command is interactive and will ask you for some questions to add your backend, you can run the command as many
times as you want, if you want to edit the config again or if you made mistake just run the same command without `--add`
flag. After adding your backends, You should import your current watch state by running the following command.
```bash
$ docker exec -ti watchstate console state:import -vv
$ docker exec -ti watchstate console state:import -vvf
```
---
# Pulling watch state.
now that you have imported your current play state, you can stop manually running the command, and rely on the tasks
Now that you have imported your current play state, you can stop manually running the command, and rely on the tasks
scheduler and webhooks to keep update your play state. To start receiving webhook events from backends you need to do
few more steps.
@@ -85,7 +83,7 @@ $ docker exec -ti watchstate console servers:edit --regenerate-webhook-token --
---
#### TIP:
#### Notice:
If you have multiple plex servers and use the same PlexPass account for all of them, you have to unify the API key, by
running the following command:
@@ -105,18 +103,18 @@ $ docker exec -ti watchstate console help servers:unify
---
If you don't want to/can't use webhooks and want to rely solely on task scheduler importing, then set the value
If you don't want to/can't use webhooks and want to rely on task scheduler importing, then set the value
of `WS_CRON_IMPORT` to `1`. By default, we run the import command every hour. However, you can change the scheduled task
timer by adding another variable `WS_CRON_IMPORT_AT` and set its value to valid cron expression. for
example, `0 */2 * * *` it will run every two hours instead of 1 hour. If your backends and this tool are not on same
server it might consume a lot of bandwidth as it's pulls the entire server library listing.
server it might consume a lot of bandwidth depending on how big is your library as it's pulls the entire server library
listing.
---
#### TIP
#### Notice
You should still have `WS_CRON_IMPORT` enabled as sometimes plex does not really report new items, or report them in a
way that is not compatible with the way we handle webhooks events.
You should still have `WS_CRON_IMPORT` enabled to keep healthy relation between storage and backend changes.
---
@@ -134,16 +132,15 @@ to sync specific server/s, use the `[-s, --servers-filter]` which accept comma s
$ docker exec -ti watchstate console state:export -vv --servers-filter 'server1,server2'
```
To enable the export scheduled task set the value of `WS_CRON_EXPORT` to `1`. By default, we run export every 90
minutes. However, you can change the schedule by adding another variable called `WS_CRON_EXPORT_AT` and set its value to
valid cron expression. for example, `0 */3 * * *` it will run every three hours instead of 90 minutes.
To enable export scheduled task set the value of `WS_CRON_EXPORT` to `1`. By default, we run export every 90 minutes.
However, you can change the timer by adding another variable called `WS_CRON_EXPORT_AT` and set its value to valid cron
expression. for example, `0 */3 * * *` it will run every three hours instead of 90 minutes.
# Start receiving webhook events.
By default, the official container includes a small http server exposed at port `80`, we officially don't support HTTPS
By default, the official container includes http server exposed at port `80`, we officially don't support HTTPS
inside the container for the HTTP server. However, for the adventurous people we expose port 443 as well, as such you
can customize the `docker/files/nginx.conf` to support SSL. and do the necessary adjustments. However, do not expect us
to help with it.
can customize the `docker/files/nginx.conf` to support SSL. and do the necessary adjustments.
#### Example nginx reverse proxy.
@@ -166,8 +163,8 @@ server {
### Adding webhook
To add webhook for your server the URL will be dependent on how you expose the tool http frontend, but typically it will
be like this:
To add webhook for your server the URL will be dependent on how you exposed webhook frontend, but typically it will be
like this:
#### Webhook URL
@@ -175,18 +172,13 @@ Via reverse proxy : `https://watchstate.domain.example/?apikey=[WEBHOOK_TOKEN]`.
Directly to container: `https://localhost:8081/?apikey=[WEBHOOK_TOKEN]`
If your media backend support sending headers then omit the query parameter '?apikey=[WEBHOOK_TOKEN]', and add new this
header
If your media backend support sending headers then remove query parameter `?apikey=[WEBHOOK_TOKEN]`, and add this header
```http request
X-apikey: [WEBHOOK_TOKEN]
```
it's more secure that way.
#### [WEBHOOK_TOKEN]
Should match the backend specific ``webhook.token`` value. Refer to the steps described
where `[WEBHOOK_TOKEN]` Should match the backend specific `webhook.token` value. Refer to the steps described
at **[Steps to enable webhook servers](#enable-webhooks-events-for-specific-backend)**.
# Configuring media backends to send webhook events.
@@ -228,7 +220,7 @@ Toggle this checkbox.
### Add Request Header
Key: `X-apikey`
Key: `x-apikey`
Value: `[WEBHOOK_TOKEN]`
@@ -251,7 +243,7 @@ Select the following events
Click `Add Webhook`
#### Plex (you need "Plex Pass" to use webhooks)
#### Plex (you need "PlexPass" to use webhooks)
Go to your plex Web UI > Settings > Your Account > Webhooks > (Click ADD WEBHOOK)
@@ -270,16 +262,17 @@ Click `Save Changes`
* Plex does not send webhooks events for "marked as Played/Unplayed".
* Sometimes does not send events if you add more than one item at time.
* If you have multi-user setup, Plex will still report the admin account user id as `1`.
* When you mark items as unwatched, Plex reset the date on the object, which renders the comparison as invalid.
* When you mark items as unwatched, Plex reset the date on the object.
# Emby
* Emby does not send webhooks events for newly added items. [See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)
* Emby does not send webhooks events for newly added
items. [See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)
# Jellyfin
* If you don't select a user id there, sometimes the webhook plugin will send `itemAdd` event without user info, and thus will fail
the check if you happen to enable `strict user match` for jellyfin.
* If you don't select a user id, the Plugin will sometimes send `itemAdd` event without user info, and thus will fail
the check if you happen to enable `strict user match` for jellyfin.
----

View File

@@ -24,14 +24,17 @@ return [
'servers:manage' => App\Commands\Servers\ManageCommand::class,
'servers:unify' => App\Commands\Servers\UnifyCommand::class,
'servers:view' => App\Commands\Servers\ViewCommand::class,
'servers:remote' => App\Commands\Servers\RemoteCommand::class,
'servers:edit' => App\Commands\Servers\EditCommand::class,
// -- db:
'db:list' => App\Commands\Database\ListCommand::class,
'db:queue' => App\Commands\Database\QueueCommand::class,
// -- backend:library
// -- backend:library:
'backend:library:list' => App\Commands\Backend\Library\ListCommand::class,
'backend:library:mismatch' => App\Commands\Backend\Library\MismatchCommand::class,
'backend:library:unmatched' => App\Commands\Backend\Library\UnmatchedCommand::class,
// -- backend:search:
'backend:search:query' => App\Commands\Backend\Search\QueryCommand::class,
'backend:search:id' => App\Commands\Backend\Search\IdCommand::class,
// -- backend:users:
'backend:users:list' => App\Commands\Backend\Users\ListCommand::class,
];

View File

@@ -167,8 +167,6 @@ class Command extends BaseCommand
);
} elseif ('table' === $mode) {
$list = [];
$x = 0;
$count = count($content);
foreach ($content as $_ => $item) {
if (false === is_array($item)) {
@@ -184,14 +182,12 @@ class Command extends BaseCommand
$subItem[$key] = $leaf;
}
$x++;
$list[] = $subItem;
if ($x < $count) {
$list[] = new TableSeparator();
}
$list[] = new TableSeparator();
}
if (!empty($list)) {
array_pop($list);
(new Table($output))->setStyle('box')->setHeaders(
array_map(fn($title) => is_string($title) ? ucfirst($title) : $title, array_keys($list[0]))
)->setRows($list)->render();

View File

@@ -6,7 +6,10 @@ namespace App\Commands\Backend\Library;
use App\Command;
use App\Libs\Config;
use App\Libs\Options;
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;
@@ -18,7 +21,7 @@ final class ListCommand extends Command
protected function configure(): void
{
$this->setName('backend:library:list')
->setDescription('Get Backend libraries list')
->setDescription('Get Backend libraries list.')
->addOption(
'output',
'o',
@@ -26,13 +29,15 @@ final class ListCommand extends Command
sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)),
$this->outputs[0],
)
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name');
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$backend = $input->getArgument('backend');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
@@ -48,12 +53,17 @@ final class ListCommand extends Command
}
try {
$server = $this->getBackend($input->getArgument('backend'));
$libraries = $server->listLibraries();
$opts = [];
if ($input->getOption('include-raw-response')) {
$opts[Options::RAW_RESPONSE] = true;
}
$libraries = $this->getBackend($backend)->listLibraries(opts: $opts);
if (count($libraries) < 1) {
$arr = [
'info' => 'No libraries were found.',
'info' => sprintf('%s: No libraries were found.', $backend),
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
@@ -80,7 +90,7 @@ final class ListCommand extends Command
return self::SUCCESS;
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage(),
'error' => sprintf('%s: %s', $backend, $e->getMessage()),
];
if ('table' !== $mode) {
$arr += [
@@ -92,4 +102,29 @@ final class ListCommand extends Command
return self::FAILURE;
}
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
$methods = [
'output' => 'outputs',
];
foreach ($methods as $key => $of) {
if ($input->mustSuggestOptionValuesFor($key)) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ($this->{$of} as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}
}

View File

@@ -28,7 +28,6 @@ final class MismatchCommand extends Command
{
$this->setName('backend:library:mismatch')
->setDescription('Find possible mis-identified movies or shows in a specific library.')
->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'Library id.')
->addOption(
'output',
'o',
@@ -56,13 +55,16 @@ final class MismatchCommand extends Command
)
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name');
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.')
->addArgument('id', InputArgument::REQUIRED, 'Library id.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$percentage = $input->getOption('percentage');
$backend = $input->getArgument('backend');
$id = $input->getArgument('id');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
@@ -77,15 +79,6 @@ final class MismatchCommand extends Command
}
}
if (null === ($id = $input->getOption('id'))) {
$arr = [
'error' => 'Library mismatch search require library id to be passed in [-i, --id].'
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
try {
$serverOpts = $opts = $list = [];
@@ -97,7 +90,7 @@ final class MismatchCommand extends Command
$opts[Options::RAW_RESPONSE] = true;
}
foreach ($this->getBackend($input->getArgument('backend'), $serverOpts)->getLibrary($id, $opts) as $item) {
foreach ($this->getBackend($backend, $serverOpts)->getLibrary(id: $id, opts: $opts) as $item) {
$processed = $this->compare(item: $item, method: $input->getOption('method'));
if (empty($processed) || $processed['percent'] >= (float)$percentage) {
@@ -108,7 +101,7 @@ final class MismatchCommand extends Command
}
} catch (Throwable $e) {
$arr = [
'error' => $e->getMessage(),
'error' => sprintf('%s: %s', $backend, $e->getMessage()),
];
if ('table' !== $mode) {
@@ -126,7 +119,7 @@ final class MismatchCommand extends Command
if (empty($list)) {
$arr = [
'info' => 'No mis-identified items were found using given parameters.',
'info' => sprintf('%s: No mis-identified items were found using given parameters.', $backend)
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);

View File

@@ -23,8 +23,7 @@ final class UnmatchedCommand extends Command
{
$this->setName('backend:library:unmatched')
->setDescription('Find top level Items in library that has no external ids.')
->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'Library id.')
->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all content regardless.')
->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all items regardless of the match status.')
->addOption(
'output',
'o',
@@ -44,13 +43,16 @@ final class UnmatchedCommand extends Command
)
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name');
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.')
->addArgument('id', InputArgument::REQUIRED, 'Library id.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$showAll = $input->getOption('show-all');
$backend = $input->getArgument('backend');
$id = $input->getArgument('id');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
@@ -65,15 +67,6 @@ final class UnmatchedCommand extends Command
}
}
if (null === ($id = $input->getOption('id'))) {
$arr = [
'error' => 'Library mismatch search require library id to be passed in [-i, --id].'
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
try {
$serverOpts = $opts = $list = [];
@@ -85,7 +78,7 @@ final class UnmatchedCommand extends Command
$opts[Options::RAW_RESPONSE] = true;
}
foreach ($this->getBackend($input->getArgument('backend'), $serverOpts)->getLibrary($id, $opts) as $item) {
foreach ($this->getBackend($backend, $serverOpts)->getLibrary(id: $id, opts: $opts) as $item) {
if (true === $showAll) {
$list[] = $item;
continue;
@@ -96,7 +89,7 @@ final class UnmatchedCommand extends Command
}
} catch (Throwable $e) {
$arr = [
'error' => $e->getMessage(),
'error' => sprintf('%s: %s', $backend, $e->getMessage()),
];
if ('table' !== $mode) {
$arr += [

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Commands\Backend\Search;
use App\Command;
use App\Libs\Config;
use App\Libs\Options;
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\Yaml\Yaml;
final class IdCommand extends Command
{
protected function configure(): void
{
$this->setName('backend:search:id')
->setDescription('Get backend metadata related to specific id.')
->addOption(
'output',
'o',
InputOption::VALUE_OPTIONAL,
sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)),
$this->outputs[0],
)
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.')
->addArgument('id', InputArgument::REQUIRED, 'Item id.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$id = $input->getArgument('id');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
try {
Config::save('servers', Yaml::parseFile($this->checkCustomServersFile($config)));
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage()
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
try {
$backend = $this->getBackend($input->getArgument('backend'));
$opts = [];
if ($input->getOption('include-raw-response')) {
$opts[Options::RAW_RESPONSE] = true;
}
$results = $backend->searchId(id: $id, opts: $opts);
if (count($results) < 1) {
$arr = [
'info' => sprintf('%s: No results were found for this id #\'%s\' .', $backend->getName(), $id),
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
$this->displayContent('table' === $mode ? [$results] : $results, $output, $mode);
return self::SUCCESS;
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage(),
];
if ('table' !== $mode) {
$arr += [
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
$methods = [
'output' => 'outputs',
];
foreach ($methods as $key => $of) {
if ($input->mustSuggestOptionValuesFor($key)) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ($this->{$of} as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Commands\Backend\Search;
use App\Command;
use App\Libs\Config;
use App\Libs\Options;
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\Yaml\Yaml;
final class QueryCommand extends Command
{
protected function configure(): void
{
$this->setName('backend:search:query')
->setDescription('Search backend libraries for specific title keyword.')
->addOption(
'output',
'o',
InputOption::VALUE_OPTIONAL,
sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)),
$this->outputs[0],
)
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit returned results.', 25)
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.')
->addArgument('query', InputArgument::REQUIRED, 'Search query.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$query = $input->getArgument('query');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
try {
Config::save('servers', Yaml::parseFile($this->checkCustomServersFile($config)));
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage()
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
try {
$backend = $this->getBackend($input->getArgument('backend'));
$opts = [];
if ($input->getOption('include-raw-response')) {
$opts[Options::RAW_RESPONSE] = true;
}
$results = $backend->search(
query: $query,
limit: (int)$input->getOption('limit'),
opts: $opts,
);
if (count($results) < 1) {
$arr = [
'info' => sprintf('%s: No results were found for this query \'%s\' .', $backend->getName(), $query),
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
$this->displayContent($results, $output, $mode);
return self::SUCCESS;
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage(),
];
if ('table' !== $mode) {
$arr += [
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
$methods = [
'output' => 'outputs',
];
foreach ($methods as $key => $of) {
if ($input->mustSuggestOptionValuesFor($key)) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ($this->{$of} as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Commands\Backend\Users;
use App\Command;
use App\Libs\Config;
use App\Libs\Options;
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\Yaml\Yaml;
use Throwable;
final class ListCommand extends Command
{
protected function configure(): void
{
$this->setName('backend:users:list')
->setDescription('Get backend users list.')
->addOption(
'output',
'o',
InputOption::VALUE_OPTIONAL,
sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)),
$this->outputs[0],
)
->addOption('with-tokens', 't', InputOption::VALUE_NONE, 'Include access tokens in response.')
->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('backend', InputArgument::REQUIRED, 'Backend name.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$backend = $input->getArgument('backend');
// -- Use Custom servers.yaml file.
if (($config = $input->getOption('config'))) {
try {
Config::save('servers', Yaml::parseFile($this->checkCustomServersFile($config)));
} catch (RuntimeException $e) {
$arr = [
'error' => $e->getMessage()
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
try {
$opts = [];
if ($input->getOption('with-tokens')) {
$opts['tokens'] = true;
}
if ($input->getOption('include-raw-response')) {
$opts[Options::RAW_RESPONSE] = true;
}
$libraries = $this->getBackend($backend)->getUsersList(opts: $opts);
if (count($libraries) < 1) {
$arr = [
'info' => sprintf('%s: No libraries were found.', $backend),
];
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
if ('table' === $mode) {
$list = [];
foreach ($libraries as $item) {
foreach ($item as $key => $val) {
if (false === is_bool($val)) {
continue;
}
$item[$key] = $val ? 'Yes' : 'No';
}
$list[] = $item;
}
$libraries = $list;
}
$this->displayContent($libraries, $output, $mode);
return self::SUCCESS;
} catch (Throwable $e) {
$arr = [
'error' => sprintf('%s: %s', $backend, $e->getMessage()),
];
if ('table' !== $mode) {
$arr += [
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$this->displayContent('table' === $mode ? [$arr] : $arr, $output, $mode);
return self::FAILURE;
}
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
$methods = [
'output' => 'outputs',
];
foreach ($methods as $key => $of) {
if ($input->mustSuggestOptionValuesFor($key)) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ($this->{$of} as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}
}

View File

@@ -5,8 +5,12 @@ declare(strict_types=1);
namespace App\Commands\Config;
use App\Command;
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;
final class EnvCommand extends Command
@@ -14,25 +18,62 @@ final class EnvCommand extends Command
protected function configure(): void
{
$this->setName('config:env')
->setDescription('Dump registered environment variables.');
->addOption(
'output',
'o',
InputOption::VALUE_OPTIONAL,
sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)),
$this->outputs[0],
)
->setDescription('Dump loaded environment variables.');
}
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$keys = [];
foreach (getenv() as $key => $val) {
if (!str_starts_with($key, 'WS_')) {
continue;
}
$keys[] = [$key, $val];
$keys[] = ['key' => $key, 'value' => $val];
}
(new Table($output))->setStyle('box')
->setHeaders(['Environment Key', 'Environment Value'])
->setRows($keys)
->render();
if (!empty($key)) {
array_pop($keys);
}
$this->displayContent($keys, $output, $mode);
return self::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
$methods = [
'output' => 'outputs',
];
foreach ($methods as $key => $of) {
if ($input->mustSuggestOptionValuesFor($key)) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach ($this->{$of} as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}
}

View File

@@ -54,9 +54,6 @@ final class PruneCommand extends Command
],
];
/** @var array<SplFileInfo> $files */
$files = [];
foreach ($paths as $item) {
if (!is_dir(ag($item, 'path'))) {
$output->writeln(sprintf('Path \'%s\' does not exists.', ag($item, 'path')));
@@ -64,25 +61,23 @@ final class PruneCommand extends Command
}
foreach (glob(ag($item, 'path') . '/' . ag($item, 'filter')) as $file) {
$files[] = new SplFileInfo($file);
$file = new SplFileInfo($file);
$fileName = $file->getBasename();
if ('.' === $fileName || '..' === $fileName || $file->isDir() || !$file->isFile()) {
continue;
}
if ($file->getMTime() > $expiresAt) {
continue;
}
$output->writeln(sprintf('Deleting %s', $file->getRealPath()));
unlink($file->getRealPath());
}
}
foreach ($files as $file) {
$fileName = $file->getBasename();
if ('.' === $fileName || '..' === $fileName || $file->isDir()) {
continue;
}
if ($file->getMTime() > $expiresAt) {
continue;
}
$output->writeln(sprintf('Deleting %s', $file->getRealPath()));
unlink($file->getRealPath());
}
return self::SUCCESS;
}

View File

@@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Commands\Servers;
use App\Command;
use App\Libs\Config;
use App\Libs\Options;
use App\Libs\Servers\ServerInterface;
use JsonException;
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\InputArgument;
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;
final class RemoteCommand extends Command
{
protected function configure(): void
{
$this->setName('servers:remote')
->setDescription('Get info from the remote server.')
->addOption('list-users', null, InputOption::VALUE_NONE, 'List Server users.')
->addOption('list-users-with-tokens', null, InputOption::VALUE_NONE, 'Show users list with tokens.')
->addOption('use-token', null, InputOption::VALUE_REQUIRED, 'Override server config token.')
->addOption('search', null, InputOption::VALUE_REQUIRED, 'Search query.')
->addOption('search-id', null, InputOption::VALUE_REQUIRED, 'Get metadata related to given id.')
->addOption('search-raw', null, InputOption::VALUE_NONE, 'Return Unfiltered results.')
->addOption('search-limit', null, InputOption::VALUE_REQUIRED, 'Search limit.', 25)
->addOption('search-output', null, InputOption::VALUE_REQUIRED, 'Search output style [json,yaml].', 'json')
->addOption('timeout', null, InputOption::VALUE_OPTIONAL, 'Request timeout in seconds.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addArgument('server', InputArgument::REQUIRED, 'Server name');
}
/**
* @throws ExceptionInterface
* @throws JsonException
*/
protected function runCommand(InputInterface $input, OutputInterface $output): int
{
// -- Use Custom servers.yaml file.
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;
}
}
$name = $input->getArgument('server');
$ref = "servers.{$name}";
if (null === Config::get("{$ref}.type", null)) {
$output->writeln(
sprintf('<error>No server named \'%s\' was found in %s.</error>', $name, $config)
);
return self::FAILURE;
}
$config = Config::get($ref);
$opts = ag($config, 'options', []);
if ($input->getOption('use-token')) {
$config['token'] = $input->getOption('use-token');
}
if ($input->getOption('timeout')) {
$opts['client']['timeout'] = (float)$input->getOption('timeout');
}
if ($input->getOption('use-token')) {
$config['token'] = $input->getOption('use-token');
}
$config['options'] = $opts ?? [];
$config['name'] = $name;
$server = makeServer($config, $name);
if ($input->getOption('list-users') || $input->getOption('list-users-with-tokens')) {
$this->listUsers($input, $output, $server);
}
if ($input->getOption('search') && $input->getOption('search-limit')) {
$this->search($server, $output, $input);
}
if ($input->getOption('search') && $input->getOption('search-limit')) {
$this->search($server, $output, $input);
}
if ($input->getOption('search-id')) {
$this->searchId($server, $output, $input);
}
return self::SUCCESS;
}
/**
* @throws JsonException
* @throws ExceptionInterface
*/
private function listUsers(
InputInterface $input,
OutputInterface $output,
ServerInterface $server,
): void {
$opts = [];
if ($input->getOption('list-users-with-tokens')) {
$opts['tokens'] = true;
}
$users = $server->getUsersList($opts);
if (count($users) < 1) {
$output->writeln('<comment>No users reported by server.</comment>');
return;
}
$list = [];
$x = 0;
$count = count($users);
foreach ($users as $user) {
$x++;
$values = array_values($user);
$list[] = $values;
if ($x < $count) {
$list[] = new TableSeparator();
}
}
(new Table($output))->setStyle('box')->setHeaders(array_keys($users[0]))->setRows($list)->render();
}
private function search(ServerInterface $server, OutputInterface $output, InputInterface $input): void
{
$result = $server->search(
query: $input->getOption('search'),
limit: (int)$input->getOption('search-limit'),
opts: [
Options::RAW_RESPONSE => (bool)$input->getOption('search-raw')
]
);
if (empty($result)) {
$output->writeln(sprintf('<error>No results found for \'%s\'.</error>', $input->getOption('search')));
exit(1);
}
if ('json' === $input->getOption('search-output')) {
$output->writeln(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} else {
$output->writeln(Yaml::dump($result, 8, 2));
}
}
private function searchId(ServerInterface $server, OutputInterface $output, InputInterface $input): void
{
$result = $server->searchId(id: $input->getOption('search-id'), opts: [
Options::RAW_RESPONSE => (bool)$input->getOption('search-raw'),
]);
if (empty($result)) {
$output->writeln(
sprintf('<error>No meta data found for id \'%s\'.</error>', $input->getOption('search-id'))
);
exit(1);
}
if ('json' === $input->getOption('search-output')) {
$output->writeln(
json_encode(
value: $result,
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE
)
);
} else {
$output->writeln(Yaml::dump($result, 8, 2));
}
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
parent::complete($input, $suggestions);
if ($input->mustSuggestOptionValuesFor('search-output')) {
$currentValue = $input->getCompletionValue();
$suggest = [];
foreach (['yaml', 'json'] as $name) {
if (empty($currentValue) || str_starts_with($name, $currentValue)) {
$suggest[] = $name;
}
}
$suggestions->suggestValues($suggest);
}
}
}

View File

@@ -24,6 +24,8 @@ interface StateInterface
public const COLUMN_PARENT = 'parent';
public const COLUMN_GUIDS = 'guids';
public const COLUMN_META_DATA = 'metadata';
public const COLUMN_META_SHOW = 'show';
public const COLUMN_META_LIBRARY = 'library';
public const COLUMN_META_DATA_ADDED_AT = 'added_at';
public const COLUMN_META_DATA_PLAYED_AT = 'played_at';
public const COLUMN_META_DATA_EXTRA = 'extra';

View File

@@ -8,11 +8,9 @@ use App\Libs\Container;
use App\Libs\Entity\StateInterface as iFace;
use App\Libs\Guid;
use App\Libs\HttpException;
use JsonException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Throwable;
class EmbyServer extends JellyfinServer
@@ -107,19 +105,26 @@ class EmbyServer extends JellyfinServer
$event = ag($json, 'Event', 'unknown');
$type = ag($json, 'Item.Type', 'not_found');
$id = ag($json, 'Item.ItemId');
if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200);
throw new HttpException(
sprintf('%s: Webhook content type is not supported. [%s]', $this->getName(), $type), 200
);
}
$type = strtolower($type);
if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200);
throw new HttpException(
sprintf('%s: Webhook event type is not supported. [%s]', $this->getName(), $event), 200
);
}
if (null === $id) {
throw new HttpException(sprintf('%s: Webhook payload has no id.', $this->getName()), 400);
}
$isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS);
$playedAt = null;
$type = strtolower($type);
if ('item.markplayed' === $event || 'playback.scrobble' === $event) {
$playedAt = time();
@@ -130,74 +135,50 @@ class EmbyServer extends JellyfinServer
$isPlayed = (int)(bool)ag($json, ['Item.Played', 'Item.PlayedToCompletion'], false);
}
$providersId = ag($json, 'Item.ProviderIds', []);
$row = [
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_UPDATED => time(),
iFace::COLUMN_WATCHED => $isPlayed,
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'),
iFace::COLUMN_GUIDS => $this->getGuids($providersId),
iFace::COLUMN_META_DATA => [
$this->name => [
iFace::COLUMN_ID => (string)ag($json, 'Item.ItemId'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => (string)$isPlayed,
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => ag($json, ['Item.Name', 'Item.OriginalTitle'], '??'),
iFace::COLUMN_GUIDS => array_change_key_case($providersId, CASE_LOWER)
try {
$fields = [
iFace::COLUMN_UPDATED => time(),
iFace::COLUMN_WATCHED => $isPlayed,
iFace::COLUMN_META_DATA => [
$this->name => [
iFace::COLUMN_WATCHED => (string)(int)(bool)$isPlayed,
]
],
],
iFace::COLUMN_EXTRA => [
$this->name => [
iFace::COLUMN_EXTRA_EVENT => $event,
iFace::COLUMN_EXTRA_DATE => makeDate(time()),
iFace::COLUMN_EXTRA => [
$this->name => [
iFace::COLUMN_EXTRA_EVENT => $event,
iFace::COLUMN_EXTRA_DATE => makeDate(time()),
],
],
],
];
];
if (iFace::TYPE_EPISODE === $type) {
$row[iFace::COLUMN_TITLE] = ag($json, 'Item.SeriesName', '??');
$row[iFace::COLUMN_SEASON] = ag($json, 'Item.ParentIndexNumber', 0);
$row[iFace::COLUMN_EPISODE] = ag($json, 'Item.IndexNumber', 0);
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_TITLE] = ag($json, 'Item.SeriesName', '??');
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_SEASON] = (string)$row[iFace::COLUMN_SEASON];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_EPISODE] = (string)$row[iFace::COLUMN_EPISODE];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_TITLE] = ag(
$json,
['Item.Name', 'Item.OriginalTitle'],
'??'
);
if (null !== ag($json, 'Item.SeriesId')) {
$row[iFace::COLUMN_PARENT] = $this->getEpisodeParent(ag($json, 'Item.SeriesId'), '');
if (null !== $playedAt && 1 === $isPlayed) {
$fields[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_PLAYED_AT] = $playedAt;
}
$providersId = ag($json, 'Item.ProviderIds', []);
if (null !== ($guids = $this->getGuids($providersId)) && !empty($guids)) {
$guids += Guid::makeVirtualGuid($this->name, (string)ag($json, $id));
$fields[iFace::COLUMN_GUIDS] = $guids;
$fields[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_GUIDS] = $fields[iFace::COLUMN_GUIDS];
}
$entity = $this->createEntity(
item: $this->getMetadata(id: $id),
type: $type,
opts: ['override' => $fields],
)->setIsTainted(isTainted: true === in_array($event, self::WEBHOOK_TAINTED_EVENTS));
} catch (Throwable $e) {
throw new HttpException(
sprintf(
'%s: Request to get item id \'%s\' metadata failed. %s',
self::NAME,
$id,
$e->getMessage()
), 500
);
}
if (null !== ($mediaYear = ag($json, 'Item.ProductionYear'))) {
$row[iFace::COLUMN_YEAR] = (int)$mediaYear;
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($premiereDate = ag($json, 'Item.PremiereDate'))) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate(
$premiereDate
)->format('Y-m-d');
}
if (null !== ($addedAt = ag($json, 'Item.DateCreated'))) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_ADDED_AT] = makeDate(
$addedAt
)->getTimestamp();
}
if (null !== $playedAt && 1 === $isPlayed) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_PLAYED_AT] = $playedAt;
}
$entity = Container::get(iFace::class)::fromArray($row)->setIsTainted($isTainted);
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
$message = sprintf('%s: No valid/supported external ids.', self::NAME);
@@ -212,72 +193,4 @@ class EmbyServer extends JellyfinServer
return $entity;
}
protected function getEpisodeParent(mixed $id, string $cacheName): array
{
if (array_key_exists($id, $this->cache['shows'] ?? [])) {
return $this->cache['shows'][$id];
}
try {
$response = $this->http->request(
'GET',
(string)$this->url->withPath(
sprintf('/Users/%s/items/' . $id, $this->user)
),
$this->getHeaders()
);
if (200 !== $response->getStatusCode()) {
return [];
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if (null === ($itemType = ag($json, 'Type')) || 'Series' !== $itemType) {
return [];
}
$providersId = (array)ag($json, 'ProviderIds', []);
if (!$this->hasSupportedIds($providersId)) {
$this->cache['shows'][$id] = [];
return [];
}
$this->cache['shows'][$id] = Guid::fromArray($this->getGuids($providersId))->getAll();
return $this->cache['shows'][$id];
} catch (ExceptionInterface $e) {
$this->logger->error($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
]);
return [];
} catch (JsonException $e) {
$this->logger->error(
sprintf('%s: Unable to decode \'%s\' JSON response. %s', $this->name, $cacheName, $e->getMessage()),
[
'file' => $e->getFile(),
'line' => $e->getLine(),
]
);
return [];
} catch (Throwable $e) {
$this->logger->error(
sprintf('%s: Failed to handle \'%s\' response. %s', $this->name, $cacheName, $e->getMessage()),
[
'file' => $e->getFile(),
'line' => $e->getLine(),
'kind' => get_class($e),
]
);
return [];
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -88,15 +88,6 @@ class PlexServer implements ServerInterface
'media.pause',
];
protected const BACKEND_CAST_KEYS = [
'lastViewedAt' => 'datetime',
'updatedAt' => 'datetime',
'addedAt' => 'datetime',
'mediaTagVersion' => 'datetime',
'duration' => 'duration_sec',
'size' => 'size',
];
/**
* Parse hama agent guid.
*/
@@ -236,18 +227,22 @@ class PlexServer implements ServerInterface
foreach ($users as $user) {
$data = [
'user_id' => ag($user, 'admin') && $adminsCount <= 1 ? 1 : ag($user, 'id'),
'username' => $user['username'] ?? $user['title'] ?? $user['friendlyName'] ?? $user['email'] ?? '??',
'is_admin' => ag($user, 'admin') ? 'Yes' : 'No',
'is_guest' => ag($user, 'guest') ? 'Yes' : 'No',
'is_restricted' => ag($user, 'restricted') ? 'Yes' : 'No',
'updated_at' => isset($user['updatedAt']) ? makeDate($user['updatedAt']) : 'Never',
'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;
}
@@ -334,13 +329,22 @@ class PlexServer implements ServerInterface
$item = ag($json, 'Metadata', []);
$type = ag($json, 'Metadata.type');
$event = ag($json, 'event', null);
$id = ag($item, 'ratingKey');
if (null === $type || false === in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
throw new HttpException(sprintf('%s: Not allowed type [%s]', self::NAME, $type), 200);
throw new HttpException(
sprintf('%s: Webhook content type is not supported. [%s]', $this->getName(), $type), 200
);
}
if (null === $event || false === in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
throw new HttpException(sprintf('%s: Not allowed event [%s]', self::NAME, $event), 200);
throw new HttpException(
sprintf('%s: Webhook event type is not supported. [%s]', $this->getName(), $event), 200
);
}
if (null === $id) {
throw new HttpException(sprintf('%s: Webhook payload has no id.', $this->getName()), 400);
}
if (null !== ($ignoreIds = ag($this->options, 'ignore', null))) {
@@ -350,90 +354,60 @@ class PlexServer implements ServerInterface
if (null !== $ignoreIds && in_array(ag($item, 'librarySectionID', '???'), $ignoreIds)) {
throw new HttpException(
sprintf(
'%s: Library id \'%s\' is ignored by user server config.',
self::NAME,
'%s: Library id \'%s\' is ignored by user config.',
$this->name,
ag($item, 'librarySectionID', '???')
), 200
);
}
$isTainted = in_array($event, self::WEBHOOK_TAINTED_EVENTS);
if (null === ag($item, 'Guid', null)) {
$item['Guid'] = [['id' => ag($item, 'guid')]];
} else {
$item['Guid'][] = ['id' => ag($item, 'guid')];
}
$guids = $this->getGuids(ag($item, 'Guid', []));
$guids += Guid::makeVirtualGuid($this->name, (string)ag($item, 'ratingKey'));
$row = [
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_UPDATED => time(),
iFace::COLUMN_WATCHED => (int)(bool)ag($item, 'viewCount', false),
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $guids,
iFace::COLUMN_META_DATA => [
$this->name => [
iFace::COLUMN_ID => (string)ag($item, 'ratingKey'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => (string)(int)(bool)ag($item, 'viewCount', false),
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $this->parseGuids(ag($item, 'Guid', [])),
iFace::COLUMN_META_DATA_ADDED_AT => (string)ag($item, 'addedAt'),
try {
$fields = [
iFace::COLUMN_UPDATED => time(),
iFace::COLUMN_WATCHED => (int)(bool)ag($item, 'viewCount', false),
iFace::COLUMN_META_DATA => [
$this->name => [
iFace::COLUMN_WATCHED => (string)(int)(bool)ag($json, 'viewCount', false),
]
],
],
iFace::COLUMN_EXTRA => [
$this->name => [
iFace::COLUMN_EXTRA_EVENT => $event,
iFace::COLUMN_EXTRA_DATE => makeDate(time()),
iFace::COLUMN_EXTRA => [
$this->name => [
iFace::COLUMN_EXTRA_EVENT => $event,
iFace::COLUMN_EXTRA_DATE => makeDate(time()),
],
],
],
];
];
if (iFace::TYPE_EPISODE === $type) {
$row[iFace::COLUMN_TITLE] = ag($item, 'grandparentTitle', '??');
$row[iFace::COLUMN_SEASON] = ag($item, 'parentIndex', 0);
$row[iFace::COLUMN_EPISODE] = ag($item, 'index', 0);
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_TITLE] = ag($item, 'grandparentTitle', '??');
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_SEASON] = (string)$row[iFace::COLUMN_SEASON];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_EPISODE] = (string)$row[iFace::COLUMN_EPISODE];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_TITLE] = ag(
$item,
['title', 'originalTitle'],
'??'
);
if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey'], null))) {
$row[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_PARENT] = $row[iFace::COLUMN_PARENT];
if (null !== ($lastPlayedAt = ag($item, 'lastViewedAt')) & 1 === (int)(bool)ag($item, 'viewCount', false)) {
$fields[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$lastPlayedAt;
}
}
if (null !== ($mediaYear = ag($item, ['grandParentYear', 'parentYear', 'year']))) {
$row[iFace::COLUMN_YEAR] = (int)$mediaYear;
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($guids = $this->getGuids(ag($item, 'Guid', []))) && !empty($guids)) {
$guids += Guid::makeVirtualGuid($this->name, (string)$id);
$fields[iFace::COLUMN_GUIDS] = $guids;
$fields[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_GUIDS] = $fields[iFace::COLUMN_GUIDS];
}
if (null !== ($premiereDate = ag($item, 'originallyAvailableAt'))) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate(
$premiereDate
)->format('Y-m-d');
$entity = $this->createEntity(
item: ag($this->getMetadata(id: $id), 'MediaContainer.Metadata.0', []),
type: $type,
opts: ['override' => $fields],
)->setIsTainted(isTainted: true === in_array($event, self::WEBHOOK_TAINTED_EVENTS));
} catch (Throwable $e) {
throw new HttpException(
sprintf(
'%s: Request to get item id \'%s\' metadata failed. %s',
self::NAME,
$id,
$e->getMessage()
), 500
);
}
if (null !== ($lastPlayedAt = ag($item, 'lastViewedAt')) & 1 === (int)(bool)ag($item, 'viewCount', false)) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$lastPlayedAt;
}
$entity = Container::get(iFace::class)::fromArray($row)->setIsTainted($isTainted);
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
$message = sprintf('%s: No valid/supported external ids.', self::NAME);
if (empty($item['Guid'])) {
if (empty($guids)) {
$message .= sprintf(' Most likely unmatched %s.', $entity->type);
}
@@ -452,13 +426,16 @@ class PlexServer implements ServerInterface
try {
$url = $this->url->withPath('/hubs/search')->withQuery(
http_build_query(
[
'query' => $query,
'limit' => $limit,
'includeGuids' => 1,
'includeExternalMedia' => 0,
'includeCollections' => 0,
]
array_replace_recursive(
[
'query' => $query,
'limit' => $limit,
'includeGuids' => 1,
'includeExternalMedia' => 0,
'includeCollections' => 0,
],
$opts['query'] ?? []
)
)
);
@@ -466,7 +443,11 @@ class PlexServer implements ServerInterface
'url' => $url
]);
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
$response = $this->http->request(
'GET',
(string)$url,
array_replace_recursive($this->getHeaders(), $opts['headers'] ?? [])
);
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
@@ -487,43 +468,133 @@ class PlexServer implements ServerInterface
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
foreach (ag($json, 'MediaContainer.Hub', []) as $item) {
$type = ag($item, 'type');
foreach (ag($json, 'MediaContainer.Hub', []) as $leaf) {
$type = ag($leaf, 'type');
if ('show' !== $type && 'movie' !== $type) {
if ('show' !== $type && 'movie' !== $type && 'episode' !== $type) {
continue;
}
foreach (ag($item, 'Metadata', []) as $subItem) {
$list[] = $subItem;
foreach (ag($leaf, 'Metadata', []) as $item) {
$watchedAt = ag($item, 'lastViewedAt');
$year = (int)ag($item, 'year', 0);
if (0 === $year && null !== ($airDate = ag($item, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$episodeNumber = ('episode' === $type) ? sprintf(
'%sx%s - ',
str_pad((string)(ag($item, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT),
str_pad((string)(ag($item, 'index', 0)), 3, '0', STR_PAD_LEFT),
) : null;
$builder = [
'id' => (int)ag($item, 'ratingKey'),
'type' => ucfirst(ag($item, 'type', '??')),
'library' => ag($item, 'librarySectionTitle', '??'),
'title' => $episodeNumber . mb_substr(ag($item, ['title', 'originalTitle'], '??'), 0, 50),
'year' => $year,
'addedAt' => makeDate(ag($item, 'addedAt'))->format('Y-m-d H:i:s T'),
'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'None',
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $item;
}
$list[] = $builder;
}
}
return true === ag($opts, Options::RAW_RESPONSE) ? $list : filterResponse($list, self::BACKEND_CAST_KEYS);
return $list;
} catch (ExceptionInterface|JsonException $e) {
throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e);
}
}
/**
* @throws InvalidArgumentException
*/
public function searchId(string|int $id, array $opts = []): array
{
$item = $this->getMetadata($id, $opts);
$metadata = ag($item, 'MediaContainer.Metadata.0', []);
$type = ag($metadata, 'type');
$watchedAt = ag($metadata, 'lastViewedAt');
$year = (int)ag($metadata, ['year', 'parentYear', 'grandparentYear'], 0);
if (0 === $year && null !== ($airDate = ag($metadata, 'originallyAvailableAt'))) {
$year = (int)makeDate($airDate)->format('Y');
}
$episodeNumber = ('episode' === $type) ? sprintf(
'%sx%s - ',
str_pad((string)(ag($metadata, 'parentIndex', 0)), 2, '0', STR_PAD_LEFT),
str_pad((string)(ag($metadata, 'index', 0)), 3, '0', STR_PAD_LEFT),
) : null;
$builder = [
'id' => (int)ag($metadata, 'ratingKey'),
'type' => ucfirst(ag($metadata, 'type', '??')),
'library' => ag($metadata, 'librarySectionTitle', '??'),
'title' => $episodeNumber . mb_substr(ag($metadata, ['title', 'originalTitle'], '??'), 0, 50),
'year' => $year,
'addedAt' => makeDate(ag($metadata, 'addedAt'))->format('Y-m-d H:i:s T'),
'watchedAt' => null !== $watchedAt ? makeDate($watchedAt)->format('Y-m-d H:i:s T') : 'None',
'duration' => ag($metadata, 'duration') ? formatDuration(ag($metadata, 'duration')) : 'None',
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $item;
}
return $builder;
}
/**
* @throws InvalidArgumentException
*/
public function getMetadata(string|int $id, array $opts = []): array
{
$this->checkConfig();
$cacheKey = false === ((bool)($opts['nocache'] ?? false)) ? $this->getName() . '_' . $id . '_metadata' : null;
if (null !== $cacheKey && $this->cacheIO->has($cacheKey)) {
return $this->cacheIO->get(key: $cacheKey);
}
try {
$url = $this->url->withPath('/library/metadata/' . $id)->withQuery(
http_build_query(['includeGuids' => 1])
http_build_query(
array_merge_recursive(
[
'includeGuids' => 1
],
$opts['query'] ?? []
)
)
);
$this->logger->debug(sprintf('%s: Sending get meta data for id \'%s\'.', $this->name, $id), [
'url' => $url
]);
$this->logger->debug(sprintf('%s: Requesting metadata for #\'%s\'.', $this->name, $id), ['url' => $url]);
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
$response = $this->http->request(
'GET',
(string)$url,
array_replace_recursive(
$this->getHeaders(),
$opts['headers'] ?? []
)
);
if (200 !== $response->getStatusCode()) {
throw new RuntimeException(
sprintf(
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
'%s: Request for #\'%s\' metadata responded with unexpected http status code \'%d\'.',
$this->name,
$id,
$response->getStatusCode()
@@ -531,13 +602,17 @@ class PlexServer implements ServerInterface
);
}
$json = json_decode(
$item = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
return true === ag($opts, Options::RAW_RESPONSE) ? $json : filterResponse($json, self::BACKEND_CAST_KEYS);
if (null !== $cacheKey) {
$this->cacheIO->set(key: $cacheKey, value: $item, ttl: new DateInterval('PT10M'));
}
return $item;
} catch (ExceptionInterface|JsonException $e) {
throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e);
}
@@ -713,13 +788,13 @@ class PlexServer implements ServerInterface
};
$it = Items::fromIterable(
httpClientChunks($this->http->stream($response)),
[
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
)
]
iterable: httpClientChunks(stream: $this->http->stream($response)),
options: [
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
innerDecoder: new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
)
]
);
$requests = [];
@@ -772,7 +847,7 @@ class PlexServer implements ServerInterface
foreach ($requests as $response) {
if (200 !== $response->getStatusCode()) {
$this->logger->error(
$this->logger->warning(
sprintf(
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
$this->name,
@@ -796,7 +871,7 @@ class PlexServer implements ServerInterface
}
}
public function listLibraries(): array
public function listLibraries(array $opts = []): array
{
$this->checkConfig();
@@ -866,7 +941,7 @@ class PlexServer implements ServerInterface
$key = (int)ag($section, 'key');
$type = ag($section, 'type', 'unknown');
$list[] = [
$builder = [
'id' => $key,
'title' => ag($section, 'title', '???'),
'type' => $type,
@@ -875,6 +950,12 @@ class PlexServer implements ServerInterface
'agent' => ag($section, 'agent'),
'scanner' => ag($section, 'scanner'),
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$builder['raw'] = $section;
}
$list[] = $builder;
}
return $list;
@@ -883,8 +964,8 @@ class PlexServer implements ServerInterface
public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array
{
return $this->getLibraries(
ok: function (string $cName, string $type) use ($after, $mapper) {
return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) {
ok: function (string $cName, string $type, string|int $id, UriInterface|string $url) use ($after, $mapper) {
return function (ResponseInterface $response) use ($mapper, $cName, $type, $after, $id, $url) {
if (200 !== $response->getStatusCode()) {
$this->logger->error(
sprintf(
@@ -892,7 +973,10 @@ class PlexServer implements ServerInterface
$this->name,
$cName,
$response->getStatusCode()
)
),
[
'url' => (string)$url,
]
);
return;
}
@@ -901,13 +985,16 @@ class PlexServer implements ServerInterface
$this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName));
$it = Items::fromIterable(
httpClientChunks($this->http->stream($response)),
[
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE)
)
]
iterable: httpClientChunks($this->http->stream($response)),
options: [
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
innerDecoder: new ExtJsonDecoder(
assoc: true,
options: JSON_INVALID_UTF8_IGNORE
)
)
]
);
foreach ($it as $entity) {
@@ -925,7 +1012,15 @@ class PlexServer implements ServerInterface
);
continue;
}
$this->processImport($mapper, $type, $cName, $entity, $after);
$this->processImport(
mapper: $mapper,
type: $type,
library: $cName,
item: $entity,
after: $after,
opts: ['library' => $id]
);
}
} catch (PathNotFoundException $e) {
$this->logger->error(
@@ -960,11 +1055,11 @@ class PlexServer implements ServerInterface
$this->logger->info(sprintf('%s: Parsing \'%s\' response is complete.', $this->name, $cName));
};
},
error: function (string $cName, string $type, UriInterface|string $url) {
error: function (string $cName, string $type, string|int $id, UriInterface|string $url) {
return fn(Throwable $e) => $this->logger->error(
sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()),
[
'url' => $url,
'url' => (string)$url,
'file' => $e->getFile(),
'line' => $e->getLine(),
]
@@ -1193,8 +1288,12 @@ class PlexServer implements ServerInterface
public function export(ImportInterface $mapper, QueueRequests $queue, DateTimeInterface|null $after = null): array
{
return $this->getLibraries(
ok: function (string $cName, string $type) use ($mapper, $queue, $after) {
return function (ResponseInterface $response) use ($mapper, $queue, $cName, $type, $after) {
ok: function (string $cName, string $type, string|int $id, UriInterface|string $url) use (
$mapper,
$queue,
$after
) {
return function (ResponseInterface $response) use ($mapper, $queue, $cName, $type, $after, $id, $url) {
if (200 !== $response->getStatusCode()) {
$this->logger->error(
sprintf(
@@ -1202,7 +1301,10 @@ class PlexServer implements ServerInterface
$this->name,
$cName,
$response->getStatusCode()
)
),
[
'url' => (string)$url,
]
);
return;
}
@@ -1211,13 +1313,16 @@ class PlexServer implements ServerInterface
$this->logger->info(sprintf('%s: Parsing \'%s\' response.', $this->name, $cName));
$it = Items::fromIterable(
httpClientChunks($this->http->stream($response)),
[
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
new ExtJsonDecoder(options: JSON_INVALID_UTF8_IGNORE)
)
]
iterable: httpClientChunks(stream: $this->http->stream($response)),
options: [
'pointer' => '/MediaContainer/Metadata',
'decoder' => new ErrorWrappingDecoder(
innerDecoder: new ExtJsonDecoder(
assoc: true,
options: JSON_INVALID_UTF8_IGNORE
)
)
]
);
foreach ($it as $entity) {
@@ -1235,7 +1340,16 @@ class PlexServer implements ServerInterface
);
continue;
}
$this->processExport($mapper, $queue, $type, $cName, $entity, $after);
$this->processExport(
mapper: $mapper,
queue: $queue,
type: $type,
library: $cName,
item: $entity,
after: $after,
opts: ['library' => $id]
);
}
} catch (PathNotFoundException $e) {
$this->logger->error(
@@ -1270,7 +1384,7 @@ class PlexServer implements ServerInterface
$this->logger->info(sprintf('%s: Parsing \'%s\' response is complete.', $this->name, $cName));
};
},
error: function (string $cName, string $type, UriInterface|string $url) {
error: function (string $cName, string $type, string|int $id, UriInterface|string $url) {
return fn(Throwable $e) => $this->logger->error(
sprintf('%s: Error encountered in \'%s\' request. %s', $this->name, $cName, $e->getMessage()),
[
@@ -1415,8 +1529,8 @@ class PlexServer implements ServerInterface
(string)$url,
array_replace_recursive($this->getHeaders(), [
'user_data' => [
'ok' => $ok($cName, 'show', $url),
'error' => $error($cName, 'show', $url),
'ok' => $ok(cName: $cName, type: 'show', id: $key, url: $url),
'error' => $error(cName: $cName, type: 'show', id: $key, url: $url),
]
])
);
@@ -1487,8 +1601,8 @@ class PlexServer implements ServerInterface
(string)$url,
array_replace_recursive($this->getHeaders(), [
'user_data' => [
'ok' => $ok($cName, $type, $url),
'error' => $error($cName, $type, $url),
'ok' => $ok(cName: $cName, type: $type, id: $key, url: $url),
'error' => $error(cName: $cName, type: $type, id: $key, url: $url),
]
])
);
@@ -1522,8 +1636,9 @@ class PlexServer implements ServerInterface
ImportInterface $mapper,
string $type,
string $library,
StdClass $item,
DateTimeInterface|null $after = null
array $item,
DateTimeInterface|null $after = null,
array $opts = []
): void {
try {
if ('show' === $type) {
@@ -1538,28 +1653,28 @@ class PlexServer implements ServerInterface
$iName = sprintf(
'%s - [%s (%d)]',
$library,
$item->title ?? $item->originalTitle ?? '??',
$item->year ?? 0000
ag($item, ['title', 'originalTitle'], '??'),
ag($item, 'year', 0000)
);
} else {
$iName = trim(
sprintf(
'%s - [%s - (%sx%s)]',
$library,
$item->grandparentTitle ?? $item->originalTitle ?? '??',
str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT),
str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT),
ag($item, ['grandparentTitle', 'originalTitle'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
)
);
}
if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) {
$this->logger->debug(sprintf('%s: Processing \'%s\' Payload.', $this->name, $iName), [
'payload' => (array)$item,
'payload' => $item,
]);
}
$date = max((int)($item->lastViewedAt ?? 0), (int)($item->addedAt ?? 0));
$date = max((int)ag($item, 'lastViewedAt', 0), (int)ag($item, 'addedAt', 0));
if (0 === $date) {
$this->logger->debug(
@@ -1572,11 +1687,11 @@ class PlexServer implements ServerInterface
return;
}
$entity = $this->createEntity($item, $type);
$entity = $this->createEntity(item: $item, type: $type, opts: $opts);
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
if (true === Config::get('debug.import')) {
$name = Config::get('tmpDir') . '/debug/' . $this->name . '.' . $item->ratingKey . '.json';
$name = Config::get('tmpDir') . '/debug/' . $this->name . '.' . $item['ratingKey'] . '.json';
if (!file_exists($name)) {
file_put_contents(
@@ -1591,17 +1706,17 @@ class PlexServer implements ServerInterface
$message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName);
if (empty($item->Guid)) {
if (empty($item['Guid'])) {
$message .= sprintf(' Most likely unmatched %s.', $entity->type);
}
if (null === ($item->Guid ?? null)) {
$item->Guid = [['id' => $item->guid]];
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [['id' => $item['guid']]];
} else {
$item->Guid[] = ['id' => $item->guid];
$item['Guid'][] = ['id' => $item['guid']];
}
$this->logger->info($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']);
$this->logger->info($message, ['guids' => !empty($item['Guid']) ? $item['Guid'] : 'None']);
Data::increment($this->name, $type . '_ignored_no_supported_guid');
return;
@@ -1622,8 +1737,9 @@ class PlexServer implements ServerInterface
QueueRequests $queue,
string $type,
string $library,
StdClass $item,
DateTimeInterface|null $after = null
array $item,
DateTimeInterface|null $after = null,
array $opts = [],
): void {
try {
Data::increment($this->name, $type . '_total');
@@ -1632,50 +1748,50 @@ class PlexServer implements ServerInterface
$iName = sprintf(
'%s - [%s (%d)]',
$library,
$item->title ?? $item->originalTitle ?? '??',
$item->year ?? 0000
ag($item, ['title', 'originalTitle'], '??'),
ag($item, 'year', 0000)
);
} else {
$iName = trim(
sprintf(
'%s - [%s - (%sx%s)]',
$library,
$item->grandparentTitle ?? $item->originalTitle ?? '??',
str_pad((string)($item->parentIndex ?? 0), 2, '0', STR_PAD_LEFT),
str_pad((string)($item->index ?? 0), 3, '0', STR_PAD_LEFT),
ag($item, ['grandparentTitle', 'originalTitle'], '??'),
str_pad((string)ag($item, 'parentIndex', 0), 2, '0', STR_PAD_LEFT),
str_pad((string)ag($item, 'index', 0), 3, '0', STR_PAD_LEFT),
)
);
}
$date = max((int)($item->lastViewedAt ?? 0), (int)($item->addedAt ?? 0));
$date = max((int)ag($item, 'lastViewedAt', 0), (int)ag($item, 'addedAt', 0));
if (0 === $date) {
$this->logger->notice(
sprintf('%s: Ignoring \'%s\'. Date is not set on backend object.', $this->name, $iName),
[
'payload' => get_object_vars($item),
'payload' => $item,
]
);
Data::increment($this->name, $type . '_ignored_no_date_is_set');
return;
}
$rItem = $this->createEntity($item, $type);
$rItem = $this->createEntity(item: $item, type: $type, opts: $opts);
if (!$rItem->hasGuids() && !$rItem->hasRelativeGuid()) {
$message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName);
if (empty($item->Guid)) {
if (empty($item['Guid'])) {
$message .= sprintf(' Most likely unmatched %s.', $rItem->type);
}
if (null === ($item->Guid ?? null)) {
$item->Guid = [['id' => $item->guid]];
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [['id' => $item['guid']]];
} else {
$item->Guid[] = ['id' => $item->guid];
$item['Guid'][] = ['id' => $item['guid']];
}
$this->logger->debug($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']);
$this->logger->debug($message, ['guids' => !empty($item['Guid']) ? $item['Guid'] : 'None']);
Data::increment($this->name, $type . '_ignored_no_supported_guid');
return;
@@ -1743,7 +1859,7 @@ class PlexServer implements ServerInterface
http_build_query(
[
'identifier' => 'com.plexapp.plugins.library',
'key' => $item->ratingKey,
'key' => $item['ratingKey'],
]
)
);
@@ -1785,12 +1901,12 @@ class PlexServer implements ServerInterface
}
}
protected function processShow(StdClass $item, string $library): void
protected function processShow(array $item, string $library): void
{
if (null === ($item->Guid ?? null)) {
$item->Guid = [['id' => $item->guid]];
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [['id' => $item['guid']]];
} else {
$item->Guid[] = ['id' => $item->guid];
$item['Guid'][] = ['id' => $item['guid']];
}
$iName = sprintf(
@@ -1802,39 +1918,39 @@ class PlexServer implements ServerInterface
if (true === (bool)ag($this->options, Options::DEBUG_TRACE)) {
$this->logger->debug(sprintf('%s: Processing \'%s\' Payload.', $this->name, $iName), [
'payload' => (array)$item,
'payload' => $item,
]);
}
if (!$this->hasSupportedGuids(guids: $item->Guid)) {
if (null === ($item->Guid ?? null)) {
$item->Guid = [['id' => $item->guid]];
if (!$this->hasSupportedGuids(guids: $item['Guid'])) {
if (null === ($item['Guid'] ?? null)) {
$item['Guid'] = [['id' => $item['guid']]];
} else {
$item->Guid[] = ['id' => $item->guid];
$item['Guid'][] = ['id' => $item['guid']];
}
$message = sprintf('%s: Ignoring \'%s\'. No valid/supported external ids.', $this->name, $iName);
if (empty($item->Guid)) {
if (empty($item['Guid'] ?? [])) {
$message .= ' Most likely unmatched TV show.';
}
$this->logger->info($message, ['guids' => !empty($item->Guid) ? $item->Guid : 'None']);
$this->logger->info($message, ['guids' => !empty($item['Guid']) ? $item['Guid'] : 'None']);
return;
}
$this->cache['shows'][$item->ratingKey] = Guid::fromArray($this->getGuids($item->Guid))->getAll();
$this->cache['shows'][$item['ratingKey']] = Guid::fromArray($this->getGuids($item['Guid']))->getAll();
}
protected function parseGuids(array $guids): array
{
$guid = [];
foreach ($guids as $_id) {
try {
$val = is_object($_id) ? $_id->id : $_id['id'];
$ids = array_column($guids, 'id');
foreach ($ids as $val) {
try {
if (empty($val)) {
continue;
}
@@ -1869,10 +1985,10 @@ class PlexServer implements ServerInterface
{
$guid = [];
foreach ($guids as $_id) {
try {
$val = is_object($_id) ? $_id->id : $_id['id'];
$ids = array_column($guids, 'id');
foreach ($ids as $val) {
try {
if (empty($val)) {
continue;
}
@@ -2008,73 +2124,88 @@ class PlexServer implements ServerInterface
}
}
protected function createEntity(StdClass $item, string $type): StateEntity
protected function createEntity(StdClass|array $item, string $type, array $opts = []): StateEntity
{
if (null === ($item->Guid ?? null)) {
$item->Guid = [['id' => $item->guid]];
} else {
$item->Guid[] = ['id' => $item->guid];
if (false === is_array($item)) {
$item = (array)$item;
}
$date = max((int)($item->lastViewedAt ?? 0), (int)($item->addedAt ?? 0));
if (null === ag($item, 'Guid')) {
$item['Guid'] = [['id' => ag($item, 'guid')]];
} else {
$item['Guid'][] = ['id' => ag($item, 'guid')];
}
$guids = $this->getGuids($item->Guid ?? []);
$guids += Guid::makeVirtualGuid($this->name, (string)$item->ratingKey);
$date = max((int)ag($item, 'lastViewedAt', 0), (int)(ag($item, 'addedAt', 0)));
$row = [
$guids = $this->getGuids(ag($item, 'Guid', []));
$guids += Guid::makeVirtualGuid($this->name, (string)ag($item, 'ratingKey'));
$builder = [
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_UPDATED => $date,
iFace::COLUMN_WATCHED => (int)(bool)($item->viewCount ?? false),
iFace::COLUMN_WATCHED => (int)(bool)ag($item, 'viewCount', false),
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => $item->title ?? $item->originalTitle ?? '??',
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $guids,
iFace::COLUMN_META_DATA => [
$this->name => [
iFace::COLUMN_ID => (string)$item->ratingKey,
iFace::COLUMN_ID => (string)ag($item, 'ratingKey'),
iFace::COLUMN_TYPE => $type,
iFace::COLUMN_WATCHED => (string)(int)(bool)($item->viewCount ?? false),
iFace::COLUMN_WATCHED => (string)(int)(bool)ag($item, 'viewCount', false),
iFace::COLUMN_VIA => $this->name,
iFace::COLUMN_TITLE => $item->title ?? $item->originalTitle ?? '??',
iFace::COLUMN_GUIDS => $this->parseGuids($item->Guid ?? []),
iFace::COLUMN_META_DATA_ADDED_AT => (string)$item->addedAt,
iFace::COLUMN_TITLE => ag($item, ['title', 'originalTitle'], '??'),
iFace::COLUMN_GUIDS => $this->parseGuids(ag($item, 'Guid', [])),
iFace::COLUMN_META_DATA_ADDED_AT => (string)ag($item, 'addedAt'),
],
],
iFace::COLUMN_EXTRA => [],
];
$metadata = &$builder[iFace::COLUMN_META_DATA][$this->name];
$metadataExtra = &$metadata[iFace::COLUMN_META_DATA_EXTRA];
if (null !== ($library = ag($item, 'librarySectionID', $opts['library'] ?? null))) {
$metadata[iFace::COLUMN_META_LIBRARY] = (string)$library;
}
if (iFace::TYPE_EPISODE === $type) {
$row[iFace::COLUMN_TITLE] = $item->grandparentTitle ?? '??';
$row[iFace::COLUMN_SEASON] = $item->parentIndex ?? 0;
$row[iFace::COLUMN_EPISODE] = $item->index ?? 0;
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_TITLE] = $item->grandparentTitle ?? '??';
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_SEASON] = (string)$row[iFace::COLUMN_SEASON];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_EPISODE] = (string)$row[iFace::COLUMN_EPISODE];
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_TITLE] = $item->title ?? $item->originalTitle ?? '??';
$builder[iFace::COLUMN_SEASON] = (int)ag($item, 'parentIndex', 0);
$builder[iFace::COLUMN_EPISODE] = (int)ag($item, 'index', 0);
$parentId = $item->grandparentRatingKey ?? $item->parentRatingKey ?? null;
$metadata[iFace::COLUMN_META_SHOW] = (string)ag($item, ['grandparentRatingKey', 'parentRatingKey'], '??');
if (null !== $parentId) {
$row[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_PARENT] = $row[iFace::COLUMN_PARENT];
$metadata[iFace::COLUMN_TITLE] = ag($item, 'grandparentTitle', '??');
$metadata[iFace::COLUMN_SEASON] = (string)$builder[iFace::COLUMN_SEASON];
$metadata[iFace::COLUMN_EPISODE] = (string)$builder[iFace::COLUMN_EPISODE];
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_TITLE] = $builder[iFace::COLUMN_TITLE];
$builder[iFace::COLUMN_TITLE] = $metadata[iFace::COLUMN_TITLE];
if (null !== ($parentId = ag($item, ['grandparentRatingKey', 'parentRatingKey']))) {
$builder[iFace::COLUMN_PARENT] = $this->getEpisodeParent($parentId);
$metadata[iFace::COLUMN_PARENT] = $builder[iFace::COLUMN_PARENT];
}
}
if (null !== ($mediaYear = $item->grandParentYear ?? $item->parentYear ?? $item->year ?? null)) {
$row[iFace::COLUMN_YEAR] = (int)$mediaYear;
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_YEAR] = (string)$mediaYear;
if (null !== ($mediaYear = ag($item, ['grandParentYear', 'parentYear', 'year'])) && !empty($mediaYear)) {
$builder[iFace::COLUMN_YEAR] = (int)$mediaYear;
$metadata[iFace::COLUMN_YEAR] = (string)$mediaYear;
}
if (null !== ($item->originallyAvailableAt ?? null)) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_EXTRA][iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate(
$item->originallyAvailableAt
)->format('Y-m-d');
if (null !== ($PremieredAt = ag($item, 'originallyAvailableAt'))) {
$metadataExtra[iFace::COLUMN_META_DATA_EXTRA_DATE] = makeDate($PremieredAt)->format('Y-m-d');
}
if (null !== ($item->lastViewedAt ?? null)) {
$row[iFace::COLUMN_META_DATA][$this->name][iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$item->lastViewedAt;
if (null !== ($playedAt = ag($item, 'lastViewedAt'))) {
$metadata[iFace::COLUMN_META_DATA_PLAYED_AT] = (string)$playedAt;
}
return Container::get(iFace::class)::fromArray($row);
unset($metadata, $metadataExtra);
$builder = array_replace_recursive($builder, $opts['override'] ?? []);
return Container::get(iFace::class)::fromArray($builder);
}
protected function getEpisodeParent(int|string $id): array

View File

@@ -128,6 +128,16 @@ interface ServerInterface
*/
public function searchId(string|int $id, array $opts = []): array;
/**
* Get Specific item metadata.
*
* @param string|int $id
* @param array $opts
*
* @return array
*/
public function getMetadata(string|int $id, array $opts = []): array;
/**
* Get Library content.
*
@@ -181,8 +191,9 @@ interface ServerInterface
/**
* Return list of server libraries.
*
* @param array $opts
*
* @return array
*/
public function listLibraries(): array;
public function listLibraries(array $opts = []): array;
}

View File

@@ -307,14 +307,14 @@ if (!function_exists('httpClientChunks')) {
/**
* Handle Response Stream as Chunks
*
* @param ResponseStreamInterface $responseStream
* @param ResponseStreamInterface $stream
* @return Generator
*
* @throws TransportExceptionInterface
*/
function httpClientChunks(ResponseStreamInterface $responseStream): Generator
function httpClientChunks(ResponseStreamInterface $stream): Generator
{
foreach ($responseStream as $chunk) {
foreach ($stream as $chunk) {
yield $chunk->getContent();
}
}
@@ -516,44 +516,6 @@ if (!function_exists('isValidName')) {
}
}
if (false === function_exists('filterResponse')) {
function filterResponse(object|array $item, array $cast = []): array
{
if (false === is_array($item)) {
$item = (array)$item;
}
if (empty($cast)) {
return $item;
}
$modified = [];
foreach ($item as $key => $value) {
if (true === is_array($value) || true === is_object($value)) {
$modified[$key] = filterResponse($value, $cast);
continue;
}
if (null === ($cast[$key] ?? null)) {
$modified[$key] = $value;
continue;
}
$modified[$key] = match ($cast[$key] ?? null) {
'datetime' => makeDate($value),
'size' => strlen((string)$value) >= 4 ? fsize($value) : $value,
'duration_sec' => formatDuration($value),
'duration_mil' => formatDuration($value / 10000),
'bool' => (bool)$value,
default => is_callable($cast[$key] ?? null) ? $cast[$key]($value) : $value,
};
}
return $modified;
}
}
if (false === function_exists('formatDuration')) {
function formatDuration(int|float $milliseconds): string
{