Begin work on custom code plugins support.

This commit is contained in:
Buster "Silver Eagle" Neece 2018-09-21 07:04:05 -05:00
parent dece3e9e5a
commit 33210b9450
30 changed files with 820 additions and 253 deletions

5
.gitignore vendored
View File

@ -32,3 +32,8 @@ web/static/yarn-error\.log
# Docker files
/docker-compose.yml
/*.tar.gz
# Plugins
/plugins/*
/plugins/**/*
!/plugins/.gitkeep

View File

@ -58,6 +58,10 @@ ini_set('session.use_strict_mode', 1);
$autoloader = require(APP_INCLUDE_VENDOR . '/autoload.php');
$autoloader->setPsr4('Proxy\\', APP_INCLUDE_TEMP . '/proxies');
// Initialize plugins
$plugins = new \App\Plugins(APP_INCLUDE_ROOT.'/plugins');
$plugins->registerAutoloaders($autoloader);
// Set up DI container.
$di = new \Slim\Container([
'settings' => [
@ -69,8 +73,12 @@ $di = new \Slim\Container([
]
]);
$di[\App\Plugins::class] = $plugins;
// Define services.
$settings = require(dirname(__DIR__).'/config/settings.php');
call_user_func(include(dirname(__DIR__).'/config/services.php'), $di, $settings);
$plugins->registerServices($di, $settings);
return $di;

View File

@ -41,6 +41,7 @@
"php-http/guzzle6-adapter": "^1.1",
"slim/slim": "^3.0",
"supervisorphp/supervisor": "^3.0",
"symfony/event-dispatcher": "^4.1",
"symfony/finder": "^4.1",
"zendframework/zend-config": "^3.1.0",
"zendframework/zend-paginator": "^2.7"

128
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "594ac3f2423a143d5621fb2cffbd11fa",
"content-hash": "1d77d7c553cb7dd8c3f52c09642fcf36",
"packages": [
{
"name": "azuracast/azuraforms",
@ -3419,6 +3419,69 @@
"homepage": "https://symfony.com",
"time": "2018-07-26T11:24:31+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/bfb30c2ad377615a463ebbc875eba64a99f6aa3e",
"reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e",
"shasum": ""
},
"require": {
"php": "^7.1.3"
},
"conflict": {
"symfony/dependency-injection": "<3.4"
},
"require-dev": {
"psr/log": "~1.0",
"symfony/config": "~3.4|~4.0",
"symfony/dependency-injection": "~3.4|~4.0",
"symfony/expression-language": "~3.4|~4.0",
"symfony/stopwatch": "~3.4|~4.0"
},
"suggest": {
"symfony/dependency-injection": "",
"symfony/http-kernel": ""
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
},
"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": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
"time": "2018-07-26T09:10:45+00:00"
},
{
"name": "symfony/finder",
"version": "v4.1.4",
@ -5956,69 +6019,6 @@
"homepage": "https://symfony.com",
"time": "2018-07-26T11:00:49+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/bfb30c2ad377615a463ebbc875eba64a99f6aa3e",
"reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e",
"shasum": ""
},
"require": {
"php": "^7.1.3"
},
"conflict": {
"symfony/dependency-injection": "<3.4"
},
"require-dev": {
"psr/log": "~1.0",
"symfony/config": "~3.4|~4.0",
"symfony/dependency-injection": "~3.4|~4.0",
"symfony/expression-language": "~3.4|~4.0",
"symfony/stopwatch": "~3.4|~4.0"
},
"suggest": {
"symfony/dependency-injection": "",
"symfony/http-kernel": ""
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
},
"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": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
"time": "2018-07-26T09:10:45+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.9.0",

14
config/events.php Normal file
View File

@ -0,0 +1,14 @@
<?php
return function (\Symfony\Component\EventDispatcher\EventDispatcher $dispatcher, \Slim\Container $di, $settings) {
$dispatcher->addSubscriber(new \App\EventHandler\DefaultRoutes(__DIR__.'/routes.php'));
$dispatcher->addSubscriber(new \App\EventHandler\DefaultView($di));
$dispatcher->addSubscriber(new \App\EventHandler\DefaultNowPlaying());
$dispatcher->addListener(\App\Event\SendWebhooks::NAME, function(\App\Event\SendWebhooks $event) use ($di) {
/** @var \App\Webhook\Dispatcher $webhook_dispatcher */
$webhook_dispatcher = $di[\App\Webhook\Dispatcher::class];
$webhook_dispatcher->dispatch($event);
});
};

View File

@ -233,46 +233,9 @@ return function (\Slim\Container $di, $settings) {
$view = new App\View(dirname(__DIR__) . '/resources/templates');
$view->setFileExtension('phtml');
$view->registerFunction('service', function($service) use ($di) {
return $di->get($service);
});
$view->registerFunction('escapeJs', function($string) {
return json_encode($string);
});
$view->registerFunction('mailto', function ($address, $link_text = null) {
$address = substr(chunk_split(bin2hex(" $address"), 2, ";&#x"), 3, -3);
$link_text = $link_text ?? $address;
return '<a href="mailto:' . $address . '">' . $link_text . '</a>';
});
$view->registerFunction('pluralize', function ($word, $num = 0) {
if ((int)$num === 1) {
return $word;
} else {
return \Doctrine\Common\Inflector\Inflector::pluralize($word);
}
});
$view->registerFunction('truncate', function ($text, $length = 80) {
return \App\Utilities::truncate_text($text, $length);
});
/** @var \App\Session $session */
$session = $di[\App\Session::class];
$view->addData([
'app_settings' => $di['app_settings'],
'router' => $di['router'],
'request' => $di['request'],
'assets' => $di[\App\Assets::class],
'auth' => $di[\App\Auth::class],
'acl' => $di[\App\Acl::class],
'flash' => $session->getFlash(),
'customization' => $di[\App\Customization::class],
]);
/** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */
$dispatcher = $di[\Symfony\Component\EventDispatcher\EventDispatcher::class];
$dispatcher->dispatch(\App\Event\BuildView::NAME, new \App\Event\BuildView($view));
return $view;
});
@ -337,6 +300,21 @@ return function (\Slim\Container $di, $settings) {
]);
};
$di[\Symfony\Component\EventDispatcher\EventDispatcher::class] = function($di) use ($settings) {
$dispatcher = new \Symfony\Component\EventDispatcher\EventDispatcher();
// Register application default events.
call_user_func(include(__DIR__.'/events.php'), $dispatcher, $di, $settings);
/** @var \App\Plugins $plugins */
$plugins = $di[\App\Plugins::class];
// Register plugin-provided events.
$plugins->registerEvents($dispatcher, $di, $settings);
return $dispatcher;
};
//
// AzuraCast-specific dependencies
//
@ -401,23 +379,9 @@ return function (\Slim\Container $di, $settings) {
$app = new \Slim\App($di);
// Get the current user entity object and assign it into the request if it exists.
$app->add(\App\Middleware\GetCurrentUser::class);
// Inject the application router into the request object.
$app->add(\App\Middleware\EnableRouter::class);
// Inject the session manager into the request object.
$app->add(\App\Middleware\EnableSession::class);
// Check HTTPS setting and enforce Content Security Policy accordingly.
$app->add(\App\Middleware\EnforceSecurity::class);
// Remove trailing slash from all URLs when routing.
$app->add(\App\Middleware\RemoveSlashes::class);
// Load routes
call_user_func(include(__DIR__.'/routes.php'), $app);
/** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */
$dispatcher = $di[\Symfony\Component\EventDispatcher\EventDispatcher::class];
$dispatcher->dispatch(\App\Event\BuildRoutes::NAME, new \App\Event\BuildRoutes($app));
return $app;
};

0
plugins/.gitkeep Normal file
View File

22
src/Event/BuildRoutes.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Event;
use Slim\App;
use Symfony\Component\EventDispatcher\Event;
class BuildRoutes extends Event
{
const NAME = 'build-routes';
protected $app;
public function __construct(App $app)
{
$this->app = $app;
}
public function getApp(): App
{
return $this->app;
}
}

22
src/Event/BuildView.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Event;
use App\View;
use Symfony\Component\EventDispatcher\Event;
class BuildView extends Event
{
const NAME = 'build-view';
protected $view;
public function __construct(View $view)
{
$this->view = $view;
}
public function getView(): View
{
return $this->view;
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Event;
use App\Entity\Station;
use App\Radio\Frontend\FrontendAbstract;
use App\Radio\Remote\RemoteAbstract;
use Symfony\Component\EventDispatcher\Event;
class GenerateRawNowPlaying extends Event
{
const NAME = 'nowplaying-generate-raw';
/** @var Station */
protected $station;
/** @var FrontendAbstract */
protected $frontend;
/** @var RemoteAbstract[] */
protected $remotes;
/** @var bool */
protected $include_clients = false;
/** @var string|null The preloaded "payload" to supply to the nowplaying adapters, if one is available. */
protected $payload;
/** @var array The composed "raw" NowPlaying data. */
protected $np_raw = [];
public function __construct(
Station $station,
FrontendAbstract $frontend,
array $remotes,
$payload = null,
$include_clients = false
) {
$this->station = $station;
$this->frontend = $frontend;
$this->remotes = $remotes;
$this->payload = $payload;
$this->include_clients = $include_clients;
}
public function getStation(): Station
{
return $this->station;
}
public function getFrontend(): FrontendAbstract
{
return $this->frontend;
}
/**
* @return RemoteAbstract[]
*/
public function getRemotes(): array
{
return $this->remotes;
}
public function includeClients(): bool
{
return $this->include_clients;
}
public function getPayload(): ?string
{
return $this->payload;
}
public function getRawResponse(): array
{
return $this->np_raw;
}
public function setRawResponse(array $np): void
{
$this->np_raw = $np;
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Event;
use App\Entity\Api\NowPlaying;
use App\Entity\Station;
use Symfony\Component\EventDispatcher\Event;
class SendWebhooks extends Event
{
const NAME = 'webhooks-send';
/** @var Station */
protected $station;
/** @var NowPlaying */
protected $np;
/** @var array */
protected $triggers = [];
/** @var bool */
protected $is_standalone = true;
public function __construct(Station $station, NowPlaying $np, $np_old = null, $is_standalone = true)
{
$this->station = $station;
$this->np = $np;
$this->is_standalone = $is_standalone;
$to_trigger = ['all'];
if ($np_old instanceof NowPlaying) {
if ($np_old->now_playing->song->id !== $np->now_playing->song->id) {
$to_trigger[] = 'song_changed';
}
if ($np_old->listeners->current > $np->listeners->current) {
$to_trigger[] = 'listener_lost';
} elseif ($np_old->listeners->current < $np->listeners->current) {
$to_trigger[] = 'listener_gained';
}
if ($np_old->live->is_live === false && $np->live->is_live === true) {
$to_trigger[] = 'live_connect';
} elseif ($np_old->live->is_live === true && $np->live->is_live === false) {
$to_trigger[] = 'live_disconnect';
}
}
$this->triggers = $to_trigger;
}
/**
* @return Station
*/
public function getStation(): Station
{
return $this->station;
}
/**
* @return NowPlaying
*/
public function getNowPlaying(): NowPlaying
{
return $this->np;
}
/**
* @return array
*/
public function getTriggers(): array
{
return $this->triggers;
}
/**
* @param $trigger_name
* @return bool
*/
public function hasTrigger($trigger_name): bool
{
return in_array($trigger_name, $this->triggers);
}
/**
* @return bool
*/
public function isStandalone(): bool
{
return $this->is_standalone;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\EventHandler;
use App\Event\GenerateRawNowPlaying;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DefaultNowPlaying implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
if (APP_TESTING_MODE) {
return [];
}
return [
GenerateRawNowPlaying::NAME => [
['loadRawFromFrontend', 10],
['addToRawFromRemotes', 0],
['cleanUpRawOutput', -10],
],
];
}
public function loadRawFromFrontend(GenerateRawNowPlaying $event)
{
$np_raw = $event->getFrontend()->getNowPlaying($event->getPayload(), $event->includeClients());
$event->setRawResponse($np_raw);
}
public function addToRawFromRemotes(GenerateRawNowPlaying $event)
{
$np_raw = $event->getRawResponse();
// Loop through all remotes and update NP data accordingly.
foreach($event->getRemotes() as $remote_adapter) {
$remote_adapter->updateNowPlaying($np_raw, $event->includeClients());
}
$event->setRawResponse($np_raw);
}
public function cleanUpRawOutput(GenerateRawNowPlaying $event)
{
$np_raw = $event->getRawResponse();
array_walk($np_raw['current_song'], function(&$value) {
$value = htmlspecialchars_decode($value);
$value = trim($value);
});
$event->setRawResponse($np_raw);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\EventHandler;
use App\Event\BuildRoutes;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DefaultRoutes implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
BuildRoutes::NAME => [
['addDefaultMiddleware', 1],
['addDefaultRoutes', 0],
],
];
}
protected $routes_path;
public function __construct($routes_path)
{
$this->routes_path = $routes_path;
}
public function addDefaultMiddleware(BuildRoutes $event)
{
$app = $event->getApp();
// Get the current user entity object and assign it into the request if it exists.
$app->add(\App\Middleware\GetCurrentUser::class);
// Inject the application router into the request object.
$app->add(\App\Middleware\EnableRouter::class);
// Inject the session manager into the request object.
$app->add(\App\Middleware\EnableSession::class);
// Check HTTPS setting and enforce Content Security Policy accordingly.
$app->add(\App\Middleware\EnforceSecurity::class);
// Remove trailing slash from all URLs when routing.
$app->add(\App\Middleware\RemoveSlashes::class);
}
public function addDefaultRoutes(BuildRoutes $event)
{
call_user_func(include($this->routes_path), $event->getApp());
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\EventHandler;
use App\Event\BuildView;
use Slim\Container;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DefaultView implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
BuildView::NAME => [
['addViewFunctions', 1],
['addViewData', 0],
],
];
}
/** @var Container */
protected $di;
public function __construct(Container $di)
{
$this->di = $di;
}
public function addViewFunctions(BuildView $event)
{
$view = $event->getView();
$view->registerFunction('service', function($service) {
return $this->di->get($service);
});
$view->registerFunction('escapeJs', function($string) {
return json_encode($string);
});
$view->registerFunction('mailto', function ($address, $link_text = null) {
$address = substr(chunk_split(bin2hex(" $address"), 2, ";&#x"), 3, -3);
$link_text = $link_text ?? $address;
return '<a href="mailto:' . $address . '">' . $link_text . '</a>';
});
$view->registerFunction('pluralize', function ($word, $num = 0) {
if ((int)$num === 1) {
return $word;
} else {
return \Doctrine\Common\Inflector\Inflector::pluralize($word);
}
});
$view->registerFunction('truncate', function ($text, $length = 80) {
return \App\Utilities::truncate_text($text, $length);
});
}
public function addViewData(BuildView $event)
{
/** @var \App\Session $session */
$session = $this->di[\App\Session::class];
$event->getView()->addData([
'app_settings' => $this->di['app_settings'],
'router' => $this->di['router'],
'request' => $this->di['request'],
'assets' => $this->di[\App\Assets::class],
'auth' => $this->di[\App\Auth::class],
'acl' => $this->di[\App\Acl::class],
'flash' => $session->getFlash(),
'customization' => $this->di[\App\Customization::class],
]);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\EventHandler;
use App\Entity;
use App\Event\SendWebhooks;
use App\Exception;
use App\Webhook\Connector;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Pimple\Psr11\ServiceLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DefaultWebhooks implements EventSubscriberInterface
{
/** @var Logger */
protected $logger;
/** @var ServiceLocator */
protected $connectors;
public function __construct(Logger $logger, ServiceLocator $connectors)
{
$this->logger = $logger;
$this->connectors = $connectors;
}
public static function getSubscribedEvents()
{
if (APP_TESTING_MODE) {
return [];
}
return [
SendWebhooks::NAME => [
['dispatchWebhooks', 0],
],
];
}
public function dispatchWebhooks(SendWebhooks $event)
{
// Compile list of connectors for the station. Always dispatch to the local websocket receiver.
$connectors = [];
if ($event->isStandalone()) {
$connectors[] = [
'type' => 'local',
'triggers' => [],
'config' => [],
];
}
// Assemble list of webhooks for the station
$station_webhooks = $event->getStation()->getWebhooks();
if ($station_webhooks->count() > 0) {
foreach($station_webhooks as $webhook) {
/** @var Entity\StationWebhook $webhook */
if ($webhook->isEnabled()) {
$connectors[] = [
'type' => $webhook->getType(),
'triggers' => $webhook->getTriggers() ?: [],
'config' => $webhook->getConfig() ?: [],
];
}
}
}
$this->logger->debug('Triggering events: '.implode(', ', $event->getTriggers()));
// Trigger all appropriate webhooks.
foreach($connectors as $connector) {
if (!$this->connectors->has($connector['type'])) {
$this->logger->error(sprintf('Webhook connector "%s" does not exist; skipping.', $connector['type']));
continue;
}
/** @var Connector\ConnectorInterface $connector_obj */
$connector_obj = $this->connectors->get($connector['type']);
if ($connector_obj->shouldDispatch($event, (array)$connector['triggers'])) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $connector['type']));
$connector_obj->dispatch($event, (array)$connector['config']);
}
}
}
/**
* Send a "test" dispatch of the web hook, regardless of whether it is currently enabled, and
* return any logging information this yields.
*
* @param Entity\Station $station
* @param Entity\StationWebhook $webhook
* @return TestHandler
* @throws Exception
*/
public function testDispatch(Entity\Station $station, Entity\StationWebhook $webhook)
{
$webhook_type = $webhook->getType();
$webhook_config = $webhook->getConfig();
if (!$this->connectors->has($webhook_type)) {
throw new Exception(sprintf('Webhook connector "%s" does not exist; skipping.', $webhook_type));
}
$handler = new TestHandler(Logger::DEBUG, false);
$this->logger->pushHandler($handler);
/** @var Connector\ConnectorInterface $connector_obj */
$connector_obj = $this->connectors->get($webhook_type);
$np = $station->getNowplaying();
$event = new SendWebhooks($station, $np);
$connector_obj->dispatch($event, $webhook_config);
$this->logger->popHandler();
return $handler;
}
/**
* Directly access a webhook connector of the specified type.
*
* @param $type
* @return Connector\ConnectorInterface
*/
public function getConnector($type): Connector\ConnectorInterface
{
if ($this->connectors->has($type)) {
return $this->connectors->get($type);
}
throw new \InvalidArgumentException('Invalid web hook connector type specified.');
}
}

View File

@ -28,7 +28,6 @@ class EnableView
{
$this->view->addData([
'request' => $request,
'user' => $request->getAttribute(Request::ATTRIBUTE_USER),
]);
$request = $request->withAttribute(Request::ATTRIBUTE_VIEW, $this->view);

View File

@ -3,9 +3,11 @@ namespace App\Middleware;
use App\Auth;
use App\Customization;
use App\Event\BuildView;
use App\Http\Request;
use App\Http\Response;
use App\Entity;
use Symfony\Component\EventDispatcher\EventDispatcher;
/**
* Get the current user entity object and assign it into the request if it exists.
@ -18,10 +20,14 @@ class GetCurrentUser
/** @var Customization */
protected $customization;
public function __construct(Auth $auth, Customization $customization)
/** @var EventDispatcher */
protected $dispatcher;
public function __construct(Auth $auth, Customization $customization, EventDispatcher $dispatcher)
{
$this->auth = $auth;
$this->customization = $customization;
$this->dispatcher = $dispatcher;
}
/**
@ -39,6 +45,12 @@ class GetCurrentUser
$this->customization->setUser($user);
$request = $this->customization->init($request);
$this->dispatcher->addListener(BuildView::NAME, function(BuildView $event) use ($user) {
$event->getView()->addData([
'user' => $user,
]);
});
$request = $request
->withAttribute(Request::ATTRIBUTE_USER, $user)
->withAttribute('is_logged_in', ($user instanceof Entity\User));

91
src/Plugins.php Normal file
View File

@ -0,0 +1,91 @@
<?php
namespace App;
use Composer\Autoload\ClassLoader;
use Slim\Container;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class Plugins
{
/** @var array An array of all plugins and their capabilities. */
protected $plugins = [];
public function __construct($base_dir)
{
$this->loadDirectory($base_dir);
}
public function loadDirectory($dir): void
{
$plugins = (new Finder())
->ignoreUnreadableDirs()
->directories()
->in($dir);
foreach($plugins as $plugin_dir) {
/** @var SplFileInfo $plugin_dir */
$plugin_prefix = $plugin_dir->getRelativePathname();
$plugin_namespace = 'Plugin\\'.\Doctrine\Common\Inflector\Inflector::classify($plugin_prefix).'\\';
$this->plugins[$plugin_prefix] = [
'namespace' => $plugin_namespace,
'path' => $plugin_dir->getPathname(),
];
}
}
/**
* Add plugin namespace classes (and any Composer dependencies) to the global include list.
*
* @param ClassLoader $autoload
*/
public function registerAutoloaders(ClassLoader $autoload): void
{
foreach($this->plugins as $plugin) {
$plugin_path = $plugin['path'];
if (file_exists($plugin_path.'/vendor/autoload.php')) {
require($plugin_path.'/vendor/autoload.php');
}
$autoload->addPsr4($plugin['namespace'], $plugin_path.'/src');
}
}
/**
* Register or override any services contained in the global Dependency Injection container.
*
* @param Container $di
* @param array $settings
*/
public function registerServices(Container $di, array $settings): void
{
foreach($this->plugins as $plugin) {
$plugin_path = $plugin['path'];
if (file_exists($plugin_path . '/services.php')) {
call_user_func(include($plugin_path . '/services.php'), $di, $settings);
}
}
}
/**
* Register custom events that the plugin overrides with the Event Dispatcher.
*
* @param EventDispatcher $dispatcher
* @param Container $di
* @param array $settings
*/
public function registerEvents(EventDispatcher $dispatcher, Container $di, array $settings): void
{
foreach($this->plugins as $plugin) {
$plugin_path = $plugin['path'];
if (file_exists($plugin_path . '/events.php')) {
call_user_func(include($plugin_path . '/events.php'), $dispatcher, $di, $settings);
}
}
}
}

View File

@ -33,7 +33,8 @@ class MiddlewareProvider implements ServiceProviderInterface
$di[Middleware\GetCurrentUser::class] = function($di) {
return new Middleware\GetCurrentUser(
$di[App\Auth::class],
$di[App\Customization::class]
$di[App\Customization::class],
$di[\Symfony\Component\EventDispatcher\EventDispatcher::class]
);
};

View File

@ -57,8 +57,8 @@ class SyncProvider implements ServiceProviderInterface
$di[\App\Radio\AutoDJ::class],
$di[\App\Cache::class],
$di[\InfluxDB\Database::class],
$di[\App\Webhook\Dispatcher::class],
$di[\Doctrine\ORM\EntityManager::class],
$di[\Symfony\Component\EventDispatcher\EventDispatcher::class],
$di[\Monolog\Logger::class]
);
};

View File

@ -2,15 +2,17 @@
namespace App\Sync\Task;
use App\Cache;
use App\Event\GenerateRawNowPlaying;
use App\Event\SendWebhooks;
use App\Radio\AutoDJ;
use App\ApiUtilities;
use App\Radio\Adapters;
use App\Radio\Frontend\FrontendAbstract;
use App\Webhook\Dispatcher;
use Doctrine\ORM\EntityManager;
use InfluxDB\Database;
use App\Entity;
use Monolog\Logger;
use Symfony\Component\EventDispatcher\EventDispatcher;
class NowPlaying extends TaskAbstract
{
@ -32,8 +34,8 @@ class NowPlaying extends TaskAbstract
/** @var Logger */
protected $logger;
/** @var Dispatcher */
protected $webhook_dispatcher;
/** @var EventDispatcher */
protected $event_dispatcher;
/** @var ApiUtilities */
protected $api_utils;
@ -54,14 +56,15 @@ class NowPlaying extends TaskAbstract
protected $analytics_level;
/**
* @param EntityManager $em
* @param Database $influx
* @param Cache $cache
* @param Adapters $adapters
* @param Dispatcher $webhook_dispatcher
* @param ApiUtilities $api_utils
* @param AutoDJ $autodj
* @param Cache $cache
* @param Database $influx
* @param EntityManager $em
* @param EventDispatcher $event_dispatcher
* @param Logger $logger
*
* @see \App\Provider\SyncProvider
*/
public function __construct(
@ -70,8 +73,8 @@ class NowPlaying extends TaskAbstract
AutoDJ $autodj,
Cache $cache,
Database $influx,
Dispatcher $webhook_dispatcher,
EntityManager $em,
EventDispatcher $event_dispatcher,
Logger $logger)
{
$this->adapters = $adapters;
@ -79,9 +82,9 @@ class NowPlaying extends TaskAbstract
$this->autodj = $autodj;
$this->cache = $cache;
$this->em = $em;
$this->event_dispatcher = $event_dispatcher;
$this->influx = $influx;
$this->logger = $logger;
$this->webhook_dispatcher = $webhook_dispatcher;
$this->history_repo = $this->em->getRepository(Entity\SongHistory::class);
$this->song_repo = $this->em->getRepository(Entity\Song::class);
@ -198,27 +201,13 @@ class NowPlaying extends TaskAbstract
/** @var Entity\Api\NowPlaying|null $np_old */
$np_old = $station->getNowplaying();
// Build the new "raw" NowPlaying data.
$event = new GenerateRawNowPlaying($station, $frontend_adapter, $remote_adapters, $payload, $include_clients);
$this->event_dispatcher->dispatch(GenerateRawNowPlaying::NAME, $event);
$np_raw = $event->getRawResponse();
$np = new Entity\Api\NowPlaying;
$np->station = $station->api($frontend_adapter, $remote_adapters);
// Build the new "raw" NowPlaying data from the adapters.
if (APP_TESTING_MODE) {
$np_raw = \NowPlaying\Adapter\AdapterAbstract::NOWPLAYING_EMPTY;
} else {
$np_raw = $frontend_adapter->getNowPlaying($payload, $include_clients);
// Loop through all remotes and update NP data accordingly.
foreach($remote_adapters as $remote_adapter) {
$remote_adapter->updateNowPlaying($np_raw, $include_clients);
}
array_walk($np_raw['current_song'], function(&$value) {
$value = htmlspecialchars_decode($value);
$value = trim($value);
});
}
// Start to convert the "raw" NowPlaying data into the proper API entities.
$np->listeners = new Entity\Api\NowPlayingListeners($np_raw['listeners']);
if (empty($np_raw['current_song']['text'])) {
@ -295,8 +284,9 @@ class NowPlaying extends TaskAbstract
$this->em->persist($station);
$this->em->flush();
$np_old = ($np_old instanceof Entity\Api\NowPlaying) ? $np_old : $np;
$this->webhook_dispatcher->dispatch($station, $np_old, $np, ($payload !== null));
// Trigger the dispatching of webhooks.
$webhook_event = new SendWebhooks($station, $np, $np_old, ($payload !== null));
$this->event_dispatcher->dispatch(SendWebhooks::NAME, $webhook_event);
$this->logger->popProcessor();

View File

@ -2,6 +2,7 @@
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use App\Utilities;
use GuzzleHttp\Client;
use Monolog\Logger;
@ -20,14 +21,14 @@ abstract class AbstractConnector implements ConnectorInterface
$this->http_client = $http_client;
}
public function shouldDispatch(array $current_events, array $triggers): bool
public function shouldDispatch(SendWebhooks $event, array $triggers): bool
{
if (empty($triggers)) {
return true;
}
foreach($triggers as $trigger) {
if (in_array($trigger, $current_events)) {
if ($event->hasTrigger($trigger)) {
return true;
}
}

View File

@ -1,7 +1,7 @@
<?php
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
interface ConnectorInterface
{
@ -9,18 +9,17 @@ interface ConnectorInterface
* Return a boolean indicating whether this connector should dispatch, given the current events
* that are set to be triggered, and the configured triggers for this connector.
*
* @param array $current_events The events that are currently being triggered.
* @param SendWebhooks $event The current webhook dispatching event being evaluated.
* @param array|null $triggers The configured triggers for this connector.
* @return bool
*/
public function shouldDispatch(array $current_events, array $triggers): bool;
public function shouldDispatch(SendWebhooks $event, array $triggers): bool;
/**
* Trigger the webhook for the specified station, now playing entry, and specified configuration.
*
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np_new
* @param array $config
* @param SendWebhooks $event The details of the event that triggered the webhook.
* @param array $config The specific settings associated with this webhook.
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np_new, array $config): void;
public function dispatch(SendWebhooks $event, array $config): void;
}

View File

@ -2,6 +2,7 @@
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Exception\TransferException;
use Monolog\Logger;
@ -60,12 +61,7 @@ use Monolog\Logger;
class Discord extends AbstractConnector
{
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
$webhook_url = $this->_getValidUrl($config['webhook_url'] ?? '');
@ -84,7 +80,7 @@ class Discord extends AbstractConnector
'footer' => $config['footer'] ?? '',
];
$vars = $this->_replaceVariables($raw_vars, $np);
$vars = $this->_replaceVariables($raw_vars, $event->getNowPlaying());
// Compose webhook
$embed = [

View File

@ -2,17 +2,13 @@
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Exception\TransferException;
use Monolog\Logger;
class Generic extends AbstractConnector
{
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
$webhook_url = $this->_getValidUrl($config['webhook_url'] ?? '');
@ -26,7 +22,7 @@ class Generic extends AbstractConnector
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $np,
'json' => $event->getNowPlaying(),
];
if (!empty($config['basic_auth_username']) && !empty($config['basic_auth_password'])) {

View File

@ -3,6 +3,7 @@ namespace App\Webhook\Connector;
use App\Cache;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Client;
use InfluxDB\Database;
use Monolog\Logger;
@ -27,33 +28,21 @@ class Local extends AbstractConnector
$this->settings_repo = $settings_repo;
}
/**
* @param array $current_events
* @param array|null $triggers
* @return bool
*/
public function shouldDispatch(array $current_events, array $triggers): bool
public function shouldDispatch(SendWebhooks $event, array $triggers): bool
{
return true;
}
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
* @throws Database\Exception
* @throws \InfluxDB\Exception
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
$this->logger->debug('Writing entry to InfluxDB...');
// Post statistics to InfluxDB.
$influx_point = new \InfluxDB\Point(
'station.' . $station->getId() . '.listeners',
'station.' . $event->getStation()->getId() . '.listeners',
(int)$np->listeners->current,
[],
['station' => $station->getId()],
['station' => $event->getStation()->getId()],
time()
);
@ -67,7 +56,7 @@ class Local extends AbstractConnector
if ($np_full) {
foreach($np_full as &$np_row) {
/** @var Entity\Api\NowPlaying $np_row */
if ($np_row->station->id === $station->getId()) {
if ($np_row->station->id === $event->getStation()->getId()) {
$np_row = $np;
}

View File

@ -1,7 +1,7 @@
<?php
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Exception\TransferException;
/**
@ -11,12 +11,7 @@ use GuzzleHttp\Exception\TransferException;
*/
class Telegram extends AbstractConnector
{
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
$bot_token = $config['bot_token'] ?? '';
$chat_id = $config['chat_id'] ?? '';
@ -28,7 +23,7 @@ class Telegram extends AbstractConnector
$messages = $this->_replaceVariables([
'text' => $config['text'],
], $np);
], $event->getNowPlaying());
try {
$api_url = (!empty($config['api'])) ? rtrim($config['api'], '/') : 'https://api.telegram.org';

View File

@ -2,27 +2,18 @@
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Exception\TransferException;
use Monolog\Logger;
class TuneIn extends AbstractConnector
{
/**
* @param array $current_events
* @param array|null $triggers
* @return bool
*/
public function shouldDispatch(array $current_events, array $triggers): bool
public function shouldDispatch(SendWebhooks $event, array $triggers): bool
{
return in_array('song_changed', $current_events);
return $event->hasTrigger('song_changed');
}
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
if (empty($config['partner_id']) || empty($config['partner_key']) || empty($config['station_id'])) {
$this->logger->error('Webhook '.$this->_getName().' is missing necessary configuration. Skipping...');
@ -32,6 +23,8 @@ class TuneIn extends AbstractConnector
$this->logger->debug('Dispatching TuneIn AIR API call...');
try {
$np = $event->getNowPlaying();
$response = $this->http_client->get('http://air.radiotime.com/Playing.ashx', [
'query' => [
'partnerId' => $config['partner_id'],

View File

@ -2,18 +2,14 @@
namespace App\Webhook\Connector;
use App\Entity;
use App\Event\SendWebhooks;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\HandlerStack;
use Monolog\Logger;
class Twitter extends AbstractConnector
{
/**
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np
* @param array $config
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void
public function dispatch(SendWebhooks $event, array $config): void
{
if (empty($config['consumer_key'])
|| empty($config['consumer_secret'])
@ -40,7 +36,7 @@ class Twitter extends AbstractConnector
'message' => $config['message'] ?? '',
];
$vars = $this->_replaceVariables($raw_vars, $np);
$vars = $this->_replaceVariables($raw_vars, $event->getNowPlaying());
// Dispatch webhook
$this->logger->debug('Posting to Twitter...');

View File

@ -3,6 +3,7 @@ namespace App\Webhook;
use App\Config;
use App\Entity;
use App\Event\SendWebhooks;
use App\Exception;
use App\Provider\WebhookProvider;
use Monolog\Handler\TestHandler;
@ -31,12 +32,9 @@ class Dispatcher
/**
* Determine which webhooks to dispatch for a given change in Now Playing data, and dispatch them.
*
* @param Entity\Station $station
* @param Entity\Api\NowPlaying $np_old
* @param Entity\Api\NowPlaying $np_new
* @param boolean $is_standalone
* @param SendWebhooks $event
*/
public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np_old, Entity\Api\NowPlaying $np_new, $is_standalone = true): void
public function dispatch(SendWebhooks $event): void
{
if (APP_TESTING_MODE) {
$this->logger->info('In testing mode; no webhooks dispatched.');
@ -46,7 +44,7 @@ class Dispatcher
// Compile list of connectors for the station. Always dispatch to the local websocket receiver.
$connectors = [];
if ($is_standalone) {
if ($event->isStandalone()) {
$connectors[] = [
'type' => 'local',
'triggers' => [],
@ -55,7 +53,7 @@ class Dispatcher
}
// Assemble list of webhooks for the station
$station_webhooks = $station->getWebhooks();
$station_webhooks = $event->getStation()->getWebhooks();
if ($station_webhooks->count() > 0) {
foreach($station_webhooks as $webhook) {
@ -70,26 +68,7 @@ class Dispatcher
}
}
// Determine which events should be triggered as a result of this change.
$to_trigger = ['all'];
if ($np_old->now_playing->song->id !== $np_new->now_playing->song->id) {
$to_trigger[] = 'song_changed';
}
if ($np_old->listeners->current > $np_new->listeners->current) {
$to_trigger[] = 'listener_lost';
} elseif ($np_old->listeners->current < $np_new->listeners->current) {
$to_trigger[] = 'listener_gained';
}
if ($np_old->live->is_live === false && $np_new->live->is_live === true) {
$to_trigger[] = 'live_connect';
} elseif ($np_old->live->is_live === true && $np_new->live->is_live === false) {
$to_trigger[] = 'live_disconnect';
}
$this->logger->debug('Triggering events: '.implode(', ', $to_trigger));
$this->logger->debug('Triggering events: '.implode(', ', $event->getTriggers()));
// Trigger all appropriate webhooks.
foreach($connectors as $connector) {
@ -101,10 +80,10 @@ class Dispatcher
/** @var Connector\ConnectorInterface $connector_obj */
$connector_obj = $this->connectors->get($connector['type']);
if ($connector_obj->shouldDispatch($to_trigger, (array)$connector['triggers'])) {
if ($connector_obj->shouldDispatch($event, (array)$connector['triggers'])) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $connector['type']));
$connector_obj->dispatch($station, $np_new, (array)$connector['config']);
$connector_obj->dispatch($event, (array)$connector['config']);
}
}
}
@ -135,7 +114,8 @@ class Dispatcher
$np = $station->getNowplaying();
$connector_obj->dispatch($station, $np, $webhook_config);
$event = new SendWebhooks($station, $np);
$connector_obj->dispatch($event, $webhook_config);
$this->logger->popHandler();