Initial commit.
This commit is contained in:
30
.editorconfig
Normal file
30
.editorconfig
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# EditorConfig is awesome: http://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# All PHP files MUST use the Unix LF (linefeed) line ending.
|
||||||
|
# Code MUST use an indent of 4 spaces, and MUST NOT use tabs for indenting.
|
||||||
|
# All PHP files MUST end with a single blank line.
|
||||||
|
# There MUST NOT be trailing whitespace at the end of non-blank lines.
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# PHP-Files, Composer.json, MD-Files
|
||||||
|
[{*.php,composer.json,*.md}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# HTML-Files LESS-Files SASS-Files CSS-Files JS-Files JSON-Files
|
||||||
|
[{*.html,*.less,*.sass,*.css,*.js,*.json}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# Gitlab-CI, Travis-CI
|
||||||
|
[{*.yml,*.yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/.idea/*
|
||||||
|
/vendor/*
|
||||||
10
.phpstorm.meta.php
Normal file
10
.phpstorm.meta.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PHPSTORM_META;
|
||||||
|
|
||||||
|
override(\App\Libs\Container::get(0), map(['' => '@']));
|
||||||
|
override(\App\Libs\Extends\PSRContainer::get(0), map(['' => '@']));
|
||||||
|
override(\Psr\Container\ContainerInterface::get(0), map(['' => '@']));
|
||||||
|
override(\League\Container\ReflectionContainer::get(0), map(['' => '@']));
|
||||||
|
override(\App\Libs\Container::getNew(0), map(['' => '@']));
|
||||||
|
override(\App\Libs\Extends\PSRContainer::getNew(0), map(['' => '@']));
|
||||||
19
LICENSE
Normal file
19
LICENSE
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2022 Abdulmohsen B. A. A.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
228
README.md
Normal file
228
README.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Watch State Sync (Early Preview)
|
||||||
|
|
||||||
|
A CLI based app to sync watch state between different media servers.
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
I created this app for my own personal use, I had multiple problems with Plex trakt.tv plugin which led to my account
|
||||||
|
being banned at trakt.tv, and on top of that the plugin no longer supported. And I like to keep my own data locally if
|
||||||
|
possible.
|
||||||
|
|
||||||
|
# Supported Media servers.
|
||||||
|
|
||||||
|
* Plex
|
||||||
|
* Emby
|
||||||
|
* Jellyfin
|
||||||
|
|
||||||
|
## Install (Early Preview)
|
||||||
|
|
||||||
|
Clone this repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ArabCoders/watchstate.git
|
||||||
|
```
|
||||||
|
|
||||||
|
after cloning the app, you have two choices
|
||||||
|
|
||||||
|
--------
|
||||||
|
|
||||||
|
# Run the CLI version only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd watchstate/docker/cli
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
# Run Full Version with Webhooks Support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd watchstate/docker/full
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This docker container will expose port 8081 by default to listen for webhooks calls.
|
||||||
|
|
||||||
|
====
|
||||||
|
|
||||||
|
# First time
|
||||||
|
|
||||||
|
regardless of what container type you have used you have to set up your servers, to do so run the following command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti watchstate console config:dump servers
|
||||||
|
```
|
||||||
|
|
||||||
|
after running the command you should have a file called ``servers.yaml`` inside ``watchstate/var/config/``, with
|
||||||
|
examples of how to define servers to use.
|
||||||
|
|
||||||
|
## First time Import
|
||||||
|
|
||||||
|
after configuring your servers at ``watchstate/var/config/servers.yaml`` you should import your current watch state by
|
||||||
|
running the following command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti watchstate console state:import
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TIP
|
||||||
|
|
||||||
|
to watch lovely debug information you could run the command with -vvv it will show excessive information, be careful it
|
||||||
|
might crash your shell depending on how many servers and media you have. the output is massive.
|
||||||
|
----
|
||||||
|
|
||||||
|
now that you have imported your watch state, you can stop manually running the command again. and rely solely on the
|
||||||
|
webhooks to update the state. If however you don't want to run a webhook server, then you have to make a cronjob that
|
||||||
|
will run the command as you see fit.
|
||||||
|
|
||||||
|
# Example for cronjob, (only for < v1.x)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0 */1 * * * docker exec -ti watchstate console state:import
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exporting watch state back to servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti watchstate console state:export
|
||||||
|
```
|
||||||
|
|
||||||
|
# Memory usage (Import)
|
||||||
|
|
||||||
|
By default, We use something called ``MemoryMapper`` this mapper store the state in memory during import/export to
|
||||||
|
massively speed up the comparison. However, this approach has drawbacks which is large memory usage. Depending on your
|
||||||
|
media count, it might use 1GB or more of memory per sync operation.(tests done on 2k movies and 70k episodes and 4
|
||||||
|
servers). We recommend this mapper and use it as default.
|
||||||
|
|
||||||
|
However, If you are on a memory constraint system, there is an alternative mapper implementation called ``DirectMapper``
|
||||||
|
, this one work directly on db the only thing stored in memory is the api call body. which get removed as soon as it's
|
||||||
|
parsed. The drawback for this mapper is it's like 10x slower than the memory mapper. for large media servers.
|
||||||
|
|
||||||
|
To see memory usage during the operation run the import command with following flags. ``-vvvrm`` these flags will
|
||||||
|
redirect logger output, log memory usage and print them to the screen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti watchstate console state:import -vvvrm
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to change the Mapper
|
||||||
|
|
||||||
|
Edit ``var/config/config.yaml`` if it does not exist create it, Put the following instruction there
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mapper:
|
||||||
|
import:
|
||||||
|
type: DirectMapper
|
||||||
|
```
|
||||||
|
|
||||||
|
# Servers.yaml
|
||||||
|
|
||||||
|
Example of working servers. You can have as many servers as you want.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# The following instruction works for both jellyfin and emby.
|
||||||
|
jellyfin_basement_server:
|
||||||
|
# What backend server is this can be jellyfin or emby
|
||||||
|
type: jellyfin|emby #Choose one
|
||||||
|
# The Url for api access.
|
||||||
|
url: 'http://172.23.0.12:8096'
|
||||||
|
# Create API token via jellyfin (Dashboard > Advanced > API keys > +)
|
||||||
|
token: api-token
|
||||||
|
options:
|
||||||
|
# Get your user id from jellyfin (Dashboard > Server > Users > click your user > copy the userId= value from url)
|
||||||
|
user: jellfin-user-id
|
||||||
|
export:
|
||||||
|
# Whether to enable exporting watch state back to this server.
|
||||||
|
enabled: true
|
||||||
|
import:
|
||||||
|
# Whether to enable importing watch state from this server.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# For plex.
|
||||||
|
my_plex_server:
|
||||||
|
# What backend server is this
|
||||||
|
type: plex
|
||||||
|
# The Url for api access.
|
||||||
|
url: 'http://172.23.0.12:8096'
|
||||||
|
# Get your plex token, (see https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)
|
||||||
|
token: api-token
|
||||||
|
export:
|
||||||
|
# Whether to enable exporting watch state back to this server.
|
||||||
|
enabled: true
|
||||||
|
import:
|
||||||
|
# Whether to enable importing watch state from this server.
|
||||||
|
enabled: false
|
||||||
|
```
|
||||||
|
|
||||||
|
# Running Webhook server.
|
||||||
|
|
||||||
|
if you want to use webhooks, you have to generate an api key.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -ti watchstate console config:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
If you don't have an api key already set at ``watchstate/var/config/config.yaml`` it will generate new key and store it
|
||||||
|
there under the key of ``webhook.apikey``
|
||||||
|
|
||||||
|
Adding webhook to your server the url will be dependent on how you expose the server, but typically it will be like this
|
||||||
|
``http://localhost:8081/?type=[SERVER_TYPE]&apikey=[YOUR_API_KEY]``
|
||||||
|
|
||||||
|
### [SERVER_TYPE]
|
||||||
|
|
||||||
|
Change the parameter to one of those ``emby, plex or jellyfin``.
|
||||||
|
|
||||||
|
### [YOUR_API_KEY]
|
||||||
|
|
||||||
|
Change this parameter to your api key you can find it by viewing ``var/config/config.yaml`` under the key
|
||||||
|
of ``webhook.apikey``
|
||||||
|
|
||||||
|
# Configuring Media servers to send webhook events.
|
||||||
|
|
||||||
|
#### Jellyfin (Free)
|
||||||
|
|
||||||
|
go to your jellyfin dashboard > plugins > Catalog > install: Notifications > Webhook, restart your jellyfin. After that
|
||||||
|
go back again to dashboard> plugins > webhook. Add A ``Add Generic Destination``,
|
||||||
|
|
||||||
|
##### Webhook Name:
|
||||||
|
|
||||||
|
Choose want you want.
|
||||||
|
|
||||||
|
##### Webhook Url:
|
||||||
|
|
||||||
|
Put your webhook server url here: for example ``http://localhost:8081/?type=jellyfin&apikey=[YOUR_API_KEY]``
|
||||||
|
|
||||||
|
##### Notification Type:
|
||||||
|
|
||||||
|
Select the following events
|
||||||
|
|
||||||
|
* Item Added
|
||||||
|
* User Data Saved
|
||||||
|
|
||||||
|
Click ``save``
|
||||||
|
|
||||||
|
#### Emby (you need emby premiere to use webhooks)
|
||||||
|
|
||||||
|
Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
|
||||||
|
|
||||||
|
##### Webhook Url:
|
||||||
|
|
||||||
|
Put your webhook server url here: for example ``http://localhost:8081/?type=emby&apikey=[YOUR_API_KEY]``
|
||||||
|
|
||||||
|
##### Webhook Events
|
||||||
|
|
||||||
|
Select the following events
|
||||||
|
|
||||||
|
* Playback events
|
||||||
|
* User events
|
||||||
|
|
||||||
|
Click ``Add Webhook``
|
||||||
|
|
||||||
|
#### Plex (you need plex pass use webhooks)
|
||||||
|
|
||||||
|
Go to Plex dashboard > Settings > Your Account > web hooks > (Click ADD WEBHOOK)
|
||||||
|
|
||||||
|
##### URL:
|
||||||
|
|
||||||
|
Put your webhook server url here: for example ``http://localhost:8081/?type=plex&apikey=[YOUR_API_KEY]``
|
||||||
|
|
||||||
|
Click ``Save Changes``
|
||||||
52
composer.json
Normal file
52
composer.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"config": {
|
||||||
|
"platform-check": true,
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": {
|
||||||
|
"*": "dist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-sqlite3": "*",
|
||||||
|
"monolog/monolog": "^2.3",
|
||||||
|
"symfony/console": "^6.0",
|
||||||
|
"symfony/yaml": "^6.0",
|
||||||
|
"league/container": "^4.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.0",
|
||||||
|
"laminas/laminas-diactoros": "^2.0",
|
||||||
|
"laminas/laminas-httphandlerrunner": "^2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"roave/security-advisories": "dev-latest",
|
||||||
|
"symfony/var-dumper": "^6.0",
|
||||||
|
"perftools/php-profiler": "^1.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/Libs/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-php54": "*",
|
||||||
|
"symfony/polyfill-php56": "*",
|
||||||
|
"symfony/polyfill-php70": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php74": "*",
|
||||||
|
"symfony/polyfill-php80": "*",
|
||||||
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*",
|
||||||
|
"symfony/polyfill-php83": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
2393
composer.lock
generated
Normal file
2393
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
config/commands.php
Normal file
23
config/commands.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Commands\Config\DumpCommand;
|
||||||
|
use App\Commands\Config\GenerateCommand;
|
||||||
|
use App\Commands\Config\PHPCommand;
|
||||||
|
use App\Commands\State\ExportCommand;
|
||||||
|
use App\Commands\State\ImportCommand;
|
||||||
|
use App\Commands\Storage\MaintenanceCommand;
|
||||||
|
use App\Commands\Storage\MakeCommand;
|
||||||
|
use App\Commands\Storage\MigrationsCommand;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config:dump' => DumpCommand::class,
|
||||||
|
'config:generate' => GenerateCommand::class,
|
||||||
|
'config:php' => PHPCommand::class,
|
||||||
|
'state:import' => ImportCommand::class,
|
||||||
|
'state:export' => ExportCommand::class,
|
||||||
|
'storage:maintenance' => MaintenanceCommand::class,
|
||||||
|
'storage:migrations' => MigrationsCommand::class,
|
||||||
|
'storage:make' => MakeCommand::class,
|
||||||
|
];
|
||||||
153
config/config.php
Normal file
153
config/config.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Mappers\Export\ExportMapper;
|
||||||
|
use App\Libs\Mappers\Import\MemoryMapper;
|
||||||
|
use App\Libs\Servers\EmbyServer;
|
||||||
|
use App\Libs\Servers\JellyfinServer;
|
||||||
|
use App\Libs\Servers\PlexServer;
|
||||||
|
use App\Libs\Storage\PDO\PDOAdapter;
|
||||||
|
use GuzzleHttp\RequestOptions;
|
||||||
|
use Monolog\Logger;
|
||||||
|
|
||||||
|
return (function () {
|
||||||
|
$config = [
|
||||||
|
'name' => 'WatchState',
|
||||||
|
'version' => 'v0.0.1-alpha',
|
||||||
|
'tz' => null,
|
||||||
|
'path' => fixPath(env('WS_DATA_PATH', fn() => realpath(__DIR__ . DS . '..' . DS . 'var'))),
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['storage'] = [
|
||||||
|
'type' => PDOAdapter::class,
|
||||||
|
'opts' => [
|
||||||
|
'dsn' => 'sqlite:' . ag($config, 'path') . DS . 'db' . DS . 'watchstate.db',
|
||||||
|
'username' => null,
|
||||||
|
'password' => null,
|
||||||
|
'options' => [],
|
||||||
|
'exec' => [
|
||||||
|
'sqlite' => [
|
||||||
|
'PRAGMA journal_mode=WAL'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['webhook'] = [
|
||||||
|
'enabled' => true,
|
||||||
|
'debug' => false,
|
||||||
|
'apikey' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['mapper'] = [
|
||||||
|
'import' => [
|
||||||
|
'type' => MemoryMapper::class,
|
||||||
|
'opts' => [
|
||||||
|
'lazyload' => true
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'export' => [
|
||||||
|
'type' => ExportMapper::class,
|
||||||
|
'opts' => [
|
||||||
|
'lazyload' => true
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['request'] = [
|
||||||
|
'default' => [
|
||||||
|
'options' => [
|
||||||
|
RequestOptions::FORCE_IP_RESOLVE => 'v4',
|
||||||
|
RequestOptions::HEADERS => [
|
||||||
|
'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WatchState/' . Config::get('version'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'export' => [
|
||||||
|
'concurrency' => 75
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['debug'] = [
|
||||||
|
'profiler' => [
|
||||||
|
'options' => [
|
||||||
|
'save.handler' => 'file',
|
||||||
|
'save.handler.file' => [
|
||||||
|
'filename' => ag($config, 'path') . DS . 'logs' . DS . 'profiler_' . gmdate('Y_m_d_His') . '.json'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['logger'] = [
|
||||||
|
'stderr' => [
|
||||||
|
'type' => 'stream',
|
||||||
|
'enabled' => true,
|
||||||
|
'level' => Logger::DEBUG,
|
||||||
|
'filename' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'file' => [
|
||||||
|
'type' => 'stream',
|
||||||
|
'enabled' => false,
|
||||||
|
'level' => Logger::INFO,
|
||||||
|
'filename' => ag($config, 'path') . DS . 'logs' . DS . 'app.log',
|
||||||
|
],
|
||||||
|
'syslog' => [
|
||||||
|
'type' => 'syslog',
|
||||||
|
'facility' => LOG_USER,
|
||||||
|
'enabled' => false,
|
||||||
|
'level' => Logger::INFO,
|
||||||
|
'name' => ag($config, 'name'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['supported'] = [
|
||||||
|
'plex' => PlexServer::class,
|
||||||
|
'jellyfin' => JellyfinServer::class,
|
||||||
|
'emby' => EmbyServer::class
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['servers'] = [];
|
||||||
|
|
||||||
|
$config['php'] = [
|
||||||
|
'ini' => [
|
||||||
|
'disable_functions' => null,
|
||||||
|
'display_errors' => 0,
|
||||||
|
'error_log' => env('IN_DOCKER') ? '/proc/self/fd/2' : 'syslog',
|
||||||
|
'syslog.ident' => 'php-fpm',
|
||||||
|
'post_max_size' => '650M',
|
||||||
|
'upload_max_filesize' => '300M',
|
||||||
|
'memory_limit' => '265M',
|
||||||
|
'pcre.jit' => 1,
|
||||||
|
'gd.jpeg_ignore_warning' => 1,
|
||||||
|
'opcache.enable' => 1,
|
||||||
|
'opcache.memory_consumption' => 128,
|
||||||
|
'opcache.interned_strings_buffer' => 8,
|
||||||
|
'opcache.max_accelerated_files' => 10000,
|
||||||
|
'opcache.max_wasted_percentage' => 5,
|
||||||
|
'expose_php' => 0,
|
||||||
|
'date.timezone' => ag($config, 'tz', 'UTC'),
|
||||||
|
'mbstring.http_input' => ag($config, 'charset', 'UTF-8'),
|
||||||
|
'mbstring.http_output' => ag($config, 'charset', 'UTF-8'),
|
||||||
|
'mbstring.internal_encoding' => ag($config, 'charset', 'UTF-8'),
|
||||||
|
],
|
||||||
|
'fpm' => [
|
||||||
|
'www' => [
|
||||||
|
'pm' => 'dynamic',
|
||||||
|
'pm.max_children' => 10,
|
||||||
|
'pm.start_servers' => 1,
|
||||||
|
'pm.min_spare_servers' => 1,
|
||||||
|
'pm.max_spare_servers' => 3,
|
||||||
|
'pm.max_requests' => 1000,
|
||||||
|
'pm.status_path' => '/fpm_status',
|
||||||
|
'ping.path' => '/fpm_ping',
|
||||||
|
'catch_workers_output' => 'yes',
|
||||||
|
'decorate_workers_output' => 'no',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
})();
|
||||||
13
config/config.yaml
Normal file
13
config/config.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
tz: Asia/Kuwait
|
||||||
|
logger:
|
||||||
|
stderr:
|
||||||
|
level: INFO
|
||||||
|
file:
|
||||||
|
enabled: true
|
||||||
|
type: stream
|
||||||
|
level: ERROR
|
||||||
|
filename: '%(path)%(DS)logs%(DS)app.log'
|
||||||
|
syslog:
|
||||||
|
enabled: false
|
||||||
|
type: syslog
|
||||||
|
level: INFO
|
||||||
9
config/directories.php
Normal file
9
config/directories.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'%(path)/db',
|
||||||
|
'%(path)/logs',
|
||||||
|
'%(path)/config',
|
||||||
|
];
|
||||||
34
config/servers.yaml
Normal file
34
config/servers.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
my_plex_server:
|
||||||
|
type: plex
|
||||||
|
url: 'http://172.23.0.11:32400'
|
||||||
|
token: X-Plex-Token
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
|
lastSync: null
|
||||||
|
import:
|
||||||
|
enabled: false
|
||||||
|
lastSync: null
|
||||||
|
my_jellyfin_server:
|
||||||
|
type: jellyfin
|
||||||
|
url: 'http://172.23.0.12:8096'
|
||||||
|
token: Jellyfin-api-token
|
||||||
|
options:
|
||||||
|
user: jellfin-user-id
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
|
lastSync: null
|
||||||
|
import:
|
||||||
|
enabled: false
|
||||||
|
lastSync: null
|
||||||
|
my_emby_server:
|
||||||
|
type: emby
|
||||||
|
url: 'http://172.23.0.13:8096'
|
||||||
|
token: emby-api-token
|
||||||
|
options:
|
||||||
|
user: emby-user-id
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
|
lastSync: null
|
||||||
|
import:
|
||||||
|
enabled: false
|
||||||
|
lastSync: null
|
||||||
14
config/services.php
Normal file
14
config/services.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
return (function (): array {
|
||||||
|
return [
|
||||||
|
LoggerInterface::class => [
|
||||||
|
'class' => fn() => new Logger('logger')
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})();
|
||||||
75
console
Normal file
75
console
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 'On');
|
||||||
|
|
||||||
|
if (!defined('BASE_MEMORY')) {
|
||||||
|
define('BASE_MEMORY', memory_get_usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('BASE_PEAK_MEMORY')) {
|
||||||
|
define('BASE_PEAK_MEMORY', memory_get_peak_usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('DS')) {
|
||||||
|
define('DS', DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('ROOT_PATH')) {
|
||||||
|
define('ROOT_PATH', __DIR__);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_error_handler(function (int $number, mixed $error, mixed $file, int $line) {
|
||||||
|
$errno = $number & error_reporting();
|
||||||
|
static $errorLevels = [
|
||||||
|
E_ERROR => 'Error',
|
||||||
|
E_WARNING => 'Warning',
|
||||||
|
E_PARSE => 'Parser Error',
|
||||||
|
E_NOTICE => 'Notice',
|
||||||
|
E_CORE_ERROR => 'Core Error',
|
||||||
|
E_CORE_WARNING => 'Core Warning',
|
||||||
|
E_COMPILE_ERROR => 'Compile Error',
|
||||||
|
E_COMPILE_WARNING => 'Compile Warning',
|
||||||
|
E_USER_ERROR => 'User Error',
|
||||||
|
E_USER_WARNING => 'User Warning',
|
||||||
|
E_USER_NOTICE => 'User notice',
|
||||||
|
E_STRICT => 'Strict Notice',
|
||||||
|
E_RECOVERABLE_ERROR => 'Recoverable Error'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (0 === $errno) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
trim(
|
||||||
|
sprintf('%s: %s (%s:%d)' . PHP_EOL, ($errorLevels[$number] ?? (string)$number), $error, $file, $line)
|
||||||
|
) . PHP_EOL
|
||||||
|
);
|
||||||
|
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function (Throwable $e) {
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
trim(
|
||||||
|
sprintf("%s: %s (%s:%d)." . PHP_EOL, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())
|
||||||
|
) . PHP_EOL
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file_exists(__DIR__ . DS . 'vendor' . DS . 'autoload.php')) {
|
||||||
|
fwrite(STDERR, 'Composer dependencies are missing. Run the following commands.' . PHP_EOL);
|
||||||
|
fwrite(STDERR, sprintf('cd %s', dirname(__DIR__)) . PHP_EOL);
|
||||||
|
fwrite(STDERR, 'composer install --optimize-autoloader' . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . DS . 'vendor' . DS . 'autoload.php';
|
||||||
|
|
||||||
|
(new App\Libs\KernelConsole())->boot()->runConsole();
|
||||||
31
docker/cli/Dockerfile
Normal file
31
docker/cli/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM php:8.1-fpm-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="admin@arabcoders.org"
|
||||||
|
|
||||||
|
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/bin/
|
||||||
|
|
||||||
|
RUN chmod +x /usr/bin/install-php-extensions && sync && \
|
||||||
|
install-php-extensions pdo mbstring ctype sqlite3 json opcache xhprof
|
||||||
|
|
||||||
|
RUN apk add --no-cache nano curl procps net-tools iproute2 shadow runuser
|
||||||
|
|
||||||
|
COPY ./entrypoint.sh /entrypoint-docker
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint-docker
|
||||||
|
|
||||||
|
RUN echo '#!/usr/bin/env ash'>/usr/bin/console && echo 'runuser -u www-data -- php /app/console "${@}"'>>/usr/bin/console && \
|
||||||
|
chmod +x /usr/bin/console
|
||||||
|
|
||||||
|
RUN mv "${PHP_INI_DIR}/php.ini-production" "${PHP_INI_DIR}/php.ini"
|
||||||
|
|
||||||
|
# Add Composer
|
||||||
|
#
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint-docker"]
|
||||||
|
|
||||||
|
WORKDIR /config
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
CMD ["php-fpm"]
|
||||||
15
docker/cli/docker-compose.yaml
Normal file
15
docker/cli/docker-compose.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: '3.3'
|
||||||
|
services:
|
||||||
|
watchstate:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: watchstate
|
||||||
|
environment:
|
||||||
|
APP_UID: 1000
|
||||||
|
APP_GID: 1000
|
||||||
|
IN_DOCKER: 1
|
||||||
|
WS_DATA_PATH: /config
|
||||||
|
volumes:
|
||||||
|
- ../../:/app
|
||||||
|
- ../../var:/config:rw
|
||||||
|
- ./entrypoint.sh:/entrypoint-docker
|
||||||
31
docker/cli/entrypoint.sh
Executable file
31
docker/cli/entrypoint.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# check for data path.
|
||||||
|
if [ -z "${WS_DATA_PATH}" ]; then
|
||||||
|
echo "Please set data path in WS_DATA_PATH ENV."
|
||||||
|
exit 1500
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_UID=${APP_UID:-1000}
|
||||||
|
APP_GID=${APP_GID:-1000}
|
||||||
|
|
||||||
|
usermod -u ${APP_UID} www-data
|
||||||
|
groupmod -g ${APP_GID} www-data
|
||||||
|
|
||||||
|
if [ ! -d "/app/vendor" ]; then
|
||||||
|
runuser -u www-data -- composer --ansi --working-dir=/app/ --optimize-autoloader --no-dev --no-progress --no-cache install
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/bin/console config:php >"${PHP_INI_DIR}/conf.d/zz-app-custom-ini-settings.ini"
|
||||||
|
/usr/bin/console config:php --fpm >"${PHP_INI_DIR}/../php-fpm.d/zzz-app-pool-settings.conf"
|
||||||
|
/usr/bin/console storage:migrations
|
||||||
|
/usr/bin/console storage:maintenance
|
||||||
|
|
||||||
|
# first arg is `-f` or `--some-option`
|
||||||
|
if [ "${1#-}" != "$1" ]; then
|
||||||
|
set -- php-fpm "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
6
docker/full/Caddyfile
Normal file
6
docker/full/Caddyfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
http:// {
|
||||||
|
root * /app/public
|
||||||
|
php_fastcgi localhost:9000
|
||||||
|
file_server
|
||||||
|
log
|
||||||
|
}
|
||||||
32
docker/full/Dockerfile
Normal file
32
docker/full/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM php:8.1-fpm-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="admin@arabcoders.org"
|
||||||
|
|
||||||
|
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/bin/
|
||||||
|
|
||||||
|
RUN chmod +x /usr/bin/install-php-extensions && sync && \
|
||||||
|
install-php-extensions pdo mbstring ctype sqlite3 json opcache xhprof
|
||||||
|
|
||||||
|
RUN apk add --no-cache caddy nano curl procps net-tools iproute2 shadow runuser
|
||||||
|
|
||||||
|
COPY ./entrypoint.sh /entrypoint-docker
|
||||||
|
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint-docker
|
||||||
|
|
||||||
|
RUN echo '#!/usr/bin/env ash'>/usr/bin/console && echo 'runuser -u www-data -- php /app/console "${@}"'>>/usr/bin/console && \
|
||||||
|
chmod +x /usr/bin/console
|
||||||
|
|
||||||
|
RUN mv "${PHP_INI_DIR}/php.ini-production" "${PHP_INI_DIR}/php.ini"
|
||||||
|
|
||||||
|
# Add Composer
|
||||||
|
#
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint-docker"]
|
||||||
|
|
||||||
|
WORKDIR /config
|
||||||
|
|
||||||
|
EXPOSE 9000 80
|
||||||
|
|
||||||
|
CMD ["php-fpm"]
|
||||||
17
docker/full/docker-compose.yaml
Normal file
17
docker/full/docker-compose.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: '3.3'
|
||||||
|
services:
|
||||||
|
watchstate:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: watchstate
|
||||||
|
environment:
|
||||||
|
APP_UID: 1000
|
||||||
|
APP_GID: 1000
|
||||||
|
IN_DOCKER: 1
|
||||||
|
WS_DATA_PATH: /config
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
volumes:
|
||||||
|
- ../../:/app
|
||||||
|
- ../../var:/config:rw
|
||||||
|
- ./entrypoint.sh:/entrypoint-docker
|
||||||
35
docker/full/entrypoint.sh
Executable file
35
docker/full/entrypoint.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# check for data path.
|
||||||
|
if [ -z "${WS_DATA_PATH}" ]; then
|
||||||
|
echo "Please set data path in WS_DATA_PATH ENV."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_UID=${APP_UID:-1000}
|
||||||
|
APP_GID=${APP_GID:-1000}
|
||||||
|
|
||||||
|
usermod -u ${APP_UID} www-data
|
||||||
|
groupmod -g ${APP_GID} www-data
|
||||||
|
|
||||||
|
if [ ! -d "/app/vendor" ]; then
|
||||||
|
runuser -u www-data -- composer --ansi --working-dir=/app/ --optimize-autoloader --no-dev --no-progress --no-cache install
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/bin/console config:php >"${PHP_INI_DIR}/conf.d/zz-app-custom-ini-settings.ini"
|
||||||
|
/usr/bin/console config:php --fpm >"${PHP_INI_DIR}/../php-fpm.d/zzz-app-pool-settings.conf"
|
||||||
|
/usr/bin/console storage:migrations
|
||||||
|
/usr/bin/console storage:maintenance
|
||||||
|
|
||||||
|
if [ -f "/etc/caddy/Caddyfile" ]; then
|
||||||
|
caddy start -config /etc/caddy/Caddyfile
|
||||||
|
fi
|
||||||
|
|
||||||
|
# first arg is `-f` or `--some-option`
|
||||||
|
if [ "${1#-}" != "$1" ]; then
|
||||||
|
set -- php-fpm "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
141
public/index.php
Normal file
141
public/index.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Container;
|
||||||
|
use App\Libs\HttpException;
|
||||||
|
use App\Libs\Servers\ServerInterface;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Laminas\Diactoros\Response\EmptyResponse;
|
||||||
|
use Laminas\Diactoros\Response\JsonResponse;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 'On');
|
||||||
|
|
||||||
|
if (!defined('BASE_MEMORY')) {
|
||||||
|
define('BASE_MEMORY', memory_get_usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('BASE_PEAK_MEMORY')) {
|
||||||
|
define('BASE_PEAK_MEMORY', memory_get_peak_usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('DS')) {
|
||||||
|
define('DS', DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('ROOT_PATH')) {
|
||||||
|
define('ROOT_PATH', __DIR__ . '..' . DS);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_error_handler(function (int $number, mixed $error, mixed $file, int $line) {
|
||||||
|
$errno = $number & error_reporting();
|
||||||
|
if (0 === $errno) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syslog(LOG_ERR, trim(sprintf('%s: %s (%s:%d)', $number, $error, $file, $line)));
|
||||||
|
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function (Throwable $e) {
|
||||||
|
syslog(LOG_ERR, trim(sprintf("%s: %s (%s:%d).", get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())));
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
|
||||||
|
echo 'App is not initialized dependencies are missing. Please refer to docs.';
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$fn = function (ServerRequestInterface $request): ResponseInterface {
|
||||||
|
try {
|
||||||
|
if (true !== Config::get('webhook.enabled', false)) {
|
||||||
|
throw new HttpException('Webhook is disabled via config.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === Config::get('webhook.apikey', null)) {
|
||||||
|
throw new HttpException('No webhook.apikey is set in config.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- get apikey from header or query.
|
||||||
|
$apikey = $request->getHeaderLine('x-apikey');
|
||||||
|
if (empty($apikey)) {
|
||||||
|
$apikey = ag($request->getQueryParams(), 'apikey', '');
|
||||||
|
if (empty($apikey)) {
|
||||||
|
throw new HttpException('No API key was given.', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash_equals(Config::get('webhook.apikey'), $apikey)) {
|
||||||
|
throw new HttpException('Invalid API key was given.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ($type = ag($request->getQueryParams(), 'type', null))) {
|
||||||
|
throw new HttpException('No type was given via type= query.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = Config::get('supported', []);
|
||||||
|
|
||||||
|
if (null === ($backend = ag($types, $type))) {
|
||||||
|
throw new HttpException('Invalid server type was given.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = new ReflectionClass($backend);
|
||||||
|
|
||||||
|
if (!$class->implementsInterface(ServerInterface::class)) {
|
||||||
|
throw new HttpException('Invalid Parser Class.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var ServerInterface $backend */
|
||||||
|
$entity = $backend::parseWebhook($request);
|
||||||
|
|
||||||
|
if (null === $entity || !$entity->hasGuids()) {
|
||||||
|
return new EmptyResponse(200, ['X-Status' => 'No GUIDs.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = Container::get(StorageInterface::class);
|
||||||
|
|
||||||
|
if (null === ($backend = $storage->get($entity))) {
|
||||||
|
$storage->insert($entity);
|
||||||
|
return new JsonResponse($entity->getAll(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backend->updated > $entity->updated) {
|
||||||
|
return new EmptyResponse(200, ['X-Status' => 'Entity date is older than what available in storage.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backend->apply($entity)->isChanged()) {
|
||||||
|
$backend = $storage->update($backend);
|
||||||
|
|
||||||
|
return new JsonResponse($backend->getAll(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmptyResponse(200, ['X-Status' => 'Entity is unchanged.']);
|
||||||
|
} catch (HttpException $e) {
|
||||||
|
Container::get(LoggerInterface::class)->error($e->getMessage());
|
||||||
|
|
||||||
|
if (200 === $e->getCode()) {
|
||||||
|
return new EmptyResponse($e->getCode(), [
|
||||||
|
'X-Status' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(
|
||||||
|
[
|
||||||
|
'error' => true,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
],
|
||||||
|
$e->getCode()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(new App\Libs\KernelConsole())->boot()->runHttp($fn);
|
||||||
38
src/Cli.php
Normal file
38
src/Cli.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Extends\PSRContainer;
|
||||||
|
use Composer\InstalledVersions;
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
use Symfony\Component\Console\Input\InputDefinition;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
|
||||||
|
class Cli extends Application
|
||||||
|
{
|
||||||
|
public function __construct(protected PSRContainer $container)
|
||||||
|
{
|
||||||
|
parent::__construct(self::getAppName(), Config::get('version'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAppName(): string
|
||||||
|
{
|
||||||
|
return Config::get('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDefaultInputDefinition(): InputDefinition
|
||||||
|
{
|
||||||
|
$definition = parent::getDefaultInputDefinition();
|
||||||
|
|
||||||
|
if (InstalledVersions::isInstalled('perftools/php-profiler')) {
|
||||||
|
$definition->addOption(
|
||||||
|
new InputOption('profile', null, InputOption::VALUE_NONE, 'Profile command.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Command.php
Normal file
54
src/Command.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Command\Command as BaseCommand;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Xhgui\Profiler\Profiler;
|
||||||
|
|
||||||
|
class Command extends BaseCommand
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
if (!$input->hasOption('profile') || !$input->getOption('profile')) {
|
||||||
|
return $this->runCommand($input, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiler = new Profiler(Config::get('debug.profiler.options', []));
|
||||||
|
|
||||||
|
$profiler->enable();
|
||||||
|
|
||||||
|
$status = $this->runCommand($input, $output);
|
||||||
|
|
||||||
|
$data = $profiler->disable();
|
||||||
|
|
||||||
|
if (empty($data)) {
|
||||||
|
throw new RuntimeException('The profiler run was unsuccessful. No data was returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = '/cli/' . Config::get('name') . '/' . Config::get('version') . '/' . $this->getName();
|
||||||
|
$data['meta']['url'] = $data['meta']['simple_url'] = $url;
|
||||||
|
$data['meta']['get'] = $data['meta']['env'] = [];
|
||||||
|
$data['meta']['SERVER'] = [
|
||||||
|
'APP_VERSION' => Config::get('version'),
|
||||||
|
'PHP_VERSION' => PHP_VERSION,
|
||||||
|
'PHP_VERSION_ID' => PHP_VERSION_ID,
|
||||||
|
'PHP_OS' => PHP_OS,
|
||||||
|
'SYSTEM' => php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('v') . ' ' . php_uname('m'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$profiler->save($data);
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Commands/Config/DumpCommand.php
Normal file
86
src/Commands/Config/DumpCommand.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Config;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class DumpCommand extends Command
|
||||||
|
{
|
||||||
|
private static array $configs = [
|
||||||
|
'servers' => 'config' . DS . 'servers.yaml',
|
||||||
|
'config' => 'config' . DS . 'config.yaml',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('config:dump')
|
||||||
|
->setDescription('Dump configs to customize.')
|
||||||
|
->addOption(
|
||||||
|
'location',
|
||||||
|
'l',
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Path to config dir.',
|
||||||
|
Config::get('path'),
|
||||||
|
)
|
||||||
|
->addOption('override', 'w', InputOption::VALUE_NONE, 'Override existing file.')
|
||||||
|
->addArgument(
|
||||||
|
'type',
|
||||||
|
InputArgument::REQUIRED,
|
||||||
|
sprintf('Config to dump. Can be one of ( %s )', implode(' or ', array_keys(self::$configs)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$type = $input->getArgument('type');
|
||||||
|
$path = $input->getOption('location');
|
||||||
|
|
||||||
|
if (!array_key_exists($type, self::$configs)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid type was given. Expecting ( %s ). but got ( %s ) instead.',
|
||||||
|
implode(' or ', array_keys(self::$configs)),
|
||||||
|
$type
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($path)) {
|
||||||
|
throw new RuntimeException(sprintf('Unable to write to location path. \'%s\'.', $path));
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $path . DS . self::$configs[$type];
|
||||||
|
|
||||||
|
if (file_exists($file) && !$input->getOption('override')) {
|
||||||
|
$message = sprintf('File exists at \'%s\'. use [-w, --override] flag to overwrite the file.', $file);
|
||||||
|
$output->writeln(sprintf('<error>%s</error>', $message));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kvSore = [
|
||||||
|
'DS' => DS,
|
||||||
|
'path' => Config::get('path'),
|
||||||
|
];
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$file,
|
||||||
|
str_replace(
|
||||||
|
array_map(fn($n) => '%(' . $n . ')', array_keys($kvSore)),
|
||||||
|
array_values($kvSore),
|
||||||
|
file_get_contents(ROOT_PATH . DS . self::$configs[$type])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$output->writeln(sprintf('<info>Generated file at \'%s\'.</info>', $file));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Commands/Config/GenerateCommand.php
Normal file
56
src/Commands/Config/GenerateCommand.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Config;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use Exception;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
class GenerateCommand extends Command
|
||||||
|
{
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('config:generate')
|
||||||
|
->setDescription('Generate API key for webhook.')
|
||||||
|
->addOption('regenerate', 'w', InputOption::VALUE_NONE, 'Regenerate the API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$yaml = [];
|
||||||
|
$config = Config::get('path') . DS . 'config' . DS . 'config.yaml';
|
||||||
|
|
||||||
|
|
||||||
|
if (file_exists($config)) {
|
||||||
|
$yaml = Yaml::parseFile($config);
|
||||||
|
if (null !== ag($yaml, 'webhook.apikey') && !$input->getOption('regenerate')) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$randomKey = $this->generateHash();
|
||||||
|
|
||||||
|
$output->writeln(sprintf('<info>Your Webhook API key is: %s</info>', $randomKey));
|
||||||
|
|
||||||
|
file_put_contents($config, Yaml::dump(ag_set($yaml, 'webhook.apikey', $randomKey), 8, 2));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function generateHash(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Commands/Config/PHPCommand.php
Normal file
60
src/Commands/Config/PHPCommand.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Config;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class PHPCommand extends Command
|
||||||
|
{
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('config:php')
|
||||||
|
->setDescription('Generate PHP Config')
|
||||||
|
->addOption('fpm', null, InputOption::VALUE_NONE, 'Generate FPM Config.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
return $input->getOption('fpm') ? $this->makeFPM($output) : $this->makeConfig($output);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeConfig(OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$config = Config::get('php.ini', []);
|
||||||
|
|
||||||
|
foreach ($config as $key => $val) {
|
||||||
|
$output->writeln(sprintf('%s=%s', $key, $this->escapeValue($val)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeFPM(OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$config = Config::get('php.fpm', []);
|
||||||
|
|
||||||
|
foreach ($config as $pool => $options) {
|
||||||
|
$output->writeln(sprintf('[%s]', $pool));
|
||||||
|
foreach ($options ?? [] as $key => $val) {
|
||||||
|
$output->writeln(sprintf('%s=%s', $key, $val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeValue(mixed $val): mixed
|
||||||
|
{
|
||||||
|
if (is_bool($val) || is_int($val)) {
|
||||||
|
return (int)$val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $val ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/Commands/State/ExportCommand.php
Normal file
231
src/Commands/State/ExportCommand.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\State;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Container;
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Extends\CliLogger;
|
||||||
|
use App\Libs\Extends\Request;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Servers\ServerInterface;
|
||||||
|
use Generator;
|
||||||
|
use GuzzleHttp\Pool;
|
||||||
|
use GuzzleHttp\Promise\Utils;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
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;
|
||||||
|
|
||||||
|
use function ag;
|
||||||
|
use function makeDate;
|
||||||
|
|
||||||
|
class ExportCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private ExportInterface $mapper, private Request $http, private LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '-1');
|
||||||
|
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('state:export')
|
||||||
|
->setDescription('Export watch state to servers.')
|
||||||
|
->addOption(
|
||||||
|
'read-mapper',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Shows what kind of mapper configured.',
|
||||||
|
$this->mapper::class
|
||||||
|
)
|
||||||
|
->addOption('redirect-logger', 'r', InputOption::VALUE_NONE, 'Redirect logger to stderr.')
|
||||||
|
->addOption('memory-usage', 'm', InputOption::VALUE_NONE, 'Display memory usage.')
|
||||||
|
->addOption('force-full', 'f', InputOption::VALUE_NONE, 'Force full export.')
|
||||||
|
->addOption(
|
||||||
|
'concurrency',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'How many Requests to send.',
|
||||||
|
(int)Config::get('request.export.concurrency')
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'servers-filter',
|
||||||
|
's',
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Sync selected servers, comma seperated. \'s1,s2\'.',
|
||||||
|
''
|
||||||
|
)
|
||||||
|
->addOption('stats-show', null, InputOption::VALUE_NONE, 'Show final status.')
|
||||||
|
->addOption(
|
||||||
|
'stats-filter',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Filter final status output e.g. (servername.key)',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
$serversFilter = (string)$input->getOption('servers-filter');
|
||||||
|
$selected = explode(',', $serversFilter);
|
||||||
|
$isCustom = !empty($serversFilter) && count($selected) >= 1;
|
||||||
|
$supported = Config::get('supported', []);
|
||||||
|
|
||||||
|
foreach (Config::get('servers', []) as $serverName => $server) {
|
||||||
|
$type = strtolower(ag($server, 'type', 'unknown'));
|
||||||
|
|
||||||
|
if ($isCustom && !in_array($serverName, $selected, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true !== ag($server, 'export.enabled')) {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf('<error>Ignoring \'%s\' as requested by \'servers.yaml\'.</error>', $serverName),
|
||||||
|
OutputInterface::VERBOSITY_VERBOSE
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($supported[$type])) {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf(
|
||||||
|
'<error>Server \'%s\' Used Unsupported type. Expecting one of \'%s\' but got \'%s\' instead.</error>',
|
||||||
|
$serverName,
|
||||||
|
implode(', ', array_keys($supported)),
|
||||||
|
$type
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ag($server, 'url')) {
|
||||||
|
$output->writeln(sprintf('<error>Server \'%s\' has no url.</error>', $serverName));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$list[] = [
|
||||||
|
'name' => $serverName,
|
||||||
|
'kind' => $supported[$type],
|
||||||
|
'server' => $server,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($list)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
$isCustom ? '--servers-filter/-s did not return any server.' : 'No server were found.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = null;
|
||||||
|
|
||||||
|
if ($input->getOption('redirect-logger') || $input->getOption('memory-usage')) {
|
||||||
|
$logger = new CliLogger($output, (bool)$input->getOption('memory-usage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$promises = [];
|
||||||
|
|
||||||
|
if (count($list) >= 1) {
|
||||||
|
$this->mapper->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $logger) {
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->mapper->setLogger($logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($list as $server) {
|
||||||
|
$name = ag($server, 'name');
|
||||||
|
Data::addBucket($name);
|
||||||
|
|
||||||
|
$class = Container::get(ag($server, 'kind'));
|
||||||
|
assert($class instanceof ServerInterface);
|
||||||
|
|
||||||
|
$class = $class->setUp(
|
||||||
|
$name,
|
||||||
|
new Uri(ag($server, 'server.url')),
|
||||||
|
ag($server, 'server.token', null),
|
||||||
|
ag($server, 'server.options', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null !== $logger) {
|
||||||
|
$class = $class->setLogger($logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $input->getOption('force-full') ? null : ag($server, 'server.import.lastSync', null);
|
||||||
|
|
||||||
|
if (null === $after) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Importing \'%s\' play state changes since beginning.', $name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$after = makeDate($after);
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Importing \'%s\' play state changes since \'%s\'.', $name, $after)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
array_push($promises, ...$class->push($this->mapper, $after));
|
||||||
|
|
||||||
|
if (true === Data::get(sprintf('%s.no_export_update', $name))) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Not updating \'%s\' export date, as the server reported an error.', $name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Config::save(sprintf('servers.%s.export.lastSync', $name), time());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->notice(sprintf('Waiting on (%d) (Compare State) Requests.', count($promises)));
|
||||||
|
Utils::settle($promises)->wait();
|
||||||
|
$this->logger->notice(sprintf('Finished waiting on (%d) Requests.', count($promises)));
|
||||||
|
|
||||||
|
$changes = $this->mapper->getQueue();
|
||||||
|
|
||||||
|
if (empty($changes)) {
|
||||||
|
$this->logger->notice('No State change detected.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = new Pool(
|
||||||
|
$this->http,
|
||||||
|
(function () use ($changes): Generator {
|
||||||
|
foreach ($changes as $request) {
|
||||||
|
yield $request;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
[
|
||||||
|
'concurrency' => $input->getOption('concurrency'),
|
||||||
|
'fulfilled' => function (ResponseInterface $response) {
|
||||||
|
},
|
||||||
|
'rejected' => function (Throwable $reason) {
|
||||||
|
$this->logger->error($reason->getMessage());
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->notice(sprintf('Waiting on (%d) (Stats Change) Requests.', count($changes)));
|
||||||
|
$pool->promise()->wait();
|
||||||
|
$this->logger->notice(sprintf('Finished waiting on (%d) Requests.', count($changes)));
|
||||||
|
|
||||||
|
// -- Update Server.yaml with new lastSync date.
|
||||||
|
file_put_contents(
|
||||||
|
Config::get('path') . DS . 'config' . DS . 'servers.yaml',
|
||||||
|
Yaml::dump(Config::get('servers', []), 8, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/Commands/State/ImportCommand.php
Normal file
217
src/Commands/State/ImportCommand.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\State;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Container;
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Extends\CliLogger;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use App\Libs\Servers\ServerInterface;
|
||||||
|
use GuzzleHttp\Promise\Utils;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
use function ag;
|
||||||
|
use function makeDate;
|
||||||
|
|
||||||
|
class ImportCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private ImportInterface $mapper, private LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '-1');
|
||||||
|
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('state:import')
|
||||||
|
->setDescription('Import watch state from servers.')
|
||||||
|
->addOption(
|
||||||
|
'read-mapper',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Shows what kind of mapper configured.',
|
||||||
|
$this->mapper::class
|
||||||
|
)
|
||||||
|
->addOption('redirect-logger', 'r', InputOption::VALUE_NONE, 'Redirect logger to stderr.')
|
||||||
|
->addOption('memory-usage', 'm', InputOption::VALUE_NONE, 'Display memory usage.')
|
||||||
|
->addOption('force-full', 'f', InputOption::VALUE_NONE, 'Force full import.')
|
||||||
|
->addOption(
|
||||||
|
'servers-filter',
|
||||||
|
's',
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Sync selected servers, comma seperated. \'s1,s2\'.',
|
||||||
|
''
|
||||||
|
)
|
||||||
|
->addOption('stats-show', null, InputOption::VALUE_NONE, 'Show final status.')
|
||||||
|
->addOption(
|
||||||
|
'stats-filter',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Filter final status output e.g. (servername.key)',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
$serversFilter = (string)$input->getOption('servers-filter');
|
||||||
|
$selected = explode(',', $serversFilter);
|
||||||
|
$isCustom = !empty($serversFilter) && count($selected) >= 1;
|
||||||
|
$supported = Config::get('supported', []);
|
||||||
|
|
||||||
|
foreach (Config::get('servers', []) as $serverName => $server) {
|
||||||
|
$type = strtolower(ag($server, 'type', 'unknown'));
|
||||||
|
|
||||||
|
if ($isCustom && !in_array($serverName, $selected, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true !== ag($server, 'import.enabled')) {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf('<error>Ignoring \'%s\' as requested by \'servers.yaml\'.</error>', $serverName),
|
||||||
|
OutputInterface::VERBOSITY_VERBOSE
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($supported[$type])) {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf(
|
||||||
|
'<error>Server \'%s\' Used Unsupported type. Expecting one of \'%s\' but got \'%s\' instead.</error>',
|
||||||
|
$serverName,
|
||||||
|
implode(', ', array_keys($supported)),
|
||||||
|
$type
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ag($server, 'url')) {
|
||||||
|
$output->writeln(sprintf('<error>Server \'%s\' has no url.</error>', $serverName));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$list[] = [
|
||||||
|
'name' => $serverName,
|
||||||
|
'kind' => $supported[$type],
|
||||||
|
'server' => $server,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($list)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
$isCustom ? '--servers-filter/-s did not return any server.' : 'No server were found.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = null;
|
||||||
|
|
||||||
|
if ($input->getOption('redirect-logger') || $input->getOption('memory-usage')) {
|
||||||
|
$logger = new CliLogger($output, (bool)$input->getOption('memory-usage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$promises = [];
|
||||||
|
|
||||||
|
if (count($list) >= 1) {
|
||||||
|
$this->mapper->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $logger) {
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->mapper->setLogger($logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($list as $server) {
|
||||||
|
$name = ag($server, 'name');
|
||||||
|
Data::addBucket($name);
|
||||||
|
|
||||||
|
$class = Container::get(ag($server, 'kind'));
|
||||||
|
assert($class instanceof ServerInterface);
|
||||||
|
|
||||||
|
$class = $class->setUp(
|
||||||
|
$name,
|
||||||
|
new Uri(ag($server, 'server.url')),
|
||||||
|
ag($server, 'server.token', null),
|
||||||
|
ag($server, 'server.options', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null !== $logger) {
|
||||||
|
$class = $class->setLogger($logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $input->getOption('force-full') ? null : ag($server, 'server.import.lastSync', null);
|
||||||
|
|
||||||
|
if (null === $after) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Importing \'%s\' play state changes since beginning.', $name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$after = makeDate($after);
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Importing \'%s\' play state changes since \'%s\'.', $name, $after)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
array_push($promises, ...$class->pull($this->mapper, $after));
|
||||||
|
|
||||||
|
if (true === Data::get(sprintf('%s.no_import_update', $name))) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Not updating \'%s\' last sync time as the server reported an error.', $name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Config::save(sprintf('servers.%s.import.lastSync', $name), time());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->notice(sprintf('Waiting on (%d) HTTP Requests.', count($promises)));
|
||||||
|
Utils::settle($promises)->wait();
|
||||||
|
$this->logger->notice(sprintf('Finished waiting on (%d) HTTP Requests.', count($promises)));
|
||||||
|
|
||||||
|
$operations = $this->mapper->commit();
|
||||||
|
|
||||||
|
if ($input->getOption('stats-show')) {
|
||||||
|
Data::add('operations', 'stats', $operations);
|
||||||
|
$output->writeln(
|
||||||
|
json_encode(
|
||||||
|
Data::get($input->getOption('stats-filter')),
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||||
|
),
|
||||||
|
OutputInterface::OUTPUT_NORMAL
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf(
|
||||||
|
'<info>Movies [A: %d - U: %d - F: %d] - Episodes [A: %d - U: %d - F: %d]</info>',
|
||||||
|
$operations[StateEntity::TYPE_MOVIE]['added'] ?? 0,
|
||||||
|
$operations[StateEntity::TYPE_MOVIE]['updated'] ?? 0,
|
||||||
|
$operations[StateEntity::TYPE_MOVIE]['failed'] ?? 0,
|
||||||
|
$operations[StateEntity::TYPE_EPISODE]['added'] ?? 0,
|
||||||
|
$operations[StateEntity::TYPE_EPISODE]['updated'] ?? 0,
|
||||||
|
$operations[StateEntity::TYPE_EPISODE]['failed'] ?? 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update Server.yaml with new lastSync date.
|
||||||
|
file_put_contents(
|
||||||
|
Config::get('path') . DS . 'config' . DS . 'servers.yaml',
|
||||||
|
Yaml::dump(Config::get('servers', []), 8, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Commands/Storage/MaintenanceCommand.php
Normal file
31
src/Commands/Storage/MaintenanceCommand.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Storage;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class MaintenanceCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('storage:maintenance')
|
||||||
|
->setDescription('Run maintenance tasks on storage backend.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$this->storage->maintenance($input, $output);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Commands/Storage/MakeCommand.php
Normal file
35
src/Commands/Storage/MakeCommand.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Storage;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class MakeCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('storage:make')
|
||||||
|
->setDescription('Create Storage backend migration.')
|
||||||
|
->addOption('extra', null, InputOption::VALUE_OPTIONAL, 'Extra options.', null)
|
||||||
|
->addArgument('name', InputArgument::REQUIRED, 'Migration name');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$this->storage->makeMigration($input->getArgument('name'), $output);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Commands/Storage/MigrationsCommand.php
Normal file
34
src/Commands/Storage/MigrationsCommand.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands\Storage;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class MigrationsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->setName('storage:migrations')
|
||||||
|
->setDescription('Update storage backend schema.')
|
||||||
|
->addOption('extra', null, InputOption::VALUE_OPTIONAL, 'Extra options', null)
|
||||||
|
->addOption('fresh', 'f', InputOption::VALUE_NONE, 'Start migrations from start')
|
||||||
|
->addArgument('direction', InputArgument::OPTIONAL, 'Migrations path (up/down).', 'up');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
return $this->storage->migrations($input->getArgument('direction'), $input, $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Libs/Config.php
Normal file
53
src/Libs/Config.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
private static array $config = [];
|
||||||
|
|
||||||
|
public static function init(array|Closure $array): void
|
||||||
|
{
|
||||||
|
self::$config = getValue($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return ag(self::$config, $key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function append(array|Closure $array): bool
|
||||||
|
{
|
||||||
|
$array = getValue($array);
|
||||||
|
|
||||||
|
foreach ((array)$array as $key => $val) {
|
||||||
|
self::$config = ag_set(self::$config, $key, $val);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAll(): array
|
||||||
|
{
|
||||||
|
return self::$config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
return ag_exists(self::$config, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save(string $key, $value): void
|
||||||
|
{
|
||||||
|
self::$config = ag_set(self::$config, $key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove(string $key): void
|
||||||
|
{
|
||||||
|
self::$config = ag_delete(self::$config, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/Libs/Container.php
Normal file
93
src/Libs/Container.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
use App\Libs\Extends\PSRContainer as BaseContainer;
|
||||||
|
use League\Container\ReflectionContainer;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class Container
|
||||||
|
{
|
||||||
|
private static BaseContainer|null $container = null;
|
||||||
|
|
||||||
|
public static function init(BaseContainer|null $container = null): self
|
||||||
|
{
|
||||||
|
if (null === self::$container) {
|
||||||
|
if (null === $container) {
|
||||||
|
$container = new BaseContainer();
|
||||||
|
$container->defaultToShared(true);
|
||||||
|
$reflectionContainer = new ReflectionContainer(true);
|
||||||
|
$container->delegate($reflectionContainer);
|
||||||
|
$container->addShared(ReflectionContainer::class, $reflectionContainer);
|
||||||
|
$reflectionContainer = null;
|
||||||
|
}
|
||||||
|
self::$container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add(string $id, mixed $concrete): self
|
||||||
|
{
|
||||||
|
self::addService($id, $concrete);
|
||||||
|
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function addService(string $id, callable|array|object $definition): void
|
||||||
|
{
|
||||||
|
if (is_callable($definition) || is_object($definition)) {
|
||||||
|
self::$container->add($id, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($definition)) {
|
||||||
|
$service = self::$container->add($id, $definition['class']);
|
||||||
|
|
||||||
|
if (!empty($definition['args'])) {
|
||||||
|
$service->addArguments($definition['args']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($definition['tag'])) {
|
||||||
|
$service->addTag($definition['tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($definition['alias'])) {
|
||||||
|
$service->setAlias($definition['alias']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('shared', $definition)) {
|
||||||
|
$service->setShared((bool)$definition['shared']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($definition['call']) && is_array($definition['call'])) {
|
||||||
|
$service->addMethodCalls($definition['call']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get($id)
|
||||||
|
{
|
||||||
|
return self::$container->get($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNew($id)
|
||||||
|
{
|
||||||
|
return self::$container->getNew($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function has(string $id): bool
|
||||||
|
{
|
||||||
|
return self::$container->has($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getContainer(): BaseContainer
|
||||||
|
{
|
||||||
|
if (null === self::$container) {
|
||||||
|
throw new RuntimeException('PSRContainer has not been initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$container;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Libs/Data.php
Normal file
55
src/Libs/Data.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
final class Data
|
||||||
|
{
|
||||||
|
private static array $data = [];
|
||||||
|
|
||||||
|
public static function addBucket(string $bucket): void
|
||||||
|
{
|
||||||
|
self::$data[$bucket] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add(string $bucket, string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if (!isset(self::$data[$bucket])) {
|
||||||
|
self::$data[$bucket] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$data[$bucket][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function increment(string $bucket, string $key, int $increment = 1): void
|
||||||
|
{
|
||||||
|
if (!isset(self::$data[$bucket])) {
|
||||||
|
self::$data[$bucket] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$data[$bucket][$key] = (self::$data[$bucket][$key] ?? 0) + $increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function append(string $bucket, string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if (!isset(self::$data[$bucket])) {
|
||||||
|
self::$data[$bucket] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset(self::$data[$bucket][$key])) {
|
||||||
|
self::$data[$bucket][$key] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array(self::$data[$bucket][$key]) && !empty(self::$data[$bucket][$key])) {
|
||||||
|
self::$data[$bucket][$key] = [self::$data[$bucket][$key]];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$data[$bucket][$key][] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(null|string $filter = null, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return ag(self::$data, $filter, $default);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/Libs/Entity/StateEntity.php
Normal file
184
src/Libs/Entity/StateEntity.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Entity;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class StateEntity
|
||||||
|
{
|
||||||
|
public const TYPE_MOVIE = 'movie';
|
||||||
|
public const TYPE_EPISODE = 'episode';
|
||||||
|
private static array $entityKeys = [
|
||||||
|
'id',
|
||||||
|
'type',
|
||||||
|
'updated',
|
||||||
|
'watched',
|
||||||
|
'meta',
|
||||||
|
'guid_plex',
|
||||||
|
'guid_imdb',
|
||||||
|
'guid_tvdb',
|
||||||
|
'guid_tmdb',
|
||||||
|
'guid_tvmaze',
|
||||||
|
'guid_tvrage',
|
||||||
|
'guid_anidb',
|
||||||
|
];
|
||||||
|
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Addressable Variables.
|
||||||
|
*/
|
||||||
|
public null|string|int $id = null;
|
||||||
|
public string $type = '';
|
||||||
|
public int $updated = 0;
|
||||||
|
public int $watched = 0;
|
||||||
|
public array $meta = [];
|
||||||
|
public string|null $guid_plex = null;
|
||||||
|
public string|null $guid_imdb = null;
|
||||||
|
public string|null $guid_tvdb = null;
|
||||||
|
public string|null $guid_tmdb = null;
|
||||||
|
public string|null $guid_tvmaze = null;
|
||||||
|
public string|null $guid_tvrage = null;
|
||||||
|
public string|null $guid_anidb = null;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $val) {
|
||||||
|
if (!in_array($key, self::$entityKeys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('type' === $key && self::TYPE_MOVIE !== $val && self::TYPE_EPISODE !== $val) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Unexpected type value was given. Was expecting \'%1$s or %2$s\' but got \'%3$s\' instead.',
|
||||||
|
self::TYPE_MOVIE,
|
||||||
|
self::TYPE_EPISODE,
|
||||||
|
$val
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('meta' === $key && is_string($val)) {
|
||||||
|
if (null === ($val = json_decode($val, true))) {
|
||||||
|
$val = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->{$key} = $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data = $this->getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function diff(): array
|
||||||
|
{
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
foreach ($this->getAll() as $key => $value) {
|
||||||
|
/**
|
||||||
|
* We ignore meta on purpose as it changes frequently.
|
||||||
|
* from one server to another.
|
||||||
|
*/
|
||||||
|
if ('meta' === $key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === ($this->data[$key] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed['new'][$key] = $value ?? 'None';
|
||||||
|
$changed['old'][$key] = $this->data[$key] ?? 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
$arr = [];
|
||||||
|
|
||||||
|
foreach (self::$entityKeys as $key) {
|
||||||
|
$arr[$key] = $this->{$key};
|
||||||
|
}
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isChanged(): bool
|
||||||
|
{
|
||||||
|
return count($this->diff()) >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasGuids(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->getAll() as $key => $val) {
|
||||||
|
if (null !== $this->{$key} && str_starts_with($key, 'guid_')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(StateEntity $entity): self
|
||||||
|
{
|
||||||
|
if ($this->isEqual($entity)) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entity->getAll() as $key => $val) {
|
||||||
|
$this->updateValue($key, $entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEqual(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
foreach ($this->getAll() as $key => $val) {
|
||||||
|
$checkedValue = $this->isEqualValue($key, $entity);
|
||||||
|
if (false === $checkedValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEqualValue(string $key, StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
if ($key === 'updated' || $key === 'watched') {
|
||||||
|
return !($entity->updated > $this->updated && $entity->watched !== $this->watched);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== ($entity->{$key} ?? null) && $this->{$key} !== $entity->{$key}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateValue(string $key, StateEntity $entity): void
|
||||||
|
{
|
||||||
|
if ($key === 'updated' || $key === 'watched') {
|
||||||
|
if ($entity->updated > $this->updated && $entity->watched !== $this->watched) {
|
||||||
|
$this->updated = $entity->updated;
|
||||||
|
$this->watched = $entity->watched;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== ($entity->{$key} ?? null) && $this->{$key} !== $entity->{$key}) {
|
||||||
|
$this->{$key} = $entity->{$key};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEntityKeys(): array
|
||||||
|
{
|
||||||
|
return self::$entityKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/Libs/Extends/CliLogger.php
Normal file
98
src/Libs/Extends/CliLogger.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Extends;
|
||||||
|
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Stringable;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class CliLogger implements LoggerInterface
|
||||||
|
{
|
||||||
|
private array $levelMapper = [
|
||||||
|
Logger::DEBUG => OutputInterface::VERBOSITY_DEBUG,
|
||||||
|
Logger::INFO => OutputInterface::VERBOSITY_VERY_VERBOSE,
|
||||||
|
Logger::NOTICE => OutputInterface::VERBOSITY_VERBOSE,
|
||||||
|
Logger::WARNING => OutputInterface::VERBOSITY_NORMAL,
|
||||||
|
Logger::ERROR => OutputInterface::VERBOSITY_QUIET,
|
||||||
|
Logger::CRITICAL => OutputInterface::VERBOSITY_QUIET,
|
||||||
|
Logger::ALERT => OutputInterface::VERBOSITY_QUIET,
|
||||||
|
Logger::EMERGENCY => OutputInterface::VERBOSITY_QUIET,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(public OutputInterface $output, public bool $debug = false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emergency(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::EMERGENCY, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alert(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::ALERT, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function critical(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::CRITICAL, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function error(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::ERROR, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::WARNING, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notice(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::NOTICE, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function info(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::INFO, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function debug(Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->log(Logger::DEBUG, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log($level, Stringable|string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$debug = '';
|
||||||
|
|
||||||
|
if ($this->debug) {
|
||||||
|
$debug = sprintf(
|
||||||
|
'[MU: %s | PMU: %s] ',
|
||||||
|
fsize(memory_get_usage() - BASE_MEMORY),
|
||||||
|
fsize(memory_get_peak_usage() - BASE_PEAK_MEMORY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$levels = array_flip(Logger::getLevels());
|
||||||
|
|
||||||
|
$message = 'logger.' . ($levels[$level] ?? $level) . ': ' . $debug . $message;
|
||||||
|
|
||||||
|
if (!empty($context)) {
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($context as $key => $val) {
|
||||||
|
$val = (is_array($val) ? json_encode($val, flags: JSON_UNESCAPED_SLASHES) : ($val ?? 'None'));
|
||||||
|
$list[] = sprintf("(%s: %s)", $key, $val);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= ' [' . implode(', ', $list) . ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln($message, $this->levelMapper[$level] ?? OutputInterface::VERBOSITY_NORMAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Libs/Extends/ConsoleOutput.php
Normal file
21
src/Libs/Extends/ConsoleOutput.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Extends;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Symfony\Component\Console\Output\ConsoleOutput as baseConsoleOutput;
|
||||||
|
|
||||||
|
final class ConsoleOutput extends baseConsoleOutput
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function doWrite(string $message, bool $newline): void
|
||||||
|
{
|
||||||
|
$message = str_replace('!{date}', '[' . makeDate('now') . ']', $message);
|
||||||
|
|
||||||
|
parent::doWrite($message, $newline);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Libs/Extends/Date.php
Normal file
23
src/Libs/Extends/Date.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Extends;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use JsonSerializable;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
final class Date extends DateTimeImmutable implements Stringable, JsonSerializable
|
||||||
|
{
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->format(DateTimeInterface::ATOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): string
|
||||||
|
{
|
||||||
|
return $this->__toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Libs/Extends/PSRContainer.php
Normal file
11
src/Libs/Extends/PSRContainer.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Extends;
|
||||||
|
|
||||||
|
use League\Container\Container;
|
||||||
|
|
||||||
|
final class PSRContainer extends Container
|
||||||
|
{
|
||||||
|
}
|
||||||
16
src/Libs/Extends/Request.php
Normal file
16
src/Libs/Extends/Request.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Extends;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
class Request extends Client
|
||||||
|
{
|
||||||
|
public function __construct(array $options = [])
|
||||||
|
{
|
||||||
|
parent::__construct(array_replace_recursive(Config::get('request.default.options', []), $options));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/Libs/Guid.php
Normal file
107
src/Libs/Guid.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class Guid
|
||||||
|
{
|
||||||
|
private const LOOKUP_KEY = '%s://%s';
|
||||||
|
|
||||||
|
public const GUID_PLEX = 'guid_plex';
|
||||||
|
public const GUID_IMDB = 'guid_imdb';
|
||||||
|
public const GUID_TVDB = 'guid_tvdb';
|
||||||
|
public const GUID_TMDB = 'guid_tmdb';
|
||||||
|
public const GUID_TVMAZE = 'guid_tvmaze';
|
||||||
|
public const GUID_TVRAGE = 'guid_tvrage';
|
||||||
|
public const GUID_ANIDB = 'guid_anidb';
|
||||||
|
|
||||||
|
public const SUPPORTED = [
|
||||||
|
self::GUID_PLEX => 'string',
|
||||||
|
self::GUID_IMDB => 'string',
|
||||||
|
self::GUID_TVDB => 'string',
|
||||||
|
self::GUID_TMDB => 'string',
|
||||||
|
self::GUID_TVMAZE => 'string',
|
||||||
|
self::GUID_TVRAGE => 'string',
|
||||||
|
self::GUID_ANIDB => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
public function __construct(array $guids)
|
||||||
|
{
|
||||||
|
foreach ($guids as $key => $value) {
|
||||||
|
if (null === $value || null === (self::SUPPORTED[$key] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->updateGuid($key, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $guids): self
|
||||||
|
{
|
||||||
|
return new self($guids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromJson(string $guids): self
|
||||||
|
{
|
||||||
|
return new self(json_decode($guids, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPointers(): array
|
||||||
|
{
|
||||||
|
$arr = [];
|
||||||
|
|
||||||
|
foreach ($this->data as $key => $value) {
|
||||||
|
$arr[] = sprintf(self::LOOKUP_KEY, $key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGuids(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateGuid(mixed $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if ($value === ($this->data[$key] ?? null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($key)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Unexpected offset type was given. Was expecting \'string\' but got \'%s\' instead.',
|
||||||
|
get_debug_type($key)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === (self::SUPPORTED[$key] ?? null)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Unexpected offset key. Was expecting one of \'%s\', but got \'%s\' instead.',
|
||||||
|
implode(', ', array_keys(self::SUPPORTED)),
|
||||||
|
$key
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::SUPPORTED[$key] !== ($valueType = get_debug_type($value))) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Unexpected value type for \'%s\'. Was Expecting \'%s\' but got \'%s\' instead.',
|
||||||
|
$key,
|
||||||
|
self::SUPPORTED[$key],
|
||||||
|
$valueType
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Libs/HttpException.php
Normal file
11
src/Libs/HttpException.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class HttpException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
362
src/Libs/KernelConsole.php
Normal file
362
src/Libs/KernelConsole.php
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs;
|
||||||
|
|
||||||
|
use App\Cli;
|
||||||
|
use App\Libs\Extends\ConsoleOutput;
|
||||||
|
use App\Libs\Mappers\Export\ExportMapper;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Mappers\Import\MemoryMapper;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Closure;
|
||||||
|
use Laminas\Diactoros\Response\EmptyResponse;
|
||||||
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
|
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
|
||||||
|
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||||
|
use League\Container\ReflectionContainer;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogHandler;
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class KernelConsole
|
||||||
|
{
|
||||||
|
private Cli $cli;
|
||||||
|
private ConsoleOutput $cliOutput;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
Container::init();
|
||||||
|
|
||||||
|
Config::init(require __DIR__ . '/../../config/config.php');
|
||||||
|
|
||||||
|
foreach ((array)require __DIR__ . '/../../config/services.php' as $name => $definition) {
|
||||||
|
Container::add($name, $definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cliOutput = new ConsoleOutput();
|
||||||
|
$this->cli = new Cli(Container::getContainer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Code Only Run once.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function boot(): self
|
||||||
|
{
|
||||||
|
$this->createDirectories();
|
||||||
|
|
||||||
|
// -- load user config.
|
||||||
|
(function () {
|
||||||
|
$path = Config::get('path') . DS . 'config' . DS . 'config.yaml';
|
||||||
|
if (file_exists($path)) {
|
||||||
|
Config::append(function () use ($path) {
|
||||||
|
return array_replace_recursive(Config::getAll(), Yaml::parseFile($path));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = Config::get('path') . DS . 'config' . DS . 'servers.yaml';
|
||||||
|
if (file_exists($path)) {
|
||||||
|
Config::save('servers', Yaml::parseFile($path));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (Config::get('tz')) {
|
||||||
|
date_default_timezone_set(Config::get('tz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = Container::get(LoggerInterface::class);
|
||||||
|
|
||||||
|
$this->setupLoggers($logger, Config::get('logger'));
|
||||||
|
|
||||||
|
set_error_handler(function (int $number, mixed $error, mixed $file, int $line) {
|
||||||
|
if (0 === error_reporting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Container::get(LoggerInterface::class)->error(
|
||||||
|
trim(sprintf('%d: %s (%s:%d).' . PHP_EOL, $number, $error, $file, $line))
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function (Throwable $e) {
|
||||||
|
Container::get(LoggerInterface::class)->error(
|
||||||
|
sprintf("%s: %s (%s:%d)." . PHP_EOL, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->setupStorage($logger);
|
||||||
|
$this->setupImportMapper($logger);
|
||||||
|
$this->setupExportMapper($logger);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runConsole(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->cli->setCatchExceptions(false);
|
||||||
|
|
||||||
|
$this->cli->setCommandLoader(
|
||||||
|
new ContainerCommandLoader(
|
||||||
|
Container::getContainer(),
|
||||||
|
require __DIR__ . '/../../config/commands.php'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->cli->run(output: $this->cliOutput);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->cli->renderThrowable($e, $this->cliOutput);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HTTP Request.
|
||||||
|
*
|
||||||
|
* @param Closure(ServerRequestInterface): ResponseInterface $fn
|
||||||
|
*/
|
||||||
|
public function runHttp(
|
||||||
|
Closure $fn,
|
||||||
|
ServerRequestInterface|null $request = null,
|
||||||
|
EmitterInterface|null $emit = null
|
||||||
|
): void {
|
||||||
|
$emitter = $emit ?? new SapiEmitter();
|
||||||
|
$request = $request ?? ServerRequestFactory::fromGlobals();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $fn($request);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Container::get(LoggerInterface::class)->error($e->getMessage());
|
||||||
|
$response = new EmptyResponse(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$emitter->emit($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDirectories(): void
|
||||||
|
{
|
||||||
|
$dirList = __DIR__ . '/../../config/directories.php';
|
||||||
|
|
||||||
|
if (!file_exists($dirList)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!($path = Config::get('path'))) {
|
||||||
|
throw new RuntimeException('No app path was set in config path or WS_DATA_PATH ENV');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
if (!@mkdir($path, 0755, true) && !is_dir($path)) {
|
||||||
|
throw new RuntimeException(sprintf('Unable to create "%s" Directory.', $path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fn = function (string $key, string $path): string {
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
throw new RuntimeException(sprintf('%s is not a directory.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($path)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'%s: Unable to write to the specified directory. \'%s\' check permissions and/or user ACL.',
|
||||||
|
$key,
|
||||||
|
$path
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'%s: Unable to read data from specified directory. \'%s\' check permissions and/or user ACL.',
|
||||||
|
$key,
|
||||||
|
$path
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DIRECTORY_SEPARATOR !== $path ? rtrim($path, DIRECTORY_SEPARATOR) : $path;
|
||||||
|
};
|
||||||
|
|
||||||
|
$path = $fn('path', $path);
|
||||||
|
|
||||||
|
foreach (require $dirList as $dir) {
|
||||||
|
$dir = str_replace('%(path)', $path, $dir);
|
||||||
|
|
||||||
|
if (!file_exists($dir)) {
|
||||||
|
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||||
|
throw new RuntimeException(sprintf('Directory "%s" was not created', $dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupImportMapper(Logger $logger): void
|
||||||
|
{
|
||||||
|
$mapper = Config::get('mapper.import.type', MemoryMapper::class);
|
||||||
|
|
||||||
|
if (class_exists($mapper)) {
|
||||||
|
$classFQN = $mapper;
|
||||||
|
} else {
|
||||||
|
$classFQN = '\\App\\Libs\\Mappers\\Import\\' . $mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists($classFQN)) {
|
||||||
|
$message = sprintf('User defined object mapper \'%s\' is not found.', $mapper);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_subclass_of($classFQN, ImportInterface::class)) {
|
||||||
|
$message = sprintf(
|
||||||
|
'User defined object mapper \'%s\' is incompatible. It does not implements the required interface.',
|
||||||
|
$mapper
|
||||||
|
);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Container::add(
|
||||||
|
ImportInterface::class,
|
||||||
|
[
|
||||||
|
'class' => fn() => Container::get(ReflectionContainer::class)->get($classFQN)
|
||||||
|
?->setup(Config::get('mapper.import.opts', []))
|
||||||
|
?->setStorage(Container::get(StorageInterface::class)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupExportMapper(Logger $logger): void
|
||||||
|
{
|
||||||
|
$mapper = Config::get('mapper.export.type', ExportMapper::class);
|
||||||
|
|
||||||
|
if (class_exists($mapper)) {
|
||||||
|
$classFQN = $mapper;
|
||||||
|
} else {
|
||||||
|
$classFQN = '\\App\\Libs\\Mappers\\Export\\' . $mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists($classFQN)) {
|
||||||
|
$message = sprintf('User defined object mapper \'%s\' is not found.', $mapper);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_subclass_of($classFQN, ExportInterface::class)) {
|
||||||
|
$message = sprintf(
|
||||||
|
'User defined object mapper \'%s\' is incompatible. It does not implements the required interface.',
|
||||||
|
$mapper
|
||||||
|
);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Container::add(
|
||||||
|
ExportInterface::class,
|
||||||
|
[
|
||||||
|
'class' => fn() => Container::get(ReflectionContainer::class)->get($classFQN)
|
||||||
|
?->setup(Config::get('mapper.export.opts', []))
|
||||||
|
?->setStorage(Container::get(StorageInterface::class)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupStorage(Logger $logger): void
|
||||||
|
{
|
||||||
|
$storage = Config::get('storage.type', 'PDOStorage');
|
||||||
|
|
||||||
|
if (class_exists($storage)) {
|
||||||
|
$classFQN = $storage;
|
||||||
|
} else {
|
||||||
|
$classFQN = '\\App\\Libs\\Storage\\' . $storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists($classFQN)) {
|
||||||
|
$message = sprintf('User defined Storage backend \'%s\' is not found.', $storage);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_subclass_of($classFQN, StorageInterface::class)) {
|
||||||
|
$message = sprintf(
|
||||||
|
'Storage backend \'%s\' is incompatible. It does not implements the required interface.',
|
||||||
|
$storage
|
||||||
|
);
|
||||||
|
$logger->error($message, ['class' => $classFQN]);
|
||||||
|
exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
Container::add(
|
||||||
|
StorageInterface::class,
|
||||||
|
fn() => Container::get(ReflectionContainer::class)?->get($classFQN)?->setup(
|
||||||
|
Config::get('storage.opts', [])
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupLoggers(Logger $logger, array $loggers): void
|
||||||
|
{
|
||||||
|
$inDocker = (bool)env('IN_DOCKER');
|
||||||
|
|
||||||
|
foreach ($loggers as $name => $context) {
|
||||||
|
if (!ag($context, 'type')) {
|
||||||
|
throw new RuntimeException(sprintf('Logger: \'%s\' has no type set.', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true !== ag($context, 'enabled')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== ($cDocker = ag($context, 'docker', null))) {
|
||||||
|
$cDocker = (bool)$cDocker;
|
||||||
|
if (true === $cDocker && !$inDocker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $cDocker && $inDocker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ag($context, 'type')) {
|
||||||
|
case 'stream':
|
||||||
|
$logger->pushHandler(
|
||||||
|
new StreamHandler(
|
||||||
|
ag($context, 'filename'),
|
||||||
|
ag($context, 'level', Logger::INFO),
|
||||||
|
(bool)ag($context, 'bubble', true),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'syslog':
|
||||||
|
$logger->pushHandler(
|
||||||
|
new SyslogHandler(
|
||||||
|
ag($context, 'name', Config::get('name')),
|
||||||
|
ag($context, 'facility', LOG_USER),
|
||||||
|
ag($context, 'level', Logger::INFO),
|
||||||
|
(bool)Config::get('bubble', true),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf('Unknown Logger type \'%s\' set by \'%s\'.', $context['type'], $name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/Libs/Mappers/Export/ExportMapper.php
Normal file
141
src/Libs/Mappers/Export/ExportMapper.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Mappers\Export;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Guid;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Psr\Http\Message\RequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class ExportMapper implements ExportInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int|string,StateEntity> Holds Entities.
|
||||||
|
*/
|
||||||
|
private array $objects = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string,int|string> Map GUIDs to entities.
|
||||||
|
*/
|
||||||
|
private array $guids = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<RequestInterface> Queued Requests.
|
||||||
|
*/
|
||||||
|
private array $queue = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Lazy lode entities.
|
||||||
|
*/
|
||||||
|
private bool $lazyLoad = false;
|
||||||
|
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): self
|
||||||
|
{
|
||||||
|
$this->storage->setLogger($logger);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStorage(StorageInterface $storage): self
|
||||||
|
{
|
||||||
|
$this->storage = $storage;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(array $opts): self
|
||||||
|
{
|
||||||
|
$this->lazyLoad = true === (bool)($opts['lazyload'] ?? false);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadData(DateTimeInterface|null $date = null): self
|
||||||
|
{
|
||||||
|
if (!empty($this->objects)) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->storage->getAll(false === $this->lazyLoad ? null : $date) as $entity) {
|
||||||
|
if (null !== ($this->objects[$entity->id] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->objects[$entity->id] = $entity;
|
||||||
|
$this->addGuids($this->objects[$entity->id], $entity->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQueue(): array
|
||||||
|
{
|
||||||
|
return $this->queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queue(RequestInterface $request): self
|
||||||
|
{
|
||||||
|
$this->queue[] = $request;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addGuids(StateEntity $entity, int|string $pointer): void
|
||||||
|
{
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
$this->guids[$key] = $pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByIds(array $ids): null|StateEntity
|
||||||
|
{
|
||||||
|
foreach (Guid::fromArray($ids)->getPointers() as $key) {
|
||||||
|
if (null !== ($this->guids[$key] ?? null)) {
|
||||||
|
return $this->objects[$this->guids[$key]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(StateEntity $entity): null|StateEntity
|
||||||
|
{
|
||||||
|
if (null !== $entity->id && null !== ($this->objects[$entity->id] ?? null)) {
|
||||||
|
return $this->objects[$entity->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
if (null !== ($this->guids[$key] ?? null)) {
|
||||||
|
return $this->objects[$this->guids[$key]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $this->lazyLoad && null !== ($lazyEntity = $this->storage->get($entity))) {
|
||||||
|
$this->objects[$lazyEntity->id] = $lazyEntity;
|
||||||
|
$this->addGuids($this->objects[$lazyEntity->id], $lazyEntity->id);
|
||||||
|
return $this->objects[$lazyEntity->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
return null !== $this->get($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): self
|
||||||
|
{
|
||||||
|
$this->objects = [];
|
||||||
|
$this->guids = [];
|
||||||
|
$this->queue = [];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/Libs/Mappers/ExportInterface.php
Normal file
93
src/Libs/Mappers/ExportInterface.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Mappers;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Psr\Http\Message\RequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
interface ExportInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initiate Export Mapper.
|
||||||
|
*
|
||||||
|
* @param array $opts
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function setUp(array $opts): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data from storage.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $date
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function loadData(DateTimeInterface|null $date = null): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get All Queued Entities.
|
||||||
|
*
|
||||||
|
* @return array<string,array<int|string,StateEntity>
|
||||||
|
*/
|
||||||
|
public function getQueue(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue State change request.
|
||||||
|
*
|
||||||
|
* @param RequestInterface $request
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function queue(RequestInterface $request): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Logger.
|
||||||
|
*
|
||||||
|
* @param LoggerInterface $logger
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function setLogger(LoggerInterface $logger): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Storage.
|
||||||
|
*
|
||||||
|
* @param StorageInterface $storage
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function SetStorage(StorageInterface $storage): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return null|StateEntity
|
||||||
|
*/
|
||||||
|
public function get(StateEntity $entity): null|StateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Entity By Ids.
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
*
|
||||||
|
* @return StateEntity|null
|
||||||
|
*/
|
||||||
|
public function findByIds(array $ids): null|StateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has(StateEntity $entity): bool;
|
||||||
|
}
|
||||||
114
src/Libs/Mappers/Import/DirectMapper.php
Normal file
114
src/Libs/Mappers/Import/DirectMapper.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Mappers\Import;
|
||||||
|
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class DirectMapper implements ImportInterface
|
||||||
|
{
|
||||||
|
private array $operations = [
|
||||||
|
StateEntity::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0],
|
||||||
|
StateEntity::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): self
|
||||||
|
{
|
||||||
|
$this->storage->setLogger($logger);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStorage(StorageInterface $storage): self
|
||||||
|
{
|
||||||
|
$this->storage = $storage;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(array $opts): ImportInterface
|
||||||
|
{
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(): mixed
|
||||||
|
{
|
||||||
|
return $this->operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadData(DateTimeImmutable|null $date = null): self
|
||||||
|
{
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(string $bucket, StateEntity $entity): self
|
||||||
|
{
|
||||||
|
if (!$entity->hasGuids()) {
|
||||||
|
Data::increment($bucket, $entity->type . '_failed_no_guid');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->get($entity);
|
||||||
|
|
||||||
|
if (null === $entity->id && null === $record) {
|
||||||
|
try {
|
||||||
|
$this->storage->insert($entity);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->operations[$entity->type]['failed']++;
|
||||||
|
Data::append($bucket, 'storage_error', $e->getMessage());
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
Data::increment($bucket, $entity->type . '_added');
|
||||||
|
$this->operations[$entity->type]['added']++;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $record->apply($entity);
|
||||||
|
|
||||||
|
if ($record->isChanged()) {
|
||||||
|
try {
|
||||||
|
$this->storage->update($record);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->operations[$entity->type]['failed']++;
|
||||||
|
Data::append($bucket, 'storage_error', $e->getMessage());
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Data::increment($bucket, $entity->type . '_updated');
|
||||||
|
$this->operations[$entity->type]['updated']++;
|
||||||
|
} else {
|
||||||
|
Data::increment($bucket, $entity->type . '_ignored_no_change');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(StateEntity $entity): null|StateEntity
|
||||||
|
{
|
||||||
|
return $this->storage->get($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
return null !== $this->storage->get($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
return null !== $this->storage->get($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): self
|
||||||
|
{
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/Libs/Mappers/Import/MemoryMapper.php
Normal file
237
src/Libs/Mappers/Import/MemoryMapper.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Mappers\Import;
|
||||||
|
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Guid;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class MemoryMapper implements ImportInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load all entities.
|
||||||
|
*
|
||||||
|
* @var array<int,StateEntity>
|
||||||
|
*/
|
||||||
|
private array $objects = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map GUIDs to entities.
|
||||||
|
*
|
||||||
|
* @var array<string,int>
|
||||||
|
*/
|
||||||
|
private array $guids = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Deleted GUIDs.
|
||||||
|
*
|
||||||
|
* @var array<int,int>
|
||||||
|
*/
|
||||||
|
private array $removed = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Changed Entities.
|
||||||
|
*
|
||||||
|
* @var array<int,int>
|
||||||
|
*/
|
||||||
|
private array $changed = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Has the data been loaded from store?
|
||||||
|
*/
|
||||||
|
private bool $loaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Lazy load data from storage. Otherwise, load all.
|
||||||
|
*/
|
||||||
|
private bool $lazyLoad = false;
|
||||||
|
|
||||||
|
public function __construct(private StorageInterface $storage)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): self
|
||||||
|
{
|
||||||
|
$this->storage->setLogger($logger);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStorage(StorageInterface $storage): self
|
||||||
|
{
|
||||||
|
$this->storage = $storage;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(array $opts): ImportInterface
|
||||||
|
{
|
||||||
|
$this->lazyLoad = true === (bool)($opts['lazyload'] ?? false);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(): mixed
|
||||||
|
{
|
||||||
|
$state = $this->storage->commit($this->getChanged());
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadData(DateTimeImmutable|null $date = null): self
|
||||||
|
{
|
||||||
|
if (true === $this->loaded) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->lazyLoad) {
|
||||||
|
$this->loaded = true;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->storage->getAll($date) as $index => $entity) {
|
||||||
|
$this->objects[$index] = $entity;
|
||||||
|
$this->addGuids($this->objects[$index], $index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loaded = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChanged(): array
|
||||||
|
{
|
||||||
|
$arr = [];
|
||||||
|
|
||||||
|
foreach ($this->changed as $id) {
|
||||||
|
$arr[] = &$this->objects[$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(string $bucket, StateEntity $entity): self
|
||||||
|
{
|
||||||
|
if (!$entity->hasGuids()) {
|
||||||
|
Data::increment($bucket, $entity->type . '_failed_no_guid');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === ($pointer = $this->getPointer($entity))) {
|
||||||
|
$this->objects[] = $entity;
|
||||||
|
|
||||||
|
$pointer = array_key_last($this->objects);
|
||||||
|
$this->changed[$pointer] = $pointer;
|
||||||
|
|
||||||
|
Data::increment($bucket, $entity->type . '_added');
|
||||||
|
$this->addGuids($this->objects[$pointer], $pointer);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->objects[$pointer] = $this->objects[$pointer]->apply($entity);
|
||||||
|
|
||||||
|
if ($this->objects[$pointer]->isChanged()) {
|
||||||
|
Data::increment($bucket, $entity->type . '_updated');
|
||||||
|
$this->changed[$pointer] = $pointer;
|
||||||
|
$this->addGuids($this->objects[$pointer], $pointer);
|
||||||
|
} else {
|
||||||
|
Data::increment($bucket, $entity->type . '_ignored_no_change');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addGuids(StateEntity $entity, int $pointer): void
|
||||||
|
{
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
$this->guids[$key] = $pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(StateEntity $entity): null|StateEntity
|
||||||
|
{
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
if (null !== ($this->guids[$key] ?? null)) {
|
||||||
|
return $this->objects[$this->guids[$key]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $this->lazyLoad && null !== ($lazyEntity = $this->storage->get($entity))) {
|
||||||
|
$this->objects[] = $lazyEntity;
|
||||||
|
$id = array_key_last($this->objects);
|
||||||
|
$this->addGuids($this->objects[$id], $id);
|
||||||
|
return $this->objects[$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
return null !== $this->get($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
if (false === ($pointer = $this->getPointer($entity))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storage->remove($this->objects[$pointer]);
|
||||||
|
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
if (null !== ($this->guids[$key] ?? null)) {
|
||||||
|
unset($this->guids[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->objects[$pointer]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the object already mapped?
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return int|bool int pointer for the object, Or false if not registered.
|
||||||
|
*/
|
||||||
|
private function getPointer(StateEntity $entity): int|bool
|
||||||
|
{
|
||||||
|
foreach (Guid::fromArray($entity->getAll())->getPointers() as $key) {
|
||||||
|
if (null !== ($this->guids[$key] ?? null)) {
|
||||||
|
if (isset($this->removed[$this->guids[$key]])) {
|
||||||
|
unset($this->guids[$key]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->guids[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $this->lazyLoad && null !== ($lazyEntity = $this->storage->get($entity))) {
|
||||||
|
$this->objects[] = $lazyEntity;
|
||||||
|
$id = array_key_last($this->objects);
|
||||||
|
$this->addGuids($this->objects[$id], $id);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): self
|
||||||
|
{
|
||||||
|
$this->objects = [];
|
||||||
|
$this->guids = [];
|
||||||
|
$this->removed = [];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/Libs/Mappers/ImportInterface.php
Normal file
96
src/Libs/Mappers/ImportInterface.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Mappers;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
interface ImportInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initiate Mapper.
|
||||||
|
*
|
||||||
|
* @param array $opts
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function setUp(array $opts): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Logger.
|
||||||
|
*
|
||||||
|
* @param LoggerInterface $logger
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function setLogger(LoggerInterface $logger): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Storage.
|
||||||
|
*
|
||||||
|
* @param StorageInterface $storage
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function SetStorage(StorageInterface $storage): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit Entities to storage backend.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function commit(): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do Data retrieval if necessary.
|
||||||
|
*
|
||||||
|
* This method get called only once. on import. and once for every export.
|
||||||
|
*
|
||||||
|
* @param DateTimeImmutable|null $date
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function loadData(DateTimeImmutable|null $date = null): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Entity. it has to search for
|
||||||
|
* existing entity if found and update it.
|
||||||
|
*
|
||||||
|
* @param string $bucket bucket name.
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function add(string $bucket, StateEntity $entity): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return null|StateEntity
|
||||||
|
*/
|
||||||
|
public function get(StateEntity $entity): null|StateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has(StateEntity $entity): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function remove(StateEntity $entity): bool;
|
||||||
|
}
|
||||||
115
src/Libs/Servers/EmbyServer.php
Normal file
115
src/Libs/Servers/EmbyServer.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Servers;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\HttpException;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class EmbyServer extends JellyfinServer
|
||||||
|
{
|
||||||
|
protected const WEBHOOK_ALLOWED_TYPES = [
|
||||||
|
'Movie',
|
||||||
|
'Episode',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected const WEBHOOK_ALLOWED_EVENTS = [
|
||||||
|
'item.markplayed',
|
||||||
|
'item.markunplayed',
|
||||||
|
'playback.scrobble',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function setUp(string $name, Uri $url, string|int|null $token = null, array $options = []): ServerInterface
|
||||||
|
{
|
||||||
|
$options['emby'] = true;
|
||||||
|
|
||||||
|
return (new self($this->http, $this->logger))->setState($name, $url, $token, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parseWebhook(ServerRequestInterface $request): StateEntity
|
||||||
|
{
|
||||||
|
$payload = ag($request->getParsedBody(), 'data', null);
|
||||||
|
|
||||||
|
if (null === $payload || null === ($json = json_decode((string)$payload, true))) {
|
||||||
|
throw new HttpException('No payload.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$via = str_replace(' ', '_', ag($json, 'Server.Name', 'Webhook'));
|
||||||
|
$event = ag($json, 'Event', 'unknown');
|
||||||
|
$type = ag($json, 'Item.Type', 'not_found');
|
||||||
|
|
||||||
|
if (true === Config::get('webhook.debug')) {
|
||||||
|
saveWebhookPayload($request, "jellyfin.{$via}.{$event}", $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Type [%s]', $type), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = strtolower($type);
|
||||||
|
|
||||||
|
if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Event [%s]', $event), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ($date = ag($json, 'Item.DateCreated', null))) {
|
||||||
|
throw new HttpException('No DateCreated value is set.', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')),
|
||||||
|
'year' => ag($json, 'Item.ProductionYear', 0000),
|
||||||
|
'date' => makeDate(
|
||||||
|
ag(
|
||||||
|
$json,
|
||||||
|
'Item.PremiereDate',
|
||||||
|
ag($json, 'Item.ProductionYear', ag($json, 'Item.DateCreated', 'now'))
|
||||||
|
)
|
||||||
|
)->format('Y-m-d'),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'series' => ag($json, 'Item.SeriesName', '??'),
|
||||||
|
'year' => ag($json, 'Item.ProductionYear', 0000),
|
||||||
|
'season' => ag($json, 'Item.ParentIndexNumber', 0),
|
||||||
|
'episode' => ag($json, 'Item.IndexNumber', 0),
|
||||||
|
'title' => ag($json, 'Item.Name', ag($json, 'Item.OriginalTitle', '??')),
|
||||||
|
'date' => makeDate(ag($json, 'Item.PremiereDate', ag($json, 'Item.ProductionYear', 'now')))->format(
|
||||||
|
'Y-m-d'
|
||||||
|
),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('markplayed' === $event || 'playback.scrobble' === $event) {
|
||||||
|
$isWatched = 1;
|
||||||
|
} elseif ('markunplayed' === $event) {
|
||||||
|
$isWatched = 0;
|
||||||
|
} else {
|
||||||
|
$isWatched = (int)(bool)ag($json, 'Item.Played', ag($json, 'Item.PlayedToCompletion', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'type' => $type,
|
||||||
|
'updated' => makeDate($date)->getTimestamp(),
|
||||||
|
'watched' => $isWatched,
|
||||||
|
'meta' => $meta,
|
||||||
|
...self::getGuids($type, ag($json, 'Item.ProviderIds', []))
|
||||||
|
];
|
||||||
|
|
||||||
|
return new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
681
src/Libs/Servers/JellyfinServer.php
Normal file
681
src/Libs/Servers/JellyfinServer.php
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Servers;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Extends\Request;
|
||||||
|
use App\Libs\Guid;
|
||||||
|
use App\Libs\HttpException;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use GuzzleHttp\RequestOptions;
|
||||||
|
use JsonException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class JellyfinServer implements ServerInterface
|
||||||
|
{
|
||||||
|
private const GUID_MAPPER = [
|
||||||
|
'plex' => Guid::GUID_PLEX,
|
||||||
|
'imdb' => Guid::GUID_IMDB,
|
||||||
|
'tmdb' => Guid::GUID_TMDB,
|
||||||
|
'tvdb' => Guid::GUID_TVDB,
|
||||||
|
'tvmaze' => Guid::GUID_TVMAZE,
|
||||||
|
'tvrage' => Guid::GUID_TVRAGE,
|
||||||
|
'anidb' => Guid::GUID_ANIDB,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected const WEBHOOK_ALLOWED_TYPES = [
|
||||||
|
'Movie',
|
||||||
|
'Episode',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected const WEBHOOK_ALLOWED_EVENTS = [
|
||||||
|
'ItemAdded',
|
||||||
|
'UserDataSaved',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected Uri|null $url = null;
|
||||||
|
protected string|null $token = null;
|
||||||
|
protected string|null $user = null;
|
||||||
|
protected array $options = [];
|
||||||
|
protected string $name = '';
|
||||||
|
protected bool $loaded = false;
|
||||||
|
protected bool $isEmby = false;
|
||||||
|
|
||||||
|
public function __construct(protected Request $http, protected LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(string $name, Uri $url, string|int|null $token = null, array $options = []): ServerInterface
|
||||||
|
{
|
||||||
|
return (new self($this->http, $this->logger))->setState($name, $url, $token, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): ServerInterface
|
||||||
|
{
|
||||||
|
$this->logger = $logger;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parseWebhook(ServerRequestInterface $request): StateEntity
|
||||||
|
{
|
||||||
|
if (null === ($json = json_decode($request->getBody()->getContents(), true))) {
|
||||||
|
throw new HttpException('No payload.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$via = str_replace(' ', '_', ag($json, 'ServerName', 'Webhook'));
|
||||||
|
$event = ag($json, 'NotificationType', 'unknown');
|
||||||
|
$type = ag($json, 'ItemType', 'not_found');
|
||||||
|
|
||||||
|
if (true === Config::get('webhook.debug')) {
|
||||||
|
saveWebhookPayload($request, "jellyfin.{$via}.{$event}", $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Type [%s]', $type), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = strtolower($type);
|
||||||
|
|
||||||
|
if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Event [%s]', $event), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $json['LastPlayedDate'] ?? $json['DateCreated'] ?? $json['PremiereDate'] ?? $json['Timestamp'] ?? null;
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'title' => ag($json, 'Name', '??'),
|
||||||
|
'year' => ag($json, 'Year', 0000),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'series' => ag($json, 'SeriesName', '??'),
|
||||||
|
'year' => ag($json, 'Year', 0000),
|
||||||
|
'season' => ag($json, 'SeasonNumber', 0),
|
||||||
|
'episode' => ag($json, 'EpisodeNumber', 0),
|
||||||
|
'title' => ag($json, 'Name', '??'),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$guids = [];
|
||||||
|
|
||||||
|
foreach ($json as $key => $val) {
|
||||||
|
if (str_starts_with($key, 'Provider_')) {
|
||||||
|
$guids[self::afterString($key, 'Provider_')] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isWatched = (int)(bool)ag($json, 'Played', ag($json, 'PlayedToCompletion', 0));
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'type' => $type,
|
||||||
|
'updated' => makeDate($date)->getTimestamp(),
|
||||||
|
'watched' => $isWatched,
|
||||||
|
'meta' => $meta,
|
||||||
|
...self::getGuids($type, $guids)
|
||||||
|
];
|
||||||
|
|
||||||
|
return new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getHeaders(): array
|
||||||
|
{
|
||||||
|
$opts = [
|
||||||
|
RequestOptions::HTTP_ERRORS => false,
|
||||||
|
RequestOptions::TIMEOUT => $this->options['timeout'] ?? 0,
|
||||||
|
RequestOptions::CONNECT_TIMEOUT => $this->options['connect_timeout'] ?? 0,
|
||||||
|
RequestOptions::HEADERS => [
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (true === $this->isEmby) {
|
||||||
|
$opts[RequestOptions::HEADERS]['X-MediaBrowser-Token'] = $this->token;
|
||||||
|
} else {
|
||||||
|
$opts[RequestOptions::HEADERS]['X-Emby-Authorization'] = sprintf(
|
||||||
|
'MediaBrowser Client="%s", Device="script", DeviceId="", Version="%s", Token="%s"',
|
||||||
|
Config::get('name'),
|
||||||
|
Config::get('version'),
|
||||||
|
$this->token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLibraries(Closure $ok, Closure $error): array
|
||||||
|
{
|
||||||
|
if (!($this->url instanceof Uri)) {
|
||||||
|
throw new RuntimeException('No host was set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->token) {
|
||||||
|
throw new RuntimeException('No token was set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->user) {
|
||||||
|
throw new RuntimeException('No User was set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('Requesting libraries From %s.', $this->name),
|
||||||
|
['url' => $this->url->getHost()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||||
|
http_build_query(
|
||||||
|
[
|
||||||
|
'Recursive' => 'false',
|
||||||
|
'Fields' => 'ProviderIds',
|
||||||
|
'enableUserData' => 'true',
|
||||||
|
'enableImages' => 'false',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->http->request('GET', $url, $this->getHeaders());
|
||||||
|
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('===[ Sample from %s List library response ]===', $this->name));
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : 'Empty response body');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s responded with unexpected code (%d).',
|
||||||
|
$this->name,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
unset($content);
|
||||||
|
|
||||||
|
$listDirs = ag($json, 'Items', []);
|
||||||
|
|
||||||
|
if (empty($listDirs)) {
|
||||||
|
$this->logger->notice(sprintf('No libraries found at %s.', $this->name));
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (GuzzleException $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage())
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ignoreIds = null;
|
||||||
|
|
||||||
|
if (null !== ($this->options['ignore'] ?? null)) {
|
||||||
|
$ignoreIds = array_map(fn($v) => trim($v), explode(',', $this->options['ignore']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$promises = [];
|
||||||
|
$ignored = $unsupported = 0;
|
||||||
|
|
||||||
|
foreach ($listDirs as $section) {
|
||||||
|
$key = (string)ag($section, 'Id');
|
||||||
|
$title = ag($section, 'Name', '???');
|
||||||
|
$type = ag($section, 'CollectionType', 'unknown');
|
||||||
|
|
||||||
|
if ('movies' !== $type && 'tvshows' !== $type) {
|
||||||
|
$unsupported++;
|
||||||
|
$this->logger->debug(sprintf('Skipping %s library - %s. Not supported type.', $this->name, $title));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $type === 'movies' ? StateEntity::TYPE_MOVIE : StateEntity::TYPE_EPISODE;
|
||||||
|
$cName = sprintf('(%s) - (%s:%s)', $title, $type, $key);
|
||||||
|
|
||||||
|
if (null !== $ignoreIds && in_array($key, $ignoreIds, true)) {
|
||||||
|
$ignored++;
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Skipping %s library - %s. Ignored by user config option.', $this->name, $cName)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||||
|
http_build_query(
|
||||||
|
[
|
||||||
|
'parentId' => $key,
|
||||||
|
'recursive' => 'true',
|
||||||
|
'enableUserData' => 'true',
|
||||||
|
'enableImages' => 'false',
|
||||||
|
'includeItemTypes' => 'Movie,Episode',
|
||||||
|
'Fields' => 'ProviderIds,DateCreated,OriginalTitle,SeasonUserData,DateLastSaved',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('Requesting %s - %s library content.', $this->name, $cName), ['url' => $url]);
|
||||||
|
|
||||||
|
$promises[] = $this->http->requestAsync('GET', $url, $this->getHeaders())->then(
|
||||||
|
$ok($cName, $type, $url),
|
||||||
|
$error($cName, $type, $url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === count($promises)) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf(
|
||||||
|
'No requests were made to any of %s libraries. (total: %d, ignored: %d, Unsupported: %d).',
|
||||||
|
$this->name,
|
||||||
|
count($listDirs),
|
||||||
|
$ignored,
|
||||||
|
$unsupported
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $promises;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array
|
||||||
|
{
|
||||||
|
return $this->getLibraries(
|
||||||
|
function (string $cName, string $type) use ($after, $mapper) {
|
||||||
|
return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) {
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s - %s responded with (%d) unexpected code.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('===[ Sample from %s - %s - response ]===', $this->name, $cName)
|
||||||
|
);
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : '***EMPTY***');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
$payload = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
unset($content);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Failed to decode %s - %s - response. Reason: \'%s\'.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$e->getMessage()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processImport($mapper, $type, $cName, $payload['Items'] ?? [], $after);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
function (string $cName, string $type, Uri|string $url) {
|
||||||
|
return fn(Throwable $e) => $this->logger->error(
|
||||||
|
sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()),
|
||||||
|
['url' => $url]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function push(ExportInterface $mapper, DateTimeInterface|null $after = null): array
|
||||||
|
{
|
||||||
|
return $this->getLibraries(
|
||||||
|
function (string $cName, string $type) use ($mapper) {
|
||||||
|
return function (ResponseInterface $response) use ($mapper, $cName, $type) {
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s - %s responded with (%d) unexpected code.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('===[ Sample from %s - %s - response ]===', $this->name, $cName)
|
||||||
|
);
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : '***EMPTY***');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
$payload = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
unset($content);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Failed to decode %s - %s - response. Reason: \'%s\'.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$e->getMessage()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processExport($mapper, $type, $cName, $payload['Items'] ?? []);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
function (string $cName, string $type, Uri|string $url) {
|
||||||
|
return fn(Throwable $e) => $this->logger->error(
|
||||||
|
sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()),
|
||||||
|
['url' => $url]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processExport(ExportInterface $mapper, string $type, string $library, array $items): void
|
||||||
|
{
|
||||||
|
$x = 0;
|
||||||
|
$total = count($items);
|
||||||
|
Data::increment($this->name, $type . '_total', $total);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
$x++;
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$iName = sprintf(
|
||||||
|
'%s - %s - [%s (%d)]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['Name'] ?? $item['OriginalTitle'] ?? '??',
|
||||||
|
$item['ProductionYear'] ?? 0000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$iName = trim(
|
||||||
|
sprintf(
|
||||||
|
'%s - %s - [%s - (%dx%d) - %s]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['SeriesName'] ?? '??',
|
||||||
|
$item['ParentIndexNumber'] ?? 0,
|
||||||
|
$item['IndexNumber'] ?? 0,
|
||||||
|
$item['Name'] ?? ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasSupportedIds($item['ProviderIds'] ?? [])) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. No supported guid.', $total, $x, $iName),
|
||||||
|
$item['ProviderIds'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_supported_guid');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $item['UserData']['LastPlayedDate'] ?? $item['DateCreated'] ?? $item['PremiereDate'] ?? null;
|
||||||
|
|
||||||
|
if (null === $date) {
|
||||||
|
$this->logger->error(sprintf('(%d/%d) Ignoring %s. No date is set.', $total, $x, $iName));
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_date_is_set');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = makeDate($date);
|
||||||
|
|
||||||
|
$guids = self::getGuids($type, $item['ProviderIds'] ?? []);
|
||||||
|
|
||||||
|
if (null === ($entity = $mapper->findByIds($guids))) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Not found in db.', $total, $x, $iName),
|
||||||
|
$item['ProviderIds'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_not_found_in_db');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($date->getTimestamp() > $entity->updated) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Date is newer then what in db.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_date_is_newer');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isWatched = (int)($item['UserData']['Played'] ?? false);
|
||||||
|
|
||||||
|
if ($isWatched === $entity->watched) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. State is unchanged.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_state_unchanged');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('(%d/%d) Queuing %s.', $total, $x, $iName), ['url' => $this->url]);
|
||||||
|
|
||||||
|
$mapper->queue(
|
||||||
|
new \GuzzleHttp\Psr7\Request(
|
||||||
|
1 === $entity->watched ? 'POST' : 'DELETE',
|
||||||
|
$this->url->withPath(sprintf('/Users/%s/PlayedItems/%s', $this->user, $item['Id'])),
|
||||||
|
$this->getHeaders()['headers'] ?? []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processImport(
|
||||||
|
ImportInterface $mapper,
|
||||||
|
string $type,
|
||||||
|
string $library,
|
||||||
|
array $items,
|
||||||
|
DateTimeInterface|null $after = null
|
||||||
|
): void {
|
||||||
|
$x = 0;
|
||||||
|
$total = count($items);
|
||||||
|
Data::increment($this->name, $type . '_total', $total);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
$x++;
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$iName = sprintf(
|
||||||
|
'%s - %s - [%s (%d)]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['Name'] ?? $item['OriginalTitle'] ?? '??',
|
||||||
|
$item['ProductionYear'] ?? 0000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$iName = trim(
|
||||||
|
sprintf(
|
||||||
|
'%s - %s - [%s - (%dx%d) - %s]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['SeriesName'] ?? '??',
|
||||||
|
$item['ParentIndexNumber'] ?? 0,
|
||||||
|
$item['IndexNumber'] ?? 0,
|
||||||
|
$item['Name'] ?? ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasSupportedIds($item['ProviderIds'] ?? [])) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. No supported guid.', $total, $x, $iName),
|
||||||
|
$item['ProviderIds'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_supported_guid');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $item['UserData']['LastPlayedDate'] ?? $item['DateCreated'] ?? $item['PremiereDate'] ?? null;
|
||||||
|
|
||||||
|
if (null === $date) {
|
||||||
|
$this->logger->error(sprintf('(%d/%d) Ignoring %s. No date is set.', $total, $x, $iName));
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_date_is_set');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedAt = makeDate($date)->getTimestamp();
|
||||||
|
|
||||||
|
if ($after !== null && $after->getTimestamp() >= $updatedAt) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Not played since last sync.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_not_played_since_last_sync');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('(%d/%d) Processing %s.', $total, $x, $iName), ['url' => $this->url]);
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$meta = [
|
||||||
|
'via' => $this->name,
|
||||||
|
'title' => $item['Name'] ?? $item['OriginalTitle'] ?? '??',
|
||||||
|
'year' => $item['ProductionYear'] ?? 0000,
|
||||||
|
'date' => makeDate($item['PremiereDate'] ?? $item['ProductionYear'] ?? 'now')->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$meta = [
|
||||||
|
'via' => $this->name,
|
||||||
|
'series' => $item['SeriesName'] ?? '??',
|
||||||
|
'year' => $item['ProductionYear'] ?? 0000,
|
||||||
|
'season' => $item['ParentIndexNumber'] ?? 0,
|
||||||
|
'episode' => $item['IndexNumber'] ?? 0,
|
||||||
|
'title' => $item['Name'] ?? '',
|
||||||
|
'date' => makeDate($item['PremiereDate'] ?? $item['ProductionYear'] ?? 'now')->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'type' => $type,
|
||||||
|
'updated' => $updatedAt,
|
||||||
|
'watched' => (int)($item['UserData']['Played'] ?? false),
|
||||||
|
'meta' => $meta,
|
||||||
|
...self::getGuids($type, $item['ProviderIds'] ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
$mapper->add($this->name, new StateEntity($row));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getGuids(string $type, array $ids): array
|
||||||
|
{
|
||||||
|
$guid = [];
|
||||||
|
|
||||||
|
$ids = array_change_key_case($ids, CASE_LOWER);
|
||||||
|
|
||||||
|
foreach ($ids as $key => $value) {
|
||||||
|
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key !== 'plex') {
|
||||||
|
$value = $type . '/' . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('string' !== Guid::SUPPORTED[self::GUID_MAPPER[$key]]) {
|
||||||
|
settype($value, Guid::SUPPORTED[self::GUID_MAPPER[$key]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guid[self::GUID_MAPPER[$key]] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasSupportedIds(array $ids): bool
|
||||||
|
{
|
||||||
|
$ids = array_change_key_case($ids, CASE_LOWER);
|
||||||
|
|
||||||
|
foreach ($ids as $key => $value) {
|
||||||
|
if (null !== (self::GUID_MAPPER[$key] ?? null) && !empty($value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setState(string $name, Uri $url, string|int|null $token = null, array $opts = []): ServerInterface
|
||||||
|
{
|
||||||
|
if (true === $this->loaded) {
|
||||||
|
throw new RuntimeException('setState: already called once');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->name = $name;
|
||||||
|
$this->url = $url;
|
||||||
|
$this->token = $token;
|
||||||
|
$this->user = $opts['user'] ?? null;
|
||||||
|
if (null !== ($opts['user'] ?? null)) {
|
||||||
|
unset($opts['user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->isEmby = (bool)($opts['emby'] ?? false);
|
||||||
|
|
||||||
|
if (null !== ($opts['emby'] ?? null)) {
|
||||||
|
unset($opts['emby']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->options = $opts;
|
||||||
|
$this->loaded = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function afterString(string $subject, string $search): string
|
||||||
|
{
|
||||||
|
return empty($search) ? $subject : array_reverse(explode($search, $subject, 2))[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
658
src/Libs/Servers/PlexServer.php
Normal file
658
src/Libs/Servers/PlexServer.php
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Servers;
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Data;
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Extends\Request;
|
||||||
|
use App\Libs\Guid;
|
||||||
|
use App\Libs\HttpException;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use GuzzleHttp\RequestOptions;
|
||||||
|
use JsonException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PlexServer implements ServerInterface
|
||||||
|
{
|
||||||
|
protected const GUID_MAPPER = [
|
||||||
|
'plex' => Guid::GUID_PLEX,
|
||||||
|
'imdb' => Guid::GUID_IMDB,
|
||||||
|
'tmdb' => Guid::GUID_TMDB,
|
||||||
|
'tvdb' => Guid::GUID_TVDB,
|
||||||
|
'tvmaze' => Guid::GUID_TVMAZE,
|
||||||
|
'tvrage' => Guid::GUID_TVRAGE,
|
||||||
|
'anidb' => Guid::GUID_ANIDB,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected const WEBHOOK_ALLOWED_TYPES = [
|
||||||
|
'movie',
|
||||||
|
'episode',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected const WEBHOOK_ALLOWED_EVENTS = [
|
||||||
|
'library.new',
|
||||||
|
'media.scrobble',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected Uri|null $url = null;
|
||||||
|
protected string|null $token = null;
|
||||||
|
protected array $options = [];
|
||||||
|
protected string $name = '';
|
||||||
|
protected bool $loaded = false;
|
||||||
|
|
||||||
|
public function __construct(protected Request $http, protected LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(string $name, Uri $url, string|int|null $token = null, array $options = []): ServerInterface
|
||||||
|
{
|
||||||
|
return (new self($this->http, $this->logger))->setState($name, $url, $token, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): ServerInterface
|
||||||
|
{
|
||||||
|
$this->logger = $logger;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parseWebhook(ServerRequestInterface $request): StateEntity
|
||||||
|
{
|
||||||
|
$payload = ag($request->getParsedBody(), 'payload', null);
|
||||||
|
|
||||||
|
if (null === $payload || null === ($json = json_decode((string)$payload, true))) {
|
||||||
|
throw new HttpException('No payload.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$via = str_replace(' ', '_', ag($json, 'Server.title', 'Webhook'));
|
||||||
|
$type = ag($json, 'Metadata.type');
|
||||||
|
$event = ag($json, 'event', null);
|
||||||
|
|
||||||
|
if (true === Config::get('webhook.debug')) {
|
||||||
|
saveWebhookPayload($request, "plex.{$via}.{$event}", $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $type || !in_array($type, self::WEBHOOK_ALLOWED_TYPES)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Type [%s]', $type), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $event || !in_array($event, self::WEBHOOK_ALLOWED_EVENTS)) {
|
||||||
|
throw new HttpException(sprintf('Not allowed Event [%s]', $event), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'title' => ag($json, 'Metadata.title', ag($json, 'Metadata.originalTitle', '??')),
|
||||||
|
'year' => ag($json, 'Metadata.year', 0000),
|
||||||
|
'date' => makeDate(ag($json, 'Metadata.originallyAvailableAt', 'now'))->format('Y-m-d'),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$meta = [
|
||||||
|
'via' => $via,
|
||||||
|
'series' => ag($json, 'Metadata.grandparentTitle', '??'),
|
||||||
|
'year' => ag($json, 'Metadata.year', 0000),
|
||||||
|
'season' => ag($json, 'Metadata.parentIndex', 0),
|
||||||
|
'episode' => ag($json, 'Metadata.index', 0),
|
||||||
|
'title' => ag($json, 'Metadata.title', ag($json, 'Metadata.originalTitle', '??')),
|
||||||
|
'date' => makeDate(ag($json, 'Metadata.originallyAvailableAt', 'now'))->format('Y-m-d'),
|
||||||
|
'webhook' => [
|
||||||
|
'event' => $event,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$guids = ag($json, 'Metadata.Guid', []);
|
||||||
|
$guids[] = ['id' => ag($json, 'Metadata.guid')];
|
||||||
|
|
||||||
|
$isWatched = (int)(bool)ag($json, 'Metadata.viewCount', 0);
|
||||||
|
|
||||||
|
$date = (int)ag(
|
||||||
|
$json,
|
||||||
|
'Metadata.lastViewedAt',
|
||||||
|
ag($json, 'Metadata.updatedAt', ag($json, 'Metadata.addedAt', 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
$meta['payload'] = $json;
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'type' => $type,
|
||||||
|
'updated' => $date,
|
||||||
|
'watched' => $isWatched,
|
||||||
|
'meta' => $meta,
|
||||||
|
...self::getGuids($type, $guids)
|
||||||
|
];
|
||||||
|
|
||||||
|
return new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RequestOptions::HTTP_ERRORS => false,
|
||||||
|
RequestOptions::TIMEOUT => $this->options['timeout'] ?? 0,
|
||||||
|
RequestOptions::CONNECT_TIMEOUT => $this->options['connect_timeout'] ?? 0,
|
||||||
|
RequestOptions::HEADERS => [
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'X-Plex-Token' => $this->token,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLibraries(Closure $ok, Closure $error): array
|
||||||
|
{
|
||||||
|
if (null === $this->url) {
|
||||||
|
throw new RuntimeException('No host was set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->token) {
|
||||||
|
throw new RuntimeException('No token was set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('Requesting libraries From %s.', $this->name),
|
||||||
|
['url' => $this->url->getHost()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$url = $this->url->withPath('/library/sections');
|
||||||
|
|
||||||
|
$response = $this->http->request('GET', $url, $this->getHeaders());
|
||||||
|
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('===[ Sample from %s List library response ]===', $this->name));
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : 'Empty response body');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s responded with unexpected code (%d).',
|
||||||
|
$this->name,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
unset($content);
|
||||||
|
|
||||||
|
$listDirs = ag($json, 'MediaContainer.Directory', []);
|
||||||
|
|
||||||
|
if (empty($listDirs)) {
|
||||||
|
$this->logger->notice(sprintf('No libraries found at %s.', $this->name));
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (GuzzleException $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf('Unable to decode %s response. Reason: \'%s\'.', $this->name, $e->getMessage())
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ignoreIds = null;
|
||||||
|
|
||||||
|
if (null !== ($this->options['ignore'] ?? null)) {
|
||||||
|
$ignoreIds = array_map(fn($v) => (int)trim($v), explode(',', $this->options['ignore']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$promises = [];
|
||||||
|
$ignored = $unsupported = 0;
|
||||||
|
|
||||||
|
foreach ($listDirs as $section) {
|
||||||
|
$key = (int)ag($section, 'key');
|
||||||
|
$type = ag($section, 'type', 'unknown');
|
||||||
|
$title = ag($section, 'title', '???');
|
||||||
|
|
||||||
|
if ('movie' !== $type && 'show' !== $type) {
|
||||||
|
$unsupported++;
|
||||||
|
$this->logger->debug(sprintf('Skipping %s library - %s. Not supported type.', $this->name, $title));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $type === 'movie' ? StateEntity::TYPE_MOVIE : StateEntity::TYPE_EPISODE;
|
||||||
|
$cName = sprintf('(%s) - (%s:%s)', $title, $type, $key);
|
||||||
|
|
||||||
|
if (null !== $ignoreIds && in_array($key, $ignoreIds)) {
|
||||||
|
$ignored++;
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf('Skipping %s library - %s. Ignored by user config option.', $this->name, $cName)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->url->withPath(sprintf('/library/sections/%d/all', $key))->withQuery(
|
||||||
|
http_build_query(
|
||||||
|
[
|
||||||
|
'type' => 'movie' === $type ? 1 : 4,
|
||||||
|
'sort' => 'addedAt:asc',
|
||||||
|
'includeGuids' => 1,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('Requesting %s - %s library content.', $this->name, $cName), ['url' => $url]);
|
||||||
|
|
||||||
|
$promises[] = $this->http->requestAsync('GET', $url, $this->getHeaders())->then(
|
||||||
|
$ok($cName, $type, $url),
|
||||||
|
$error($cName, $type, $url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === count($promises)) {
|
||||||
|
$this->logger->notice(
|
||||||
|
sprintf(
|
||||||
|
'No requests were made to any of %s libraries. (total: %d, ignored: %d, Unsupported: %d).',
|
||||||
|
$this->name,
|
||||||
|
count($listDirs),
|
||||||
|
$ignored,
|
||||||
|
$unsupported
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Data::add($this->name, 'no_import_update', true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $promises;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array
|
||||||
|
{
|
||||||
|
return $this->getLibraries(
|
||||||
|
function (string $cName, string $type) use ($after, $mapper) {
|
||||||
|
return function (ResponseInterface $response) use ($mapper, $cName, $type, $after) {
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s - %s responded with (%d) unexpected code.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('===[ Sample from %s - %s - response ]===', $this->name, $cName)
|
||||||
|
);
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : '***EMPTY***');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
$payload = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
unset($content);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Failed to decode %s - %s - response. Reason: \'%s\'.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$e->getMessage()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processImport($mapper, $type, $cName, $payload['MediaContainer']['Metadata'] ?? [], $after);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
function (string $cName, string $type, Uri|string $url) {
|
||||||
|
return fn(Throwable $e) => $this->logger->error(
|
||||||
|
sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()),
|
||||||
|
['url' => $url]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function push(ExportInterface $mapper, DateTimeInterface|null $after = null): array
|
||||||
|
{
|
||||||
|
return $this->getLibraries(
|
||||||
|
function (string $cName, string $type) use ($mapper) {
|
||||||
|
return function (ResponseInterface $response) use ($mapper, $cName, $type) {
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Request to %s - %s responded with (%d) unexpected code.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$response->getStatusCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $response->getBody()->getContents();
|
||||||
|
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('===[ Sample from %s - %s - response ]===', $this->name, $cName)
|
||||||
|
);
|
||||||
|
$this->logger->debug(!empty($content) ? mb_substr($content, 0, 200) : '***EMPTY***');
|
||||||
|
$this->logger->debug('===[ End ]===');
|
||||||
|
|
||||||
|
$payload = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
unset($content);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error(
|
||||||
|
sprintf(
|
||||||
|
'Failed to decode %s - %s - response. Reason: \'%s\'.',
|
||||||
|
$this->name,
|
||||||
|
$cName,
|
||||||
|
$e->getMessage()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processExport($mapper, $type, $cName, $payload['MediaContainer']['Metadata'] ?? []);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
function (string $cName, string $type, Uri|string $url) {
|
||||||
|
return fn(Throwable $e) => $this->logger->error(
|
||||||
|
sprintf('Request to %s - %s - failed. Reason: \'%s\'.', $this->name, $cName, $e->getMessage()),
|
||||||
|
['url' => $url]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processExport(ExportInterface $mapper, string $type, string $library, array $items): void
|
||||||
|
{
|
||||||
|
$x = 0;
|
||||||
|
$total = count($items);
|
||||||
|
Data::increment($this->name, $type . '_total', count($items));
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
$x++;
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$iName = sprintf(
|
||||||
|
'%s - %s - [%s (%d)]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['title'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
$item['year'] ?? 0000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$iName = trim(
|
||||||
|
sprintf(
|
||||||
|
'%s - %s - [%s - (%dx%d) - %s]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['grandparentTitle'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
$item['parentIndex'] ?? 0,
|
||||||
|
$item['index'] ?? 0,
|
||||||
|
$item['title'] ?? $item['originalTitle'] ?? '',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ($item['Guid'] ?? null)) {
|
||||||
|
$item['Guid'] = [['id' => $item['guid']]];
|
||||||
|
} else {
|
||||||
|
$item['Guid'][] = ['id' => $item['guid']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasSupportedIds($item['Guid'])) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. No supported guid.', $total, $x, $iName),
|
||||||
|
$item['Guid'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_supported_guid');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = (int)($item['lastViewedAt'] ?? $item['updatedAt'] ?? $item['addedAt'] ?? 0);
|
||||||
|
|
||||||
|
if (0 === $date) {
|
||||||
|
$this->logger->error(sprintf('(%d/%d) Ignoring %s. No date is set.', $total, $x, $iName));
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_date_is_set');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = makeDate($date);
|
||||||
|
$isWatched = (int)(bool)($item['viewCount'] ?? false);
|
||||||
|
|
||||||
|
$guids = self::getGuids($type, $item['Guid'] ?? []);
|
||||||
|
|
||||||
|
if (null === ($entity = $mapper->findByIds($guids))) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Not found in db.', $total, $x, $iName),
|
||||||
|
$item['ProviderIds'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_not_found_in_db');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($date->getTimestamp() > $entity->updated) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Date is newer then what in db.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_date_is_newer');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isWatched === $entity->watched) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. State is unchanged.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_state_unchanged');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('(%d/%d) Queuing %s.', $total, $x, $iName), ['url' => $this->url]);
|
||||||
|
|
||||||
|
$url = $this->url->withPath('/:' . (1 === $entity->watched ? '/scrobble' : '/unscrobble'))
|
||||||
|
->withQuery(
|
||||||
|
http_build_query(
|
||||||
|
[
|
||||||
|
'identifier' => 'com.plexapp.plugins.library',
|
||||||
|
'key' => $item['ratingKey'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$mapper->queue(new \GuzzleHttp\Psr7\Request('GET', $url, $this->getHeaders()['headers'] ?? []));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processImport(
|
||||||
|
ImportInterface $mapper,
|
||||||
|
string $type,
|
||||||
|
string $library,
|
||||||
|
array $items,
|
||||||
|
DateTimeInterface|null $after = null
|
||||||
|
): void {
|
||||||
|
$x = 0;
|
||||||
|
$total = count($items);
|
||||||
|
Data::increment($this->name, $type . '_total', count($items));
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
$x++;
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$iName = sprintf(
|
||||||
|
'%s - %s - [%s (%d)]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['title'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
$item['year'] ?? 0000
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$iName = trim(
|
||||||
|
sprintf(
|
||||||
|
'%s - %s - [%s - (%dx%d) - %s]',
|
||||||
|
$this->name,
|
||||||
|
$library,
|
||||||
|
$item['grandparentTitle'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
$item['parentIndex'] ?? 0,
|
||||||
|
$item['index'] ?? 0,
|
||||||
|
$item['title'] ?? $item['originalTitle'] ?? '',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === ($item['Guid'] ?? null)) {
|
||||||
|
$item['Guid'] = [['id' => $item['guid']]];
|
||||||
|
} else {
|
||||||
|
$item['Guid'][] = ['id' => $item['guid']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasSupportedIds($item['Guid'])) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. No supported guid.', $total, $x, $iName),
|
||||||
|
$item['Guid'] ?? []
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_supported_guid');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item['viewCount'] = (int)($item['viewCount'] ?? 0);
|
||||||
|
$date = (int)($item['lastViewedAt'] ?? $item['updatedAt'] ?? $item['addedAt'] ?? 0);
|
||||||
|
|
||||||
|
if (0 === $date) {
|
||||||
|
$this->logger->error(sprintf('(%d/%d) Ignoring %s. No date is set.', $total, $x, $iName));
|
||||||
|
Data::increment($this->name, $type . '_ignored_no_date_is_set');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $after && $after->getTimestamp() >= $date) {
|
||||||
|
$this->logger->debug(
|
||||||
|
sprintf('(%d/%d) Ignoring %s. Not played since last sync.', $total, $x, $iName)
|
||||||
|
);
|
||||||
|
Data::increment($this->name, $type . '_ignored_not_played_since_last_sync');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug(sprintf('(%d/%d) Processing %s.', $total, $x, $iName));
|
||||||
|
|
||||||
|
if (StateEntity::TYPE_MOVIE === $type) {
|
||||||
|
$meta = [
|
||||||
|
'via' => $this->name,
|
||||||
|
'title' => $item['title'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
'year' => $item['year'] ?? 0000,
|
||||||
|
'date' => makeDate($item['originallyAvailableAt'] ?? 'now')->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$meta = [
|
||||||
|
'via' => $this->name,
|
||||||
|
'series' => $item['grandparentTitle'] ?? '??',
|
||||||
|
'year' => $item['year'] ?? 0000,
|
||||||
|
'season' => $item['parentIndex'] ?? 0,
|
||||||
|
'episode' => $item['index'] ?? 0,
|
||||||
|
'title' => $item['title'] ?? $item['originalTitle'] ?? '??',
|
||||||
|
'date' => makeDate($item['originallyAvailableAt'] ?? 'now')->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'type' => $type,
|
||||||
|
'updated' => $date,
|
||||||
|
'watched' => (int)($item['viewCount'] >= 1),
|
||||||
|
'meta' => $meta,
|
||||||
|
...self::getGuids($type, $item['Guid'] ?? [])
|
||||||
|
];
|
||||||
|
|
||||||
|
$mapper->add($this->name, new StateEntity($row));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getGuids(string $type, array $guids): array
|
||||||
|
{
|
||||||
|
$guid = [];
|
||||||
|
foreach ($guids as $_id) {
|
||||||
|
if (empty($_id['id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $value] = explode('://', $_id['id']);
|
||||||
|
$key = strtolower($key);
|
||||||
|
|
||||||
|
if (null === (self::GUID_MAPPER[$key] ?? null) || empty($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key !== 'plex') {
|
||||||
|
$value = $type . '/' . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('string' !== Guid::SUPPORTED[self::GUID_MAPPER[$key]]) {
|
||||||
|
settype($value, Guid::SUPPORTED[self::GUID_MAPPER[$key]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guid[self::GUID_MAPPER[$key]] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasSupportedIds(array $guids): bool
|
||||||
|
{
|
||||||
|
foreach ($guids as $_id) {
|
||||||
|
if (empty($_id['id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $value] = explode('://', $_id['id']);
|
||||||
|
$key = strtolower($key);
|
||||||
|
|
||||||
|
if (null !== (self::GUID_MAPPER[$key] ?? null) && !empty($value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setState(string $name, Uri $url, string|int|null $token = null, array $opts = []): ServerInterface
|
||||||
|
{
|
||||||
|
if (true === $this->loaded) {
|
||||||
|
throw new RuntimeException('setState: already called once');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->name = $name;
|
||||||
|
$this->url = $url;
|
||||||
|
$this->token = $token;
|
||||||
|
$this->options = $opts;
|
||||||
|
$this->loaded = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Libs/Servers/ServerInterface.php
Normal file
66
src/Libs/Servers/ServerInterface.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Servers;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Mappers\ExportInterface;
|
||||||
|
use App\Libs\Mappers\ImportInterface;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use GuzzleHttp\Promise\PromiseInterface;
|
||||||
|
use GuzzleHttp\Psr7\Uri;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
interface ServerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initiate Server. It should return **NEW OBJECT**
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @param Uri $url
|
||||||
|
* @param null|int|string $token
|
||||||
|
* @param array $options
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function setUp(string $name, Uri $url, null|string|int $token = null, array $options = []): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Logger.
|
||||||
|
*
|
||||||
|
* @param LoggerInterface $logger
|
||||||
|
*
|
||||||
|
* @return ServerInterface
|
||||||
|
*/
|
||||||
|
public function setLogger(LoggerInterface $logger): ServerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Server Specific Webhook event. for play/unplayed event.
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @return StateEntity|null
|
||||||
|
*/
|
||||||
|
public static function parseWebhook(ServerRequestInterface $request): StateEntity|null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import Watch state.
|
||||||
|
*
|
||||||
|
* @param ImportInterface $mapper
|
||||||
|
* @param DateTimeInterface|null $after
|
||||||
|
*
|
||||||
|
* @return array<array-key,PromiseInterface>
|
||||||
|
*/
|
||||||
|
public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Watch State to Server.
|
||||||
|
*
|
||||||
|
* @param ExportInterface $mapper
|
||||||
|
* @param DateTimeInterface|null $after
|
||||||
|
*
|
||||||
|
* @return array<array-key,PromiseInterface>
|
||||||
|
*/
|
||||||
|
public function push(ExportInterface $mapper, DateTimeInterface|null $after = null): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- # migrate_up
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "state"
|
||||||
|
(
|
||||||
|
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"updated" integer NOT NULL,
|
||||||
|
"watched" integer NOT NULL DEFAULT 0,
|
||||||
|
"meta" text NULL,
|
||||||
|
"guid_plex" text NULL,
|
||||||
|
"guid_imdb" text NULL,
|
||||||
|
"guid_tvdb" text NULL,
|
||||||
|
"guid_tmdb" text NULL,
|
||||||
|
"guid_tvmaze" text NULL,
|
||||||
|
"guid_tvrage" text NULL,
|
||||||
|
"guid_anidb" text NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_type" ON "state" ("type");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_watched" ON "state" ("watched");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_updated" ON "state" ("updated");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_plex" ON "state" ("guid_plex");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_imdb" ON "state" ("guid_imdb");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_tvdb" ON "state" ("guid_tvdb");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_tvmaze" ON "state" ("guid_tvmaze");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_tvrage" ON "state" ("guid_tvrage");
|
||||||
|
CREATE INDEX IF NOT EXISTS "state_guid_anidb" ON "state" ("guid_anidb");
|
||||||
|
|
||||||
|
-- # migrate_down
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS "state";
|
||||||
505
src/Libs/Storage/PDO/PDOAdapter.php
Normal file
505
src/Libs/Storage/PDO/PDOAdapter.php
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Storage\PDO;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Exception;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use PDOStatement;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class PDOAdapter implements StorageInterface
|
||||||
|
{
|
||||||
|
private array $supported = [
|
||||||
|
'sqlite',
|
||||||
|
// @TODO For v1.x support mysql/pgsql
|
||||||
|
//'mysql',
|
||||||
|
//'pgsql'
|
||||||
|
];
|
||||||
|
|
||||||
|
private PDO|null $pdo = null;
|
||||||
|
private string|null $driver = null;
|
||||||
|
private bool $viaCommit = false;
|
||||||
|
|
||||||
|
private PDOStatement|null $stmtInsert = null;
|
||||||
|
private PDOStatement|null $stmtUpdate = null;
|
||||||
|
private PDOStatement|null $stmtDelete = null;
|
||||||
|
|
||||||
|
public function __construct(private LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(DateTimeInterface|null $date = null): array
|
||||||
|
{
|
||||||
|
$arr = [];
|
||||||
|
|
||||||
|
$sql = sprintf("SELECT * FROM %s", $this->escapeIdentifier('state'));
|
||||||
|
|
||||||
|
if (null !== $date) {
|
||||||
|
$sql .= sprintf(' WHERE %s > %d', $this->escapeIdentifier('updated'), $date->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->query($sql);
|
||||||
|
|
||||||
|
foreach ($stmt as $row) {
|
||||||
|
$arr[] = new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUp(array $opts): StorageInterface
|
||||||
|
{
|
||||||
|
if (null === ($opts['dsn'] ?? null)) {
|
||||||
|
throw new RuntimeException('No storage.opts.dsn (Data Source Name) was provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo = new PDO(
|
||||||
|
$opts['dsn'], $opts['username'] ?? null, $opts['password'] ?? null,
|
||||||
|
array_replace_recursive(
|
||||||
|
[
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::ATTR_STRINGIFY_FETCHES => false,
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
$opts['options'] ?? []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->driver = $this->getDriver();
|
||||||
|
|
||||||
|
if (!in_array($this->driver, $this->supported)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'\'%s\' Backend engine is not supported right now. only \'%s\' are supported.',
|
||||||
|
$this->driver,
|
||||||
|
implode(', ', $this->supported)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== ($exec = ag($opts, "exec.{$this->driver}")) && is_array($exec)) {
|
||||||
|
foreach ($exec as $cmd) {
|
||||||
|
$this->pdo->exec($cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(LoggerInterface $logger): StorageInterface
|
||||||
|
{
|
||||||
|
$this->logger = $logger;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(StateEntity $entity): StateEntity
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $entity->getAll();
|
||||||
|
|
||||||
|
if (is_array($data['meta'])) {
|
||||||
|
$data['meta'] = json_encode($data['meta']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $data['id']) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf('Trying to insert already saved entity #%s', $data['id'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['id']);
|
||||||
|
|
||||||
|
if (null === $this->stmtInsert) {
|
||||||
|
$this->stmtInsert = $this->pdo->prepare(
|
||||||
|
$this->pdoInsert('state', array_keys($data))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stmtInsert->execute($data);
|
||||||
|
|
||||||
|
$entity->id = (int)$this->pdo->lastInsertId();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$this->stmtInsert = null;
|
||||||
|
if (false === $this->viaCommit) {
|
||||||
|
$this->logger->error($e->getMessage(), $entity->meta ?? []);
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(StateEntity $entity): StateEntity
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $entity->getAll();
|
||||||
|
|
||||||
|
if (is_array($data['meta'])) {
|
||||||
|
$data['meta'] = json_encode($data['meta']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $data['id']) {
|
||||||
|
throw new RuntimeException('Trying to update unsaved entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->stmtUpdate) {
|
||||||
|
$this->stmtUpdate = $this->pdo->prepare($this->pdoUpdate('state', array_keys($data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stmtUpdate->execute($data);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$this->stmtUpdate = null;
|
||||||
|
if (false === $this->viaCommit) {
|
||||||
|
$this->logger->error($e->getMessage(), $entity->meta ?? []);
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(StateEntity $entity): StateEntity|null
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $entity->id) {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
sprintf(
|
||||||
|
'SELECT * FROM %s WHERE %s = :id LIMIT 1',
|
||||||
|
$this->escapeIdentifier('state'),
|
||||||
|
$this->escapeIdentifier('id'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (false === ($stmt->execute(['id' => $entity->id]))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cond = $where = [];
|
||||||
|
foreach ($entity::getEntityKeys() as $key) {
|
||||||
|
if (null === $entity->{$key} || !str_starts_with($key, 'guid_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cond[$key] = $entity->{$key};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($cond)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($cond as $key => $_) {
|
||||||
|
$where[] = $this->escapeIdentifier($key) . ' = :' . $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlWhere = implode(' OR ', $where);
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
sprintf(
|
||||||
|
"SELECT * FROM %s WHERE %s LIMIT 1",
|
||||||
|
$this->escapeIdentifier('state'),
|
||||||
|
$sqlWhere
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (false === $stmt->execute($cond)) {
|
||||||
|
throw new RuntimeException('Unable to prepare sql statement');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StateEntity($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(StateEntity $entity): bool
|
||||||
|
{
|
||||||
|
if (null === $entity->id && !$entity->hasGuids()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (null === $entity->id) {
|
||||||
|
if (null === $dbEntity = $this->get($entity)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$id = $dbEntity->id;
|
||||||
|
} else {
|
||||||
|
$id = $entity->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->stmtDelete) {
|
||||||
|
$this->stmtDelete = $this->pdo->prepare(
|
||||||
|
sprintf(
|
||||||
|
'DELETE FROM %s WHERE %s = :id',
|
||||||
|
$this->escapeIdentifier('state'),
|
||||||
|
$this->escapeIdentifier('id'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stmtDelete->execute(['id' => $id]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
$this->stmtDelete = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(array $entities): array
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->transactional(function () use ($entities) {
|
||||||
|
$list = [
|
||||||
|
StateEntity::TYPE_MOVIE => ['added' => 0, 'updated' => 0, 'failed' => 0],
|
||||||
|
StateEntity::TYPE_EPISODE => ['added' => 0, 'updated' => 0, 'failed' => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
$count = count($entities);
|
||||||
|
|
||||||
|
$this->logger->info(
|
||||||
|
0 === $count ? 'No changes detected.' : sprintf('Updating database with \'%d\' changes.', $count)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->viaCommit = true;
|
||||||
|
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
try {
|
||||||
|
if (null === $entity->id) {
|
||||||
|
$this->logger->debug('Inserting ' . $entity->type, $entity->meta ?? []);
|
||||||
|
|
||||||
|
$this->insert($entity);
|
||||||
|
|
||||||
|
$list[$entity->type]['added']++;
|
||||||
|
} else {
|
||||||
|
$this->logger->debug(
|
||||||
|
'Updating ' . $entity->type,
|
||||||
|
['id' => $entity->id] + ($entity->diff() ?? [])
|
||||||
|
);
|
||||||
|
$this->update($entity);
|
||||||
|
$list[$entity->type]['updated']++;
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$list[$entity->type]['failed']++;
|
||||||
|
$this->logger->error($e->getMessage(), $entity->getAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->viaCommit = false;
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap Transaction.
|
||||||
|
*
|
||||||
|
* @param Closure(PDO): mixed $callback
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
* @throws PDOException
|
||||||
|
*/
|
||||||
|
private function transactional(Closure $callback): mixed
|
||||||
|
{
|
||||||
|
$autoStartTransaction = false === $this->pdo->inTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!$autoStartTransaction) {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $callback($this->pdo);
|
||||||
|
|
||||||
|
if (!$autoStartTransaction) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if (!$autoStartTransaction && $this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SQL Insert Statement.
|
||||||
|
*
|
||||||
|
* @param string $table
|
||||||
|
* @param array $columns
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function pdoInsert(string $table, array $columns): string
|
||||||
|
{
|
||||||
|
$queryString = 'INSERT INTO ' . $this->escapeIdentifier($table) . ' (%{columns}) VALUES(%{values})';
|
||||||
|
|
||||||
|
$sql_columns = $sql_placeholder = [];
|
||||||
|
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
if ('id' === $column) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql_columns[] = $this->escapeIdentifier($column, true);
|
||||||
|
$sql_placeholder[] = ':' . $this->escapeIdentifier($column, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryString = str_replace(
|
||||||
|
['%{columns}', '%{values}'],
|
||||||
|
[implode(', ', $sql_columns), implode(', ', $sql_placeholder)],
|
||||||
|
$queryString
|
||||||
|
);
|
||||||
|
|
||||||
|
return trim($queryString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SQL Update Statement.
|
||||||
|
*
|
||||||
|
* @param string $table
|
||||||
|
* @param array $columns
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function pdoUpdate(string $table, array $columns): string
|
||||||
|
{
|
||||||
|
$queryString = sprintf(
|
||||||
|
'UPDATE %s SET ${place} = ${holder} WHERE %s = :id',
|
||||||
|
$this->escapeIdentifier($table, true),
|
||||||
|
$this->escapeIdentifier('id', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
$placeholders = [];
|
||||||
|
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
if ('id' === $column) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$placeholders[] = sprintf(
|
||||||
|
'%1$s = :%2$s',
|
||||||
|
$this->escapeIdentifier($column, true),
|
||||||
|
$this->escapeIdentifier($column, false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(str_replace('${place} = ${holder}', implode(', ', $placeholders), $queryString));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeIdentifier(string $text, bool $quote = true): string
|
||||||
|
{
|
||||||
|
// table or column has to be valid ASCII name.
|
||||||
|
// this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name.
|
||||||
|
if (!preg_match('#\w#', $text)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid identifier "%s": Column/table must be valid ASCII code.',
|
||||||
|
$text
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first character cannot be [0-9]:
|
||||||
|
if (preg_match('/^\d/', $text)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid identifier "%s": Must begin with a letter or underscore.',
|
||||||
|
$text
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$quote) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->driver) {
|
||||||
|
'mssql' => '[' . $text . ']',
|
||||||
|
'mysql' => '`' . $text . '`',
|
||||||
|
default => '"' . $text . '"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
$this->stmtDelete = $this->stmtUpdate = $this->stmtInsert = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrations(string $dir, InputInterface $input, OutputInterface $output, array $opts = []): mixed
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = new PDOMigrations($this->pdo);
|
||||||
|
|
||||||
|
return match ($dir) {
|
||||||
|
StorageInterface::MIGRATE_UP => $class->up($input, $output),
|
||||||
|
StorageInterface::MIGRATE_DOWN => $class->down($output),
|
||||||
|
default => throw new RuntimeException(sprintf('Unknown direction \'%s\' was given.', $dir)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function makeMigration(string $name, OutputInterface $output, array $opts = []): void
|
||||||
|
{
|
||||||
|
if (null === $this->pdo) {
|
||||||
|
throw new RuntimeException('Setup(): method was not called.');
|
||||||
|
}
|
||||||
|
|
||||||
|
(new PDOMigrations($this->pdo))->make($name, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maintenance(InputInterface $input, OutputInterface $output, array $opts = []): mixed
|
||||||
|
{
|
||||||
|
return (new PDOMigrations($this->pdo))->runMaintenance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDriver(): string
|
||||||
|
{
|
||||||
|
$driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME);
|
||||||
|
|
||||||
|
if (empty($driver) || !is_string($driver)) {
|
||||||
|
$driver = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($driver);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/Libs/Storage/PDO/PDOMigrations.php
Normal file
204
src/Libs/Storage/PDO/PDOMigrations.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Storage\PDO;
|
||||||
|
|
||||||
|
use App\Command;
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Storage\StorageInterface;
|
||||||
|
use Exception;
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class PDOMigrations
|
||||||
|
{
|
||||||
|
private string $path;
|
||||||
|
private string $versionFile;
|
||||||
|
|
||||||
|
public function __construct(private PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->path = __DIR__ . DS . 'Migrations';
|
||||||
|
$this->versionFile = Config::get('path') . DS . 'db' . DS . 'pdo_migrations_version';
|
||||||
|
|
||||||
|
if (!file_exists($this->versionFile)) {
|
||||||
|
$this->setVersion(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
if ($input->hasOption('fresh') && $input->getOption('fresh')) {
|
||||||
|
$version = 0;
|
||||||
|
} else {
|
||||||
|
$version = $this->getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = StorageInterface::MIGRATE_UP;
|
||||||
|
|
||||||
|
$run = 0;
|
||||||
|
|
||||||
|
foreach ($this->parseFiles() as $migrate) {
|
||||||
|
if ($version >= ag($migrate, 'id')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run++;
|
||||||
|
|
||||||
|
if (!ag($migrate, $dir)) {
|
||||||
|
$output->writeln(
|
||||||
|
sprintf(
|
||||||
|
'<error>Migration #%d - %s has no %s. Skipping.</error>',
|
||||||
|
ag($migrate, 'id'),
|
||||||
|
ag($migrate, 'name'),
|
||||||
|
$dir
|
||||||
|
),
|
||||||
|
OutputInterface::VERBOSITY_DEBUG
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln(
|
||||||
|
sprintf(
|
||||||
|
'<info>Applying Migration #%d - %s (%s)</info>',
|
||||||
|
ag($migrate, 'id'),
|
||||||
|
ag($migrate, 'name'),
|
||||||
|
$dir
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = ag($migrate, $dir);
|
||||||
|
|
||||||
|
$output->writeln(
|
||||||
|
sprintf('<comment>Applying %s.</comment>', PHP_EOL . $data),
|
||||||
|
OutputInterface::VERBOSITY_DEBUG
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pdo->exec((string)$data);
|
||||||
|
$this->setVersion(ag($migrate, 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = !$run ? sprintf('<comment>No migrations is needed. Version @ %d</comment>', $version) : sprintf(
|
||||||
|
'<info>Applied %s migrations. Version @ %d</info>',
|
||||||
|
$run,
|
||||||
|
$this->getVersion()
|
||||||
|
);
|
||||||
|
|
||||||
|
$output->writeln($message);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('<comment>This driver does not support down migrations at this time.</comment>');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function make(string $name, OutputInterface $output): string
|
||||||
|
{
|
||||||
|
$name = str_replace(chr(040), '_', $name);
|
||||||
|
|
||||||
|
$fileName = sprintf('%s_%d_%s.sql', $this->getDriver(), time(), $name);
|
||||||
|
|
||||||
|
$file = $this->path . DS . $fileName;
|
||||||
|
|
||||||
|
if (!touch($file)) {
|
||||||
|
throw new RuntimeException(sprintf('Unable to create new migration at \'%s\'.', $this->path . DS));
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$file,
|
||||||
|
<<<SQL
|
||||||
|
-- # migrate_up
|
||||||
|
|
||||||
|
-- Put your upgrade database commands here.
|
||||||
|
|
||||||
|
-- # migrate_down
|
||||||
|
|
||||||
|
-- put your downgrade database commands here.
|
||||||
|
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
|
||||||
|
$output->writeln(sprintf('<info>Created new Migration file at \'%s\'.</info>', $file));
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runMaintenance(): int|bool
|
||||||
|
{
|
||||||
|
return $this->pdo->exec('VACUUM;');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getVersion(): int
|
||||||
|
{
|
||||||
|
return (int)file_get_contents($this->versionFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setVersion(int $version): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->versionFile, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDriver(): string
|
||||||
|
{
|
||||||
|
$driver = $this->pdo->getAttribute($this->pdo::ATTR_DRIVER_NAME);
|
||||||
|
|
||||||
|
if (empty($driver) || !is_string($driver)) {
|
||||||
|
$driver = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseFiles(): array
|
||||||
|
{
|
||||||
|
$migrations = [];
|
||||||
|
$driver = $this->getDriver();
|
||||||
|
|
||||||
|
foreach ((array)glob($this->path . DS . '*.sql') as $file) {
|
||||||
|
if (!is_string($file) || false === ($f = realpath($file))) {
|
||||||
|
throw new RuntimeException(sprintf('Unable to get real path to \'%s\'', $file));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$type, $id, $name] = (array)preg_split(
|
||||||
|
'#^(\w+)_(\d+)_(.+)\.sql$#',
|
||||||
|
basename($f),
|
||||||
|
-1,
|
||||||
|
PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($type !== $driver) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int)$id;
|
||||||
|
|
||||||
|
[$up, $down] = (array)preg_split(
|
||||||
|
'/^-- #\s+?migrate_down\b/im',
|
||||||
|
(string)file_get_contents($f),
|
||||||
|
-1,
|
||||||
|
PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
|
||||||
|
);
|
||||||
|
|
||||||
|
$up = trim(preg_replace('/^-- #\s+?migrate_up\b/i', '', (string)$up));
|
||||||
|
$down = trim((string)$down);
|
||||||
|
|
||||||
|
$migrations[$id] = [
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'up' => $up,
|
||||||
|
'down' => $down,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $migrations;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Libs/Storage/StorageInterface.php
Normal file
120
src/Libs/Storage/StorageInterface.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libs\Storage;
|
||||||
|
|
||||||
|
use App\Libs\Entity\StateEntity;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
interface StorageInterface
|
||||||
|
{
|
||||||
|
public const MIGRATE_UP = 'up';
|
||||||
|
|
||||||
|
public const MIGRATE_DOWN = 'down';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate Driver.
|
||||||
|
*
|
||||||
|
* @param array $opts
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setUp(array $opts): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Logger.
|
||||||
|
*
|
||||||
|
* @param LoggerInterface $logger
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setLogger(LoggerInterface $logger): self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert Entity immediately.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return StateEntity Return given entity with valid $id
|
||||||
|
*/
|
||||||
|
public function insert(StateEntity $entity): StateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Entity immediately.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return StateEntity Return the given entity.
|
||||||
|
*/
|
||||||
|
public function update(StateEntity $entity): StateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return StateEntity|null
|
||||||
|
*/
|
||||||
|
public function get(StateEntity $entity): StateEntity|null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Entity.
|
||||||
|
*
|
||||||
|
* @param StateEntity $entity
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function remove(StateEntity $entity): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert/Update Entities.
|
||||||
|
*
|
||||||
|
* @param array<StateEntity> $entities
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function commit(array $entities): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load All Entities From backend.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $date Get Entities That has changed since given time.
|
||||||
|
*
|
||||||
|
* @return array<StateEntity>
|
||||||
|
*/
|
||||||
|
public function getAll(DateTimeInterface|null $date = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate Backend Storage Schema.
|
||||||
|
*
|
||||||
|
* @param string $dir direction {@see MIGRATE_UP}, {@see MIGRATE_DOWN}
|
||||||
|
* @param InputInterface $input
|
||||||
|
* @param OutputInterface $output
|
||||||
|
* @param array $opts
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function migrations(string $dir, InputInterface $input, OutputInterface $output, array $opts = []): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Maintenance on backend storage.
|
||||||
|
*
|
||||||
|
* @param InputInterface $input
|
||||||
|
* @param OutputInterface $output
|
||||||
|
* @param array $opts
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function maintenance(InputInterface $input, OutputInterface $output, array $opts = []): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make Migration.
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @param OutputInterface $output
|
||||||
|
* @param array $opts
|
||||||
|
*/
|
||||||
|
public function makeMigration(string $name, OutputInterface $output, array $opts = []): void;
|
||||||
|
}
|
||||||
234
src/Libs/helpers.php
Normal file
234
src/Libs/helpers.php
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Libs\Config;
|
||||||
|
use App\Libs\Extends\Date;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
if (!function_exists('env')) {
|
||||||
|
function env(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if (false === ($value = $_ENV[$key] ?? getenv($key))) {
|
||||||
|
return getValue($default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (strtolower($value)) {
|
||||||
|
'true', '(true)' => true,
|
||||||
|
'false', '(false)' => false,
|
||||||
|
'empty', '(empty)' => '',
|
||||||
|
'null', '(null)' => null,
|
||||||
|
default => $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('getValue')) {
|
||||||
|
function getValue(mixed $var): mixed
|
||||||
|
{
|
||||||
|
return ($var instanceof Closure) ? $var() : $var;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('makeDate')) {
|
||||||
|
/**
|
||||||
|
* Make Date Time Object.
|
||||||
|
*
|
||||||
|
* @param string|int $date Defaults to now
|
||||||
|
* @param string|DateTimeZone|null $tz For given $date, not for display.
|
||||||
|
*
|
||||||
|
* @return Date
|
||||||
|
*/
|
||||||
|
function makeDate(string|int $date = 'now', DateTimeZone|string|null $tz = null): Date
|
||||||
|
{
|
||||||
|
if (ctype_digit((string)$date)) {
|
||||||
|
$date = '@' . $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $tz) {
|
||||||
|
$tz = date_default_timezone_get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!($tz instanceof DateTimeZone)) {
|
||||||
|
$tz = new DateTimeZone($tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new Date($date))->setTimezone($tz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('ag')) {
|
||||||
|
function ag(array $array, string|null $path, mixed $default = null, string $separator = '.'): mixed
|
||||||
|
{
|
||||||
|
if (null === $path) {
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($path, $array)) {
|
||||||
|
return $array[$path];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_contains($path, $separator)) {
|
||||||
|
return $array[$path] ?? getValue($default);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode($separator, $path) as $segment) {
|
||||||
|
if (is_array($array) && array_key_exists($segment, $array)) {
|
||||||
|
$array = $array[$segment];
|
||||||
|
} else {
|
||||||
|
return getValue($default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('ag_set')) {
|
||||||
|
/**
|
||||||
|
* Set an array item to a given value using "dot" notation.
|
||||||
|
*
|
||||||
|
* If no key is given to the method, the entire array will be replaced.
|
||||||
|
*
|
||||||
|
* @param array $array
|
||||||
|
* @param string $path
|
||||||
|
* @param mixed $value
|
||||||
|
* @param string $separator
|
||||||
|
*
|
||||||
|
* @return array return modified array.
|
||||||
|
*/
|
||||||
|
function ag_set(array $array, string $path, mixed $value, string $separator = '.'): array
|
||||||
|
{
|
||||||
|
$keys = explode($separator, $path);
|
||||||
|
|
||||||
|
$at = &$array;
|
||||||
|
|
||||||
|
while (count($keys) > 0) {
|
||||||
|
if (1 === count($keys)) {
|
||||||
|
if (is_array($at)) {
|
||||||
|
$at[array_shift($keys)] = $value;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Can not set value at this path ($path) because is not array.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$path = array_shift($keys);
|
||||||
|
if (!isset($at[$path])) {
|
||||||
|
$at[$path] = [];
|
||||||
|
}
|
||||||
|
$at = &$at[$path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('ag_exists')) {
|
||||||
|
/**
|
||||||
|
* Determine if the given key exists in the provided array.
|
||||||
|
*
|
||||||
|
* @param array $array
|
||||||
|
* @param string|int $path
|
||||||
|
* @param string $separator
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function ag_exists(array $array, string|int $path, string $separator = '.'): bool
|
||||||
|
{
|
||||||
|
if (is_int($path)) {
|
||||||
|
return isset($array[$path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode($separator, $path) as $lookup) {
|
||||||
|
if (isset($array[$lookup])) {
|
||||||
|
$array = $array[$lookup];
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('ag_delete')) {
|
||||||
|
/**
|
||||||
|
* Delete given key path.
|
||||||
|
*
|
||||||
|
* @param array $array
|
||||||
|
* @param int|string $path
|
||||||
|
* @param string $separator
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function ag_delete(array $array, string|int $path, string $separator = '.'): array
|
||||||
|
{
|
||||||
|
if (array_key_exists($path, $array)) {
|
||||||
|
unset($array[$path]);
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($path)) {
|
||||||
|
if (isset($array[$path])) {
|
||||||
|
unset($array[$path]);
|
||||||
|
}
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = &$array;
|
||||||
|
|
||||||
|
$segments = explode($separator, $path);
|
||||||
|
|
||||||
|
$lastSegment = array_pop($segments);
|
||||||
|
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if (!isset($items[$segment]) || !is_array($items[$segment])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = &$items[$segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $lastSegment && array_key_exists($lastSegment, $items)) {
|
||||||
|
unset($items[$lastSegment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fixPath')) {
|
||||||
|
function fixPath(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim(implode(DS, explode(DS, $path)), DS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fsize')) {
|
||||||
|
function fsize(string|int $bytes = 0, bool $showUnit = true, int $decimals = 2, int $mod = 1000): string
|
||||||
|
{
|
||||||
|
$sz = 'BKMGTP';
|
||||||
|
|
||||||
|
$factor = floor((strlen((string)$bytes) - 1) / 3);
|
||||||
|
|
||||||
|
return sprintf("%.{$decimals}f", (int)($bytes) / ($mod ** $factor)) . ($showUnit ? $sz[(int)$factor] : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('saveWebhookPayload')) {
|
||||||
|
function saveWebhookPayload(ServerRequestInterface $request, string $name, array $parsed = [])
|
||||||
|
{
|
||||||
|
$content = [
|
||||||
|
'q' => $request->getQueryParams(),
|
||||||
|
'p' => $request->getParsedBody(),
|
||||||
|
's' => $request->getServerParams(),
|
||||||
|
'b' => $request->getBody()->getContents(),
|
||||||
|
'd' => $parsed,
|
||||||
|
];
|
||||||
|
|
||||||
|
@file_put_contents(
|
||||||
|
Config::get('path') . DS . 'logs' . DS . sprintf('webhook.%s.json', $name),
|
||||||
|
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
var/config/.gitignore
vendored
Normal file
1
var/config/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
1
var/db/.gitignore
vendored
Normal file
1
var/db/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
1
var/logs/.gitignore
vendored
Normal file
1
var/logs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
Reference in New Issue
Block a user