Cleaned up backend code, and seperated and removed servers:remote command.
This commit is contained in:
219
FAQ.md
219
FAQ.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
63
README.md
63
README.md
@@ -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.
|
||||
|
||||
----
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 += [
|
||||
|
||||
117
src/Commands/Backend/Search/IdCommand.php
Normal file
117
src/Commands/Backend/Search/IdCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Commands/Backend/Search/QueryCommand.php
Normal file
122
src/Commands/Backend/Search/QueryCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Commands/Backend/Users/ListCommand.php
Normal file
136
src/Commands/Backend/Users/ListCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user