4
0
mirror of https://github.com/AzuraCast/AzuraCast.git synced 2024-06-14 05:06:37 +00:00
AzuraCast/src/Radio/Backend/Liquidsoap/ConfigWriter.php
Buster "Silver Eagle" Neece 0069df6d2d
Disable certain dangerous "advanced" features by default in new installs.
Some functionality of AzuraCast has always been intended for "Power Users", but seemingly no amount of warnings or labels will prevent users from discovering these features, misusing them, and either burdening our support channels or declaring AzuraCast to be "broken". With this update, new installations have some of these most dangerous settings (manual port assignments, manual directory selection, custom LS/Icecast config, etc.) disabled. They can easily be re-enabled by editing "azuracast.env" and turning them on, and will remain available for all previous users by default.
2020-05-27 02:36:30 -05:00

1061 lines
40 KiB
PHP

<?php
namespace App\Radio\Backend\Liquidsoap;
use App\Entity;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception;
use App\Logger;
use App\Message;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use App\Settings;
use Doctrine\ORM\EntityManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigWriter implements EventSubscriberInterface
{
public const CUSTOM_TOP = 'custom_config_top';
public const CUSTOM_PRE_PLAYLISTS = 'custom_config_pre_playlists';
public const CUSTOM_PRE_BROADCAST = 'custom_config';
public const CUSTOM_PRE_LIVE = 'custom_config_pre_live';
public const CUSTOM_PRE_FADE = 'custom_config_pre_fade';
public const CROSSFADE_NORMAL = 'normal';
public const CROSSFADE_DISABLED = 'none';
public const CROSSFADE_SMART = 'smart';
protected EntityManager $em;
protected Liquidsoap $liquidsoap;
public function __construct(EntityManager $em, Liquidsoap $liquidsoap)
{
$this->em = $em;
$this->liquidsoap = $liquidsoap;
}
/**
* Handle event dispatch.
*
* @param Message\AbstractMessage $message
*/
public function __invoke(Message\AbstractMessage $message)
{
try {
if ($message instanceof Message\WritePlaylistFileMessage) {
$playlist = $this->em->find(Entity\StationPlaylist::class, $message->playlist_id);
if ($playlist instanceof Entity\StationPlaylist) {
$this->writePlaylistFile($playlist, true);
}
}
} finally {
$this->em->clear();
}
}
public static function getSubscribedEvents()
{
return [
WriteLiquidsoapConfiguration::class => [
['writeHeaderFunctions', 35],
['writePlaylistConfiguration', 30],
['writeCrossfadeConfiguration', 25],
['writeHarborConfiguration', 20],
['writePreBroadcastConfiguration', 10],
['writeLocalBroadcastConfiguration', 5],
['writeRemoteBroadcastConfiguration', 0],
],
];
}
public function writeCustomConfigurationSection(WriteLiquidsoapConfiguration $event, string $sectionName): void
{
if ($event->isForEditing()) {
$event->appendLines([
'•' . $sectionName . '•',
]);
return;
}
$appSettings = Settings::getInstance();
if (!$appSettings->enableAdvancedFeatures()) {
return;
}
$station = $event->getStation();
$settings = $station->getBackendConfig();
if (!empty($settings[$sectionName])) {
$event->appendLines([
'# Custom Configuration (Specified in Station Profile)',
$settings[$sectionName],
]);
}
}
public function writeHeaderFunctions(WriteLiquidsoapConfiguration $event): void
{
if ($event->isForEditing()) {
$event->prependLines([
'# ' . __('Welcome to the AzuraCast Liquidsoap configuration editor.'),
'# ' . __('Using this page, you can customize several sections of the Liquidsoap configuration.'),
'# ' . __('The non-editable sections are automatically generated by AzuraCast.'),
]);
} else {
$event->prependLines([
'# WARNING! This file is automatically generated by AzuraCast.',
'# Do not update it directly!',
]);
}
$this->writeCustomConfigurationSection($event, self::CUSTOM_TOP);
$station = $event->getStation();
$config_path = $station->getRadioConfigDir();
$event->appendLines([
'set("init.daemon", false)',
'set("init.daemon.pidfile.path","' . $config_path . '/liquidsoap.pid")',
'set("log.stdout", true)',
'set("log.file", false)',
'set("server.telnet",true)',
'set("server.telnet.bind_addr","' . (Settings::getInstance()->isDocker() ? '0.0.0.0' : '127.0.0.1') . '")',
'set("server.telnet.port", ' . $this->liquidsoap->getTelnetPort($station) . ')',
'set("harbor.bind_addrs",["0.0.0.0"])',
'',
'set("tag.encodings",["UTF-8","ISO-8859-1"])',
'set("encoder.encoder.export",["artist","title","album","song"])',
'',
'setenv("TZ", "' . self::cleanUpString($station->getTimezone()) . '")',
'',
]);
}
public function writePlaylistConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_PLAYLISTS);
// Clear out existing playlists directory.
$playlistPath = $station->getRadioPlaylistsDir();
$currentPlaylists = array_diff(scandir($playlistPath, SCANDIR_SORT_NONE), ['..', '.']);
foreach ($currentPlaylists as $list) {
@unlink($playlistPath . '/' . $list);
}
// Set up playlists using older format as a fallback.
$hasDefaultPlaylist = false;
$playlistObjects = [];
foreach ($station->getPlaylists() as $playlistRaw) {
/** @var Entity\StationPlaylist $playlistRaw */
if (!$playlistRaw->getIsEnabled()) {
continue;
}
if ($playlistRaw->getType() === Entity\StationPlaylist::TYPE_DEFAULT) {
$hasDefaultPlaylist = true;
}
$playlistObjects[] = $playlistRaw;
}
// Create a new default playlist if one doesn't exist.
if (!$hasDefaultPlaylist) {
Logger::getInstance()->info('No default playlist existed for this station; new one was automatically created.',
['station_id' => $station->getId(), 'station_name' => $station->getName()]);
// Auto-create an empty default playlist.
$defaultPlaylist = new Entity\StationPlaylist($station);
$defaultPlaylist->setName('default');
/** @var EntityManager $em */
$this->em->persist($defaultPlaylist);
$this->em->flush();
$playlistObjects[] = $defaultPlaylist;
}
$playlistVarNames = [];
$genPlaylistWeights = [];
$genPlaylistVars = [];
$specialPlaylists = [
'once_per_x_songs' => [
'# Once per x Songs Playlists',
],
'once_per_x_minutes' => [
'# Once per x Minutes Playlists',
],
];
$scheduleSwitches = [];
$scheduleSwitchesInterrupting = [];
foreach ($playlistObjects as $playlist) {
/** @var Entity\StationPlaylist $playlist */
$playlistVarName = 'playlist_' . str_replace('-', '_', $playlist->getShortName());
if (in_array($playlistVarName, $playlistVarNames, true)) {
$playlistVarName .= '_' . $playlist->getId();
}
$playlistVarNames[] = $playlistVarName;
$usesRandom = true;
$usesReloadMode = true;
$usesConservative = false;
if ($playlist->backendLoopPlaylistOnce()) {
$playlistFuncName = 'playlist.once';
} elseif ($playlist->backendMerge()) {
$playlistFuncName = 'playlist.merge';
$usesReloadMode = false;
} else {
$playlistFuncName = 'playlist';
$usesRandom = false;
$usesConservative = true;
}
$playlistConfigLines = [];
if (Entity\StationPlaylist::SOURCE_SONGS === $playlist->getSource()) {
$playlistFilePath = $this->writePlaylistFile($playlist, false);
if (!$playlistFilePath) {
continue;
}
// Liquidsoap's playlist functions support very different argument patterns. :/
$playlistParams = [
'id="' . self::cleanUpString($playlistVarName) . '"',
];
if ($usesRandom) {
if (Entity\StationPlaylist::ORDER_SEQUENTIAL !== $playlist->getOrder()) {
$playlistParams[] = 'random=true';
}
} else {
$playlistModes = [
Entity\StationPlaylist::ORDER_SEQUENTIAL => 'normal',
Entity\StationPlaylist::ORDER_SHUFFLE => 'randomize',
Entity\StationPlaylist::ORDER_RANDOM => 'random',
];
$playlistParams[] = 'mode="' . $playlistModes[$playlist->getOrder()] . '"';
}
if ($usesReloadMode) {
$playlistParams[] = 'reload_mode="watch"';
}
if ($usesConservative) {
$playlistParams[] = 'conservative=true';
$playlistParams[] = 'default_duration=10.';
$playlistParams[] = 'length=20.';
}
$playlistParams[] = '"' . $playlistFilePath . '"';
$playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFuncName . '(' . implode(',',
$playlistParams) . ')';
} else {
switch ($playlist->getRemoteType()) {
case Entity\StationPlaylist::REMOTE_TYPE_PLAYLIST:
$playlistFunc = $playlistFuncName . '("' . self::cleanUpString($playlist->getRemoteUrl()) . '")';
$playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFunc;
break;
case Entity\StationPlaylist::REMOTE_TYPE_STREAM:
default:
$remote_url = $playlist->getRemoteUrl();
$remote_url_scheme = parse_url($remote_url, PHP_URL_SCHEME);
$remote_url_function = ('https' === $remote_url_scheme) ? 'input.https' : 'input.http';
$buffer = $playlist->getRemoteBuffer();
$buffer = ($buffer < 1) ? Entity\StationPlaylist::DEFAULT_REMOTE_BUFFER : $buffer;
$playlistConfigLines[] = $playlistVarName . ' = mksafe(' . $remote_url_function . '(max=' . $buffer . '., "' . self::cleanUpString($remote_url) . '"))';
break;
}
}
$playlistConfigLines[] = $playlistVarName . ' = audio_to_stereo(id="stereo_' . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
$playlistConfigLines[] = $playlistVarName . ' = cue_cut(id="cue_' . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
if ($playlist->isJingle()) {
$playlistConfigLines[] = $playlistVarName . ' = drop_metadata(' . $playlistVarName . ')';
}
if (Entity\StationPlaylist::TYPE_ADVANCED === $playlist->getType()) {
$playlistConfigLines[] = 'ignore(' . $playlistVarName . ')';
}
$event->appendLines($playlistConfigLines);
if ($playlist->backendPlaySingleTrack()) {
$playlistVarName = 'once(' . $playlistVarName . ')';
}
$scheduleItems = $playlist->getScheduleItems();
switch ($playlist->getType()) {
case Entity\StationPlaylist::TYPE_DEFAULT:
if ($scheduleItems->count() > 0) {
foreach ($scheduleItems as $scheduleItem) {
$play_time = $this->getScheduledPlaylistPlayTime($scheduleItem);
$schedule_timing = '({ ' . $play_time . ' }, ' . $playlistVarName . ')';
if ($playlist->backendInterruptOtherSongs()) {
$scheduleSwitchesInterrupting[] = $schedule_timing;
} else {
$scheduleSwitches[] = $schedule_timing;
}
}
} else {
$genPlaylistWeights[] = $playlist->getWeight();
$genPlaylistVars[] = $playlistVarName;
}
break;
case Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS:
case Entity\StationPlaylist::TYPE_ONCE_PER_X_MINUTES:
if (Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS === $playlist->getType()) {
$playlistScheduleVar = 'rotate(weights=[1,' . $playlist->getPlayPerSongs() . '], [' . $playlistVarName . ', radio])';
} else {
$delaySeconds = $playlist->getPlayPerMinutes() * 60;
$delayTrackSensitive = $playlist->backendInterruptOtherSongs() ? 'false' : 'true';
$playlistScheduleVar = 'fallback(track_sensitive=' . $delayTrackSensitive . ', [delay(' . $delaySeconds . '., ' . $playlistVarName . '), radio])';
}
if ($scheduleItems->count() > 0) {
foreach ($scheduleItems as $scheduleItem) {
$play_time = $this->getScheduledPlaylistPlayTime($scheduleItem);
$schedule_timing = '({ ' . $play_time . ' }, ' . $playlistScheduleVar . ')';
if ($playlist->backendInterruptOtherSongs()) {
$scheduleSwitchesInterrupting[] = $schedule_timing;
} else {
$scheduleSwitches[] = $schedule_timing;
}
}
} else {
$specialPlaylists[$playlist->getType()][] = 'radio = ' . $playlistScheduleVar;
}
break;
case Entity\StationPlaylist::TYPE_ONCE_PER_HOUR:
$minutePlayTime = $playlist->getPlayPerHourMinute() . 'm';
if ($scheduleItems->count() > 0) {
foreach ($scheduleItems as $scheduleItem) {
$playTime = '(' . $minutePlayTime . ') and (' . $this->getScheduledPlaylistPlayTime($scheduleItem) . ')';
$schedule_timing = '({ ' . $playTime . ' }, ' . $playlistVarName . ')';
if ($playlist->backendInterruptOtherSongs()) {
$scheduleSwitchesInterrupting[] = $schedule_timing;
} else {
$scheduleSwitches[] = $schedule_timing;
}
}
} else {
$schedule_timing = '({ ' . $minutePlayTime . ' }, ' . $playlistVarName . ')';
if ($playlist->backendInterruptOtherSongs()) {
$scheduleSwitchesInterrupting[] = $schedule_timing;
} else {
$scheduleSwitches[] = $schedule_timing;
}
}
break;
}
}
// Build "default" type playlists.
$event->appendLines([
'# Standard Playlists',
'radio = random(id="' . self::getVarName($station, 'standard_playlists') . '", weights=[' . implode(', ',
$genPlaylistWeights) . '], [' . implode(', ', $genPlaylistVars) . '])',
]);
if (!empty($scheduleSwitches)) {
$scheduleSwitches[] = '({true}, radio)';
$event->appendLines([
'# Standard Schedule Switches',
'radio = switch(id="' . self::getVarName($station,
'schedule_switch') . '", track_sensitive=true, [ ' . implode(', ', $scheduleSwitches) . ' ])',
]);
}
// Add in special playlists if necessary.
foreach ($specialPlaylists as $playlist_type => $playlistConfigLines) {
if (count($playlistConfigLines) > 1) {
$event->appendLines($playlistConfigLines);
}
}
if (!$station->useManualAutoDJ()) {
$nextsongCommand = $this->getApiUrlCommand($station, 'nextsong');
$event->appendBlock(<<< EOF
# AutoDJ Next Song Script
def azuracast_next_song() =
uri = {$nextsongCommand}
log("AzuraCast Raw Response: #{uri}")
if uri == "" or string.match(pattern="Error", uri) then
[]
else
req = request.create(uri)
[req]
end
end
EOF
);
$event->appendLines([
'dynamic = request.dynamic.list(id="' . self::getVarName($station,
'next_song') . '", timeout=20., retry_delay=3., azuracast_next_song)',
'dynamic = audio_to_stereo(id="' . self::getVarName($station, 'stereo_next_song') . '", dynamic)',
'dynamic = cue_cut(id="' . self::getVarName($station, 'cue_next_song') . '", dynamic)',
'radio = fallback(id="' . self::getVarName($station,
'autodj_fallback') . '", track_sensitive = true, [dynamic, radio])',
]);
}
if (!empty($scheduleSwitchesInterrupting)) {
$scheduleSwitchesInterrupting[] = '({true}, radio)';
$event->appendLines([
'# Interrupting Schedule Switches',
'radio = switch(id="' . self::getVarName($station,
'interrupt_switch') . '", track_sensitive=false, [ ' . implode(', ',
$scheduleSwitchesInterrupting) . ' ])',
]);
}
$event->appendLines([
'requests = request.queue(id="' . self::getVarName($station, 'requests') . '")',
'requests = audio_to_stereo(id="' . self::getVarName($station, 'stereo_requests') . '", requests)',
'requests = cue_cut(id="' . self::getVarName($station, 'cue_requests') . '", requests)',
'radio = fallback(id="' . self::getVarName($station,
'requests_fallback') . '", track_sensitive = true, [requests, radio])',
'',
'add_skip_command(radio)',
'',
]);
}
/**
* Write a playlist's contents to file so Liquidsoap can process it, and optionally notify
* Liquidsoap of the change.
*
* @param Entity\StationPlaylist $playlist
* @param bool $notify
*
* @return string The full path that was written to.
*/
public function writePlaylistFile(Entity\StationPlaylist $playlist, $notify = true): ?string
{
$station = $playlist->getStation();
$playlistPath = $station->getRadioPlaylistsDir();
$playlistVarName = 'playlist_' . $playlist->getShortName();
$logger = Logger::getInstance();
$logger->info('Writing playlist file to disk...', [
'station' => $station->getName(),
'playlist' => $playlist->getName(),
]);
$mediaBaseDir = $station->getRadioMediaDir() . '/';
$playlistFile = [];
$mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT DISTINCT sm
FROM App\Entity\StationMedia sm
JOIN sm.playlists spm
WHERE spm.playlist = :playlist
ORDER BY spm.weight ASC
')->setParameter('playlist', $playlist);
$mediaIterator = $mediaQuery->iterate();
foreach ($mediaIterator as $row) {
/** @var Entity\StationMedia $mediaFile */
$mediaFile = $row[0];
$mediaFilePath = $mediaBaseDir . $mediaFile->getPath();
$mediaAnnotations = $this->liquidsoap->annotateMedia($mediaFile);
if ($playlist->isJingle()) {
$mediaAnnotations['is_jingle_mode'] = 'true';
unset($mediaAnnotations['media_id']);
} else {
$mediaAnnotations['playlist_id'] = $playlist->getId();
}
$annotations_str = [];
foreach ($mediaAnnotations as $annotation_key => $annotation_val) {
if ('liq_amplify' === $annotation_key) {
$annotations_str[] = $annotation_key . '="' . $annotation_val . 'dB"';
continue;
}
$annotations_str[] = $annotation_key . '="' . $annotation_val . '"';
}
$playlistFile[] = 'annotate:' . implode(',', $annotations_str) . ':' . $mediaFilePath;
$this->em->detach($mediaFile);
unset($mediaFile);
}
$playlistFilePath = $playlistPath . '/' . $playlistVarName . '.m3u';
file_put_contents($playlistFilePath, implode("\n", $playlistFile));
if ($notify) {
try {
$this->liquidsoap->command($station, $playlistVarName . '.reload');
} catch (Exception $e) {
Logger::getInstance()->error('Could not reload playlist with AutoDJ.', [
'message' => $e->getMessage(),
'playlist' => $playlistVarName,
'station' => $station->getId(),
]);
}
}
return $playlistFilePath;
}
/**
* Given a scheduled playlist, return the time criteria that Liquidsoap can use to determine when to play it.
*
* @param Entity\StationSchedule $playlistSchedule
*
* @return string
*/
protected function getScheduledPlaylistPlayTime(Entity\StationSchedule $playlistSchedule): string
{
$start_time = $playlistSchedule->getStartTime();
$end_time = $playlistSchedule->getEndTime();
// Handle multi-day playlists.
if ($start_time > $end_time) {
$play_times = [
self::formatTimeCode($start_time) . '-23h59m59s',
'00h00m-' . self::formatTimeCode($end_time),
];
$playlist_schedule_days = $playlistSchedule->getDays();
if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) {
$current_play_days = [];
$next_play_days = [];
foreach ($playlist_schedule_days as $day) {
$day = (int)$day;
$current_play_days[] = (($day === 7) ? '0' : $day) . 'w';
$day++;
if ($day > 7) {
$day = 1;
}
$next_play_days[] = (($day === 7) ? '0' : $day) . 'w';
}
$play_times[0] = '(' . implode(' or ', $current_play_days) . ') and ' . $play_times[0];
$play_times[1] = '(' . implode(' or ', $next_play_days) . ') and ' . $play_times[1];
}
return '(' . implode(') or (', $play_times) . ')';
}
// Handle once-per-day playlists.
$play_time = ($start_time === $end_time)
? self::formatTimeCode($start_time)
: self::formatTimeCode($start_time) . '-' . self::formatTimeCode($end_time);
$playlist_schedule_days = $playlistSchedule->getDays();
if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) {
$play_days = [];
foreach ($playlist_schedule_days as $day) {
$day = (int)$day;
$play_days[] = (($day === 7) ? '0' : $day) . 'w';
}
$play_time = '(' . implode(' or ', $play_days) . ') and ' . $play_time;
}
return $play_time;
}
/**
* Returns the URL that LiquidSoap should call when attempting to execute AzuraCast API commands.
*
* @param Entity\Station $station
* @param string $endpoint
* @param array $params
*
* @return string
*/
protected function getApiUrlCommand(Entity\Station $station, $endpoint, $params = []): string
{
$settings = Settings::getInstance();
// Docker cURL-based API URL call with API authentication.
if ($settings->isDocker()) {
$params = (array)$params;
$params['api_auth'] = '"' . $station->getAdapterApiKey() . '"';
$service_uri = ($settings[Settings::DOCKER_REVISION] >= 5) ? 'web' : 'nginx';
$api_url = 'http://' . $service_uri . '/api/internal/' . $station->getId() . '/' . $endpoint;
$command = 'curl -s --request POST --url ' . $api_url;
foreach ($params as $param_key => $param_val) {
$command .= ' --form ' . $param_key . '="^string.quote(' . $param_val . ')^"';
}
} else {
// Ansible shell-script call.
$shell_path = '/usr/bin/php ' . $settings->getBaseDirectory() . '/bin/console';
$shell_args = [];
$shell_args[] = 'azuracast:internal:' . $endpoint;
$shell_args[] = $station->getId();
foreach ((array)$params as $param_key => $param_val) {
$shell_args [] = '--' . $param_key . '="^string.quote(' . $param_val . ')^"';
}
$command = $shell_path . ' ' . implode(' ', $shell_args);
}
return 'list.hd(get_process_lines("' . $command . '"), default="")';
}
public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
$settings = $station->getBackendConfig();
// Write pre-crossfade section.
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_FADE);
// Crossfading happens before the live broadcast is mixed in, because of buffer issues.
$crossfade_type = $settings['crossfade_type'] ?? self::CROSSFADE_NORMAL;
$crossfade = round($settings['crossfade'] ?? 2, 1);
if (self::CROSSFADE_DISABLED !== $crossfade_type && $crossfade > 0) {
$start_next = round($crossfade * 1.5, 2);
$crossfadeIsSmart = (self::CROSSFADE_SMART === $crossfade_type) ? 'true' : 'false';
$event->appendLines([
'radio = crossfade(smart=' . $crossfadeIsSmart . ', duration=' . self::toFloat($start_next) . ',fade_out=' . self::toFloat($crossfade) . ',fade_in=' . self::toFloat($crossfade) . ',radio)',
]);
}
// Write fallback to safety file immediately after crossfade.
$error_file = Settings::getInstance()->isDocker()
? '/usr/local/share/icecast/web/error.mp3'
: Settings::getInstance()->getBaseDirectory() . '/resources/error.mp3';
$event->appendLines([
'radio = fallback(id="' . self::getVarName($station,
'safe_fallback') . '", track_sensitive = false, [radio, single(id="error_jingle", "' . $error_file . '")])',
]);
}
public function writeHarborConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
if (!$station->getEnableStreamers()) {
return;
}
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_LIVE);
$settings = $station->getBackendConfig();
$charset = $settings->getCharset();
$dj_mount = $settings->getDjMountPoint();
$recordLiveStreams = $settings->recordStreams();
$authCommand = $this->getApiUrlCommand($station, 'auth', ['dj-user' => '!user', 'dj-password' => '!password']);
$djonCommand = $this->getApiUrlCommand($station, 'djon', ['dj-user' => 'dj']);
$djoffCommand = $this->getApiUrlCommand($station, 'djoff', ['dj-user' => 'dj']);
$event->appendBlock(<<< EOF
# DJ Authentication
live_enabled = ref false
last_authenticated_dj = ref ""
live_dj = ref ""
def dj_auth(auth_user,auth_pw) =
user = ref ""
password = ref ""
if (auth_user == "source" or auth_user == "") and (string.match(pattern="(:|,)+", auth_pw)) then
auth_string = string.split(separator="(:|,)", auth_pw)
user := list.nth(default="", auth_string, 0)
password := list.nth(default="", auth_string, 2)
else
user := auth_user
password := auth_pw
end
log("Authenticating DJ: #{!user}")
ret = {$authCommand}
log("AzuraCast DJ Auth Response: #{ret}")
authed = bool_of_string(ret)
if (authed) then
last_authenticated_dj := !user
end
authed
end
def live_connected(header) =
dj = !last_authenticated_dj
log("DJ Source connected! Last authenticated DJ: #{dj} - #{header}")
live_enabled := true
live_dj := dj
ret = {$djonCommand}
log("AzuraCast Live Connected Response: #{ret}")
end
def live_disconnected() =
dj = !live_dj
log("DJ Source disconnected! Current live DJ: #{dj}")
ret = {$djoffCommand}
log("AzuraCast Live Disconnected Response: #{ret}")
live_enabled := false
last_authenticated_dj := ""
live_dj := ""
end
EOF
);
$harbor_params = [
'"' . self::cleanUpString($dj_mount) . '"',
'id = "' . self::getVarName($station, 'input_streamer') . '"',
'port = ' . $this->liquidsoap->getStreamPort($station),
'auth = dj_auth',
'icy = true',
'icy_metadata_charset = "' . $charset . '"',
'metadata_charset = "' . $charset . '"',
'on_connect = live_connected',
'on_disconnect = live_disconnected',
];
$djBuffer = (int)($settings['dj_buffer'] ?? 5);
if (0 !== $djBuffer) {
$harbor_params[] = 'buffer = ' . self::toFloat($djBuffer);
$harbor_params[] = 'max = ' . self::toFloat(max($djBuffer + 5, 10));
}
$event->appendLines([
'# A Pre-DJ source of radio that can be broadcast if needed',
'radio_without_live = radio',
'ignore(radio_without_live)',
'',
'# Live Broadcasting',
'live = audio_to_stereo(input.harbor(' . implode(', ', $harbor_params) . '))',
'ignore(output.dummy(live, fallible=true))',
'',
'radio = fallback(id="' . self::getVarName($station,
'live_fallback') . '", replay_metadata=false, track_sensitive=false, [live, radio])',
]);
if ($recordLiveStreams) {
$recordLiveStreamsFormat = $settings['record_streams_format'] ?? Entity\StationMountInterface::FORMAT_MP3;
$recordLiveStreamsBitrate = (int)($settings['record_streams_bitrate'] ?? 128);
$formatString = $this->getOutputFormatString($recordLiveStreamsFormat, $recordLiveStreamsBitrate);
$event->appendBlock(<<< EOF
# Record Live Broadcasts
stop_recording_f = ref (fun () -> ())
def start_recording(path) =
output_live_recording = output.file({$formatString}, fallible=true, reopen_on_metadata=false, "#{path}", live)
stop_recording_f := fun () -> source.shutdown(output_live_recording)
end
def stop_recording() =
f = !stop_recording_f
f ()
stop_recording_f := fun () -> ()
end
server.register(namespace="recording", description="Start recording.", usage="recording.start filename", "start", fun (s) -> begin start_recording(s) "Done!" end)
server.register(namespace="recording", description="Stop recording.", usage="recording.stop", "stop", fun (s) -> begin stop_recording() "Done!" end)
EOF
);
}
}
public function writePreBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
$settings = $station->getBackendConfig();
$event->appendLines([
'# Allow for Telnet-driven insertion of custom metadata.',
'radio = server.insert_metadata(id="custom_metadata", radio)',
'',
'# Apply amplification metadata (if supplied)',
'radio = amplify(override="liq_amplify", 1., radio)',
]);
// NRJ normalization
if ($settings->useNormalizer()) {
$event->appendLines([
'# Normalization and Compression',
'radio = normalize(target = 0., window = 0.03, gain_min = -16., gain_max = 0., radio)',
'radio = compress.exponential(radio, mu = 1.0)',
]);
}
// Replaygain metadata
if ($settings->useReplayGain()) {
$event->appendLines([
'# Replaygain Metadata',
'enable_replaygain_metadata()',
]);
}
// Custom configuration
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_BROADCAST);
$feedbackCommand = $this->getApiUrlCommand($station, 'feedback',
['song' => 'm["song_id"]', 'media' => 'm["media_id"]', 'playlist' => 'm["playlist_id"]']);
$event->appendBlock(<<<EOF
# Send metadata changes back to AzuraCast
def metadata_updated(m) =
if (m["song_id"] != "") then
ret = {$feedbackCommand}
log("AzuraCast Feedback Response: #{ret}")
end
end
radio = on_metadata(metadata_updated,radio)
EOF
);
}
public function writeLocalBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
if (Adapters::FRONTEND_REMOTE === $station->getFrontendType()) {
return;
}
$ls_config = [
'# Local Broadcasts',
];
// Configure the outbound broadcast.
$i = 0;
foreach ($station->getMounts() as $mount_row) {
$i++;
/** @var Entity\StationMount $mount_row */
if (!$mount_row->getEnableAutodj()) {
continue;
}
$ls_config[] = $this->getOutputString($station, $mount_row, 'local_', $i);
}
$event->appendLines($ls_config);
}
/**
* Given outbound broadcast information, produce a suitable LiquidSoap configuration line for the stream.
*
* @param Entity\Station $station
* @param Entity\StationMountInterface $mount
* @param string $idPrefix
* @param int $id
*
* @return string
*/
protected function getOutputString(
Entity\Station $station,
Entity\StationMountInterface $mount,
string $idPrefix,
int $id
): string {
$settings = $station->getBackendConfig();
$charset = $settings->getCharset();
$output_format = $this->getOutputFormatString(
$mount->getAutodjFormat(),
$mount->getAutodjBitrate() ?? 128
);
$output_params = [];
$output_params[] = $output_format;
$output_params[] = 'id="' . self::getVarName($station, $idPrefix . $id) . '"';
$output_params[] = 'host = "' . self::cleanUpString($mount->getAutodjHost()) . '"';
$output_params[] = 'port = ' . (int)$mount->getAutodjPort();
$username = $mount->getAutodjUsername();
if (!empty($username)) {
$output_params[] = 'user = "' . self::cleanUpString($username) . '"';
}
$password = self::cleanUpString($mount->getAutodjPassword());
if ($mount->getAutodjShoutcastMode()) {
$password .= ':#' . $id;
}
$output_params[] = 'password = "' . $password . '"';
if (!empty($mount->getAutodjMount())) {
if ($mount->getAutodjShoutcastMode()) {
$output_params[] = 'icy_id = ' . $id;
} else {
$output_params[] = 'mount = "' . self::cleanUpString($mount->getAutodjMount()) . '"';
}
}
$output_params[] = 'name = "' . self::cleanUpString($station->getName()) . '"';
$output_params[] = 'description = "' . self::cleanUpString($station->getDescription()) . '"';
$output_params[] = 'genre = "' . self::cleanUpString($station->getGenre()) . '"';
if (!empty($station->getUrl())) {
$output_params[] = 'url = "' . self::cleanUpString($station->getUrl()) . '"';
}
$output_params[] = 'public = ' . ($mount->getIsPublic() ? 'true' : 'false');
$output_params[] = 'encoding = "' . $charset . '"';
if ($mount->getAutodjShoutcastMode()) {
$output_params[] = 'protocol="icy"';
}
$output_params[] = 'radio';
return 'output.icecast(' . implode(', ', $output_params) . ')';
}
protected function getOutputFormatString(string $format, int $bitrate = 128): string
{
switch (strtolower($format)) {
case Entity\StationMountInterface::FORMAT_AAC:
$afterburner = ($bitrate >= 160) ? 'true' : 'false';
$aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2';
return '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)';
break;
case Entity\StationMountInterface::FORMAT_OGG:
return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
break;
case Entity\StationMountInterface::FORMAT_OPUS:
return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="none", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
break;
case Entity\StationMountInterface::FORMAT_MP3:
default:
return '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
break;
}
}
public function writeRemoteBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
$ls_config = [
'# Remote Relays',
];
// Set up broadcast to remote relays.
$i = 0;
foreach ($station->getRemotes() as $remote_row) {
$i++;
/** @var Entity\StationRemote $remote_row */
if (!$remote_row->getEnableAutodj()) {
continue;
}
$ls_config[] = $this->getOutputString($station, $remote_row, 'relay_', $i);
}
$event->appendLines($ls_config);
}
/**
* Filter a user-supplied string to be a valid LiquidSoap config entry.
*
* @param string $string
*
* @return mixed
*/
public static function cleanUpString($string)
{
return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string);
}
/**
* Convert an integer or float into a Liquidsoap configuration compatible float.
*
* @param float|int $number
* @param int $decimals
*
* @return string
*/
public static function toFloat($number, $decimals = 2): string
{
if ((int)$number == $number) {
return (int)$number . '.';
}
return number_format($number, $decimals, '.', '');
}
public static function formatTimeCode($time_code): string
{
$hours = floor($time_code / 100);
$mins = $time_code % 100;
return $hours . 'h' . $mins . 'm';
}
/**
* Given an original name and a station, return a filtered prefixed variable identifying the station.
*
* @param Entity\Station $station
* @param string $original_name
*
* @return string
*/
public static function getVarName(Entity\Station $station, $original_name): string
{
$short_name = self::cleanUpString($station->getShortName());
return (!empty($short_name))
? $short_name . '_' . $original_name
: 'station_' . $station->getId() . '_' . $original_name;
}
}