AzuraCast/src/Radio/Backend/Liquidsoap/ConfigWriter.php

1181 lines
42 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Radio\Backend\Liquidsoap;
use App\Entity;
use App\Environment;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception;
use App\Flysystem\StationFilesystems;
use App\Message;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\StorageAttributes;
use Psr\Log\LoggerInterface;
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_LIVE = 'custom_config_pre_live';
public const CUSTOM_PRE_FADE = 'custom_config_pre_fade';
public const CUSTOM_PRE_BROADCAST = 'custom_config';
public const CUSTOM_BOTTOM = 'custom_config_bottom';
public const CROSSFADE_NORMAL = 'normal';
public const CROSSFADE_DISABLED = 'none';
public const CROSSFADE_SMART = 'smart';
public function __construct(
protected EntityManagerInterface $em,
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Liquidsoap $liquidsoap,
protected Environment $environment,
protected LoggerInterface $logger
) {
}
/**
* Handle event dispatch.
*
* @param Message\AbstractMessage $message
*/
public function __invoke(Message\AbstractMessage $message): void
{
if ($message instanceof Message\WritePlaylistFileMessage) {
$playlist = $this->em->find(Entity\StationPlaylist::class, $message->playlist_id);
if ($playlist instanceof Entity\StationPlaylist) {
$this->writePlaylistFile($playlist);
}
}
}
/**
* @return mixed[]
*/
public static function getSubscribedEvents(): array
{
return [
WriteLiquidsoapConfiguration::class => [
['writeHeaderFunctions', 35],
['writePlaylistConfiguration', 30],
['writeCrossfadeConfiguration', 25],
['writeHarborConfiguration', 20],
['writePreBroadcastConfiguration', 10],
['writeLocalBroadcastConfiguration', 5],
['writeRemoteBroadcastConfiguration', 0],
['writePostBroadcastConfiguration', -5],
],
];
}
public function writeCustomConfigurationSection(WriteLiquidsoapConfiguration $event, string $sectionName): void
{
if ($event->isForEditing()) {
$divider = self::getDividerString();
$event->appendLines(
[
$divider . $sectionName . $divider,
]
);
return;
}
$settings = $this->settingsRepo->readSettings();
if (!$settings->getEnableAdvancedFeatures()) {
return;
}
$settings = $event->getStation()->getBackendConfig();
if (!empty($settings[$sectionName])) {
$event->appendLines(
[
'# Custom Configuration (Specified in Station Profile)',
$settings[$sectionName],
]
);
}
}
public static function getDividerString(): string
{
return chr(7);
}
public function writeHeaderFunctions(WriteLiquidsoapConfiguration $event): void
{
if (!$event->isForEditing()) {
$event->prependLines(
[
'# WARNING! This file is automatically generated by AzuraCast.',
'# Do not update it directly!',
]
);
}
$this->writeCustomConfigurationSection($event, self::CUSTOM_TOP);
$station = $event->getStation();
$configDir = $station->getRadioConfigDir();
$pidfile = $configDir . DIRECTORY_SEPARATOR . 'liquidsoap.pid';
$telnetBindAddr = $this->environment->isDocker() ? '0.0.0.0' : '127.0.0.1';
$telnetPort = $this->liquidsoap->getTelnetPort($station);
$stationTz = self::cleanUpString($station->getTimezone());
$stationApiAuth = self::cleanUpString($station->getAdapterApiKey());
$event->appendBlock(
<<<EOF
set("init.daemon", false)
set("init.daemon.pidfile.path","${pidfile}")
set("log.stdout", true)
set("log.file", false)
set("server.telnet",true)
set("server.telnet.bind_addr","${telnetBindAddr}")
set("server.telnet.port", ${telnetPort})
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", "${stationTz}")
azuracast_api_auth = ref "${stationApiAuth}"
ignore(azuracast_api_auth)
autodj_is_loading = ref true
ignore(autodj_is_loading)
autodj_ping_attempts = ref 0
ignore(autodj_ping_attempts)
EOF
);
}
public function writePlaylistConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_PLAYLISTS);
// Clear out existing playlists directory.
$fsPlaylists = (new StationFilesystems($station))->getPlaylistsFilesystem();
foreach ($fsPlaylists->listContents('', false) as $file) {
/** @var StorageAttributes $file */
if ($file->isDir()) {
$fsPlaylists->deleteDirectory($file->path());
} else {
$fsPlaylists->delete($file->path());
}
}
// Set up playlists using older format as a fallback.
$playlistObjects = [];
foreach ($station->getPlaylists() as $playlistRaw) {
/** @var Entity\StationPlaylist $playlistRaw */
if (!$playlistRaw->getIsEnabled()) {
continue;
}
$playlistObjects[] = $playlistRaw;
}
$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 = self::cleanUpVarName('playlist_' . $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();
if (null !== $remote_url) {
$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->getIsJingle()) {
$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',
sprintf(
'radio = random(id="standard_playlists", weights=[%s], [%s])',
implode(', ', $genPlaylistWeights),
implode(', ', $genPlaylistVars)
),
]
);
if (!empty($scheduleSwitches)) {
$event->appendLines(['# Standard Schedule Switches']);
// Chunk scheduled switches to avoid hitting the max amount of playlists in a switch()
foreach (array_chunk($scheduleSwitches, 168, true) as $scheduleSwitchesChunk) {
$scheduleSwitchesChunk[] = '({true}, radio)';
$event->appendLines(
[
sprintf(
'radio = switch(id="schedule_switch", track_sensitive=true, [ %s ])',
implode(', ', $scheduleSwitchesChunk)
),
]
);
}
}
// Add in special playlists if necessary.
foreach ($specialPlaylists as $playlistConfigLines) {
if (count($playlistConfigLines) > 1) {
$event->appendLines($playlistConfigLines);
}
}
if (!$station->useManualAutoDJ()) {
$nextsongCommand = $this->getApiUrlCommand($station, 'nextsong');
$event->appendBlock(
<<< EOF
# AutoDJ Next Song Script
def autodj_next_song() =
uri = {$nextsongCommand}
log("AzuraCast Raw Response: #{uri}")
if uri == "" or string.match(pattern="Error", uri) then
[]
else
r = request.create(uri)
if request.resolve(r) then
[r]
else
[]
end
end
end
# Delayed ping for AutoDJ Next Song
def wait_for_next_song(autodj)
autodj_ping_attempts := !autodj_ping_attempts + 1
delay = ref 0.5
if source.is_ready(!autodj) then
log("AutoDJ is ready!")
autodj_is_loading := false
delay := -1.0
elsif !autodj_ping_attempts > 200 then
log("AutoDJ could not be initialized within the specified timeout.")
autodj_is_loading := false
delay := -1.0
end
!delay
end
dynamic = request.dynamic.list(id="next_song", timeout=20., retry_delay=2., autodj_next_song)
dynamic = audio_to_stereo(id="stereo_next_song", dynamic)
dynamic = cue_cut(id="cue_next_song", dynamic)
dynamic_startup = fallback(
id = "dynamic_startup",
track_sensitive = false,
[
dynamic,
at(
fun()-> !autodj_is_loading,
blank(id = "autodj_startup_blank", duration = 120.)
)
]
)
radio = fallback(id="autodj_fallback", track_sensitive = true, [dynamic_startup, radio])
ref_dynamic = ref dynamic;
add_timeout(0.25,fun()->wait_for_next_song(ref_dynamic))
EOF
);
}
if (!empty($scheduleSwitchesInterrupting)) {
$scheduleSwitchesInterrupting[] = '({true}, radio)';
$event->appendLines(
[
'# Interrupting Schedule Switches',
sprintf(
'radio = switch(id="interrupt_switch", track_sensitive=false, [ %s ])',
implode(', ', $scheduleSwitchesInterrupting)
),
]
);
}
$event->appendBlock(
<<< EOF
requests = request.queue(id="requests")
requests = audio_to_stereo(id="stereo_requests", requests)
requests = cue_cut(id="cue_requests", requests)
radio = fallback(id="requests_fallback", track_sensitive = true, [requests, radio])
add_skip_command(radio)
EOF
);
}
/**
* 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|null The full path that was written to.
*/
public function writePlaylistFile(Entity\StationPlaylist $playlist, bool $notify = true): ?string
{
$station = $playlist->getStation();
$mediaStorage = $station->getMediaStorageLocation();
if (!$mediaStorage->isLocal()) {
return null;
}
$playlistPath = $station->getRadioPlaylistsDir();
$playlistVarName = 'playlist_' . $playlist->getShortName();
$this->logger->info(
'Writing playlist file to disk...',
[
'station' => $station->getName(),
'playlist' => $playlist->getName(),
]
);
$mediaBaseDir = $mediaStorage->getPath() . '/';
$playlistFile = [];
$mediaQuery = $this->em->createQuery(
<<<'DQL'
SELECT DISTINCT sm
FROM App\Entity\StationMedia sm
JOIN sm.playlists spm
WHERE spm.playlist = :playlist
ORDER BY spm.weight ASC
DQL
)->setParameter('playlist', $playlist);
/** @var Entity\StationMedia $mediaFile */
foreach ($mediaQuery->toIterable() as $mediaFile) {
$mediaFilePath = $mediaBaseDir . $mediaFile->getPath();
$mediaAnnotations = $this->liquidsoap->annotateMedia($mediaFile);
if ($playlist->getIsJingle()) {
$mediaAnnotations['is_jingle_mode'] = 'true';
unset($mediaAnnotations['media_id']);
} else {
$mediaAnnotations['playlist_id'] = $playlist->getId();
}
$annotations_str = [];
foreach ($mediaAnnotations as $annotation_key => $annotation_val) {
$annotations_str[] = $annotation_key . '="' . $annotation_val . '"';
}
$playlistFile[] = 'annotate:' . implode(',', $annotations_str) . ':' . $mediaFilePath;
}
$playlistFilePath = $playlistPath . '/' . $playlistVarName . '.m3u';
file_put_contents($playlistFilePath, implode("\n", $playlistFile));
if ($notify) {
try {
$this->liquidsoap->command($station, $playlistVarName . '.reload');
} catch (Exception $e) {
$this->logger->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
*/
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) {
$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) {
$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
*/
protected function getApiUrlCommand(Entity\Station $station, string $endpoint, array $params = []): string
{
// Docker cURL-based API URL call with API authentication.
if ($this->environment->isDocker()) {
$params = (array)$params;
$params['api_auth'] = '!azuracast_api_auth';
$service_uri = ($this->environment->isDockerRevisionAtLeast(5)) ? 'web' : 'nginx';
/** @noinspection HttpUrlsUsage */
$api_url = 'http://' . $service_uri . '/api/internal/' . $station->getId() . '/' . $endpoint;
$command = 'curl -s --request POST --url ' . $api_url;
foreach ($params as $paramKey => $paramVal) {
$envVarKey = strtoupper(str_replace('-', '_', $paramKey));
$command .= ' --form ' . $paramKey . '="$' . $envVarKey . '"';
}
} else {
// Ansible shell-script call.
$shell_path = '/usr/bin/php ' . $this->environment->getBaseDirectory() . '/bin/console';
$shell_args = [];
$shell_args[] = 'azuracast:internal:' . $endpoint;
$shell_args[] = $station->getId();
foreach ((array)$params as $paramKey => $paramVal) {
$envVarKey = strtoupper(str_replace('-', '_', $paramKey));
$shell_args [] = '--' . $paramKey . '="$' . $envVarKey . '"';
}
$command = $shell_path . ' ' . implode(' ', $shell_args);
}
$envVarsParts = [];
foreach ($params as $envVarName => $envVarValue) {
$envVarKey = strtoupper(str_replace('-', '_', $envVarName));
$envVarsParts[] = '("' . $envVarKey . '", ' . $envVarValue . ')';
}
$envVarsStr = 'env=[' . implode(', ', $envVarsParts) . ']';
return 'list.hd(get_process_lines(' . $envVarsStr . ', \'' . $command . '\'), default="")';
}
public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void
{
$settings = $event->getStation()->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->getCrossfadeType();
$crossfade = $settings->getCrossfade();
$crossDuration = $settings->getCrossfadeDuration();
if ($crossDuration > 0) {
$crossfadeIsSmart = (Entity\StationBackendConfiguration::CROSSFADE_SMART === $crossfade_type) ? 'true' : 'false';
$event->appendLines(
[
'radio = crossfade(smart=' . $crossfadeIsSmart . ', duration=' . self::toFloat(
$crossDuration
) . ',fade_out=' . self::toFloat($crossfade) . ',fade_in=' . self::toFloat($crossfade) . ',radio)',
]
);
}
}
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 = "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));
}
$harborParams = implode(', ', $harbor_params);
$event->appendBlock(
<<<EOF
# 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(${harborParams}))
ignore(output.dummy(live, fallible=true))
radio = fallback(id="live_fallback", replay_metadata=false, track_sensitive=false, [live, radio])
EOF
);
if ($recordLiveStreams) {
$recordLiveStreamsFormat = $settings['record_streams_format'] ?? Entity\Interfaces\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()',
]
);
}
// Write fallback to safety file to ensure infallible source for the broadcast outputs.
$error_file = $this->environment->isDocker()
? '/usr/local/share/icecast/web/error.mp3'
: $this->environment->getBaseDirectory() . '/resources/error.mp3';
$event->appendLines(
[
sprintf(
'radio = fallback(id="safe_fallback", track_sensitive = false, [radio, single(id="error_jingle", "%s")])',
$error_file
),
]
);
// 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) =
def f() =
if (m["song_id"] != "") then
ret = {$feedbackCommand}
log("AzuraCast Feedback Response: #{ret}")
end
(-1.)
end
add_timeout(fast=false, 0., f)
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.
*/
protected function getOutputString(
Entity\Station $station,
Entity\Interfaces\StationMountInterface $mount,
string $idPrefix,
int $id
): string {
$charset = $station->getBackendConfig()->getCharset();
$output_format = $this->getOutputFormatString(
$mount->getAutodjFormat() ?? $mount::FORMAT_MP3,
$mount->getAutodjBitrate() ?? 128
);
$output_params = [];
$output_params[] = $output_format;
$output_params[] = 'id="' . $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 (Adapters::REMOTE_SHOUTCAST2 === $mount->getAutodjAdapterType()) {
$password .= ':#' . $id;
}
$output_params[] = 'password = "' . $password . '"';
$protocol = $mount->getAutodjProtocol();
if (!empty($mount->getAutodjMount())) {
if ($mount::PROTOCOL_ICY === $protocol) {
$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 (null !== $protocol) {
$output_params[] = 'protocol="' . $protocol . '"';
}
if (
Entity\Interfaces\StationMountInterface::FORMAT_OPUS === $mount->getAutodjFormat()
|| Entity\Interfaces\StationMountInterface::FORMAT_FLAC === $mount->getAutodjFormat()
) {
$output_params[] = 'icy_metadata="true"';
}
$output_params[] = 'radio';
return 'output.icecast(' . implode(', ', $output_params) . ')';
}
protected function getOutputFormatString(string $format, int $bitrate = 128): string
{
switch (strtolower($format)) {
case Entity\Interfaces\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)';
case Entity\Interfaces\StationMountInterface::FORMAT_OGG:
return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
case Entity\Interfaces\StationMountInterface::FORMAT_OPUS:
return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="constrained", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
case Entity\Interfaces\StationMountInterface::FORMAT_FLAC:
return '%ogg(%flac(samplerate=48000, channels=2, compression=4, bits_per_sample=24))';
case Entity\Interfaces\StationMountInterface::FORMAT_MP3:
default:
return '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
}
}
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);
}
public function writePostBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
{
$this->writeCustomConfigurationSection($event, self::CUSTOM_BOTTOM);
}
/**
* @return string[]
*/
public static function getCustomConfigurationSections(): array
{
return [
self::CUSTOM_TOP,
self::CUSTOM_PRE_PLAYLISTS,
self::CUSTOM_PRE_FADE,
self::CUSTOM_PRE_LIVE,
self::CUSTOM_PRE_BROADCAST,
self::CUSTOM_BOTTOM,
];
}
/**
* Convert an integer or float into a Liquidsoap configuration compatible float.
*
* @param float|int|string $number
* @param int $decimals
*/
public static function toFloat(float|int|string $number, int $decimals = 2): string
{
return number_format((float)$number, $decimals, '.', '');
}
public static function formatTimeCode(int $time_code): string
{
$hours = floor($time_code / 100);
$mins = $time_code % 100;
return $hours . 'h' . $mins . 'm';
}
/**
* Filter a user-supplied string to be a valid LiquidSoap config entry.
*
* @param string|null $string
*
*/
public static function cleanUpString(?string $string): string
{
return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string ?? '');
}
/**
* Apply a more aggressive string filtering to variable names used in Liquidsoap.
*
* @param string $str
*
* @return string The cleaned up, variable-name-friendly string.
*/
public static function cleanUpVarName(string $str): string
{
$str = strip_tags($str);
$str = preg_replace(['/[\r\n\t ]+/', '/[\"\*\/\:\<\>\?\'\|]+/'], ' ', $str) ?? '';
$str = strtolower($str);
$str = html_entity_decode($str, ENT_QUOTES, "utf-8");
$str = htmlentities($str, ENT_QUOTES, "utf-8");
$str = preg_replace("/(&)([a-z])([a-z]+;)/i", '$2', $str) ?? '';
$str = str_replace(' ', '_', $str);
$str = rawurlencode($str);
$str = str_replace(['%', '-'], ['', '_'], $str);
return $str;
}
}