commit 1e13cc4bf134888b8fd7386c7bf58d22e730204a Author: Abdulmhsen B. A. A Date: Thu Feb 10 16:41:48 2022 +0300 Initial commit. 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 @@ +*