Closes #4 -- Add basic system-wide support for the ShoutCast 2 broadcast system.

This commit is contained in:
Buster Silver 2016-11-21 23:09:18 -06:00
parent c57d70b2a7
commit f1276b347c
14 changed files with 379 additions and 138 deletions

View File

@ -210,24 +210,39 @@ class LiquidSoap extends BackendAbstract
$ls_config[] = '# Outbound Broadcast';
// Configure the outbound broadcast.
$fe_settings = (array)$this->station->frontend_config;
$broadcast_port = $fe_settings['port'];
$broadcast_source_pw = $fe_settings['source_pw'];
switch($this->station->frontend_type)
{
case 'shoutcast2':
// TODO: Implement Shoutcast 2 Broadcasting
break;
case 'remote':
$this->log(_('You cannot use an AutoDJ with a remote frontend. Please change the frontend type or update the backend to be "Disabled".'), 'error');
return false;
break;
case 'shoutcast2':
$format = 'mp3';
$bitrate = 128;
$output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate='.(int)$bitrate.')';
$output_params = [
'id="radio_out"',
'host = "localhost"',
'port = '.($broadcast_port),
'password = "'.$broadcast_source_pw.'"',
'name = "' . $this->_cleanUpString($this->station->name) . '"',
'public = false',
$output_format, // Required output format (%mp3 etc)
'radio', // Required
];
$ls_config[] = 'output.shoutcast('.implode(', ', $output_params).')';
break;
case 'icecast':
default:
$ic_settings = (array)$this->station->frontend_config;
$icecast_port = $ic_settings['port'];
$icecast_source_pw = $ic_settings['source_pw'];
foreach($this->station->mounts as $mount_row)
{
if (!$mount_row->enable_autodj)
@ -241,18 +256,21 @@ class LiquidSoap extends BackendAbstract
else
$output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate='.(int)$bitrate.')';
$output_params = [
$output_format, // Required output format (%mp3 or %ogg)
'id="radio_out_'.$mount_row->id.'"',
'host = "localhost"',
'port = '.$icecast_port,
'password = "'.$icecast_source_pw.'"',
'name = "'.$this->_cleanUpString($this->station->name).'"',
'description = "'.$this->_cleanUpString($this->station->description).'"',
'mount = "'.$mount_row->name.'"',
'radio', // Required
];
$ls_config[] = 'output.icecast('.implode(', ', $output_params).')';
if (!empty($output_format))
{
$output_params = [
$output_format, // Required output format (%mp3 or %ogg)
'id="radio_out_' . $mount_row->id . '"',
'host = "localhost"',
'port = ' . $broadcast_port,
'password = "' . $broadcast_source_pw . '"',
'name = "' . $this->_cleanUpString($this->station->name) . '"',
'description = "' . $this->_cleanUpString($this->station->description) . '"',
'mount = "' . $mount_row->name . '"',
'radio', // Required
];
$ls_config[] = 'output.icecast(' . implode(', ', $output_params) . ')';
}
}
break;
}

View File

@ -14,6 +14,8 @@ abstract class FrontendAbstract extends \App\Radio\AdapterAbstract
return $this->supports_mounts;
}
public function getDefaultMounts() {}
protected $supports_streamers = true;
public function supportsStreamers()
@ -140,4 +142,26 @@ abstract class FrontendAbstract extends \App\Radio\AdapterAbstract
if (!empty(trim($message)))
parent::log(str_pad('Radio Frontend: ', 20, ' ', STR_PAD_RIGHT).$message, $class);
}
protected function _processCustomConfig($custom_config_raw)
{
$custom_config = [];
if (substr($custom_config_raw, 0, 1) == '{')
{
$custom_config = @json_decode($custom_config_raw, true);
}
elseif (substr($custom_config_raw, 0, 1) == '<')
{
$reader = new \App\Xml\Reader;
$custom_config = $reader->fromString('<icecast>'.$custom_config_raw.'</icecast>');
}
return $custom_config;
}
protected function _getRadioPort()
{
return (8000 + (($this->station->id - 1) * 10));
}
}

View File

@ -351,25 +351,25 @@ class IceCast extends FrontendAbstract
return $defaults;
}
protected function _processCustomConfig($custom_config_raw)
public function getDefaultMounts()
{
$custom_config = [];
if (substr($custom_config_raw, 0, 1) == '{')
{
$custom_config = @json_decode($custom_config_raw, true);
}
elseif (substr($custom_config_raw, 0, 1) == '<')
{
$reader = new \App\Xml\Reader;
$custom_config = $reader->fromString('<icecast>'.$custom_config_raw.'</icecast>');
}
return $custom_config;
}
protected function _getRadioPort()
{
return (8000 + (($this->station->id - 1) * 10));
return [
[
'name' => '/radio.mp3',
'is_default' => 1,
'fallback_mount' => '/autodj.mp3',
'enable_streamers' => 1,
'enable_autodj' => 0,
],
[
'name' => '/autodj.mp3',
'is_default' => 0,
'fallback_mount' => '/error.mp3',
'enable_streamers' => 0,
'enable_autodj' => 1,
'autodj_format' => 'mp3',
'autodj_bitrate' => 128,
]
];
}
}

View File

@ -1,23 +1,35 @@
<?php
namespace App\Radio\Frontend;
use App\Debug;
use App\Utilities;
use Entity\Station;
use Doctrine\ORM\EntityManager;
class ShoutCast2 extends AdapterAbstract
class ShoutCast2 extends FrontendAbstract
{
/* TODO: This class not fully implemented! */
protected $supports_mounts = false;
/* Process a nowplaying record. */
protected function _process(&$np)
protected function _getNowPlaying(&$np)
{
$return_raw = $this->getUrl();
$fe_config = (array)$this->station->frontend_config;
$radio_port = $fe_config['port'];
$np_url = 'http://localhost:'.$radio_port.'/stats';
$return_raw = $this->getUrl($np_url);
if (empty($return_raw))
return false;
$current_data = \App\Export::xml_to_array($return_raw);
Debug::print_r($return_raw);
$song_data = $current_data['SHOUTCASTSERVER'];
Debug::print_r($song_data);
$np['meta']['status'] = 'online';
$np['meta']['bitrate'] = $song_data['BITRATE'];
$np['meta']['format'] = $song_data['CONTENT'];
@ -34,4 +46,176 @@ class ShoutCast2 extends AdapterAbstract
return true;
}
public function read()
{
$config = $this->_getConfig();
$this->station->frontend_config = $this->_loadFromConfig($config);
return true;
}
public function write()
{
$config = $this->_getDefaults();
$frontend_config = (array)$this->station->frontend_config;
if (!empty($frontend_config['port']))
$config['portbase'] = $frontend_config['port'];
if (!empty($frontend_config['source_pw']))
$config['password'] = $frontend_config['source_pw'];
if (!empty($frontend_config['admin_pw']))
$config['adminpassword'] = $frontend_config['admin_pw'];
if (!empty($frontend_config['custom_config']))
{
$custom_conf = $this->_processCustomConfig($frontend_config['custom_config']);
if (!empty($custom_conf))
$config = array_merge($config, $custom_conf);
}
// Set any unset values back to the DB config.
$this->station->frontend_config = $this->_loadFromConfig($config);
$em = $this->di['em'];
$em->persist($this->station);
$em->flush();
$config_path = $this->station->getRadioConfigDir();
$sc_path = $config_path.'/sc_serv.conf';
$sc_file = '';
foreach($config as $config_key => $config_value)
$sc_file .= $config_key.'='.str_replace("\n", "", $config_value)."\n";
file_put_contents($sc_path, $sc_file);
}
/*
* Process Management
*/
public function isRunning()
{
return $this->_isPidRunning($this->station->getRadioConfigDir().'/sc_serv.pid');
}
public function stop()
{
$this->_killPid($this->station->getRadioConfigDir().'/sc_serv.pid');
}
public function start()
{
$config_path = $this->station->getRadioConfigDir();
$sc_binary = realpath(APP_INCLUDE_ROOT.'/..').'/servers/sc_serv';
$sc_config = $config_path.'/sc_serv.conf';
if ($this->isRunning())
{
$this->log(_('Not starting, process is already running.'));
return;
}
$cmd = \App\Utilities::run_command($sc_binary.' daemon '.$sc_config.' > '.$config_path.'/sc_pid_raw.txt');
if (file_exists($config_path.'/sc_pid_raw.txt'))
{
$pid_raw = file_get_contents($config_path.'/sc_pid_raw.txt');
$this->log($pid_raw);
preg_match('#\[(.*?)\]#', $pid_raw, $match);
$pid = (int)$match[1];
if ($pid != 0)
file_put_contents($config_path.'/sc_serv.pid', $pid);
@unlink($config_path.'/sc_pid_raw.txt');
}
if (!empty($cmd['output']))
$this->log($cmd['output']);
if (!empty($cmd['error']))
$this->log($cmd['error'], 'red');
}
public function getStreamUrl()
{
return $this->getUrlForMount('/stream/1/');
}
public function getStreamUrls()
{
return [$this->getUrlForMount('/stream/1/')];
}
public function getUrlForMount($mount_name)
{
return $this->getPublicUrl().$mount_name.'?'.time();
}
public function getAdminUrl()
{
return $this->getPublicUrl().'/admin.cgi';
}
public function getPublicUrl()
{
$fe_config = (array)$this->station->frontend_config;
$radio_port = $fe_config['port'];
$base_url = $this->di['em']->getRepository('Entity\Settings')->getSetting('base_url', 'localhost');
// Vagrant port-forwarding mode.
if (APP_APPLICATION_ENV == 'development')
return 'http://'.$base_url.':8080/radio/'.$radio_port;
else
return 'http://'.$base_url.':'.$radio_port;
}
/*
* Configuration
*/
protected function _getConfig()
{
$config_dir = $this->station->getRadioConfigDir();
$config = @parse_ini_file($config_dir.'/sc_serv.conf', false, INI_SCANNER_RAW);
return $config;
}
protected function _loadFromConfig($config)
{
return [
'port' => $config['portbase'],
'source_pw' => $config['password'],
'admin_pw' => $config['adminpassword'],
];
}
protected function _getDefaults()
{
$config_path = $this->station->getRadioConfigDir();
$defaults = [
'password' => Utilities::generatePassword(),
'adminpassword' => Utilities::generatePassword(),
'logfile' => $config_path.'/sc_serv.log',
'w3clog' => $config_path.'/sc_w3c.log',
'publicserver' => 'never',
'banfile' => $config_path.'/sc_serv.ban',
'ripfile' => $config_path.'/sc_serv.rip',
'maxuser' => 500,
'portbase' => $this->_getRadioPort(),
];
return $defaults;
}
}

View File

@ -205,19 +205,13 @@ class Station extends \App\Doctrine\Entity
'default' => 'icecast',
'adapters' => [
'icecast' => [
'name' => 'IceCast v2.4',
'name' => 'IceCast 2.4',
'class' => '\App\Radio\Frontend\IceCast',
],
/*
'shoutcast1' => array(
'name' => 'ShoutCast 1',
'class' => '\App\Radio\Frontend\ShoutCast1',
),
'shoutcast2' => array(
'name' => 'ShoutCast 2',
'class' => '\App\Radio\Frontend\ShoutCast2',
),
*/
'remote' => [
'name' => _('External Radio Server (Statistics Only)'),
'class' => '\App\Radio\Frontend\Remote',
@ -254,6 +248,8 @@ class Station extends \App\Doctrine\Entity
*/
public static function api(Station $row, ContainerInterface $di)
{
$fa = $row->getFrontendAdapter($di);
$api = [
'id' => (int)$row->id,
'name' => $row->name,
@ -261,16 +257,12 @@ class Station extends \App\Doctrine\Entity
'description' => $row->description,
'frontend' => $row->frontend_type,
'backend' => $row->backend_type,
'listen_url' => '',
'listen_url' => $fa->getStreamUrl(),
'mounts' => [],
];
if ($row->mounts->count() > 0)
{
$fa = $row->getFrontendAdapter($di);
$api['listen_url'] = $fa->getStreamUrl();
if ($fa->supportsMounts())
{
foreach($row->mounts as $mount_row)
@ -418,29 +410,11 @@ class StationRepository extends Repository
if ($frontend_adapter->supportsMounts())
{
// Create default mount points.
$mount_points = [
[
'name' => '/radio.mp3',
'is_default' => 1,
'fallback_mount' => '/autodj.mp3',
'enable_streamers' => 1,
'enable_autodj' => 0,
],
[
'name' => '/autodj.mp3',
'is_default' => 0,
'fallback_mount' => '/error.mp3',
'enable_streamers' => 0,
'enable_autodj' => 1,
'autodj_format' => 'mp3',
'autodj_bitrate' => 128,
]
];
$mount_points = $frontend_adapter->getDefaultMounts();
foreach($mount_points as $mount_point)
{
$mount_point['station'] = $station;
$mount_record = new StationMount;
$mount_record->fromArray($this->_em, $mount_point);

View File

@ -8,7 +8,7 @@ use \Doctrine\Common\Collections\ArrayCollection;
* @Table(name="station_media", indexes={
* @index(name="search_idx", columns={"title", "artist", "album"})
* }, uniqueConstraints={
* @UniqueConstraint(name="path_unique_idx", columns={"path"})
* @UniqueConstraint(name="path_unique_idx", columns={"path", "station_id"})
* })
* @Entity(repositoryClass="StationMediaRepository")
* @HasLifecycleCallbacks

View File

@ -0,0 +1,36 @@
<?php
namespace Migration;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20161122035237 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('DROP INDEX path_unique_idx ON station_media');
$this->addSql('CREATE UNIQUE INDEX path_unique_idx ON station_media (path, station_id)');
}
/**
* @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('DROP INDEX path_unique_idx ON station_media');
$this->addSql('CREATE UNIQUE INDEX path_unique_idx ON station_media (path)');
}
}

View File

@ -46,6 +46,15 @@ return [
'default' => $backend_default,
]],
],
],
'frontend_local' => [
'legend' => _('Configure Radio Broadcasting'),
'class' => 'frontend_fieldset',
'elements' => [
'enable_streamers' => ['radio', [
'label' => _('Allow Streamers / DJs'),
'description' => _('If this setting is turned on, streamers (or DJs) will be able to connect directly to your stream and broadcast live music that interrupts the AutoDJ stream.'),
@ -53,29 +62,21 @@ return [
'options' => [0 => 'No', 1 => 'Yes'],
]],
],
],
'frontend_icecast' => [
'legend' => _('Configure IceCast 2'),
'class' => 'frontend_fieldset',
'description' => _('These settings are intended for advanced users only. You can safely leave all of these options blank and sensible defaults will be used for them.'),
'elements' => [
'port' => ['text', [
'label' => _('Broadcasting Port'),
'description' => _('No other program can be using this port. An available port is automatically assigned to each new station.'),
'description' => _('No other program can be using this port. Leave blank to automatically assign a port.'),
'belongsTo' => 'frontend_config',
]],
'source_pw' => ['text', [
'label' => _('Source Password'),
'description' => _('Leave blank to automatically generate a new password.'),
'belongsTo' => 'frontend_config',
]],
'admin_pw' => ['text', [
'label' => _('Admin Password'),
'description' => _('Leave blank to automatically generate a new password.'),
'belongsTo' => 'frontend_config',
]],

View File

@ -1,25 +1,3 @@
<?php $this->layout('main', ['title' => _('Edit Station Profile')]); ?>
<?=$this->fetch('partials/form') ?>
<script type="text/javascript">
$(function() {
showFieldset('frontend', $('form #field_frontend_type input:checked').val());
showFieldset('backend', $('form #field_backend_type input:checked').val());
$('form #field_frontend_type input').on('change', function() {
showFieldset('frontend', $(this).val());
});
$('form #field_backend_type input').on('change', function() {
showFieldset('backend', $(this).val());
});
});
function showFieldset(fieldset_group, fieldset_id)
{
$('form fieldset.'+fieldset_group+'_fieldset').hide();
$('form fieldset#'+fieldset_group+'_'+fieldset_id).show();
}
</script>
<?=$this->fetch('partials/station_form') ?>

View File

@ -2,30 +2,8 @@
<p><?=_('To continue the setup process, enter the basic details of your first station below. Some technical details will automatically be retrieved from the system after you complete this step.') ?></p>
<?=$this->fetch('partials/form') ?>
<?=$this->fetch('partials/station_form') ?>
<div class="alert alert-info m-b-0 m-t-25">
<?=_('Note: If you are not starting with an empty station directory, it may take a while to process any existing MP3 files or playlists that are in this station\'s directory.') ?>
</div>
<script type="text/javascript">
$(function() {
showFieldset('frontend', $('form #field_frontend_type input:checked').val());
showFieldset('backend', $('form #field_backend_type input:checked').val());
$('form #field_frontend_type input').on('change', function() {
showFieldset('frontend', $(this).val());
});
$('form #field_backend_type input').on('change', function() {
showFieldset('backend', $(this).val());
});
});
function showFieldset(fieldset_group, fieldset_id)
{
$('form fieldset.'+fieldset_group+'_fieldset').hide();
$('form fieldset#'+fieldset_group+'_'+fieldset_id).show();
}
</script>
</div>

View File

@ -0,0 +1,33 @@
<?=$this->fetch('partials/form') ?>
<script type="text/javascript">
$(function() {
showFrontend($('form #field_frontend_type input:checked').val());
showBackend($('form #field_backend_type input:checked').val());
$('form #field_frontend_type input').on('change', function() {
showFrontend($(this).val());
});
$('form #field_backend_type input').on('change', function() {
showBackend($(this).val());
});
});
function showFrontend(selected)
{
$('form fieldset.frontend_fieldset').hide();
if (selected == 'remote')
$('form fieldset#frontend_remote').show();
else
$('form fieldset#frontend_local').show();
}
function showBackend(selected)
{
$('form fieldset.backend_fieldset').hide();
$('form fieldset#backend_'+selected).show();
}
</script>

View File

@ -24,4 +24,5 @@
- "{{ tmp_base }}/cache"
- "{{ tmp_base }}/sessions"
- "{{ tmp_base }}/proxies"
- "{{ app_base }}/stations"
- "{{ app_base }}/stations"
- "{{ app_base }}/servers"

View File

@ -9,6 +9,9 @@
apt: pkg=icecast2 state=latest
when: ansible_distribution == 'Ubuntu' and ansible_distribution_release == 'xenial'
- name: Link Fallback Error MP3
file: src="{{ www_base }}/resources/error.mp3" dest="/usr/share/icecast2/web/error.mp3" state=link
- name: Remove problematic LiquidSoap plugins
become: true
apt: pkg="{{ item }}" state=absent
@ -27,5 +30,15 @@
- liquidsoap-plugin-flac
- liquidsoap-plugin-icecast
- name: Link Fallback Error MP3
file: src="{{ www_base }}/resources/error.mp3" dest="/usr/share/icecast2/web/error.mp3" state=link
- name: Download ShoutCast 2 (x86)
command: "wget http://download.nullsoft.com/shoutcast/tools/sc_serv2_linux-latest.tar.gz -O {{ app_base }}/servers/sc_serv.tar.gz"
when: ansible_architecture == 'i386'
- name: Download ShoutCast 2 (x64)
command: "wget http://download.nullsoft.com/shoutcast/tools/sc_serv2_linux_x64-latest.tar.gz -O {{ app_base }}/servers/sc_serv.tar.gz"
when: ansible_architecture == 'x86_64'
- name: Extract ShoutCast 2
command: "tar xvzf {{ app_base }}/servers/sc_serv.tar.gz"
when: ansible_architecture == 'x86_64' or ansible_architecture == 'i386'

View File

@ -1,6 +1,6 @@
---
- debug:
msg: "Running Ansible on {{ inventory_hostname }} with OS {{ ansible_distribution }} {{ ansible_distribution_release }} {{ ansible_distribution_version }} ({{ app_env }})"
msg: "Running Ansible on {{ inventory_hostname }} with OS {{ ansible_distribution }} {{ ansible_distribution_release }} {{ ansible_distribution_version }} {{ ansible_architecture }} ({{ app_env }})"
- debug:
msg: "Running in Testing Mode."
@ -17,6 +17,7 @@
- apt-transport-https
- curl
- wget
- tar
- build-essential
- python-software-properties
- pwgen