Closes #5 -- Move LiquidSoap and IceCast to run in localized "sandbox" instances instead of global services, allowing multiple stations to coexist in the same server without issue. This updated method of operation is functional but still needs evaluation (particularly why liquidsoap can't be initialized directly via exec/shell_exec).

This commit is contained in:
Buster Silver 2016-05-19 09:56:21 -05:00
parent cb01a68c31
commit d3876e2118
10 changed files with 431 additions and 74 deletions

View File

@ -18,11 +18,19 @@ class LiquidSoap extends AdapterAbstract
*/
public function write()
{
$station_base_dir = $this->station->radio_base_dir;
$playlist_path = $station_base_dir.'/playlists';
$media_path = $station_base_dir.'/media';
$playlist_path = $this->station->getRadioPlaylistsDir();
$media_path = $this->station->getRadioMediaDir();
$config_path = $this->station->getRadioConfigDir();
$ls_config = array();
$ls_config[] = '# WARNING! This file is automatically generated by AzuraCast.';
$ls_config[] = '# Do not update it directly!';
$ls_config[] = '';
$ls_config[] = 'set("init.daemon",true)';
$ls_config[] = 'set("init.daemon.pidfile.path","'.$config_path.'/liquidsoap.pid")';
$ls_config[] = 'set("log.file.path","'.$config_path.'/liquidsoap.log")';
$ls_config[] = '';
// Clear out existing playlists directory.
$current_playlists = array_diff(scandir($playlist_path), array('..', '.'));
@ -77,12 +85,10 @@ class LiquidSoap extends AdapterAbstract
{
case 'icecast':
default:
if (!empty($this->station->radio_port))
$icecast_port = $this->station->radio_port;
else
$icecast_port = 8000;
$ic_settings = (array)$this->station->frontend_config;
$icecast_source_pw = $this->station->radio_source_pw;
$icecast_port = $ic_settings['port'];
$icecast_source_pw = $ic_settings['source_pw'];
$output_params = [
'%mp3', // Required output format (%mp3 or %ogg)
@ -99,18 +105,63 @@ class LiquidSoap extends AdapterAbstract
}
$ls_config_contents = implode("\n", $ls_config);
$ls_config_path = '/etc/liquidsoap/station_'.$this->station->id.'_'.$this->station->getShortName().'.liq';
$ls_config_path = $config_path.'/liquidsoap.liq';
file_put_contents($ls_config_path, $ls_config_contents);
return true;
}
/**
* Restart the executable service.
* @return mixed
*/
public function isRunning()
{
$config_path = $this->station->getRadioConfigDir();
$ls_pid_file = $config_path.'/liquidsoap.pid';
if (file_exists($ls_pid_file))
{
$ls_pid = file_get_contents($ls_pid_file);
$pid_result = exec('ps --pid '.$ls_pid.' &>/dev/null');
return ($pid_result == 0);
}
return false;
}
public function stop()
{
$config_path = $this->station->getRadioConfigDir();
$ls_pid_file = $config_path.'/liquidsoap.pid';
if (file_exists($ls_pid_file))
{
$ls_pid = file_get_contents($ls_pid_file);
$kill_result = exec('kill -9 '.$ls_pid);
@unlink($ls_pid_file);
return $kill_result;
}
return null;
}
public function start()
{
$config_path = $this->station->getRadioConfigDir();
$ls_config = escapeshellarg($config_path.'/liquidsoap.liq');
/*
* TODO: Figure out why this works, but simply running this script AS
* the 'azuracast' user (the default state) doesn't. No idea.
*/
return shell_exec('sudo -u azuracast liquidsoap '.$ls_config.' 2>&1');
}
public function restart()
{
return exec('sudo /etc/init.d/liquidsoap force-reload');
$return = array();
$return[] = $this->stop();
$return[] = $this->start();
return implode("\n", $return);
}
}

View File

@ -71,6 +71,11 @@ class AdapterAbstract
return $np;
}
public function getStreamUrl()
{
return '';
}
/* Stub function for the process internal handler. */
protected function _getNowPlaying(&$np)
{

View File

@ -1,14 +1,17 @@
<?php
namespace App\RadioFrontend;
use App\Utilities;
use Entity\Station;
use Entity\Settings;
class IceCast extends AdapterAbstract
{
/* Process a nowplaying record. */
protected function _getNowPlaying(&$np)
{
$radio_port = $this->station->radio_port;
$fe_config = (array)$this->station->frontend_config;
$radio_port = $fe_config['port'];
$np_url = 'http://localhost:'.$radio_port.'/status-json.xsl';
@ -74,33 +77,187 @@ class IceCast extends AdapterAbstract
return true;
}
public function getStreamUrl()
{
$base_url = Settings::getSetting('base_url');
$fe_config = (array)$this->station->frontend_config;
$radio_port = $fe_config['port'];
// Vagrant port-forwarding mode.
if (APP_APPLICATION_ENV == 'development' && $radio_port == 8000)
$radio_port = 8088;
/* TODO: Abstract out mountpoint names */
return 'http://'.$base_url.':'.$radio_port.'/radio.mp3?played='.time();
}
public function read()
{
$config = $this->_getConfig();
$this->station->radio_port = $config['listen-socket']['port'];
$this->station->radio_source_pw = $config['authentication']['source-password'];
$this->station->radio_admin_pw = $config['authentication']['admin-password'];
$frontend_config = [
'port' => $config['listen-socket']['port'],
'source_pw' => $config['authentication']['source-password'],
'admin_pw' => $config['authentication']['admin-password'],
];
$this->station->frontend_config = $frontend_config;
return true;
}
public function write()
{
/* TODO: Implement config writing */
$config = $this->_getDefaults();
$frontend_config = (array)$this->station->frontend_config;
$config['listen-socket']['port'] = $frontend_config['port'];
$config['authentication']['source-password'] = $frontend_config['source_pw'];
$config['authentication']['admin-password'] = $frontend_config['admin_pw'];
$config_path = $this->station->getRadioConfigDir();
$icecast_path = $config_path.'/icecast.xml';
$writer = new \App\Xml\Writer;
$icecast_config_str = $writer->toString($config, 'icecast');
// Strip the first line (the XML charset)
$icecast_config_str = substr( $icecast_config_str, strpos($icecast_config_str, "\n")+1 );
file_put_contents($icecast_path, $icecast_config_str);
}
/*
* Process Management
*/
public function isRunning()
{
$config_path = $this->station->getRadioConfigDir();
$icecast_pid_file = $config_path.'/icecast.pid';
if (file_exists($icecast_pid_file))
{
$icecast_pid = file_get_contents($icecast_pid_file);
$pid_result = exec('ps --pid '.$icecast_pid.' &>/dev/null');
return ($pid_result == 0);
}
return false;
}
public function stop()
{
$config_path = $this->station->getRadioConfigDir();
$icecast_pid_file = $config_path.'/icecast.pid';
if (file_exists($icecast_pid_file))
{
$icecast_pid = file_get_contents($icecast_pid_file);
$kill_result = exec('kill -9 '.$icecast_pid);
@unlink($icecast_pid_file);
return $kill_result;
}
return null;
}
public function start()
{
$config_path = $this->station->getRadioConfigDir();
$icecast_config = $config_path.'/icecast.xml';
exec('icecast2 -b -c '.$icecast_config, $output);
return implode("\n", $output);
}
public function restart()
{
return exec('sudo service icecast2 restart');
$return = array();
$return[] = $this->stop();
$return[] = $this->start();
return implode("\n", $return);
}
/*
* Configuration
*/
protected function _getConfig()
{
$config_path = '/etc/icecast2/icecast.xml';
$config_path = $this->station->getRadioConfigDir();
$icecast_path = $config_path.'/icecast.xml';
$reader = new \Zend\Config\Reader\Xml();
$data = $reader->fromFile($config_path);
return $data;
$defaults = $this->_getDefaults();
if (file_exists($icecast_path))
{
$reader = new \App\Xml\Reader;
$data = $reader->fromFile($icecast_path);
return Utilities::array_merge_recursive_distinct($defaults, $data);
}
return $defaults;
}
protected function _getDefaults()
{
$config_dir = $this->station->getRadioConfigDir();
return [
'location' => 'Earth',
'admin' => 'icemaster@localhost',
'limits' => [
'clients' => 100,
'sources' => 2,
'threadpool' => 5,
'queue-size' => 524288,
'client-timeout' => 30,
'header-timeout' => 15,
'source-timeout' => 10,
'burst-on-connect' => 1,
'burst-size' => 65535,
],
'authentication' => [
'source-password' => Utilities::generatePassword(),
'relay-password' => Utilities::generatePassword(),
'admin-user' => 'admin',
'admin-password' => Utilities::generatePassword(),
],
'hostname' => 'localhost',
'listen-socket' => [
'port' => 8000,
],
'mount' => [
'@type' => 'normal',
'mount-name' => '/radio.mp3',
],
'fileserve' => 1,
'paths' => [
'basedir' => '/usr/share/icecast2',
'logdir' => $config_dir,
'webroot' => '/usr/share/icecast2/web',
'adminroot' => '/usr/share/icecast2/admin',
'pidfile' => $config_dir.'/icecast.pid',
'alias' => [
'@source' => '/',
'@destination' => '/status.xsl',
],
],
'logging' => [
'accesslog' => 'icecast_access.log',
'errorlog' => 'icecast_error.log',
'loglevel' => 3,
'logsize' => 10000,
],
'security' => [
'chroot' => 0,
],
];
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Extends the Zend Config XML library to allow attribute handling.
*/
namespace App\Xml;
use XMLReader;
use Zend\Config\Exception;
/**
* XML config reader.
*/
class Reader extends \Zend\Config\Reader\Xml
{
/**
* Get all attributes on the current node.
*
* @return array
*/
protected function getAttributes()
{
$attributes = [];
if ($this->reader->hasAttributes) {
while ($this->reader->moveToNextAttribute()) {
$attributes['@'.$this->reader->localName] = $this->reader->value;
}
$this->reader->moveToElement();
}
return $attributes;
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Extends the Zend Config XML library to allow attribute handling.
*/
namespace App\Xml;
use Traversable;
use Zend\Config\Exception;
use Zend\Stdlib\ArrayUtils;
use XMLWriter;
class Writer extends \Zend\Config\Writer\Xml
{
/**
* toString(): defined by Writer interface.
*
* @see WriterInterface::toString()
* @param mixed $config
* @param string $base_element
* @return string
*/
public function toString($config, $base_element = 'zend-config')
{
if ($config instanceof Traversable) {
$config = ArrayUtils::iteratorToArray($config);
} elseif (!is_array($config)) {
throw new Exception\InvalidArgumentException(__METHOD__ . ' expects an array or Traversable config');
}
return $this->processConfig($config, $base_element);
}
/**
* processConfig(): defined by AbstractWriter.
*
* @param array $config
* @param string $base_element
* @return string
*/
public function processConfig(array $config, $base_element = 'zend-config')
{
$writer = new XMLWriter();
$writer->openMemory();
$writer->setIndent(true);
$writer->setIndentString(str_repeat(' ', 4));
$writer->startDocument('1.0', 'UTF-8');
$writer->startElement($base_element);
foreach ($config as $sectionName => $data) {
if (!is_array($data)) {
$writer->writeElement($sectionName, (string) $data);
} else {
$this->addBranch($sectionName, $data, $writer);
}
}
$writer->endElement();
$writer->endDocument();
return $writer->outputMemory();
}
/**
* Add a branch to an XML object recursively.
*
* @param string $branchName
* @param array $config
* @param XMLWriter $writer
* @return void
* @throws Exception\RuntimeException
*/
protected function addBranch($branchName, array $config, XMLWriter $writer)
{
$branchType = null;
foreach ($config as $key => $value) {
if ($branchType === null) {
if (is_numeric($key)) {
$branchType = 'numeric';
} else {
$writer->startElement($branchName);
$branchType = 'string';
}
} elseif ($branchType !== (is_numeric($key) ? 'numeric' : 'string')) {
throw new Exception\RuntimeException('Mixing of string and numeric keys is not allowed');
}
if ($branchType === 'numeric') {
if (is_array($value)) {
$this->addBranch($branchName, $value, $writer);
} else {
$writer->writeElement($branchName, (string) $value);
}
} else {
if (is_array($value)) {
$this->addBranch($key, $value, $writer);
} else {
if (substr($key, 0, 1) == '@')
$writer->writeAttribute(substr($key, 1), (string)$value);
else
$writer->writeElement($key, (string) $value);
}
}
}
if ($branchType === 'string') {
$writer->endElement();
}
}
}

View File

@ -39,6 +39,9 @@ class Station extends \App\Doctrine\Entity
/** @Column(name="frontend_type", type="string", length=100, nullable=true) */
protected $frontend_type;
/** @Column(name="frontend_config", type="json", nullable=true) */
protected $frontend_config;
/**
* @return \App\RadioFrontend\AdapterAbstract
* @throws \Exception
@ -57,6 +60,9 @@ class Station extends \App\Doctrine\Entity
/** @Column(name="backend_type", type="string", length=100, nullable=true) */
protected $backend_type;
/** @Column(name="backend_config", type="json", nullable=true) */
protected $backend_config;
/**
* @return \App\RadioBackend\AdapterAbstract
* @throws \Exception
@ -75,35 +81,15 @@ class Station extends \App\Doctrine\Entity
/** @Column(name="description", type="text", nullable=true) */
protected $description;
/*
* Administrative Data
*/
/** @Column(name="nowplaying_data", type="json", nullable=true) */
protected $nowplaying_data;
/** @Column(name="radio_port", type="smallint", nullable=true) */
protected $radio_port;
public function getRadioStreamUrl()
{
$base_url = Settings::getSetting('base_url');
$radio_port = $this->radio_port;
// Vagrant port-forwarding mode.
if (APP_APPLICATION_ENV == 'development' && $radio_port == 8000)
$radio_port = 8088;
/* TODO: Abstract out mountpoint names */
return 'http://'.$base_url.':'.$radio_port.'/radio.mp3?played='.time();
$frontend_adapter = $this->getFrontendAdapter();
return $frontend_adapter->getStreamUrl();
}
/** @Column(name="radio_source_pw", type="string", length=100, nullable=true) */
protected $radio_source_pw;
/** @Column(name="radio_admin_pw", type="string", length=100, nullable=true) */
protected $radio_admin_pw;
/** @Column(name="radio_base_dir", type="string", length=255, nullable=true) */
protected $radio_base_dir;
@ -115,6 +101,7 @@ class Station extends \App\Doctrine\Entity
@mkdir($this->getRadioMediaDir(), 0777, TRUE);
@mkdir($this->getRadioPlaylistsDir(), 0777, TRUE);
@mkdir($this->getRadioConfigDir(), 0777, TRUE);
}
}
@ -128,6 +115,14 @@ class Station extends \App\Doctrine\Entity
return $this->radio_base_dir.'/playlists';
}
public function getRadioConfigDir()
{
return $this->radio_base_dir.'/config';
}
/** @Column(name="nowplaying_data", type="json", nullable=true) */
protected $nowplaying_data;
/**
* @OneToMany(targetEntity="SongHistory", mappedBy="station")
* @OrderBy({"timestamp" = "DESC"})

View File

@ -118,10 +118,13 @@ class SetupController extends BaseController
\App\Sync\Media::importPlaylists($station);
$this->em->refresh($station);
// Load configuration from adapter to pull source and admin PWs.
$frontend_adapter = $station->getFrontendAdapter();
$frontend_adapter->read();
// Write initial XML file (if it doesn't exist).
$frontend_adapter->write();
$frontend_adapter->restart();
// Write an empty placeholder configuration.

View File

@ -30,9 +30,20 @@ class UtilController extends BaseController
// -------- START HERE -------- //
$station = \Entity\Station::find(1);
$fe = $station->getFrontendAdapter();
$fe->read();
$config_path = '/etc/icecast2/icecast.xml';
$reader = new \App\Xml\Reader();
$data = $reader->fromFile($config_path);
\App\Utilities::print_r($data);
$data['mount'] = array(
array('asdf' => array('true')),
array('foo' => array('true')),
);
$writer = new \App\Xml\Writer();
\App\Utilities::print_r($writer->toString($data, 'icecast'));
// -------- END HERE -------- //

View File

@ -11,8 +11,8 @@ class UtilController extends BaseController
public function writeAction()
{
$backend = $this->station->getBackendAdapter();
$backend->write();
$this->view->backend_result = $backend->restart();
}
@ -21,10 +21,16 @@ class UtilController extends BaseController
*/
public function restartAction()
{
$backend = $this->station->getBackendAdapter();
$this->view->backend_result = $backend->restart();
$frontend = $this->station->getFrontendAdapter();
$this->view->frontend_result = $frontend->restart();
$backend = $this->station->getBackendAdapter();
$frontend->stop();
$backend->stop();
$frontend->write();
$backend->write();
$this->view->frontend_result = $frontend->start();
$this->view->backend_result = $backend->start();
}
}

View File

@ -12,22 +12,4 @@ wget -qO - http://icecast.org/multimedia-obs.key | sudo apt-key add -
sudo sh -c "echo deb http://download.opensuse.org/repositories/multimedia:/xiph/xUbuntu_14.04/ ./ >>/etc/apt/sources.list.d/icecast.list"
apt-get update
apt-get -q -y install pwgen icecast2 liquidsoap
# Generate new passwords for Icecast
export icecast_pw_source=$(pwgen 8 -sn 1)
export icecast_pw_relay=$(pwgen 8 -sn 1)
export icecast_pw_admin=$(pwgen 8 -sn 1)
# Add them into the config files
sedeasy "<source-password>hackme</source-password>" "<source-password>$icecast_pw_source</source-password>" /etc/icecast2/icecast.xml
sedeasy "<relay-password>hackme</relay-password>" "<relay-password>$icecast_pw_relay</relay-password>" /etc/icecast2/icecast.xml
sedeasy "<admin-password>hackme</admin-password>" "<admin-password>$icecast_pw_admin</admin-password>" /etc/icecast2/icecast.xml
# Enable IceCast daemon
sedeasy "ENABLE=false" "ENABLE=true" /etc/default/icecast2
# Allow PHP script to edit the config folders
chmod -R 777 /etc/icecast2
chmod -R 777 /etc/liquidsoap
apt-get -q -y install icecast2 liquidsoap