From 1e13cc4bf134888b8fd7386c7bf58d22e730204a Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Thu, 10 Feb 2022 16:41:48 +0300 Subject: [PATCH] Initial commit. --- .editorconfig | 30 + .gitignore | 2 + .phpstorm.meta.php | 10 + LICENSE | 19 + README.md | 228 ++ composer.json | 52 + composer.lock | 2393 +++++++++++++++++ config/commands.php | 23 + config/config.php | 153 ++ config/config.yaml | 13 + config/directories.php | 9 + config/servers.yaml | 34 + config/services.php | 14 + console | 75 + docker/cli/Dockerfile | 31 + docker/cli/docker-compose.yaml | 15 + docker/cli/entrypoint.sh | 31 + docker/full/Caddyfile | 6 + docker/full/Dockerfile | 32 + docker/full/docker-compose.yaml | 17 + docker/full/entrypoint.sh | 35 + public/index.php | 141 + src/Cli.php | 38 + src/Command.php | 54 + src/Commands/Config/DumpCommand.php | 86 + src/Commands/Config/GenerateCommand.php | 56 + src/Commands/Config/PHPCommand.php | 60 + src/Commands/State/ExportCommand.php | 231 ++ src/Commands/State/ImportCommand.php | 217 ++ src/Commands/Storage/MaintenanceCommand.php | 31 + src/Commands/Storage/MakeCommand.php | 35 + src/Commands/Storage/MigrationsCommand.php | 34 + src/Libs/Config.php | 53 + src/Libs/Container.php | 93 + src/Libs/Data.php | 55 + src/Libs/Entity/StateEntity.php | 184 ++ src/Libs/Extends/CliLogger.php | 98 + src/Libs/Extends/ConsoleOutput.php | 21 + src/Libs/Extends/Date.php | 23 + src/Libs/Extends/PSRContainer.php | 11 + src/Libs/Extends/Request.php | 16 + src/Libs/Guid.php | 107 + src/Libs/HttpException.php | 11 + src/Libs/KernelConsole.php | 362 +++ src/Libs/Mappers/Export/ExportMapper.php | 141 + src/Libs/Mappers/ExportInterface.php | 93 + src/Libs/Mappers/Import/DirectMapper.php | 114 + src/Libs/Mappers/Import/MemoryMapper.php | 237 ++ src/Libs/Mappers/ImportInterface.php | 96 + src/Libs/Servers/EmbyServer.php | 115 + src/Libs/Servers/JellyfinServer.php | 681 +++++ src/Libs/Servers/PlexServer.php | 658 +++++ src/Libs/Servers/ServerInterface.php | 66 + .../sqlite_1644418046_create_state_table.sql | 31 + src/Libs/Storage/PDO/PDOAdapter.php | 505 ++++ src/Libs/Storage/PDO/PDOMigrations.php | 204 ++ src/Libs/Storage/StorageInterface.php | 120 + src/Libs/helpers.php | 234 ++ var/config/.gitignore | 1 + var/db/.gitignore | 1 + var/logs/.gitignore | 1 + 61 files changed, 8537 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .phpstorm.meta.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/commands.php create mode 100644 config/config.php create mode 100644 config/config.yaml create mode 100644 config/directories.php create mode 100644 config/servers.yaml create mode 100644 config/services.php create mode 100644 console create mode 100644 docker/cli/Dockerfile create mode 100644 docker/cli/docker-compose.yaml create mode 100755 docker/cli/entrypoint.sh create mode 100644 docker/full/Caddyfile create mode 100644 docker/full/Dockerfile create mode 100644 docker/full/docker-compose.yaml create mode 100755 docker/full/entrypoint.sh create mode 100644 public/index.php create mode 100644 src/Cli.php create mode 100644 src/Command.php create mode 100644 src/Commands/Config/DumpCommand.php create mode 100644 src/Commands/Config/GenerateCommand.php create mode 100644 src/Commands/Config/PHPCommand.php create mode 100644 src/Commands/State/ExportCommand.php create mode 100644 src/Commands/State/ImportCommand.php create mode 100644 src/Commands/Storage/MaintenanceCommand.php create mode 100644 src/Commands/Storage/MakeCommand.php create mode 100644 src/Commands/Storage/MigrationsCommand.php create mode 100644 src/Libs/Config.php create mode 100644 src/Libs/Container.php create mode 100644 src/Libs/Data.php create mode 100644 src/Libs/Entity/StateEntity.php create mode 100644 src/Libs/Extends/CliLogger.php create mode 100644 src/Libs/Extends/ConsoleOutput.php create mode 100644 src/Libs/Extends/Date.php create mode 100644 src/Libs/Extends/PSRContainer.php create mode 100644 src/Libs/Extends/Request.php create mode 100644 src/Libs/Guid.php create mode 100644 src/Libs/HttpException.php create mode 100644 src/Libs/KernelConsole.php create mode 100644 src/Libs/Mappers/Export/ExportMapper.php create mode 100644 src/Libs/Mappers/ExportInterface.php create mode 100644 src/Libs/Mappers/Import/DirectMapper.php create mode 100644 src/Libs/Mappers/Import/MemoryMapper.php create mode 100644 src/Libs/Mappers/ImportInterface.php create mode 100644 src/Libs/Servers/EmbyServer.php create mode 100644 src/Libs/Servers/JellyfinServer.php create mode 100644 src/Libs/Servers/PlexServer.php create mode 100644 src/Libs/Servers/ServerInterface.php create mode 100644 src/Libs/Storage/PDO/Migrations/sqlite_1644418046_create_state_table.sql create mode 100644 src/Libs/Storage/PDO/PDOAdapter.php create mode 100644 src/Libs/Storage/PDO/PDOMigrations.php create mode 100644 src/Libs/Storage/StorageInterface.php create mode 100644 src/Libs/helpers.php create mode 100644 var/config/.gitignore create mode 100644 var/db/.gitignore create mode 100644 var/logs/.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d3b5b1ad --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1eaa3875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea/* +/vendor/* diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 00000000..2ca32f35 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,10 @@ + '@'])); +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(['' => '@'])); diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..fa63b7c9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..44790679 --- /dev/null +++ b/README.md @@ -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`` diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..1600e6ac --- /dev/null +++ b/composer.json @@ -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": "*" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..3660fa87 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2393 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6579d075122b36fec733d04dc510facc", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee0a041b1760e6a53d2a39c8c34115adc2af2c79", + "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5", + "guzzlehttp/psr7": "^1.8.3 || ^2.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-curl": "*", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^8.5.5 || ^9.3.5", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2021-12-06T18:43:05+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4 || ^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2021-10-22T20:56:57+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/089edd38f5b8abba6cb01567c2a8aaa47cec4c72", + "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2021-10-06T17:43:30+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "0c26ef1d95b6d7e6e3943a243ba3dc0797227199" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/0c26ef1d95b6d7e6e3943a243ba3dc0797227199", + "reference": "0c26ef1d95b6d7e6e3943a243ba3dc0797227199", + "shasum": "" + }, + "require": { + "php": "^7.3 || ~8.0.0 || ~8.1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "conflict": { + "phpspec/prophecy": "<1.9.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.8.0", + "laminas/laminas-coding-standard": "~1.0.0", + "php-http/psr7-integration-tests": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.1", + "psalm/plugin-phpunit": "^0.14.0", + "vimeo/psalm": "^4.3" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\Diactoros\\ConfigProvider", + "module": "Laminas\\Diactoros" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/marshal_uri_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php", + "src/functions/create_uploaded_file.legacy.php", + "src/functions/marshal_headers_from_sapi.legacy.php", + "src/functions/marshal_method_from_sapi.legacy.php", + "src/functions/marshal_protocol_version_from_sapi.legacy.php", + "src/functions/marshal_uri_from_sapi.legacy.php", + "src/functions/normalize_server.legacy.php", + "src/functions/normalize_uploaded_files.legacy.php", + "src/functions/parse_cookie_header.legacy.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2021-09-22T03:54:36+00:00" + }, + { + "name": "laminas/laminas-httphandlerrunner", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-httphandlerrunner.git", + "reference": "4d337cde83e6b901a4443b0ab5c3b97cbaa46413" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/4d337cde83e6b901a4443b0ab5c3b97cbaa46413", + "reference": "4d337cde83e6b901a4443b0ab5c3b97cbaa46413", + "shasum": "" + }, + "require": { + "php": "^7.3 || ~8.0.0 || ~8.1.0", + "psr/http-message": "^1.0", + "psr/http-message-implementation": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~2.3.0", + "laminas/laminas-diactoros": "^2.8.0", + "phpunit/phpunit": "^9.5.9", + "psalm/plugin-phpunit": "^0.16.1", + "vimeo/psalm": "^4.10.0" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "homepage": "https://laminas.dev", + "keywords": [ + "components", + "laminas", + "mezzio", + "psr-15", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", + "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", + "source": "https://github.com/laminas/laminas-httphandlerrunner" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2021-09-22T09:27:36+00:00" + }, + { + "name": "league/container", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/container.git", + "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/container/zipball/375d13cb828649599ef5d48a339c4af7a26cd0ab", + "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "replace": { + "orno/di": "~2.0" + }, + "require-dev": { + "nette/php-generator": "^3.4", + "nikic/php-parser": "^4.10", + "phpstan/phpstan": "^0.12.47", + "phpunit/phpunit": "^8.5.17", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev", + "dev-4.x": "4.x-dev", + "dev-3.x": "3.x-dev", + "dev-2.x": "2.x-dev", + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Container\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "A fast and intuitive dependency injection container.", + "homepage": "https://github.com/thephpleague/container", + "keywords": [ + "container", + "dependency", + "di", + "injection", + "league", + "provider", + "service" + ], + "support": { + "issues": "https://github.com/thephpleague/container/issues", + "source": "https://github.com/thephpleague/container/tree/4.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2021-11-16T10:29:06+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9", + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7", + "graylog2/gelf-php": "^1.4.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.3", + "phpspec/prophecy": "^1.6.1", + "phpstan/phpstan": "^0.12.91", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3", + "ruflin/elastica": ">=0.90@dev", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.3.5" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2021-10-01T21:08:31+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/master" + }, + "time": "2020-06-29T06:28:15+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/master" + }, + "time": "2019-04-30T12:38:16+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/master" + }, + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-handler/issues", + "source": "https://github.com/php-fig/http-server-handler/tree/master" + }, + "time": "2018-10-30T16:46:14+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/console", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "22e8efd019c3270c4f79376234a3f8752cd25490" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/22e8efd019c3270c4f79376234a3f8752cd25490", + "reference": "22e8efd019c3270c4f79376234a3f8752cd25490", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/string": "^5.4|^6.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-26T17:23:29+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/c726b64c1ccfe2896cb7df2e1331c357ad1c8ced", + "reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced", + "shasum": "" + }, + "require": { + "php": ">=8.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-11-01T23:48:49+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "30885182c981ab175d4d034db0f6f469898070ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/30885182c981ab175d4d034db0f6f469898070ab", + "reference": "30885182c981ab175d4d034db0f6f469898070ab", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.24.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-10-20T20:35:02+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783", + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.24.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-11-23T21:10:46+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.24.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/0abb51d2f102e00a4eefcf46ba7fec406d245825", + "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.24.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-11-30T18:21:41+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "36715ebf9fb9db73db0cb24263c79077c6fe8603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/36715ebf9fb9db73db0cb24263c79077c6fe8603", + "reference": "36715ebf9fb9db73db0cb24263c79077c6fe8603", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "psr/container": "^2.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-11-04T17:53:12+00:00" + }, + { + "name": "symfony/string", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/522144f0c4c004c80d56fa47e40e17028e2eefc2", + "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.0" + }, + "require-dev": { + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "files": [ + "Resources/functions.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:55:41+00:00" + }, + { + "name": "symfony/yaml", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "e77f3ea0b21141d771d4a5655faa54f692b34af5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e77f3ea0b21141d771d4a5655faa54f692b34af5", + "reference": "e77f3ea0b21141d771d4a5655faa54f692b34af5", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-26T17:23:29+00:00" + } + ], + "packages-dev": [ + { + "name": "perftools/php-profiler", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/perftools/php-profiler.git", + "reference": "f860cb59e39ca67c3db37f680e8cd3b460db68fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/perftools/php-profiler/zipball/f860cb59e39ca67c3db37f680e8cd3b460db68fb", + "reference": "f860cb59e39ca67c3db37f680e8cd3b460db68fb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^5.3.0 || ^7.0 || ^8.0" + }, + "conflict": { + "perftools/xhgui-collector": "<1.8" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "perftools/xhgui-collector": "^1.8" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "Adapter to provide ext-mongo interface on top of mongo-php-library (PHP>=5.6)", + "ext-curl": "cURL extension for upload saver", + "ext-mongo": "mongo extension (PHP>=5.3,<7.0)", + "ext-mongodb": "mongodb extension (PHP>=5.4)", + "ext-tideways": "Use tideways to profile", + "ext-uprofiler": "Use uprofiler to profile", + "ext-xhprof": "Use xhprof to profile", + "perftools/xhgui-collector": "For mongodb or pdo savers" + }, + "type": "library", + "autoload": { + "psr-4": { + "Xhgui\\Profiler\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lauri Piisang", + "email": "lauri.piisang@eesti.ee" + }, + { + "name": "Elan Ruusamäe", + "email": "glen@pld-linux.org" + } + ], + "description": "PHP Profiling based on XHGUI", + "support": { + "issues": "https://github.com/perftools/php-profiler/issues", + "source": "https://github.com/perftools/php-profiler/tree/1.0.0" + }, + "time": "2021-10-18T01:40:28+00:00" + }, + { + "name": "roave/security-advisories", + "version": "dev-latest", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "2ec9ad634c459ee60c42d99390be37c8a3c6e8e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/2ec9ad634c459ee60c42d99390be37c8a3c6e8e5", + "reference": "2ec9ad634c459ee60c42d99390be37c8a3c6e8e5", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "adodb/adodb-php": "<=5.20.20|>=5.21,<=5.21.3", + "akaunting/akaunting": "<2.1.13", + "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", + "amazing/media2click": ">=1,<1.3.3", + "amphp/artax": "<1.0.6|>=2,<2.0.6", + "amphp/http": "<1.0.1", + "amphp/http-client": ">=4,<4.4", + "anchorcms/anchor-cms": "<=0.12.7", + "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6", + "area17/twill": "<1.2.5|>=2,<2.5.3", + "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99", + "aws/aws-sdk-php": ">=3,<3.2.1", + "bagisto/bagisto": "<0.1.5", + "barrelstrength/sprout-base-email": "<1.2.7", + "barrelstrength/sprout-forms": "<3.9", + "baserproject/basercms": "<4.5.4", + "billz/raspap-webgui": "<=2.6.6", + "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", + "bolt/bolt": "<3.7.2", + "bolt/core": "<4.1.13", + "bottelet/flarepoint": "<2.2.1", + "brightlocal/phpwhois": "<=4.2.5", + "buddypress/buddypress": "<7.2.1", + "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "bytefury/crater": "<6", + "cachethq/cachet": "<2.5.1", + "cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7", + "cardgate/magento2": "<2.0.33", + "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cartalyst/sentry": "<=2.1.6", + "catfan/medoo": "<1.7.5", + "centreon/centreon": "<20.10.7", + "cesnet/simplesamlphp-module-proxystatistics": "<3.1", + "codeception/codeception": "<3.1.3|>=4,<4.1.22", + "codeigniter/framework": "<=3.0.6", + "codeigniter4/framework": "<4.1.8", + "codiad/codiad": "<=2.8.4", + "composer/composer": "<1.10.23|>=2-alpha.1,<2.1.9", + "concrete5/concrete5": "<8.5.5", + "concrete5/core": "<8.5.7", + "contao-components/mediaelement": ">=2.14.2,<2.21.1", + "contao/core": ">=2,<3.5.39", + "contao/core-bundle": ">=4,<4.4.56|>=4.5,<4.9.18|>=4.10,<4.11.7|= 4.10.0", + "contao/listing-bundle": ">=4,<4.4.8", + "craftcms/cms": "<3.7.14", + "croogo/croogo": "<3.0.7", + "datadog/dd-trace": ">=0.30,<0.30.2", + "david-garcia/phpwhois": "<=4.3.1", + "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1", + "directmailteam/direct-mail": "<5.2.4", + "doctrine/annotations": ">=1,<1.2.7", + "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", + "doctrine/common": ">=2,<2.4.3|>=2.5,<2.5.1", + "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", + "doctrine/doctrine-bundle": "<1.5.2", + "doctrine/doctrine-module": "<=0.7.1", + "doctrine/mongodb-odm": ">=1,<1.0.2", + "doctrine/mongodb-odm-bundle": ">=2,<3.0.1", + "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", + "dolibarr/dolibarr": "<=14.0.5|>= 3.3.beta1, < 13.0.2", + "dompdf/dompdf": ">=0.6,<0.6.2", + "drupal/core": ">=7,<7.80|>=8,<8.9.16|>=9,<9.1.12|>=9.2,<9.2.4", + "drupal/drupal": ">=7,<7.80|>=8,<8.9.16|>=9,<9.1.12|>=9.2,<9.2.4", + "dweeves/magmi": "<=0.7.24", + "ecodev/newsletter": "<=4", + "elgg/elgg": "<3.3.24|>=4,<4.0.5", + "endroid/qr-code-bundle": "<3.4.2", + "enshrined/svg-sanitize": "<0.13.1", + "erusev/parsedown": "<1.7.2", + "ether/logs": "<3.0.4", + "ezsystems/demobundle": ">=5.4,<5.4.6.1", + "ezsystems/ez-support-tools": ">=2.2,<2.2.3", + "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1", + "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1", + "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", + "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<=1.5.25", + "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1", + "ezsystems/ezplatform-kernel": "<=1.2.5|>=1.3,<=1.3.1", + "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", + "ezsystems/ezplatform-richtext": ">=2.3,<=2.3.7", + "ezsystems/ezplatform-user": ">=1,<1.0.1", + "ezsystems/ezpublish-kernel": "<=6.13.8.1|>=7,<7.5.26", + "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.3.5.1", + "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", + "ezsystems/repository-forms": ">=2.3,<2.3.2.1", + "ezyang/htmlpurifier": "<4.1.1", + "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", + "feehi/cms": "<=2.1.1", + "feehi/feehicms": "<=0.1.3", + "firebase/php-jwt": "<2", + "flarum/core": ">=1,<=1.0.1", + "flarum/sticky": ">=0.1-beta.14,<=0.1-beta.15", + "flarum/tags": "<=0.1-beta.13", + "fluidtypo3/vhs": "<5.1.1", + "fooman/tcpdf": "<6.2.22", + "forkcms/forkcms": "<=5.9.2", + "fossar/tcpdf-parser": "<6.2.22", + "francoisjacquet/rosariosis": "<8.1.1", + "friendsofsymfony/oauth2-php": "<1.3", + "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", + "friendsofsymfony/user-bundle": ">=1.2,<1.3.5", + "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", + "froala/wysiwyg-editor": "<3.2.7", + "fuel/core": "<1.8.1", + "gaoming13/wechat-php-sdk": "<=1.10.2", + "getgrav/grav": "<1.7.28", + "getkirby/cms": "<3.5.8", + "getkirby/panel": "<2.5.14", + "gilacms/gila": "<=1.11.4", + "globalpayments/php-sdk": "<2", + "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", + "gree/jose": "<=2.2", + "gregwar/rst": "<1.0.3", + "grumpydictator/firefly-iii": "<5.6.5", + "guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1", + "helloxz/imgurl": "<=2.31", + "hillelcoren/invoice-ninja": "<5.3.35", + "hjue/justwriting": "<=1", + "hov/jobfair": "<1.0.13|>=2,<2.0.2", + "ibexa/post-install": "<=1.0.4", + "icecoder/icecoder": "<=8.1", + "illuminate/auth": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.10", + "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.99999|>=4.2,<=4.2.99999|>=5,<=5.0.99999|>=5.1,<=5.1.99999|>=5.2,<=5.2.99999|>=5.3,<=5.3.99999|>=5.4,<=5.4.99999|>=5.5,<=5.5.49|>=5.6,<=5.6.99999|>=5.7,<=5.7.99999|>=5.8,<=5.8.99999|>=6,<6.18.31|>=7,<7.22.4", + "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", + "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", + "impresscms/impresscms": "<=1.4.2", + "in2code/femanager": "<5.5.1|>=6,<6.3.1", + "intelliants/subrion": "<=4.2.1", + "ivankristianto/phpwhois": "<=4.3", + "jackalope/jackalope-doctrine-dbal": "<1.7.4", + "james-heinrich/getid3": "<1.9.21", + "joomla/archive": "<1.1.10", + "joomla/session": "<1.3.1", + "jsmitty12/phpwhois": "<5.1", + "kazist/phpwhois": "<=4.2.6", + "kevinpapst/kimai2": "<1.16.7", + "kitodo/presentation": "<3.1.2", + "klaviyo/magento2-extension": ">=1,<3", + "kreait/firebase-php": ">=3.2,<3.8.1", + "la-haute-societe/tcpdf": "<6.2.22", + "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", + "laminas/laminas-http": "<2.14.2", + "laravel/framework": "<6.20.42|>=7,<7.30.6|>=8,<8.75", + "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", + "latte/latte": "<2.10.8", + "lavalite/cms": "<=5.8", + "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", + "league/commonmark": "<0.18.3", + "league/flysystem": "<1.1.4|>=2,<2.1.1", + "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", + "librenms/librenms": "<=21.11", + "limesurvey/limesurvey": "<3.27.19", + "livehelperchat/livehelperchat": "<=3.91", + "livewire/livewire": ">2.2.4,<2.2.6", + "lms/routes": "<2.1.1", + "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", + "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3", + "magento/magento1ce": "<1.9.4.3", + "magento/magento1ee": ">=1,<1.14.4.3", + "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", + "marcwillmann/turn": "<0.3.3", + "mautic/core": "<4|= 2.13.1", + "mediawiki/core": ">=1.27,<1.27.6|>=1.29,<1.29.3|>=1.30,<1.30.2|>=1.31,<1.31.9|>=1.32,<1.32.6|>=1.32.99,<1.33.3|>=1.33.99,<1.34.3|>=1.34.99,<1.35", + "microweber/microweber": "<1.2.11", + "miniorange/miniorange-saml": "<1.4.3", + "mittwald/typo3_forum": "<1.2.1", + "modx/revolution": "<2.8", + "monolog/monolog": ">=1.8,<1.12", + "moodle/moodle": "<3.9.11|>=3.10-beta,<3.10.8|>=3.11,<3.11.5", + "mustache/mustache": ">=2,<2.14.1", + "namshi/jose": "<2.2", + "neoan3-apps/template": "<1.1.1", + "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", + "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.9.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", + "neos/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", + "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", + "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", + "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", + "nilsteampassnet/teampass": "<=2.1.27.36", + "nukeviet/nukeviet": "<4.3.4", + "nystudio107/craft-seomatic": "<3.3", + "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", + "october/backend": "<1.1.2", + "october/cms": "= 1.1.1|= 1.0.471|= 1.0.469|>=1.0.319,<1.0.469", + "october/october": ">=1.0.319,<1.0.466|>=2.1,<2.1.12", + "october/rain": "<1.0.472|>=1.1,<1.1.2", + "october/system": "<1.0.473|>=1.1,<1.1.6|>=2.1,<2.1.12", + "onelogin/php-saml": "<2.10.4", + "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", + "opencart/opencart": "<=3.0.3.2", + "openid/php-openid": "<2.3", + "openmage/magento-lts": "<19.4.15|>=20,<20.0.13", + "orchid/platform": ">=9,<9.4.4", + "oro/crm": ">=1.7,<1.7.4|>=3.1,<4.1.17|>=4.2,<4.2.7", + "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<4.2.8", + "padraic/humbug_get_contents": "<1.1.2", + "pagarme/pagarme-php": ">=0,<3", + "pagekit/pagekit": "<=1.0.18", + "paragonie/random_compat": "<2", + "passbolt/passbolt_api": "<2.11", + "paypal/merchant-sdk-php": "<3.12", + "pear/archive_tar": "<1.4.14", + "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", + "personnummer/personnummer": "<3.0.2", + "phanan/koel": "<5.1.4", + "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", + "phpmailer/phpmailer": "<6.5", + "phpmussel/phpmussel": ">=1,<1.6", + "phpmyadmin/phpmyadmin": "<4.9.8|>=5,<5.0.3|>=5.1,<5.1.2", + "phpoffice/phpexcel": "<1.8.2", + "phpoffice/phpspreadsheet": "<1.16", + "phpseclib/phpseclib": "<2.0.31|>=3,<3.0.7", + "phpservermon/phpservermon": "<=3.5.2", + "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3", + "phpwhois/phpwhois": "<=4.2.5", + "phpxmlrpc/extras": "<0.6.1", + "pimcore/pimcore": "<=10.2.9", + "pocketmine/pocketmine-mp": "<4.0.7", + "pressbooks/pressbooks": "<5.18", + "prestashop/autoupgrade": ">=4,<4.10.1", + "prestashop/contactform": ">1.0.1,<4.3", + "prestashop/gamification": "<2.3.2", + "prestashop/prestashop": ">=1.7,<=1.7.8.2", + "prestashop/productcomments": ">=4,<4.2.1", + "prestashop/ps_emailsubscription": "<2.6.1", + "prestashop/ps_facetedsearch": "<3.4.1", + "prestashop/ps_linklist": "<3.1", + "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2", + "propel/propel": ">=2-alpha.1,<=2-alpha.7", + "propel/propel1": ">=1,<=1.7.1", + "pterodactyl/panel": "<1.7", + "pusher/pusher-php-server": "<2.2.1", + "pwweb/laravel-core": "<=0.3.6-beta", + "rainlab/debugbar-plugin": "<3.1", + "remdex/livehelperchat": "<3.93", + "rmccue/requests": ">=1.6,<1.8", + "robrichards/xmlseclibs": "<3.0.4", + "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", + "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9", + "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", + "sensiolabs/connect": "<4.2.3", + "serluck/phpwhois": "<=4.2.6", + "shopware/core": "<=6.4.6", + "shopware/platform": "<=6.4.6", + "shopware/production": "<=6.3.5.2", + "shopware/shopware": "<5.7.7", + "showdoc/showdoc": "<2.10.2", + "silverstripe/admin": ">=1,<1.8.1", + "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", + "silverstripe/cms": "<4.3.6|>=4.4,<4.4.4", + "silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1", + "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", + "silverstripe/framework": "<4.7.4", + "silverstripe/graphql": "<3.5.2|>=4-alpha.1,<4-alpha.2", + "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", + "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4", + "silverstripe/subsites": ">=2,<2.1.1", + "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", + "silverstripe/userforms": "<3", + "simple-updates/phpwhois": "<=1", + "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4", + "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", + "simplito/elliptic-php": "<1.0.6", + "slim/slim": "<2.6", + "smarty/smarty": "<3.1.43|>=4,<4.0.3", + "snipe/snipe-it": "<=5.3.7", + "socalnick/scn-social-auth": "<1.15.2", + "socialiteproviders/steam": "<1.1", + "spipu/html2pdf": "<5.2.4", + "spoonity/tcpdf": "<6.2.22", + "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "ssddanbrown/bookstack": "<21.12.1", + "stormpath/sdk": ">=0,<9.9.99", + "studio-42/elfinder": "<2.1.59", + "subrion/cms": "<=4.2.1", + "sulu/sulu": "= 2.4.0-RC1|<1.6.44|>=2,<2.2.18|>=2.3,<2.3.8", + "swiftmailer/swiftmailer": ">=4,<5.4.5", + "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", + "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/paypal-plugin": ">=1,<1.2.4|>=1.3,<1.3.1", + "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", + "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3|>=1.9,<1.9.5", + "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", + "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", + "symbiote/silverstripe-versionedfiles": "<=2.0.3", + "symfont/process": ">=0,<4", + "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", + "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<=5.3.14|>=5.4.3,<=5.4.3|>=6.0.3,<=6.0.3|= 6.0.3|= 5.4.3|= 5.3.14", + "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", + "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5|>=5.2,<5.3.12", + "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", + "symfony/mime": ">=4.3,<4.3.8", + "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill-php55": ">=1,<1.10", + "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/routing": ">=2,<2.0.19", + "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", + "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11|>=5.3,<5.3.12", + "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", + "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", + "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8|>=5.3,<5.3.2", + "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", + "symfony/symfony": ">=2,<3.4.49|>=4,<4.4.35|>=5,<5.3.12|>=5.3.14,<=5.3.14|>=5.4.3,<=5.4.3|>=6.0.3,<=6.0.3", + "symfony/translation": ">=2,<2.0.17", + "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", + "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", + "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7", + "t3/dce": ">=2.2,<2.6.2", + "t3g/svg-sanitizer": "<1.0.3", + "tecnickcom/tcpdf": "<6.2.22", + "terminal42/contao-tablelookupwizard": ">=1,<3.3.5", + "thelia/backoffice-default-template": ">=2.1,<2.1.2", + "thelia/thelia": ">=2.1-beta.1,<2.1.3", + "theonedemon/phpwhois": "<=4.2.5", + "tinymce/tinymce": "<5.10", + "titon/framework": ">=0,<9.9.99", + "topthink/framework": "<6.0.9", + "topthink/think": "<=6.0.9", + "topthink/thinkphp": "<=3.2.3", + "tribalsystems/zenario": "<8.8.53370", + "truckersmp/phpwhois": "<=4.3.1", + "twig/twig": "<1.38|>=2,<2.7", + "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.38|>=9,<9.5.29|>=10,<10.4.19|>=11,<11.5", + "typo3/cms-backend": ">=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", + "typo3/cms-core": ">=6.2,<=6.2.56|>=7,<=7.6.52|>=8,<=8.7.41|>=9,<9.5.29|>=10,<10.4.19|>=11,<11.5", + "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", + "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", + "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", + "typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", + "typo3fluid/fluid": ">=2,<2.0.8|>=2.1,<2.1.7|>=2.2,<2.2.4|>=2.3,<2.3.7|>=2.4,<2.4.4|>=2.5,<2.5.11|>=2.6,<2.6.10", + "ua-parser/uap-php": "<3.8", + "unisharp/laravel-filemanager": "<=2.3", + "userfrosting/userfrosting": ">=0.3.1,<4.6.3", + "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", + "vanilla/safecurl": "<0.9.2", + "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4", + "vrana/adminer": "<4.7.9", + "wallabag/tcpdf": "<6.2.22", + "wanglelecc/laracms": "<=1.0.3", + "web-auth/webauthn-framework": ">=3.3,<3.3.4", + "webcoast/deferred-image-processing": "<1.0.2", + "wikimedia/parsoid": "<0.12.2", + "willdurand/js-translation-bundle": "<2.1.1", + "wp-cli/wp-cli": "<2.5", + "yetiforce/yetiforce-crm": "<=6.3", + "yidashi/yii2cmf": "<=2", + "yii2mod/yii2-cms": "<1.9.2", + "yiisoft/yii": ">=1.1.14,<1.1.15", + "yiisoft/yii2": "<2.0.38", + "yiisoft/yii2-bootstrap": "<2.0.4", + "yiisoft/yii2-dev": "<2.0.43", + "yiisoft/yii2-elasticsearch": "<2.0.5", + "yiisoft/yii2-gii": "<2.0.4", + "yiisoft/yii2-jui": "<2.0.4", + "yiisoft/yii2-redis": "<2.0.8", + "yoast-seo-for-typo3/yoast_seo": "<7.2.3", + "yourls/yourls": "<=1.8.2", + "zendesk/zendesk_api_client_php": "<2.2.11", + "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", + "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-db": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.10|>=2.3,<2.3.5", + "zendframework/zend-developer-tools": ">=1.2.2,<1.2.3", + "zendframework/zend-diactoros": ">=1,<1.8.4", + "zendframework/zend-feed": ">=1,<2.10.3", + "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-http": ">=1,<2.8.1", + "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", + "zendframework/zend-mail": ">=2,<2.4.11|>=2.5,<2.7.2", + "zendframework/zend-navigation": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-session": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.9|>=2.3,<2.3.4", + "zendframework/zend-validator": ">=2.3,<2.3.6", + "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zendframework": "<=3", + "zendframework/zendframework1": "<1.12.20", + "zendframework/zendopenid": ">=2,<2.0.2", + "zendframework/zendxml": ">=1,<1.0.1", + "zetacomponents/mail": "<1.8.2", + "zf-commons/zfc-user": "<1.2.2", + "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", + "zfr/zfr-oauth2-server-module": "<0.1.2", + "zoujingli/thinkadmin": "<6.0.22" + }, + "default-branch": true, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2022-02-05T03:12:57+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "7b701676fc64f9ef11f9b4870f16b48f66be4834" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7b701676fc64f9ef11f9b4870f16b48f66be4834", + "reference": "7b701676fc64f9ef11f9b4870f16b48f66be4834", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "phpunit/phpunit": "<5.4.3", + "symfony/console": "<5.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", + "twig/twig": "^2.13|^3.0.4" + }, + "suggest": { + "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", + "ext-intl": "To show region name in time zone dump", + "symfony/console": "To use the ServerDumpCommand and/or the bin/var-dump-server script" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-17T16:30:44+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "roave/security-advisories": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.1", + "ext-pdo": "*", + "ext-mbstring": "*", + "ext-ctype": "*", + "ext-sqlite3": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/config/commands.php b/config/commands.php new file mode 100644 index 00000000..e88eab12 --- /dev/null +++ b/config/commands.php @@ -0,0 +1,23 @@ + 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, +]; diff --git a/config/config.php b/config/config.php new file mode 100644 index 00000000..45ea536e --- /dev/null +++ b/config/config.php @@ -0,0 +1,153 @@ + '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; +})(); diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 00000000..6578bf52 --- /dev/null +++ b/config/config.yaml @@ -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 diff --git a/config/directories.php b/config/directories.php new file mode 100644 index 00000000..13efcaf2 --- /dev/null +++ b/config/directories.php @@ -0,0 +1,9 @@ + [ + 'class' => fn() => new Logger('logger') + ], + ]; +})(); diff --git a/console b/console new file mode 100644 index 00000000..c8544991 --- /dev/null +++ b/console @@ -0,0 +1,75 @@ + '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(); diff --git a/docker/cli/Dockerfile b/docker/cli/Dockerfile new file mode 100644 index 00000000..7d2e397c --- /dev/null +++ b/docker/cli/Dockerfile @@ -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"] diff --git a/docker/cli/docker-compose.yaml b/docker/cli/docker-compose.yaml new file mode 100644 index 00000000..c4bc0912 --- /dev/null +++ b/docker/cli/docker-compose.yaml @@ -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 diff --git a/docker/cli/entrypoint.sh b/docker/cli/entrypoint.sh new file mode 100755 index 00000000..b74e7e54 --- /dev/null +++ b/docker/cli/entrypoint.sh @@ -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 "$@" diff --git a/docker/full/Caddyfile b/docker/full/Caddyfile new file mode 100644 index 00000000..0f9b39c6 --- /dev/null +++ b/docker/full/Caddyfile @@ -0,0 +1,6 @@ +http:// { + root * /app/public + php_fastcgi localhost:9000 + file_server + log +} diff --git a/docker/full/Dockerfile b/docker/full/Dockerfile new file mode 100644 index 00000000..dd90297a --- /dev/null +++ b/docker/full/Dockerfile @@ -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"] diff --git a/docker/full/docker-compose.yaml b/docker/full/docker-compose.yaml new file mode 100644 index 00000000..dd3cd2f4 --- /dev/null +++ b/docker/full/docker-compose.yaml @@ -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 diff --git a/docker/full/entrypoint.sh b/docker/full/entrypoint.sh new file mode 100755 index 00000000..9419b2bd --- /dev/null +++ b/docker/full/entrypoint.sh @@ -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 "$@" diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..0401e2c1 --- /dev/null +++ b/public/index.php @@ -0,0 +1,141 @@ +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); diff --git a/src/Cli.php b/src/Cli.php new file mode 100644 index 00000000..6787f996 --- /dev/null +++ b/src/Cli.php @@ -0,0 +1,38 @@ +addOption( + new InputOption('profile', null, InputOption::VALUE_NONE, 'Profile command.') + ); + } + + return $definition; + } +} diff --git a/src/Command.php b/src/Command.php new file mode 100644 index 00000000..df589a89 --- /dev/null +++ b/src/Command.php @@ -0,0 +1,54 @@ +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; + } +} diff --git a/src/Commands/Config/DumpCommand.php b/src/Commands/Config/DumpCommand.php new file mode 100644 index 00000000..01c0b988 --- /dev/null +++ b/src/Commands/Config/DumpCommand.php @@ -0,0 +1,86 @@ + '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('%s', $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('Generated file at \'%s\'.', $file)); + + return self::SUCCESS; + } +} diff --git a/src/Commands/Config/GenerateCommand.php b/src/Commands/Config/GenerateCommand.php new file mode 100644 index 00000000..09590c1e --- /dev/null +++ b/src/Commands/Config/GenerateCommand.php @@ -0,0 +1,56 @@ +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('Your Webhook API key is: %s', $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)); + } +} diff --git a/src/Commands/Config/PHPCommand.php b/src/Commands/Config/PHPCommand.php new file mode 100644 index 00000000..378d1881 --- /dev/null +++ b/src/Commands/Config/PHPCommand.php @@ -0,0 +1,60 @@ +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 ?? ''; + } +} diff --git a/src/Commands/State/ExportCommand.php b/src/Commands/State/ExportCommand.php new file mode 100644 index 00000000..987d6124 --- /dev/null +++ b/src/Commands/State/ExportCommand.php @@ -0,0 +1,231 @@ +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('Ignoring \'%s\' as requested by \'servers.yaml\'.', $serverName), + OutputInterface::VERBOSITY_VERBOSE + ); + continue; + } + + if (!isset($supported[$type])) { + $output->writeln( + sprintf( + 'Server \'%s\' Used Unsupported type. Expecting one of \'%s\' but got \'%s\' instead.', + $serverName, + implode(', ', array_keys($supported)), + $type + ) + ); + return self::FAILURE; + } + + if (null === ag($server, 'url')) { + $output->writeln(sprintf('Server \'%s\' has no url.', $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; + } +} diff --git a/src/Commands/State/ImportCommand.php b/src/Commands/State/ImportCommand.php new file mode 100644 index 00000000..a64adc91 --- /dev/null +++ b/src/Commands/State/ImportCommand.php @@ -0,0 +1,217 @@ +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('Ignoring \'%s\' as requested by \'servers.yaml\'.', $serverName), + OutputInterface::VERBOSITY_VERBOSE + ); + continue; + } + + if (!isset($supported[$type])) { + $output->writeln( + sprintf( + 'Server \'%s\' Used Unsupported type. Expecting one of \'%s\' but got \'%s\' instead.', + $serverName, + implode(', ', array_keys($supported)), + $type + ) + ); + return self::FAILURE; + } + + if (null === ag($server, 'url')) { + $output->writeln(sprintf('Server \'%s\' has no url.', $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( + 'Movies [A: %d - U: %d - F: %d] - Episodes [A: %d - U: %d - F: %d]', + $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; + } +} diff --git a/src/Commands/Storage/MaintenanceCommand.php b/src/Commands/Storage/MaintenanceCommand.php new file mode 100644 index 00000000..c16e1fd6 --- /dev/null +++ b/src/Commands/Storage/MaintenanceCommand.php @@ -0,0 +1,31 @@ +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; + } +} diff --git a/src/Commands/Storage/MakeCommand.php b/src/Commands/Storage/MakeCommand.php new file mode 100644 index 00000000..bbfb5f37 --- /dev/null +++ b/src/Commands/Storage/MakeCommand.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/src/Commands/Storage/MigrationsCommand.php b/src/Commands/Storage/MigrationsCommand.php new file mode 100644 index 00000000..6458ce85 --- /dev/null +++ b/src/Commands/Storage/MigrationsCommand.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/src/Libs/Config.php b/src/Libs/Config.php new file mode 100644 index 00000000..5afdd16f --- /dev/null +++ b/src/Libs/Config.php @@ -0,0 +1,53 @@ + $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); + } +} diff --git a/src/Libs/Container.php b/src/Libs/Container.php new file mode 100644 index 00000000..4fc68046 --- /dev/null +++ b/src/Libs/Container.php @@ -0,0 +1,93 @@ +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; + } +} diff --git a/src/Libs/Data.php b/src/Libs/Data.php new file mode 100644 index 00000000..dd43ae2a --- /dev/null +++ b/src/Libs/Data.php @@ -0,0 +1,55 @@ + $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; + } +} diff --git a/src/Libs/Extends/CliLogger.php b/src/Libs/Extends/CliLogger.php new file mode 100644 index 00000000..15984b0b --- /dev/null +++ b/src/Libs/Extends/CliLogger.php @@ -0,0 +1,98 @@ + 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); + } +} diff --git a/src/Libs/Extends/ConsoleOutput.php b/src/Libs/Extends/ConsoleOutput.php new file mode 100644 index 00000000..8422db18 --- /dev/null +++ b/src/Libs/Extends/ConsoleOutput.php @@ -0,0 +1,21 @@ +format(DateTimeInterface::ATOM); + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } +} diff --git a/src/Libs/Extends/PSRContainer.php b/src/Libs/Extends/PSRContainer.php new file mode 100644 index 00000000..26630085 --- /dev/null +++ b/src/Libs/Extends/PSRContainer.php @@ -0,0 +1,11 @@ + '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; + } +} diff --git a/src/Libs/HttpException.php b/src/Libs/HttpException.php new file mode 100644 index 00000000..0e1e95c3 --- /dev/null +++ b/src/Libs/HttpException.php @@ -0,0 +1,11 @@ + $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) + ); + } + } + } +} diff --git a/src/Libs/Mappers/Export/ExportMapper.php b/src/Libs/Mappers/Export/ExportMapper.php new file mode 100644 index 00000000..8895b05b --- /dev/null +++ b/src/Libs/Mappers/Export/ExportMapper.php @@ -0,0 +1,141 @@ + Holds Entities. + */ + private array $objects = []; + + /** + * @var array Map GUIDs to entities. + */ + private array $guids = []; + + /** + * @var array 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; + } +} diff --git a/src/Libs/Mappers/ExportInterface.php b/src/Libs/Mappers/ExportInterface.php new file mode 100644 index 00000000..4462b4b3 --- /dev/null +++ b/src/Libs/Mappers/ExportInterface.php @@ -0,0 +1,93 @@ + + */ + 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; +} diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php new file mode 100644 index 00000000..8dbc2109 --- /dev/null +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -0,0 +1,114 @@ + ['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; + } +} diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php new file mode 100644 index 00000000..ed040179 --- /dev/null +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -0,0 +1,237 @@ + + */ + private array $objects = []; + + /** + * Map GUIDs to entities. + * + * @var array + */ + private array $guids = []; + + /** + * Map Deleted GUIDs. + * + * @var array + */ + private array $removed = []; + + /** + * List Changed Entities. + * + * @var array + */ + 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; + } +} diff --git a/src/Libs/Mappers/ImportInterface.php b/src/Libs/Mappers/ImportInterface.php new file mode 100644 index 00000000..57168a40 --- /dev/null +++ b/src/Libs/Mappers/ImportInterface.php @@ -0,0 +1,96 @@ +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); + } + +} diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php new file mode 100644 index 00000000..2e733337 --- /dev/null +++ b/src/Libs/Servers/JellyfinServer.php @@ -0,0 +1,681 @@ + 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]; + } +} diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php new file mode 100644 index 00000000..a39c8a35 --- /dev/null +++ b/src/Libs/Servers/PlexServer.php @@ -0,0 +1,658 @@ + 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; + } +} diff --git a/src/Libs/Servers/ServerInterface.php b/src/Libs/Servers/ServerInterface.php new file mode 100644 index 00000000..362e78e1 --- /dev/null +++ b/src/Libs/Servers/ServerInterface.php @@ -0,0 +1,66 @@ + + */ + public function pull(ImportInterface $mapper, DateTimeInterface|null $after = null): array; + + /** + * Export Watch State to Server. + * + * @param ExportInterface $mapper + * @param DateTimeInterface|null $after + * + * @return array + */ + public function push(ExportInterface $mapper, DateTimeInterface|null $after = null): array; +} diff --git a/src/Libs/Storage/PDO/Migrations/sqlite_1644418046_create_state_table.sql b/src/Libs/Storage/PDO/Migrations/sqlite_1644418046_create_state_table.sql new file mode 100644 index 00000000..9498d476 --- /dev/null +++ b/src/Libs/Storage/PDO/Migrations/sqlite_1644418046_create_state_table.sql @@ -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"; diff --git a/src/Libs/Storage/PDO/PDOAdapter.php b/src/Libs/Storage/PDO/PDOAdapter.php new file mode 100644 index 00000000..e089a601 --- /dev/null +++ b/src/Libs/Storage/PDO/PDOAdapter.php @@ -0,0 +1,505 @@ +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); + } +} diff --git a/src/Libs/Storage/PDO/PDOMigrations.php b/src/Libs/Storage/PDO/PDOMigrations.php new file mode 100644 index 00000000..769a6ad8 --- /dev/null +++ b/src/Libs/Storage/PDO/PDOMigrations.php @@ -0,0 +1,204 @@ +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( + 'Migration #%d - %s has no %s. Skipping.', + ag($migrate, 'id'), + ag($migrate, 'name'), + $dir + ), + OutputInterface::VERBOSITY_DEBUG + ); + } + + $output->writeln( + sprintf( + 'Applying Migration #%d - %s (%s)', + ag($migrate, 'id'), + ag($migrate, 'name'), + $dir + ) + ); + + $data = ag($migrate, $dir); + + $output->writeln( + sprintf('Applying %s.', PHP_EOL . $data), + OutputInterface::VERBOSITY_DEBUG + ); + + $this->pdo->exec((string)$data); + $this->setVersion(ag($migrate, 'id')); + } + + $message = !$run ? sprintf('No migrations is needed. Version @ %d', $version) : sprintf( + 'Applied %s migrations. Version @ %d', + $run, + $this->getVersion() + ); + + $output->writeln($message); + + return Command::SUCCESS; + } + + public function down(OutputInterface $output): int + { + $output->writeln('This driver does not support down migrations at this time.'); + + 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, + <<writeln(sprintf('Created new Migration file at \'%s\'.', $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; + } +} diff --git a/src/Libs/Storage/StorageInterface.php b/src/Libs/Storage/StorageInterface.php new file mode 100644 index 00000000..f9ccf93b --- /dev/null +++ b/src/Libs/Storage/StorageInterface.php @@ -0,0 +1,120 @@ + $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 + */ + 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; +} diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php new file mode 100644 index 00000000..3aee222f --- /dev/null +++ b/src/Libs/helpers.php @@ -0,0 +1,234 @@ + 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) + ); + } +} diff --git a/var/config/.gitignore b/var/config/.gitignore new file mode 100644 index 00000000..72e8ffc0 --- /dev/null +++ b/var/config/.gitignore @@ -0,0 +1 @@ +* diff --git a/var/db/.gitignore b/var/db/.gitignore new file mode 100644 index 00000000..72e8ffc0 --- /dev/null +++ b/var/db/.gitignore @@ -0,0 +1 @@ +* diff --git a/var/logs/.gitignore b/var/logs/.gitignore new file mode 100644 index 00000000..72e8ffc0 --- /dev/null +++ b/var/logs/.gitignore @@ -0,0 +1 @@ +*