Initial commit.

This commit is contained in:
Abdulmhsen B. A. A
2022-02-10 16:41:48 +03:00
commit 1e13cc4bf1
61 changed files with 8537 additions and 0 deletions

30
.editorconfig Normal file
View 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
View File

@@ -0,0 +1,2 @@
/.idea/*
/vendor/*

10
.phpstorm.meta.php Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
config/commands.php Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'%(path)/db',
'%(path)/logs',
'%(path)/config',
];

34
config/servers.yaml Normal file
View 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
View 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
View 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
View 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"]

View 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
View 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
View File

@@ -0,0 +1,6 @@
http:// {
root * /app/public
php_fastcgi localhost:9000
file_server
log
}

32
docker/full/Dockerfile Normal file
View 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"]

View 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
View 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
View 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
View 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
View 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;
}
}

View 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;
}
}

View 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));
}
}

View 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 ?? '';
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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
View 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
View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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
View 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();
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Libs\Extends;
use League\Container\Container;
final class PSRContainer extends Container
{
}

View 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
View 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;
}
}

View 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
View 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)
);
}
}
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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);
}
}

View 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];
}
}

View 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;
}
}

View 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;
}

View File

@@ -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";

View 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);
}
}

View 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;
}
}

View 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
View 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
View File

@@ -0,0 +1 @@
*

1
var/db/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

1
var/logs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*