4
0
mirror of https://github.com/AzuraCast/AzuraCast.git synced 2024-06-17 22:47:04 +00:00

Major push: live station updates

Implements both a frontend websocket-based live notifications system powered by the nginx nchan module and a backend station watcher (currently for Docker only) that triggers push notifications immediately upon song or listener count changes. Both result in more timely, more accurate updates to now-playing data on the site.
This commit is contained in:
Buster "Silver Eagle" Neece 2017-09-06 22:53:25 -05:00
parent cebb1fb361
commit 568af47c5b
26 changed files with 1570 additions and 246 deletions

View File

@ -47,11 +47,6 @@ return function (\Slim\Container $di, $settings) {
'modelPath' => APP_INCLUDE_BASE . '/models',
'conn' => [
'driver' => 'pdo_mysql',
'host' => $_ENV['db_host'] ?? ((APP_INSIDE_DOCKER) ? 'mariadb' : 'localhost'),
'port' => $_ENV['db_port'] ?? '3306',
'dbname' => $_ENV['db_name'] ?? 'azuracast',
'user' => $_ENV['db_username'] ?? 'azuracast',
'password' => (APP_INSIDE_DOCKER) ? 'azur4c457' : $_ENV['db_password'],
'charset' => 'utf8mb4',
'driverOptions' => [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci',
@ -59,6 +54,20 @@ return function (\Slim\Container $di, $settings) {
]
];
if (APP_INSIDE_DOCKER) {
$options['conn']['host'] = 'mariadb';
$options['conn']['port'] = 3306;
$options['conn']['dbname'] = 'azuracast';
$options['conn']['user'] = 'azuracast';
$options['conn']['password'] = 'azur4c457';
} else {
$options['conn']['host'] = $_ENV['db_host'] ?? 'localhost';
$options['conn']['port'] = $_ENV['db_port'] ?? '3306';
$options['conn']['dbname'] = $_ENV['db_name'] ?? 'azuracast';
$options['conn']['user'] = $_ENV['db_username'] ?? 'azuracast';
$options['conn']['password'] = $_ENV['db_password'];
}
\Doctrine\Common\Proxy\Autoloader::register($options['proxyPath'], $options['proxyNamespace']);
// Fetch and store entity manager.

View File

@ -54,4 +54,12 @@ class NowPlaying
* @var string
*/
public $cache;
/**
* Update any variable items in the feed.
*/
public function update()
{
$this->now_playing->recalculate();
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Migration;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20170906080352 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station ADD adapter_api_key VARCHAR(150) DEFAULT NULL, ADD nowplaying_timestamp INT DEFAULT NULL');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station DROP adapter_api_key, DROP nowplaying_timestamp');
}
}

View File

@ -4,6 +4,7 @@ namespace Entity;
use AzuraCast\Radio\Frontend\FrontendAbstract;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use Interop\Container\ContainerInterface;
/**
@ -51,6 +52,12 @@ class Station
*/
protected $backend_config;
/**
* @Column(name="adapter_api_key", type="string", length=150, nullable=true)
* @var string|null
*/
protected $adapter_api_key;
/**
* @Column(name="description", type="text", nullable=true)
* @var string|null
@ -81,6 +88,12 @@ class Station
*/
protected $nowplaying;
/**
* @Column(name="nowplaying_timestamp", type="integer", nullable=true)
* @var int
*/
protected $nowplaying_timestamp;
/**
* @Column(name="automation_settings", type="json_array", nullable=true)
* @var array|null
@ -292,6 +305,33 @@ class Station
$this->backend_config = $config;
}
/**
* @return null|string
*/
public function getAdapterApiKey(): ?string
{
return $this->adapter_api_key;
}
/**
* Generate a random new adapter API key.
*/
public function generateAdapterApiKey()
{
$this->adapter_api_key = bin2hex(random_bytes(50));
}
/**
* Authenticate the supplied adapter API key.
*
* @param $api_key
* @return bool
*/
public function validateAdapterApiKey($api_key)
{
return hash_equals($api_key, $this->adapter_api_key);
}
/**
* @return null|string
*/
@ -410,6 +450,15 @@ class Station
public function setNowplaying($nowplaying = null)
{
$this->nowplaying = $nowplaying;
$this->nowplaying_timestamp = time();
}
/**
* @return int
*/
public function getNowplayingTimestamp(): int
{
return (int)$this->nowplaying_timestamp;
}
/**
@ -633,6 +682,15 @@ class Station
return;
}
/** @var EntityManager $em */
$em = $di['em'];
// Always regenerate a new API auth key when rewriting service configuration.
$this->generateAdapterApiKey();
$em->persist($this);
$em->flush();
// Initialize adapters.
$config_path = $this->getRadioConfigDir();
$supervisor_config = [];
$supervisor_config_path = $config_path . '/supervisord.conf';
@ -656,7 +714,10 @@ class Station
list($backend_group, $backend_program) = explode(':', $backend_name);
$frontend_name = $frontend->getProgramName();
list($frontend_group, $frontend_program) = explode(':', $frontend_name);
list(,$frontend_program) = explode(':', $frontend_name);
$frontend_watch_name = $frontend->getWatchProgramName();
list(,$frontend_watch_program) = explode(':', $frontend_watch_name);
// Write group section of config
$programs = [];
@ -666,6 +727,9 @@ class Station
if ($frontend->hasCommand()) {
$programs[] = $frontend_program;
}
if ($frontend->hasWatchCommand()) {
$programs[] = $frontend_watch_program;
}
$supervisor_config[] = '[group:' . $backend_group . ']';
$supervisor_config[] = 'programs=' . implode(',', $programs);
@ -689,6 +753,24 @@ class Station
$supervisor_config[] = '';
}
// Write frontend watcher program
if ($frontend->hasWatchCommand()) {
$supervisor_config[] = '[program:' . $frontend_watch_program . ']';
$supervisor_config[] = 'directory=' . $config_path;
$supervisor_config[] = 'command=' . $frontend->getWatchCommand();
$supervisor_config[] = 'user=azuracast';
$supervisor_config[] = 'priority=95';
if (APP_INSIDE_DOCKER) {
$supervisor_config[] = 'stdout_logfile=/dev/stdout';
$supervisor_config[] = 'stdout_logfile_maxbytes=0';
$supervisor_config[] = 'stderr_logfile=/dev/stderr';
$supervisor_config[] = 'stderr_logfile_maxbytes=0';
}
$supervisor_config[] = '';
}
// Write backend
if ($backend->hasCommand()) {
$supervisor_config[] = '[program:' . $backend_program . ']';

View File

@ -19,17 +19,11 @@ class InternalController extends BaseController
throw new \App\Exception('Station not found.');
}
$backend_adapter = $station->getBackendAdapter($this->di);
if (!($backend_adapter instanceof \AzuraCast\Radio\Backend\LiquidSoap)) {
throw new \App\Exception('Not a LiquidSoap station.');
}
try {
$this->checkStationPermission($station, 'view administration');
} catch (\App\Exception\PermissionDenied $e) {
$auth_key = $this->getParam('api_auth', '');
if (!$backend_adapter->validateApiPassword($auth_key)) {
if (!$station->validateAdapterApiKey($auth_key)) {
throw new \App\Exception\PermissionDenied();
}
}
@ -64,6 +58,12 @@ class InternalController extends BaseController
public function nextsongAction()
{
$backend_adapter = $this->station->getBackendAdapter($this->di);
if (!($backend_adapter instanceof \AzuraCast\Radio\Backend\LiquidSoap)) {
throw new \App\Exception('Not a LiquidSoap station.');
}
/** @var Entity\Repository\SongHistoryRepository $history_repo */
$history_repo = $this->em->getRepository(Entity\SongHistory::class);
@ -81,6 +81,21 @@ class InternalController extends BaseController
}
}
public function notifyAction()
{
$payload = $this->request->getBody()->getContents();
if (!APP_IN_PRODUCTION) {
$log = date('Y-m-d g:i:s')."\n".$this->station->getName()."\n".$payload."\n\n";
file_put_contents(APP_INCLUDE_TEMP.'/notify.log', $log, \FILE_APPEND);
}
$np_sync = new \AzuraCast\Sync\NowPlaying($this->di);
$np_sync->processStation($this->station, $payload);
return $this->_return('received');
}
protected function _return($output)
{
$this->response->getBody()->write($output);

View File

@ -19,6 +19,7 @@ return function(\Slim\App $app) {
$this->map(['GET', 'POST'], '/auth', 'api:internal:auth')->setName('api:internal:auth');
$this->map(['GET', 'POST'], '/nextsong', 'api:internal:nextsong')->setName('api:internal:nextsong');
$this->map(['GET', 'POST'], '/notify', 'api:internal:notify')->setName('api:internal:notify');
});

View File

@ -10,7 +10,8 @@
<?php endif; ?>
<script type="text/javascript" src="<?=$url->content('vendors/bower_components/store-js/store.min.js') ?>"></script>
<script type="text/javascript" src="<?=$url->content('js/radio.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/radio.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/nchan.js') ?>"></script>
<?php $this->stop('custom_js') ?>
<div class="block-header">
@ -47,7 +48,6 @@
<h2><?=_('Station Overview') ?></h2>
<ul class="actions">
<li><a class="btn-refresh" href="#" title="<?=_('Refresh') ?>"><i class="zmdi zmdi-refresh"></i></a></li>
<?php if ($acl->isAllowed('administer stations')): ?>
<li><a class="" title="<?=_('Add') ?>" href="<?=$url->route(['module' => 'admin', 'controller' => 'stations', 'action' => 'edit']) ?>"><i class="zmdi zmdi-plus"></i></a></li>
<?php endif; ?>
@ -189,15 +189,14 @@ $(function () {
});
});
var np_timeout;
$(function() {
var sub = new NchanSubscriber('/api/live/nowplaying');
function nowPlaying() {
clearTimeout(np_timeout);
$('.btn-refresh i').addClass('zmdi-hc-spin');
sub.on("message", function(message, message_metadata) {
var data = JSON.parse(message);
$.getJSON('<?=$url->route(['module' => 'api', 'controller' => 'nowplaying']) ?>', {}, function(data) {
$.each(data, function (i, row) {
var station_row = $('#station_'+row.station.id);
var station_row = $('#station_' + row.station.id);
if (station_row.length) {
@ -213,19 +212,8 @@ function nowPlaying() {
station_row.find('.nowplaying-listeners').text(row.listeners.total)
}
});
$('.btn-refresh i').removeClass('zmdi-hc-spin');
np_timeout = setTimeout('nowPlaying()', 15*1000);
});
}
$(function() {
nowPlaying();
$('.btn-refresh').on('click', function (e) {
e.preventDefault();
nowPlaying();
return false;
});
sub.start();
});
</script>

View File

@ -5,6 +5,7 @@
<?php $this->start('custom_js') ?>
<script type="text/javascript" src="<?=$url->content('vendors/bower_components/store-js/store.min.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/radio.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/nchan.js') ?>"></script>
<?php $this->stop('custom_js') ?>
<ul class="actions pull-right">
@ -25,12 +26,12 @@
</div>
<script type="text/javascript">
function nowPlaying() {
$.getJSON('<?=$url->route([
'module' => 'api',
'controller' => 'nowplaying',
'station' => $station->getId()
]) ?>', {}, function (row) {
$(function() {
var sub = new NchanSubscriber('/api/live/nowplaying/<?=$station->getId() ?>');
sub.on("message", function(message, message_metadata) {
var row = JSON.parse(message);
var station_row = $('#station_'+row.station.id);
if ('mediaSession' in navigator) {
@ -43,12 +44,6 @@ function nowPlaying() {
station_row.find('.nowplaying-title').text(row.now_playing.song.title);
station_row.find('.nowplaying-artist').text(row.now_playing.song.artist);
station_row.find('.nowplaying-listeners').text(row.listeners.total);
setTimeout('nowPlaying()', 30*1000);
});
}
$(function() {
nowPlaying();
});
</script>

View File

@ -7,6 +7,7 @@
<script type="text/javascript" src="<?=$url->content('vendors/bower_components/store-js/store.min.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/radio.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/nchan.js') ?>"></script>
<?php $this->stop('custom_js') ?>
<?php $this->start('custom_css') ?>
@ -108,12 +109,47 @@
</div>
<script type="text/javascript">
function nowPlaying() {
$.getJSON('<?=$url->route([
'module' => 'api',
'controller' => 'nowplaying',
'station' => $station->getId()
]) ?>', {}, function (row) {
function iterateTimer() {
$('.nowplaying-progress:visible').each(function () {
var time_played = $(this).data('time-played') + 1;
var time_total = $(this).data('time-total');
if (time_played > time_total) {
time_played = time_total;
}
var time_display = formatTime(time_played) + ' / ' + formatTime(time_total);
$(this).data('time-played', time_played)
.text(time_display)
});
}
function formatTime(time) {
var sec_num = parseInt(time, 10);
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
return (hours !== "00" ? hours + ':' : "") + minutes + ':' + seconds;
}
$(function() {
var sub = new NchanSubscriber('/api/live/nowplaying/<?=$station->getId() ?>');
sub.on("message", function(message, message_metadata) {
var row = JSON.parse(message);
var station_row = $('#station_'+row.station.id);
if ('mediaSession' in navigator) {
@ -155,48 +191,9 @@ function nowPlaying() {
var li = $('<li>').append('<b>'+history_row.song.title+'</b><br>'+history_row.song.artist);
$('#song-history').append(li);
});
setTimeout('nowPlaying()', 10 * 1000);
});
}
function iterateTimer() {
$('.nowplaying-progress:visible').each(function () {
var time_played = $(this).data('time-played') + 1;
var time_total = $(this).data('time-total');
if (time_played > time_total) {
time_played = time_total;
}
var time_display = formatTime(time_played) + ' / ' + formatTime(time_total);
$(this).data('time-played', time_played)
.text(time_display)
});
}
function formatTime(time) {
var sec_num = parseInt(time, 10);
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
return (hours !== "00" ? hours + ':' : "") + minutes + ':' + seconds;
}
$(function() {
nowPlaying();
sub.start();
setInterval(iterateTimer, 1000);
});

View File

@ -7,6 +7,7 @@
<?php $this->start('custom_js') ?>
<script type="text/javascript" src="<?=$url->content('vendors/bower_components/store-js/store.min.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/radio.js') ?>"></script>
<script type="text/javascript" src="<?=$assets->getPath('js/nchan.js') ?>"></script>
<?php $this->stop('custom_js') ?>
<div class="row">
@ -288,12 +289,47 @@
</div>
<script type="text/javascript">
function nowPlaying() {
$.getJSON('<?=$url->route([
'module' => 'api',
'controller' => 'nowplaying',
'station' => $station->getId()
]) ?>', {}, function (row) {
function iterateTimer() {
$('.nowplaying-progress:visible').each(function () {
var time_played = $(this).data('time-played') + 1;
var time_total = $(this).data('time-total');
if (time_played > time_total) {
time_played = time_total;
}
var time_display = formatTime(time_played) + ' / ' + formatTime(time_total);
$(this).data('time-played', time_played)
.text(time_display)
});
}
function formatTime(time) {
var sec_num = parseInt(time, 10);
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
return (hours !== "00" ? hours + ':' : "") + minutes + ':' + seconds;
}
$(function() {
var sub = new NchanSubscriber('/api/live/nowplaying/<?=$station->getId() ?>');
sub.on("message", function(message, message_metadata) {
var row = JSON.parse(message);
var station_row = $('#station_'+row.station.id);
if ('mediaSession' in navigator) {
@ -344,48 +380,9 @@ function nowPlaying() {
var li = $('<li>').append('<b>'+history_row.song.title+'</b><br>'+history_row.song.artist);
$('#song-history').append(li);
});
setTimeout('nowPlaying()', 10 * 1000);
});
}
function iterateTimer() {
$('.nowplaying-progress:visible').each(function () {
var time_played = $(this).data('time-played') + 1;
var time_total = $(this).data('time-total');
if (time_played > time_total) {
time_played = time_total;
}
var time_display = formatTime(time_played) + ' / ' + formatTime(time_total);
$(this).data('time-played', time_played)
.text(time_display)
});
}
function formatTime(time) {
var sec_num = parseInt(time, 10);
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
return (hours !== "00" ? hours + ':' : "") + minutes + ':' + seconds;
}
$(function() {
nowPlaying();
sub.start();
setInterval(iterateTimer, 1000);
});

View File

@ -282,7 +282,7 @@ class LiquidSoap extends BackendAbstract
protected function _getApiUrlCommand($endpoint, $params = [])
{
$params = (array)$params;
$params['api_auth'] = $this->_getApiPassword();
$params['api_auth'] = $this->station->getAdapterApiKey();
$base_url = (APP_INSIDE_DOCKER) ? 'http://nginx' : 'http://localhost';
@ -325,39 +325,6 @@ class LiquidSoap extends BackendAbstract
return $hours . 'h' . $mins . 'm';
}
/**
* Generate a stream-unique API password.
*
* @return string
*/
public function _getApiPassword()
{
$be_settings = (array)$this->station->getBackendConfig();
if (empty($be_settings['api_password'])) {
$be_settings['api_password'] = bin2hex(random_bytes(50));
$em = $this->di['em'];
$this->station->setBackendConfig($be_settings);
$em->persist($this->station);
$em->flush();
}
return $be_settings['api_password'];
}
/**
* Validate the API password used for internal API authentication.
*
* @param $password
* @return bool
*/
public function validateApiPassword($password)
{
return hash_equals($password, $this->_getApiPassword());
}
public function getCommand()
{
if ($binary = self::getBinary()) {

View File

@ -22,6 +22,56 @@ abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
return $this->supports_listener_detail;
}
/**
* @return null|string The command to pass the station-watcher app.
*/
public function getWatchCommand()
{
return null;
}
/**
* @return bool Whether a station-watcher command exists for this adapter.
*/
public function hasWatchCommand()
{
if (APP_TESTING_MODE || !APP_INSIDE_DOCKER) {
return false;
}
return ($this->getCommand() !== null);
}
/**
* Return the supervisord programmatic name for the station-watcher command.
*
* @return string
*/
public function getWatchProgramName()
{
return 'station_' . $this->station->getId() . ':station_' . $this->station->getId() . '_watcher';
}
/**
* Get the AzuraCast station-watcher binary command for the specified adapter and watch URI.
*
* @param $adapter
* @param $watch_uri
* @return string
*/
protected function _getStationWatcherCommand($adapter, $watch_uri)
{
$base_url = (APP_INSIDE_DOCKER) ? 'http://nginx' : 'http://localhost';
$notify_uri = $base_url.'/api/internal/'.$this->station->getId().'/notify?api_auth='.$this->station->getAdapterApiKey();
return '/var/azuracast/servers/station-watcher/cast-watcher '.$adapter.' 2000 '.$watch_uri.' '.$notify_uri;
}
/**
* Get the default mounts when resetting or initializing a station.
*
* @return array
*/
public function getDefaultMounts()
{
return [
@ -80,7 +130,11 @@ abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
return Curl::request($c_opts);
}
public function getNowPlaying()
/**
* @param string|null $payload The payload from the push notification service (if applicable)
* @return array
*/
public function getNowPlaying($payload = null)
{
// Now Playing defaults.
$np = [
@ -102,7 +156,7 @@ abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
];
// Merge station-specific info into defaults.
$this->_getNowPlaying($np);
$this->_getNowPlaying($np, $payload);
// Update status code for offline stations, clean up song info for online ones.
if ($np['current_song']['text'] == 'Stream Offline') {
@ -125,8 +179,14 @@ abstract class FrontendAbstract extends \AzuraCast\Radio\AdapterAbstract
return $np;
}
/* Stub function for the process internal handler. */
abstract protected function _getNowPlaying(&$np);
/**
* Stub function for the process internal handler.
*
* @param $np
* @param string|null $payload
* @return mixed
*/
abstract protected function _getNowPlaying(&$np, $payload = null);
protected function _cleanUpString(&$value)
{

View File

@ -9,26 +9,36 @@ use Entity;
class IceCast extends FrontendAbstract
{
public function getWatchCommand()
{
$fe_config = (array)$this->station->getFrontendConfig();
return $this->_getStationWatcherCommand(
'icecast',
'http://admin:'.$fe_config['admin_pw'].'@localhost:' . $fe_config['port'] . '/admin/stats'
);
}
/* Process a nowplaying record. */
protected function _getNowPlaying(&$np)
protected function _getNowPlaying(&$np, $payload = null)
{
$fe_config = (array)$this->station->getFrontendConfig();
$reader = new \App\Xml\Reader();
$radio_port = $fe_config['port'];
$np_url = 'http://'.(APP_INSIDE_DOCKER ? 'stations' : 'localhost').':' . $radio_port . '/admin/stats';
if (empty($payload)) {
$radio_port = $fe_config['port'];
$np_url = 'http://' . (APP_INSIDE_DOCKER ? 'stations' : 'localhost') . ':' . $radio_port . '/admin/stats';
Debug::log($np_url);
$payload = $this->getUrl($np_url, [
'basic_auth' => 'admin:' . $fe_config['admin_pw'],
]);
$return_raw = $this->getUrl($np_url, [
'basic_auth' => 'admin:'.$fe_config['admin_pw'],
]);
if (!$return_raw) {
return false;
if (!$payload) {
return false;
}
}
$return = $reader->fromString($return_raw);
$return = $reader->fromString($payload);
Debug::print_r($return);
if (!$return || empty($return['source'])) {

View File

@ -9,7 +9,7 @@ class Remote extends FrontendAbstract
protected $supports_listener_detail = false;
/* Process a nowplaying record. */
protected function _getNowPlaying(&$np)
protected function _getNowPlaying(&$np, $payload = null)
{
$mounts = $this->_getMounts();

View File

@ -10,20 +10,33 @@ class ShoutCast2 extends FrontendAbstract
{
protected $supports_mounts = true;
public function getWatchCommand()
{
$fe_config = (array)$this->station->getFrontendConfig();
return $this->_getStationWatcherCommand(
'shoutcast',
'http://localhost:' . $fe_config['port'] . '/statistics?json=1'
);
}
/* Process a nowplaying record. */
protected function _getNowPlaying(&$np)
protected function _getNowPlaying(&$np, $payload = null)
{
$fe_config = (array)$this->station->getFrontendConfig();
$radio_port = $fe_config['port'];
$np_url = 'http://'.(APP_INSIDE_DOCKER ? 'stations' : 'localhost').':' . $radio_port . '/statistics?json=1';
$return_raw = $this->getUrl($np_url);
if (empty($payload)) {
$np_url = 'http://'.(APP_INSIDE_DOCKER ? 'stations' : 'localhost').':' . $radio_port . '/statistics?json=1';
$payload = $this->getUrl($np_url);
if (empty($return_raw)) {
return false;
if (empty($payload)) {
return false;
}
}
$current_data = json_decode($return_raw, true);
$current_data = json_decode($payload, true);
Debug::print_r($current_data);
$streams = count($current_data['streams']);

View File

@ -4,13 +4,41 @@ namespace AzuraCast\Sync;
use App\Debug;
use Doctrine\ORM\EntityManager;
use Entity;
use Interop\Container\ContainerInterface;
class NowPlaying extends SyncAbstract
{
public function __construct(ContainerInterface $di)
{
parent::__construct($di);
$this->em = $di['em'];
$this->history_repo = $this->em->getRepository(Entity\SongHistory::class);
$this->song_repo = $this->em->getRepository(Entity\Song::class);
$this->listener_repo = $this->em->getRepository(Entity\Listener::class);
}
/** @var EntityManager */
protected $em;
/** @var Entity\Repository\SongHistoryRepository */
protected $history_repo;
/** @var Entity\Repository\SongRepository */
protected $song_repo;
/** @var Entity\Repository\ListenerRepository */
protected $listener_repo;
public function run()
{
$nowplaying = $this->_loadNowPlaying();
// Trigger notification to the websocket listeners.
$this->_notify('nowplaying_all', $nowplaying);
// Post statistics to InfluxDB.
$influx = $this->di->get('influx');
$influx_points = [];
@ -55,59 +83,54 @@ class NowPlaying extends SyncAbstract
$nowplaying[$station]->cache = 'database';
}
$this->di['em']->getRepository(Entity\Settings::class)
->setSetting('nowplaying', $nowplaying);
/** @var Entity\Repository\SettingsRepository $settings_repo */
$settings_repo = $this->em->getRepository(Entity\Settings::class);
$settings_repo->setSetting('nowplaying', $nowplaying);
}
/** @var Entity\Repository\SongHistoryRepository */
protected $history_repo;
/** @var Entity\Repository\SongRepository */
protected $song_repo;
/** @var Entity\Repository\ListenerRepository */
protected $listener_repo;
/**
* @return Entity\Api\NowPlaying[]
*/
protected function _loadNowPlaying()
{
/** @var EntityManager $em */
$em = $this->di['em'];
$this->history_repo = $em->getRepository(Entity\SongHistory::class);
$this->song_repo = $em->getRepository(Entity\Song::class);
$this->listener_repo = $em->getRepository(Entity\Listener::class);
$stations = $em->getRepository(Entity\Station::class)->findAll();
$stations = $this->em->getRepository(Entity\Station::class)->findAll();
$nowplaying = [];
foreach ($stations as $station) {
/** @var Entity\Station $station */
Debug::startTimer($station->getName());
$last_run = $station->getNowplayingTimestamp();
// $name = $station->short_name;
$nowplaying[] = $this->_processStation($station);
if ($last_run >= (time()-10)) {
$np = $station->getNowplaying();
$np->update();
Debug::endTimer($station->getName());
$nowplaying[] = $np;
} else {
Debug::startTimer($station->getName());
// $name = $station->short_name;
$nowplaying[] = $this->processStation($station);
Debug::endTimer($station->getName());
}
}
return $nowplaying;
}
/**
* Generate Structured NowPlaying Data
* Generate Structured NowPlaying Data for a given station.
*
* @param Entity\Station $station
* @param string|null $payload The request body from the watcher notification service (if applicable).
* @return Entity\Api\NowPlaying
*/
protected function _processStation(Entity\Station $station)
public function processStation(Entity\Station $station, $payload = null)
{
/** @var EntityManager $em */
$em = $this->di['em'];
/** @var Entity\Api\NowPlaying $np_old */
$np_old = $station->getNowplaying();
@ -115,7 +138,7 @@ class NowPlaying extends SyncAbstract
$np->station = $station->api($station->getFrontendAdapter($this->di));
$frontend_adapter = $station->getFrontendAdapter($this->di);
$np_raw = $frontend_adapter->getNowPlaying();
$np_raw = $frontend_adapter->getNowPlaying($payload);
$np->listeners = new Entity\Api\NowPlayingListeners($np_raw['listeners']);
@ -163,13 +186,29 @@ class NowPlaying extends SyncAbstract
$np->now_playing = $sh_obj->api(true);
}
$np->update();
$np->cache = 'station';
$station->setNowplaying($np);
$em->persist($station);
$em->flush();
$this->em->persist($station);
$this->em->flush();
$this->_notify('nowplaying_'.$station->getId(), $np);
return $np;
}
protected function _notify($channel, $body)
{
$base_url = (APP_INSIDE_DOCKER) ? 'nginx' : 'localhost';
$client = new \GuzzleHttp\Client();
$res = $client->request('POST', 'http://'.$base_url.':9010/pub/'.urlencode($channel), [
'headers' => [
'Accept' => 'text/json',
],
'body' => json_encode($body),
]);
}
}

View File

@ -30,7 +30,7 @@ if [ "" == "$PKG_OK" ]; then
fi
APP_ENV="${APP_ENV:-production}"
UPDATE_REVISION="{$UPDATE_REVISION:-15}"
UPDATE_REVISION="{$UPDATE_REVISION:-16}"
echo "Updating AzuraCast (Environment: $APP_ENV)"

View File

@ -1,7 +1,17 @@
---
- name: Install nginx
- name: Remove packaged nginx
become: true
apt: pkg=nginx state=latest
apt: package=nginx state=absent
- name: Install nginx/nchan
become: true
apt:
deb: "{{ item }}"
state: present
force: yes
with_items:
- "https://nchan.io/download/nginx-common.ubuntu.deb"
- "https://nchan.io/download/nginx-extras.ubuntu.deb"
- name: Create nginx ssl directory
file: path="{{ item }}" state=directory owner=root group=root mode=0744

View File

@ -1,4 +1,19 @@
client_max_body_size 50M;
nchan_redis_url "redis://localhost:6379";
server {
listen 9010;
location ~ /pub/(\w+)$ {
nchan_publisher;
nchan_use_redis on;
nchan_channel_id $1;
nchan_message_buffer_length 1;
nchan_message_timeout "30s";
}
}
server {
listen 80;
@ -55,6 +70,21 @@ server {
proxy_pass http://127.0.0.1:$1/$3;
}
# pub/sub endpoints
location /api/live/nowplaying {
nchan_subscriber;
nchan_use_redis on;
nchan_channel_id nowplaying_all;
}
location ~ /api/live/nowplaying/(\d+)$ {
nchan_subscriber;
nchan_use_redis on;
nchan_channel_id "nowplaying_$1";
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#

View File

@ -16,7 +16,7 @@
- { role: azuracast-radio, when: update_revision|int < 8 }
- { role: supervisord, when: update_revision|int < 13 }
- { role: mariadb, when: update_revision|int < 15 }
- { role: nginx, when: update_revision|int < 10 }
- { role: nginx, when: update_revision|int < 16 }
- { role: redis, when: update_revision|int < 14 }
- { role: php, when: update_revision|int < 14 }
- { role: influxdb, when: update_revision|int < 10 }

View File

@ -1,12 +1,154 @@
FROM nginx:alpine
FROM alpine:3.5
RUN rm -f /etc/nginx/conf.d/default.conf
LABEL maintainer="NGINX Docker Maintainers <docker-maint@nginx.com>"
ENV NGINX_VERSION 1.13.4
ENV NCHAN_VERSION 1.1.3
RUN GPG_KEYS=B0F4253373F8F6F510D42178520A9993A1C052F8 \
&& CONFIG="\
--prefix=/etc/nginx \
--sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log \
--pid-path=/var/run/nginx.pid \
--lock-path=/var/run/nginx.lock \
--http-client-body-temp-path=/var/cache/nginx/client_temp \
--http-proxy-temp-path=/var/cache/nginx/proxy_temp \
--http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
--http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
--http-scgi-temp-path=/var/cache/nginx/scgi_temp \
--user=nginx \
--group=nginx \
--with-http_ssl_module \
--with-http_realip_module \
--with-http_addition_module \
--with-http_sub_module \
--with-http_dav_module \
--with-http_flv_module \
--with-http_mp4_module \
--with-http_gunzip_module \
--with-http_gzip_static_module \
--with-http_random_index_module \
--with-http_secure_link_module \
--with-http_stub_status_module \
--with-http_auth_request_module \
--with-http_xslt_module=dynamic \
--with-http_image_filter_module=dynamic \
--with-http_geoip_module=dynamic \
--with-threads \
--with-stream \
--with-stream_ssl_module \
--with-stream_ssl_preread_module \
--with-stream_realip_module \
--with-stream_geoip_module=dynamic \
--with-http_slice_module \
--with-mail \
--with-mail_ssl_module \
--with-compat \
--with-file-aio \
--with-http_v2_module \
--add-module=/usr/src/nchan-${NCHAN_VERSION} \
" \
&& addgroup -S nginx \
&& adduser -D -S -h /var/cache/nginx -s /sbin/nologin -G nginx nginx \
&& apk add --no-cache --virtual .build-deps \
gcc \
libc-dev \
make \
openssl-dev \
pcre-dev \
zlib-dev \
linux-headers \
curl \
gnupg \
libxslt-dev \
gd-dev \
geoip-dev \
&& curl -fSL http://nginx.org/download/nginx-$NGINX_VERSION.tar.gz -o nginx.tar.gz \
&& curl -fSL http://nginx.org/download/nginx-$NGINX_VERSION.tar.gz.asc -o nginx.tar.gz.asc \
&& curl -fSL https://github.com/slact/nchan/archive/v${NCHAN_VERSION}.tar.gz -o nchan.tar.gz \
&& export GNUPGHOME="$(mktemp -d)" \
&& found=''; \
for server in \
ha.pool.sks-keyservers.net \
hkp://keyserver.ubuntu.com:80 \
hkp://p80.pool.sks-keyservers.net:80 \
pgp.mit.edu \
; do \
echo "Fetching GPG key $GPG_KEYS from $server"; \
gpg --keyserver "$server" --keyserver-options timeout=10 --recv-keys "$GPG_KEYS" && found=yes && break; \
done; \
test -z "$found" && echo >&2 "error: failed to fetch GPG key $GPG_KEYS" && exit 1; \
gpg --batch --verify nginx.tar.gz.asc nginx.tar.gz \
&& rm -r "$GNUPGHOME" nginx.tar.gz.asc \
&& mkdir -p /usr/src \
&& tar -zxC /usr/src -f nginx.tar.gz \
&& tar -zxC /usr/src -f nchan.tar.gz \
&& rm nginx.tar.gz \
&& rm nchan.tar.gz \
&& cd /usr/src/nginx-$NGINX_VERSION \
&& ./configure $CONFIG --with-debug \
&& make -j$(getconf _NPROCESSORS_ONLN) \
&& mv objs/nginx objs/nginx-debug \
&& mv objs/ngx_http_xslt_filter_module.so objs/ngx_http_xslt_filter_module-debug.so \
&& mv objs/ngx_http_image_filter_module.so objs/ngx_http_image_filter_module-debug.so \
&& mv objs/ngx_http_geoip_module.so objs/ngx_http_geoip_module-debug.so \
&& mv objs/ngx_stream_geoip_module.so objs/ngx_stream_geoip_module-debug.so \
&& ./configure $CONFIG \
&& make -j$(getconf _NPROCESSORS_ONLN) \
&& make install \
&& rm -rf /etc/nginx/html/ \
&& mkdir /etc/nginx/conf.d/ \
&& mkdir -p /usr/share/nginx/html/ \
&& install -m644 html/index.html /usr/share/nginx/html/ \
&& install -m644 html/50x.html /usr/share/nginx/html/ \
&& install -m755 objs/nginx-debug /usr/sbin/nginx-debug \
&& install -m755 objs/ngx_http_xslt_filter_module-debug.so /usr/lib/nginx/modules/ngx_http_xslt_filter_module-debug.so \
&& install -m755 objs/ngx_http_image_filter_module-debug.so /usr/lib/nginx/modules/ngx_http_image_filter_module-debug.so \
&& install -m755 objs/ngx_http_geoip_module-debug.so /usr/lib/nginx/modules/ngx_http_geoip_module-debug.so \
&& install -m755 objs/ngx_stream_geoip_module-debug.so /usr/lib/nginx/modules/ngx_stream_geoip_module-debug.so \
&& ln -s ../../usr/lib/nginx/modules /etc/nginx/modules \
&& strip /usr/sbin/nginx* \
&& strip /usr/lib/nginx/modules/*.so \
&& rm -rf /usr/src/nginx-$NGINX_VERSION \
\
# Bring in gettext so we can get `envsubst`, then throw
# the rest away. To do this, we need to install `gettext`
# then move `envsubst` out of the way so `gettext` can
# be deleted completely, then move `envsubst` back.
&& apk add --no-cache --virtual .gettext gettext \
&& mv /usr/bin/envsubst /tmp/ \
\
&& runDeps="$( \
scanelf --needed --nobanner /usr/sbin/nginx /usr/lib/nginx/modules/*.so /tmp/envsubst \
| awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \
| sort -u \
| xargs -r apk info --installed \
| sort -u \
)" \
&& apk add --no-cache --virtual .nginx-rundeps $runDeps \
&& apk del .build-deps \
&& apk del .gettext \
&& mv /tmp/envsubst /usr/local/bin/ \
\
# forward request and error logs to docker log collector
&& ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
EXPOSE 80 443
STOPSIGNAL SIGTERM
CMD ["nginx", "-g", "daemon off;"]
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./nginx_azuracast.conf /etc/nginx/conf.d/azuracast.conf
RUN apk update && \
apk add openssl
RUN mkdir -p /etc/nginx/ssl/
RUN openssl req -new -nodes -x509 -subj "/C=US/ST=Texas/L=Austin/O=IT/CN=localhost" -days 3650 -keyout /etc/nginx/ssl/server.key -out /etc/nginx/ssl/server.crt -extensions v3_ca
RUN sed -i "s/sendfile on/sendfile off/" /etc/nginx/nginx.conf
RUN openssl req -new -nodes -x509 -subj "/C=US/ST=Texas/L=Austin/O=IT/CN=localhost" -days 3650 -keyout /etc/nginx/ssl/server.key -out /etc/nginx/ssl/server.crt -extensions v3_ca

View File

@ -0,0 +1,31 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile off;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
client_max_body_size 50M;
include /etc/nginx/conf.d/*.conf;
}

View File

@ -1,4 +1,18 @@
client_max_body_size 50M;
nchan_redis_url "redis://redis:6379";
server {
listen 9010;
location ~ /pub/(\w+)$ {
nchan_publisher;
nchan_use_redis on;
nchan_channel_id $1;
nchan_message_buffer_length 1;
nchan_message_timeout "30s";
}
}
server {
listen 80;
@ -54,6 +68,21 @@ server {
proxy_pass http://stations:$1/$3;
}
# pub/sub endpoints
location /api/live/nowplaying {
nchan_subscriber;
nchan_use_redis on;
nchan_channel_id nowplaying_all;
}
location ~ /api/live/nowplaying/(\d+)$ {
nchan_subscriber;
nchan_use_redis on;
nchan_channel_id "nowplaying_$1";
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#

View File

@ -1,7 +1,15 @@
FROM ubuntu:xenial
RUN mkdir -p /var/azuracast/servers/shoutcast2 && \
mkdir -p /var/azuracast/servers/icecast2 && \
mkdir -p /var/azuracast/servers/station-watcher && \
mkdir -p /var/azuracast/stations
RUN adduser --home /var/azuracast --disabled-password --gecos "" azuracast && \
chown -R azuracast:azuracast /var/azuracast
RUN apt-get update && \
apt-get install -q -y curl
apt-get install -q -y wget git gcc pkg-config curl libssl-dev libcurl4-openssl-dev
# Install supervisord
RUN mkdir -p /var/log/supervisor
@ -11,28 +19,28 @@ RUN apt-get update && \
ADD ./supervisord.conf /etc/supervisor/supervisord.conf
# Set up broadcast sections
RUN mkdir -p /var/azuracast/servers/shoutcast2 && \
mkdir -p /var/azuracast/servers/icecast2 && \
mkdir -p /var/azuracast/stations
# Install Rust and the station-watcher app
WORKDIR /var/azuracast/servers/station-watcher
RUN adduser --home /var/azuracast --disabled-password --gecos "" azuracast && \
chown -R azuracast:azuracast /var/azuracast
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y && \
git clone https://github.com/AzuraCast/station-watcher ./src && \
cd src && \
~/.cargo/bin/cargo build --release && \
cp target/release/cast-watcher /var/azuracast/servers/station-watcher/ && \
rm -rf /var/azuracast/servers/station-watcher/src && \
rm -rf ~/.cargo
# Download Shoutcast 2
WORKDIR /var/azuracast/servers/shoutcast2
RUN apt-get update && \
apt-get install -y wget && \
wget http://download.nullsoft.com/shoutcast/tools/sc_serv2_linux_x64-latest.tar.gz && \
RUN wget http://download.nullsoft.com/shoutcast/tools/sc_serv2_linux_x64-latest.tar.gz && \
tar -xzf sc_serv2_linux_x64-latest.tar.gz
# Download and build IceCast-KH
WORKDIR /var/azuracast/servers/icecast2
RUN apt-get update && \
apt-get install -y --no-install-recommends libxml2 libxslt1-dev libvorbis-dev libssl-dev libcurl4-openssl-dev \
gcc pkg-config && \
apt-get install -q -y --no-install-recommends libxml2 libxslt1-dev libvorbis-dev && \
wget https://github.com/karlheyes/icecast-kh/archive/icecast-2.4.0-kh5.tar.gz && \
tar --strip-components=1 -xzf icecast-2.4.0-kh5.tar.gz && \
./configure && \
@ -49,7 +57,7 @@ ADD ./xml2json.xslt /usr/local/share/icecast/web/xml2json.xslt
RUN sed -e 's#main#main contrib non-free#' -i /etc/apt/sources.list
RUN apt-get update && \
apt-get install -y --no-install-recommends ocaml git rsync opam libpcre3-dev libfdk-aac-dev libmad0-dev \
apt-get install -y --no-install-recommends ocaml rsync opam libpcre3-dev libfdk-aac-dev libmad0-dev \
libmp3lame-dev libtag1-dev libfaad-dev libflac-dev m4 aspcud
USER azuracast

View File

@ -1 +1 @@
{"js/radio.js":"js/radio.js?2c18564955716845","js/app.min.js":"js/app.min.js?f3e29a2e4ed3bf96","js/app.js":"js/app.js?2422c19d99917bc5","css/light.css":"css/light.css?5eb77e59bac195af","css/dark.css":"css/dark.css?d76a38e41049bf09"}
{"js/radio.js":"js/radio.js?2c18564955716845","js/nchan.js":"js/nchan.js?c512cda73ce7b123","js/app.min.js":"js/app.min.js?f3e29a2e4ed3bf96","js/app.js":"js/app.js?2422c19d99917bc5","css/light.css":"css/light.css?5eb77e59bac195af","css/dark.css":"css/dark.css?d76a38e41049bf09"}

859
web/static/js/nchan.js Normal file
View File

@ -0,0 +1,859 @@
/*
* NchanSubscriber
* usage: var sub = new NchanSubscriber(url, opt);
*
* opt = {
* subscriber: 'longpoll', 'eventsource', or 'websocket',
* //or an array of the above indicating subscriber type preference
* reconnect: undefined or 'session' or 'persist'
* //if the HTML5 sessionStore or localStore should be used to resume
* //connections interrupted by a page load
* shared: true or undefined
* //share connection to same subscriber url between browser
* //windows and tabs using localStorage. In shared mode,
* //only 1 running subscriber is allowed per url per window/tab.
* }
*
* sub.on("message", function(message, message_metadata) {
* // message is a string
* // message_metadata may contain 'id' and 'content-type'
* });
*
* sub.on('connect', function(evt) {
* //fired when first connected.
* });
*
* sub.on('disconnect', function(evt) {
* // when disconnected.
* });
*
* sub.on('error', function(code, message) {
* //error callback. not sure about the parameters yet
* });
*
* sub.reconnect; // should subscriber try to reconnect? true by default.
* sub.reconnectTimeout; //how long to wait to reconnect? does not apply to EventSource, which reconnects on its own.
* sub.lastMessageId; //last message id. useful for resuming a connection without loss or repetition.
*
* sub.start(); // begin (or resume) subscribing
* sub.stop(); // stop subscriber. do not reconnect.
*/
//Thanks Darren Whitlen ( @prawnsalad ) for your feedback
;(function (global, moduleName, factory) { // eslint-disable-line
"use strict";
/* eslint-disable no-undef */
var newModule = factory(global);
if (typeof module === "object" && module != null && module.exports) {
module.exports = newModule;
} else if (typeof define === "function" && define.amd) {
define(function () { return newModule; });
} else {
global[moduleName] = newModule;
}
/* eslint-enable no-undef */
})(typeof window !== "undefined" ? window : this, "NchanSubscriber", function factory(global, undefined) { // eslint-disable-line
// https://github.com/yanatan16/nanoajax
var nanoajax={};
(function(){var e=["responseType","withCredentials","timeout","onprogress"];nanoajax.ajax=function(r,o){var a=r.headers||{},u=r.body,s=r.method||(u?"POST":"GET"),i=false;var f=t(r.cors);function l(e,t){return function(){if(!i){o(f.status===undefined?e:f.status,f.status===0?"Error":f.response||f.responseText||t,f);i=true}}}f.open(s,r.url,true);var d=f.onload=l(200);f.onreadystatechange=function(){if(f.readyState===4)d()};f.onerror=l(null,"Error");f.ontimeout=l(null,"Timeout");f.onabort=l(null,"Abort");if(u){n(a,"X-Requested-With","XMLHttpRequest");if(!global.FormData||!(u instanceof global.FormData)){n(a,"Content-Type","application/x-www-form-urlencoded")}}for(var p=0,c=e.length,g;p<c;p++){g=e[p];if(r[g]!==undefined)f[g]=r[g]}for(var g in a)f.setRequestHeader(g,a[g]);f.send(u);return f};function t(e){if(e&&global.XDomainRequest&&!/MSIE 1/.test(navigator.userAgent))return new XDomainRequest;if(global.XMLHttpRequest)return new XMLHttpRequest}function n(e,t,n){e[t]=e[t]||n}})(); // eslint-disable-line
// https://github.com/component/emitter
function Emitter(t){return t?mixin(t):void 0}function mixin(t){for(var e in Emitter.prototype)t[e]=Emitter.prototype[e];return t}Emitter.prototype.on=Emitter.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this},Emitter.prototype.once=function(t,e){function i(){this.off(t,i),e.apply(this,arguments)}return i.fn=e,this.on(t,i),this},Emitter.prototype.off=Emitter.prototype.removeListener=Emitter.prototype.removeAllListeners=Emitter.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var i=this._callbacks["$"+t];if(!i)return this;if(1==arguments.length)return delete this._callbacks["$"+t],this;for(var r,s=0;s<i.length;s++)if(r=i[s],r===e||r.fn===e){i.splice(s,1);break}return this},Emitter.prototype.emit=function(t){this._callbacks=this._callbacks||{};var e=[].slice.call(arguments,1),i=this._callbacks["$"+t];if(i){i=i.slice(0);for(var r=0,s=i.length;s>r;++r)i[r].apply(this,e)}return this},Emitter.prototype.listeners=function(t){return this._callbacks=this._callbacks||{},this._callbacks["$"+t]||[]},Emitter.prototype.hasListeners=function(t){return!!this.listeners(t).length};// eslint-disable-line
var ughbind = (Function.prototype.bind
? function ughbind(fn, thisObj) {
return fn.bind(thisObj);
}
: function ughbind(fn, thisObj) {
return function() {
fn.apply(thisObj, arguments);
};
}
);
var sharedSubscriberTable={};
"use strict";
function NchanSubscriber(url, opt) {
if(this === window) {
throw "use 'new NchanSubscriber(...)' to initialize";
}
this.url = url;
opt = opt || {};
//which transport should i use?
if(typeof opt === "string") {
opt = {subscriber: opt};
}
if(opt.transport && !opt.subscriber) {
opt.subscriber = opt.transport;
}
if(typeof opt.subscriber === "string") {
opt.subscriber = [ opt.subscriber ];
}
this.desiredTransport = opt.subscriber;
if(opt.shared) {
if (!("localStorage" in global)) {
throw "localStorage unavailable for use in shared NchanSubscriber";
}
var pre = "NchanSubscriber:" + this.url + ":shared:";
var sharedKey = function(key) { return pre + key; };
var localStorage = global.localStorage;
this.shared = {
id: "" + Math.random() + Math.random(),
key: sharedKey,
get: function(key) {
return localStorage.getItem(sharedKey(key));
},
set: function(key, val) {
return localStorage.setItem(sharedKey(key), val);
},
setWithId: ughbind(function(key, val) {
return this.shared.set(key, "##" + this.shared.id + ":" + val);
}, this),
getWithId: ughbind(function(key) {
return this.shared.stripIdFromVal(this.shared.get(key));
}, this),
stripIdFromVal: function(val) {
if(!val) {
return val;
}
var sep = val.indexOf(":");
if(val[0]!=val[1] || val[0]!="#" || !sep) {
//throw "not an event value with id";
return val; //for backwards-compatibility
}
return val.substring(sep+1, val.length);
},
remove: function(key) {
return localStorage.removeItem(sharedKey(key));
},
matchEventKey: ughbind(function(ev, key) {
if(ev.storageArea && ev.storageArea != localStorage){
return false;
}
return ev.key == sharedKey(key);
}, this),
matchEventKeyWithId: ughbind(function(ev, key) {
if(this.shared.matchEventKey(ev, key)) {
var val = ev.newValue;
var sep = val.indexOf(":");
if(val[0]!=val[1] || val[0]!="#" || !sep) {
//throw "not an event value with id";
return true; //for backwards-compatibility
}
var id = val.substring(2, sep);
return (id != this.shared.id); //ignore own events (accomodations for IE. Fuckin' IE, even after all these years...)
}
else {
return false;
}
}, this),
setRole: ughbind(function(role) {
//console.log(this.url, "set shared role to ", role);
if(role == "master" && this.shared.role != "master") {
var now = new Date().getTime()/1000;
this.shared.setWithId("master:created", now);
this.shared.setWithId("master:lastSeen", now);
}
if(role == "slave" && !this.lastMessageId) {
this.lastMessageId = this.shared.get("msg:id");
}
this.shared.role = role;
return this;
}, this),
demoteToSlave: ughbind(function() {
//console.log("demote to slave");
if(this.shared.role != "master") {
throw "can't demote non-master to slave";
}
if(this.running) {
this.stop();
this.shared.setRole("slave");
this.initializeTransport();
this.start();
}
else {
this.initializeTransport();
}
}, this),
maybePromoteToMaster: ughbind(function() {
if(!(this.running || this.starting)) {
//console.log(this.url, "stopped Subscriber won't be promoted to master");
return this;
}
if(this.shared.maybePromotingToMaster) {
//console.log(this.url, " already maybePromotingToMaster");
return;
}
this.shared.maybePromotingToMaster = true;
//console.log(this.url, "maybe promote to master");
var processRoll;
var lotteryRoundDuration = 2000;
var currentContenders = 0;
//roll the dice
var roll = Math.random();
var bestRoll = roll;
var checkRollInterval;
var checkRoll = ughbind(function(dontProcess) {
var latestSharedRollTime = parseFloat(this.shared.getWithId("lotteryTime"));
var latestSharedRoll = parseFloat(this.shared.getWithId("lottery"));
var notStale = !latestSharedRollTime || (latestSharedRollTime > (new Date().getTime() - lotteryRoundDuration * 2));
if(notStale && latestSharedRoll && (!bestRoll || latestSharedRoll > bestRoll)) {
bestRoll = latestSharedRoll;
}
if(!dontProcess) {
processRoll();
}
}, this);
checkRoll(true);
this.shared.setWithId("lottery", roll);
this.shared.setWithId("lotteryTime", new Date().getTime() / 1000);
var rollCallback = ughbind(function(ev) {
if(this.shared.matchEventKeyWithId(ev, "lottery") && ev.newValue) {
currentContenders += 1;
var newVal = parseFloat(this.shared.stripIdFromVal(ev.newValue));
var oldVal = parseFloat(this.shared.stripIdFromVal(ev.oldValue));
if(oldVal > newVal) {
this.shared.setWithId("lottery", oldVal);
}
if(!bestRoll || newVal >= bestRoll) {
//console.log("new bestRoll", newVal);
bestRoll = newVal;
}
}
}, this);
global.addEventListener("storage", rollCallback);
var finish = ughbind(function() {
//console.log("finish");
this.shared.maybePromotingToMaster = false;
//console.log(this.url, this.shared.role);
global.removeEventListener("storage", rollCallback);
if(checkRollInterval) {
clearInterval(checkRollInterval);
}
if(this.shared && this.shared.role == "master") {
this.shared.remove("lottery");
this.shared.remove("lotteryTime");
}
if(this.running) {
this.stop();
this.initializeTransport();
this.start();
}
else {
this.initializeTransport();
if(this.starting) {
this.start();
}
}
}, this);
processRoll = ughbind(function() {
//console.log("roll, bestroll", roll, bestRoll);
if(roll < bestRoll) {
//console.log(this.url, "loser");
this.shared.setRole("slave");
finish();
}
else if(roll >= bestRoll) {
//var now = new Date().getTime() / 1000;
//var lotteryTime = parseFloat(this.shared.getWithId("lotteryTime"));
//console.log(lotteryTime, now - lotteryRoundDuration/1000);
if(currentContenders == 0) {
//console.log("winner, no more contenders!");
this.shared.setRole("master");
finish();
}
else {
//console.log("winning, but have contenders", currentContenders);
currentContenders = 0;
}
}
}, this);
checkRollInterval = global.setInterval(checkRoll, lotteryRoundDuration);
}, this),
masterCheckInterval: 10000
};
}
this.lastMessageId = opt.id || opt.msgId;
this.reconnect = typeof opt.reconnect == "undefined" ? true : opt.reconnect;
this.reconnectTimeout = opt.reconnectTimeout || 1000;
var saveConnectionState;
if(!opt.reconnect) {
saveConnectionState = function() {};
}
else {
var index = "NchanSubscriber:" + url + ":lastMessageId";
var storage;
if(opt.reconnect == "persist") {
storage = ("localStorage" in global) && global.localStorage;
if(!storage)
throw "can't use reconnect: 'persist' option: localStorage not available";
}
else if(opt.reconnect == "session") {
storage = ("sessionStorage" in global) && global.sessionStorage;
if(!storage)
throw "can't use reconnect: 'session' option: sessionStorage not available";
}
else {
throw "invalid 'reconnect' option value " + opt.reconnect;
}
saveConnectionState = ughbind(function(msgid) {
if(this.shared && this.shared.role == "slave") return;
storage.setItem(index, msgid);
}, this);
this.lastMessageId = storage.getItem(index);
}
var onUnloadEvent = ughbind(function() {
if(this.running) {
this.stop();
}
if(this.shared && this.shared.role == "master") {
this.shared.setWithId('status', "disconnected");
}
}, this);
global.addEventListener('beforeunload', onUnloadEvent, false);
// swap `beforeunload` to `unload` after DOM is loaded
global.addEventListener('DOMContentLoaded', function() {
global.removeEventListener('beforeunload', onUnloadEvent, false);
global.addEventListener('unload', onUnloadEvent, false);
}, false);
var notifySharedSubscribers;
if(this.shared) {
notifySharedSubscribers = ughbind(function(name, data) {
if(this.shared.role != "master") {
return;
}
if(name == "message") {
this.shared.set("msg:id", data[1] && data[1].id || "");
this.shared.set("msg:content-type", data[1] && data[1]["content-type"] || "");
this.shared.set("msg", data[0]);
}
else if(name == "error") {
//TODO
}
else if(name == "connecting") {
this.shared.setWithId("status", "connecting");
}
else if(name == "connect") {
this.shared.setWithId("status", "connected");
}
else if(name == "reconnect") {
this.shared.setWithId("status", "reconnecting");
}
else if(name == "disconnect") {
this.shared.setWithId("status", "disconnected");
}
}, this);
}
else {
notifySharedSubscribers = function(){};
}
var restartTimeoutIndex;
var stopHandler = ughbind(function() {
if(!restartTimeoutIndex && this.running && this.reconnect && !this.transport.reconnecting && !this.transport.doNotReconnect) {
//console.log("stopHAndler reconnect plz", this.running, this.reconnect);
notifySharedSubscribers("reconnect");
restartTimeoutIndex = global.setTimeout(ughbind(function() {
restartTimeoutIndex = null;
this.stop();
this.start();
}, this), this.reconnectTimeout);
}
else {
notifySharedSubscribers("disconnect");
}
}, this);
this.on("message", function msg(msg, meta) {
this.lastMessageId=meta.id;
if(meta.id) {
saveConnectionState(meta.id);
}
notifySharedSubscribers("message", [msg, meta]);
//console.log(msg, meta);
});
this.on("error", function fail(code, text) {
stopHandler(code, text);
notifySharedSubscribers("error", [code, text]);
//console.log("failure", code, text);
});
this.on("connect", function() {
this.connected = true;
notifySharedSubscribers("connect");
});
this.on("__disconnect", function fail(code, text) {
this.connected = false;
this.emit("disconnect", code, text);
stopHandler(code, text);
//console.log("__disconnect", code, text);
});
}
Emitter(NchanSubscriber.prototype);
NchanSubscriber.prototype.initializeTransport = function(possibleTransports) {
if(possibleTransports) {
this.desiredTransport = possibleTransports;
}
if(this.shared && this.shared.role == "slave") {
this.transport = new this.SubscriberClass["__slave"](ughbind(this.emit, this)); //try it
}
else {
var tryInitializeTransport = ughbind(function(name) {
if(!this.SubscriberClass[name]) {
throw "unknown subscriber type " + name;
}
try {
this.transport = new this.SubscriberClass[name](ughbind(this.emit, this)); //try it
return this.transport;
} catch(err) { /*meh...*/ }
}, this);
var i;
if(this.desiredTransport) {
for(i=0; i<this.desiredTransport.length; i++) {
if(tryInitializeTransport(this.desiredTransport[i])) {
break;
}
}
}
else {
for(i in this.SubscriberClass) {
if (this.SubscriberClass.hasOwnProperty(i) && i[0] != "_" && tryInitializeTransport(i)) {
break;
}
}
}
}
if(! this.transport) {
throw "can't use any transport type";
}
};
var storageEventListener;
NchanSubscriber.prototype.start = function() {
if(this.running)
throw "Can't start NchanSubscriber, it's already started.";
this.starting = true;
if(this.shared) {
if(sharedSubscriberTable[this.url] && sharedSubscriberTable[this.url] != this) {
throw "Only 1 shared subscriber allowed per url per window/tab.";
}
sharedSubscriberTable[this.url] = this;
if(!this.shared.role) {
var status = this.shared.getWithId("status");
storageEventListener = ughbind(function(ev) {
if(this.shared.matchEventKeyWithId(ev, "status")) {
var newValue = this.shared.stripIdFromVal(ev.newValue);
if(newValue == "disconnected") {
if(this.shared.role == "slave") {
//play the promotion lottery
//console.log(this.url, "status changed to disconnected, maybepromotetomaster", ev.newValue, ev.oldValue);
this.shared.maybePromoteToMaster();
}
else if(this.shared.role == "master") {
//do nothing
//console.log(this.url, "current role is master, do nothing?...");
}
}
}
else if(this.shared.role == "master" && this.shared.matchEventKeyWithId(ev, "master:created") && ev.newValue) {
//a new master has arrived. demote to slave.
this.shared.demoteToSlave();
}
}, this);
global.addEventListener("storage", storageEventListener);
if(status == "disconnected") {
//console.log(this.url, "status == disconnected, maybepromotetomaster");
this.shared.maybePromoteToMaster();
}
else {
this.shared.setRole(status ? "slave" : "master");
this.initializeTransport();
}
}
if(this.shared.role == "master") {
this.shared.setWithId("status", "connecting");
this.transport.listen(this.url, this.lastMessageId);
this.running = true;
delete this.starting;
//master checkin interval
this.shared.masterIntervalCheckID = global.setInterval(ughbind(function() {
this.shared.setWithId("master:lastSeen", new Date().getTime() / 1000);
}, this), this.shared.masterCheckInterval * 0.8);
}
else if(this.shared.role == "slave") {
this.transport.listen(this.url, this.shared);
this.running = true;
delete this.starting;
//slave check if master is around
this.shared.masterIntervalCheckID = global.setInterval(ughbind(function() {
var lastCheckin = parseFloat(this.shared.getWithId("master:lastSeen"));
if(!lastCheckin || lastCheckin < (new Date().getTime() / 1000) - this.shared.masterCheckInterval / 1000) {
//master hasn't checked in for too long. assume it's gone.
this.shared.maybePromoteToMaster();
}
}, this), this.shared.masterCheckInterval);
}
}
else {
if(!this.transport) {
this.initializeTransport();
}
this.transport.listen(this.url, this.lastMessageId);
this.running = true;
delete this.starting;
}
return this;
};
NchanSubscriber.prototype.stop = function() {
if(!this.running)
throw "Can't stop NchanSubscriber, it's not running.";
this.running = false;
if(storageEventListener) {
global.removeEventListener("storage", storageEventListener);
}
this.transport.cancel();
if(this.shared) {
delete sharedSubscriberTable[this.url];
if(this.shared.masterIntervalCheckID) {
clearInterval(this.shared.masterIntervalCheckID);
delete this.shared.masterIntervalCheckID;
}
}
return this;
};
function addLastMsgIdToQueryString(url, msgid) {
if(msgid) {
var m = url.match(/(\?.*)$/);
url += (m ? "&" : "?") + "last_event_id=" + encodeURIComponent(msgid);
}
return url;
}
NchanSubscriber.prototype.SubscriberClass = {
'websocket': (function() {
function WSWrapper(emit) {
WebSocket;
this.emit = emit;
}
WSWrapper.prototype.websocketizeURL = function(url) {
var m = url.match(/^((\w+:)?\/\/([^\/]+))?(\/)?(.*)/);
var protocol = m[2];
var host = m[3];
var absolute = m[4];
var path = m[5];
var loc;
if(typeof window == "object") {
loc = window.location;
}
if(!protocol && loc) {
protocol = loc.protocol;
}
if(protocol == "https:") {
protocol = "wss:";
}
else if(protocol == "http:") {
protocol = "ws:";
}
else {
protocol = "wss:"; //default setting: secure
}
if(!host && loc) {
host = loc.host;
}
if(!absolute) {
path = loc ? loc.pathname.match(/(.*\/)[^/]*/)[1] + path : "/" + path;
}
else {
path = "/" + path;
}
return protocol + "//" + host + path;
};
WSWrapper.prototype.listen = function(url, msgid) {
url = this.websocketizeURL(url);
url = addLastMsgIdToQueryString(url, msgid);
//console.log(url);
if(this.listener) {
throw "websocket already listening";
}
this.listener = new WebSocket(url, 'ws+meta.nchan');
var l = this.listener;
l.onmessage = ughbind(function(evt) {
var m = evt.data.match(/^id: (.*)\n(content-type: (.*)\n)?\n/m);
this.emit('message', evt.data.substr(m[0].length), {'id': m[1], 'content-type': m[3]});
}, this);
l.onopen = ughbind(function(evt) {
this.emit('connect', evt);
//console.log("connect", evt);
}, this);
l.onerror = ughbind(function(evt) {
//console.log("error", evt);
this.emit('error', evt, l);
delete this.listener;
}, this);
l.onclose = ughbind(function(evt) {
this.emit('__disconnect', evt);
delete this.listener;
}, this);
};
WSWrapper.prototype.cancel = function() {
if(this.listener) {
this.listener.close();
delete this.listener;
}
};
return WSWrapper;
})(),
'eventsource': (function() {
function ESWrapper(emit) {
EventSource;
this.emit = emit;
}
ESWrapper.prototype.listen= function(url, msgid) {
url = addLastMsgIdToQueryString(url, msgid);
if(this.listener) {
throw "there's a ES listener running already";
}
this.listener = new EventSource(url);
var l = this.listener;
l.onmessage = ughbind(function(evt){
//console.log("message", evt);
this.emit('message', evt.data, {id: evt.lastEventId});
}, this);
l.onopen = ughbind(function(evt) {
this.reconnecting = false;
//console.log("connect", evt);
this.emit('connect', evt);
}, this);
l.onerror = ughbind(function(evt) {
//EventSource will try to reconnect by itself
//console.log("onerror", this.listener.readyState, evt);
if(this.listener.readyState == EventSource.CONNECTING && !this.reconnecting) {
if(!this.reconnecting) {
this.reconnecting = true;
this.emit('__disconnect', evt);
}
}
else {
this.emit('__disconnect', evt);
//console.log('other __disconnect', evt);
}
}, this);
};
ESWrapper.prototype.cancel= function() {
if(this.listener) {
this.listener.close();
delete this.listener;
}
};
return ESWrapper;
})(),
'longpoll': (function () {
function Longpoll(emit) {
this.headers = {};
this.longPollStartTime = null;
this.maxLongPollTime = 5*60*1000; //5 minutes
this.emit = emit;
}
Longpoll.prototype.listen = function(url, msgid) {
if(this.req) {
throw "already listening";
}
if(url) { this.url=url; }
var setHeader = ughbind(function(incoming, name) {
if(incoming) { this.headers[name]= incoming; }
}, this);
if(msgid) {
this.headers = {"Etag": msgid};
}
this.reqStartTime = new Date().getTime();
var requestCallback;
requestCallback = ughbind(function (code, response_text, req) {
setHeader(req.getResponseHeader('Last-Modified'), 'If-Modified-Since');
setHeader(req.getResponseHeader('Etag'), 'If-None-Match');
if(code >= 200 && code <= 210) {
//legit reply
var content_type = req.getResponseHeader('Content-Type');
if (!this.parseMultipartMixedMessage(content_type, response_text, req)) {
this.emit("message", response_text || "", {'content-type': content_type, 'id': this.msgIdFromResponseHeaders(req)});
}
this.reqStartTime = new Date().getTime();
this.req = nanoajax.ajax({url: this.url, headers: this.headers}, requestCallback);
}
else if((code == 0 && response_text == "Error" && req.readyState == 4) || (code === null && response_text != "Abort")) {
//console.log("abort!!!");
this.emit("__disconnect", code || 0, response_text);
delete this.req;
}
else if(code !== null) {
//HTTP error
this.emit("error", code, response_text);
delete this.req;
}
else {
//don't care about abortions
delete this.req;
this.emit("__disconnect");
//console.log("abort!");
}
}, this);
this.reqStartTime = new Date().getTime();
this.req = nanoajax.ajax({url: this.url, headers: this.headers}, requestCallback);
this.emit("connect");
return this;
};
Longpoll.prototype.parseMultipartMixedMessage = function(content_type, text, req) {
var m = content_type && content_type.match(/^multipart\/mixed;\s+boundary=(.*)$/);
if(!m) {
return false;
}
var boundary = m[1];
var msgs = text.split("--" + boundary);
if(msgs[0] != "" || !msgs[msgs.length-1].match(/--\r?\n/)) { throw "weird multipart/mixed split"; }
msgs = msgs.slice(1, -1);
for(var i in msgs) {
m = msgs[i].match(/^(.*)\r?\n\r?\n([\s\S]*)\r?\n$/m);
var hdrs = m[1].split("\n");
var meta = {};
for(var j in hdrs) {
var hdr = hdrs[j].match(/^([^:]+):\s+(.*)/);
if(hdr && hdr[1] == "Content-Type") {
meta["content-type"] = hdr[2];
}
}
if(i == msgs.length - 1) {
meta["id"] = this.msgIdFromResponseHeaders(req);
}
this.emit('message', m[2], meta);
}
return true;
};
Longpoll.prototype.msgIdFromResponseHeaders = function(req) {
var lastModified, etag;
lastModified = req.getResponseHeader('Last-Modified');
etag = req.getResponseHeader('Etag');
if(lastModified) {
return "" + Date.parse(lastModified)/1000 + ":" + (etag || "0");
}
else if(etag) {
return etag;
}
else {
return null;
}
};
Longpoll.prototype.cancel = function() {
if(this.req) {
this.req.abort();
delete this.req;
}
return this;
};
return Longpoll;
})(),
'__slave': (function() {
function LocalStoreSlaveTransport(emit) {
this.emit = emit;
this.doNotReconnect = true;
}
LocalStoreSlaveTransport.prototype.listen = function(url, shared) {
this.shared = shared;
this.statusChangeChecker = ughbind(function(ev) {
if(this.shared.matchEventKey(ev, "msg")) {
var msgId = this.shared.get("msg:id");
var contentType = this.shared.get("msg:content-type");
var msg = this.shared.get("msg");
this.emit('message', msg, {'id': msgId == "" ? undefined : msgId, 'content-type': contentType == "" ? undefined : contentType});
}
}, this);
global.addEventListener("storage", this.statusChangeChecker);
};
LocalStoreSlaveTransport.prototype.cancel = function() {
global.removeEventListener("storage", this.statusChangeChecker);
};
return LocalStoreSlaveTransport;
})()
};
return NchanSubscriber;
});