AzuraCast/src/Radio/Backend/Liquidsoap.php

1175 lines
42 KiB
PHP

<?php
namespace App\Radio\Backend;
use App\Entity;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Message;
use App\Radio\Adapters;
use App\Radio\AutoDJ;
use App\Radio\Filesystem;
use App\Settings;
use Azura\EventDispatcher;
use Azura\Logger;
use Doctrine\ORM\EntityManager;
use Exception;
use Psr\Http\Message\UriInterface;
use Supervisor\Supervisor;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use const PHP_URL_SCHEME;
class Liquidsoap extends AbstractBackend implements EventSubscriberInterface
{
public const CROSSFADE_DISABLED = 'none';
public const CROSSFADE_SMART = 'smart';
public const CROSSFADE_NORMAL = 'normal';
protected AutoDJ $autodj;
protected Filesystem $filesystem;
protected Entity\Repository\StationStreamerRepository $streamerRepo;
public function __construct(
EntityManager $em,
Supervisor $supervisor,
EventDispatcher $dispatcher,
Entity\Repository\StationStreamerRepository $streamerRepo,
AutoDJ $autodj,
Filesystem $filesystem
) {
parent::__construct($em, $supervisor, $dispatcher);
$this->streamerRepo = $streamerRepo;
$this->autodj = $autodj;
$this->filesystem = $filesystem;
}
/**
* 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', 30],
['writePlaylistConfiguration', 25],
['writeHarborConfiguration', 20],
['writeCustomConfiguration', 15],
['writeMetadataFeedbackConfiguration', 10],
['writeLocalBroadcastConfiguration', 5],
['writeRemoteBroadcastConfiguration', 0],
],
];
}
/**
* Write configuration from Station object to the external service.
*
* Special thanks to the team of PonyvilleFM for assisting with Liquidsoap configuration and debugging.
*
* @param Entity\Station $station
*
* @return bool
*/
public function write(Entity\Station $station): bool
{
$event = new WriteLiquidsoapConfiguration($station);
$this->dispatcher->dispatch($event);
$ls_config_contents = $event->buildConfiguration();
$config_path = $station->getRadioConfigDir();
$ls_config_path = $config_path . '/liquidsoap.liq';
file_put_contents($ls_config_path, $ls_config_contents);
return true;
}
public function writeHeaderFunctions(WriteLiquidsoapConfiguration $event)
{
$event->prependLines([
'# WARNING! This file is automatically generated by AzuraCast.',
'# Do not update it directly!',
]);
$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->_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", "' . $this->_cleanUpString($station->getTimezone()) . '")',
'',
]);
}
/**
* Returns the internal port used to relay requests and other changes from AzuraCast to LiquidSoap.
*
* @param Entity\Station $station
*
* @return int The port number to use for this station.
*/
protected function _getTelnetPort(Entity\Station $station): int
{
$settings = (array)$station->getBackendConfig();
return (int)($settings['telnet_port'] ?? ($this->getStreamPort($station) - 1));
}
/**
* Returns the port used for DJs/Streamers to connect to LiquidSoap for broadcasting.
*
* @param Entity\Station $station
*
* @return int The port number to use for this station.
*/
public function getStreamPort(Entity\Station $station): int
{
$settings = (array)$station->getBackendConfig();
if (!empty($settings['dj_port'])) {
return (int)$settings['dj_port'];
}
// Default to frontend port + 5
$frontend_config = (array)$station->getFrontendConfig();
$frontend_port = $frontend_config['port'] ?? (8000 + (($station->getId() - 1) * 10));
return $frontend_port + 5;
}
/**
* Filter a user-supplied string to be a valid LiquidSoap config entry.
*
* @param string $string
*
* @return mixed
*/
protected function _cleanUpString($string)
{
return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string);
}
public function writePlaylistConfiguration(WriteLiquidsoapConfiguration $event)
{
$station = $event->getStation();
// 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)) {
$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="' . $this->_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 . '("' . $this->_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 . '., "' . $this->_cleanUpString($remote_url) . '"))';
break;
}
}
$playlistConfigLines[] = $playlistVarName . ' = audio_to_stereo(id="stereo_' . $this->_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="' . $this->_getVarName('standard_playlists', $station) . '", weights=[' . implode(', ',
$genPlaylistWeights) . '], [' . implode(', ', $genPlaylistVars) . '])',
]);
if (!empty($scheduleSwitches)) {
$scheduleSwitches[] = '({true}, radio)';
$event->appendLines([
'# Standard Schedule Switches',
'radio = switch(id="' . $this->_getVarName('schedule_switch',
$station) . '", 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()) {
$event->appendLines([
'# AutoDJ Next Song Script',
'def azuracast_next_song() =',
' uri = ' . $this->_getApiUrlCommand($station, 'nextsong'),
' log("AzuraCast Raw Response: #{uri}")',
' ',
' if uri == "" or string.match(pattern="Error", uri) then',
' log("AzuraCast Error: Delaying subsequent requests...")',
' system("sleep 2")',
' request.create("")',
' else',
' request.create(uri)',
' end',
'end',
]);
$event->appendLines([
'dynamic = audio_to_stereo(request.dynamic(id="' . $this->_getVarName('next_song',
$station) . '", timeout=20., azuracast_next_song))',
'radio = fallback(id="' . $this->_getVarName('autodj_fallback',
$station) . '", track_sensitive = true, [dynamic, radio])',
]);
}
if (!empty($scheduleSwitchesInterrupting)) {
$scheduleSwitchesInterrupting[] = '({true}, radio)';
$event->appendLines([
'# Interrupting Schedule Switches',
'radio = switch(id="' . $this->_getVarName('interrupt_switch',
$station) . '", track_sensitive=false, [ ' . implode(', ', $scheduleSwitchesInterrupting) . ' ])',
]);
}
$error_file = Settings::getInstance()->isDocker()
? '/usr/local/share/icecast/web/error.mp3'
: Settings::getInstance()->getBaseDirectory() . '/resources/error.mp3';
$event->appendLines([
'requests = audio_to_stereo(request.queue(id="' . $this->_getVarName('requests', $station) . '"))',
'radio = fallback(id="' . $this->_getVarName('requests_fallback',
$station) . '", track_sensitive = true, [requests, radio])',
'',
'radio = cue_cut(id="' . $this->_getVarName('radio_cue', $station) . '", radio)',
'add_skip_command(radio)',
'',
'radio = fallback(id="' . $this->_getVarName('safe_fallback',
$station) . '", track_sensitive = false, [radio, single(id="error_jingle", "' . $error_file . '")])',
]);
}
/**
* 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 = $mediaFile->getAnnotations();
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) {
$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->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;
}
/**
* Execute the specified remote command on LiquidSoap via the telnet API.
*
* @param Entity\Station $station
* @param string $command_str
*
* @return array
* @throws \Azura\Exception
*/
public function command(Entity\Station $station, $command_str)
{
$fp = stream_socket_client('tcp://' . (Settings::getInstance()->isDocker() ? 'stations' : 'localhost') . ':' . $this->_getTelnetPort($station),
$errno, $errstr, 20);
if (!$fp) {
throw new \Azura\Exception('Telnet failure: ' . $errstr . ' (' . $errno . ')');
}
fwrite($fp, str_replace(["\\'", '&amp;'], ["'", '&'], urldecode($command_str)) . "\nquit\n");
$response = [];
while (!feof($fp)) {
$response[] = trim(fgets($fp, 1024));
}
fclose($fp);
return $response;
}
/**
* Given a scheduled playlist, return the time criteria that Liquidsoap can use to determine when to play it.
*
* @param Entity\StationPlaylistSchedule $playlistSchedule
*
* @return string
*/
protected function _getScheduledPlaylistPlayTime(Entity\StationPlaylistSchedule $playlistSchedule): string
{
$start_time = $playlistSchedule->getStartTime();
$end_time = $playlistSchedule->getEndTime();
// Handle multi-day playlists.
if ($start_time > $end_time) {
$play_times = [
$this->_formatTimeCode($start_time) . '-23h59m59s',
'00h00m-' . $this->_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)
? $this->_formatTimeCode($start_time)
: $this->_formatTimeCode($start_time) . '-' . $this->_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;
}
/**
* Configure the time offset
*
* @param int $time_code
*
* @return string
*/
protected 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 string $original_name
* @param Entity\Station $station
*
* @return string
*/
protected function _getVarName($original_name, Entity\Station $station): string
{
$short_name = $this->_cleanUpString($station->getShortName());
return (!empty($short_name))
? $short_name . '_' . $original_name
: 'station_' . $station->getId() . '_' . $original_name;
}
/**
* 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 writeHarborConfiguration(WriteLiquidsoapConfiguration $event)
{
$station = $event->getStation();
if (!$station->getEnableStreamers()) {
return;
}
$event->appendLines([
'# DJ Authentication',
'def dj_auth(user,password) =',
' log("Authenticating DJ: #{user}")',
' ret = ' . $this->_getApiUrlCommand($station, 'auth', ['dj-user' => 'user', 'dj-password' => 'password']),
' log("AzuraCast DJ Auth Response: #{ret}")',
' bool_of_string(ret)',
'end',
'',
'live_enabled = ref false',
'',
'def live_connected(header) =',
' log("DJ Source connected! #{header}")',
' live_enabled := true',
' ret = ' . $this->_getApiUrlCommand($station, 'djon'),
' log("AzuraCast Live Connected Response: #{ret}")',
'end',
'',
'def live_disconnected() =',
' log("DJ Source disconnected!")',
' live_enabled := false',
' ret = ' . $this->_getApiUrlCommand($station, 'djoff'),
' log("AzuraCast Live Disconnected Response: #{ret}")',
'end',
]);
$settings = (array)$station->getBackendConfig();
$charset = $settings['charset'] ?? 'UTF-8';
$dj_mount = $settings['dj_mount_point'] ?? '/';
$harbor_params = [
'"' . $this->_cleanUpString($dj_mount) . '"',
'id="' . $this->_getVarName('input_streamer', $station) . '"',
'port=' . $this->getStreamPort($station),
'user="shoutcast"',
'auth=dj_auth',
'icy=true',
'max=30.',
'buffer=' . ((int)($settings['dj_buffer'] ?? 5)) . '.',
'icy_metadata_charset="' . $charset . '"',
'metadata_charset="' . $charset . '"',
'on_connect=live_connected',
'on_disconnect=live_disconnected',
];
$event->appendLines([
'# A Pre-DJ source of radio that can be broadcasted 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))',
'live = fallback(id="' . $this->_getVarName('live_fallback',
$station) . '", track_sensitive=false, [live, blank(duration=2.)])',
'',
'radio = switch(id="' . $this->_getVarName('live_switch',
$station) . '", track_sensitive=false, [({!live_enabled}, live), ({true}, radio)])',
]);
}
public function writeCustomConfiguration(WriteLiquidsoapConfiguration $event)
{
$station = $event->getStation();
$settings = (array)$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 (true === (bool)($settings['nrj'] ?? false)) {
$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 (true === (bool)($settings['enable_replaygain_metadata'] ?? false)) {
$event->appendLines([
'# Replaygain Metadata',
'enable_replaygain_metadata()',
]);
}
// Crossfading
$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)',
]);
}
// Custom configuration
if (!empty($settings['custom_config'])) {
$event->appendLines([
'# Custom Configuration (Specified in Station Profile)',
$settings['custom_config'],
]);
}
}
/**
* Convert an integer or float into a Liquidsoap configuration compatible float.
*
* @param float $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 function writeMetadataFeedbackConfiguration(WriteLiquidsoapConfiguration $event)
{
$station = $event->getStation();
$event->appendLines([
'# Send metadata changes back to AzuraCast',
'def metadata_updated(m) =',
' if (m["song_id"] != "") then',
' ret = ' . $this->_getApiUrlCommand($station, 'feedback',
['song' => 'm["song_id"]', 'media' => 'm["media_id"]', 'playlist' => 'm["playlist_id"]']),
' log("AzuraCast Feedback Response: #{ret}")',
' end',
'end',
'',
'radio = on_metadata(metadata_updated,radio)',
]);
}
public function writeLocalBroadcastConfiguration(WriteLiquidsoapConfiguration $event)
{
$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 $id
*
* @return string
*/
protected function _getOutputString(Entity\Station $station, Entity\StationMountInterface $mount, $id = '')
{
$settings = (array)$station->getBackendConfig();
$charset = $settings['charset'] ?? 'UTF-8';
$bitrate = ($mount->getAutodjBitrate() ?? 128);
switch (strtolower($mount->getAutodjFormat())) {
case $mount::FORMAT_AAC:
$afterburner = ($bitrate >= 160) ? 'true' : 'false';
$aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2';
$output_format = '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)';
break;
case $mount::FORMAT_OGG:
$output_format = '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
break;
case $mount::FORMAT_OPUS:
$output_format = '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="none", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
break;
case $mount::FORMAT_MP3:
default:
$output_format = '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
break;
}
$output_params = [];
$output_params[] = $output_format;
$output_params[] = 'id="' . $this->_getVarName($id, $station) . '"';
$output_params[] = 'host = "' . $this->_cleanUpString($mount->getAutodjHost()) . '"';
$output_params[] = 'port = ' . (int)$mount->getAutodjPort();
$username = $mount->getAutodjUsername();
if (!empty($username)) {
$output_params[] = 'user = "' . $this->_cleanUpString($username) . '"';
}
$output_params[] = 'password = "' . $this->_cleanUpString($mount->getAutodjPassword()) . '"';
if (!empty($mount->getAutodjMount())) {
$output_params[] = 'mount = "' . $this->_cleanUpString($mount->getAutodjMount()) . '"';
}
$output_params[] = 'name = "' . $this->_cleanUpString($station->getName()) . '"';
$output_params[] = 'description = "' . $this->_cleanUpString($station->getDescription()) . '"';
$output_params[] = 'genre = "' . $this->_cleanUpString($station->getGenre()) . '"';
if (!empty($station->getUrl())) {
$output_params[] = 'url = "' . $this->_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) . ')';
}
public function writeRemoteBroadcastConfiguration(WriteLiquidsoapConfiguration $event)
{
$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);
}
/**
* @inheritdoc
*/
public function getCommand(Entity\Station $station): ?string
{
if ($binary = self::getBinary()) {
$config_path = $station->getRadioConfigDir() . '/liquidsoap.liq';
return $binary . ' ' . $config_path;
}
return '/bin/false';
}
/**
* @inheritdoc
*/
public static function getBinary()
{
// Docker revisions 3 and later use the `radio` container.
$settings = Settings::getInstance();
if ($settings->isDocker() && $settings[Settings::DOCKER_REVISION] < 3) {
return '/var/azuracast/.opam/system/bin/liquidsoap';
}
return '/usr/local/bin/liquidsoap';
}
/**
* If a station uses Manual AutoDJ mode, enqueue a request directly with Liquidsoap.
*
* @param Entity\Station $station
* @param string $music_file
*
* @return array
*/
public function request(Entity\Station $station, $music_file): array
{
$requests_var = $this->_getVarName('requests', $station);
$queue = $this->command($station, $requests_var . '.queue');
if (!empty($queue[0])) {
throw new Exception('Song(s) still pending in request queue.');
}
return $this->command($station, $requests_var . '.push ' . $music_file);
}
/*
* INTERNAL LIQUIDSOAP COMMANDS
*/
/**
* Tell LiquidSoap to skip the currently playing song.
*
* @param Entity\Station $station
*
* @return array
*/
public function skip(Entity\Station $station): array
{
return $this->command(
$station,
$this->_getVarName('radio_cue', $station) . '.skip'
);
}
/**
* Tell LiquidSoap to disconnect the current live streamer.
*
* @param Entity\Station $station
*
* @return array
*/
public function disconnectStreamer(Entity\Station $station): array
{
$current_streamer = $station->getCurrentStreamer();
$disconnect_timeout = $station->getDisconnectDeactivateStreamer();
if ($current_streamer instanceof Entity\StationStreamer && $disconnect_timeout > 0) {
$current_streamer->deactivateFor($disconnect_timeout);
$this->em->persist($current_streamer);
$this->em->flush();
}
return $this->command(
$station,
$this->_getVarName('input_streamer', $station) . '.stop'
);
}
public function authenticateStreamer(Entity\Station $station, $user, $pass): string
{
// Allow connections using the exact broadcast source password.
$fe_config = (array)$station->getFrontendConfig();
if (!empty($fe_config['source_pw']) && strcmp($fe_config['source_pw'], $pass) === 0) {
return 'true';
}
// Handle login conditions where the username and password are joined in the password field.
if (strpos($pass, ',') !== false) {
[$user, $pass] = explode(',', $pass);
}
if (strpos($pass, ':') !== false) {
[$user, $pass] = explode(':', $pass);
}
$streamer = $this->streamerRepo->authenticate($station, $user, $pass);
if ($streamer instanceof Entity\StationStreamer) {
Logger::getInstance()->debug('DJ successfully authenticated.', ['username' => $user]);
try {
// Successful authentication: update current streamer on station.
$station->setCurrentStreamer($streamer);
$this->em->persist($station);
$this->em->flush();
} catch (Exception $e) {
Logger::getInstance()->error('Error when calling post-DJ-authentication functions.', [
'file' => $e->getFile(),
'line' => $e->getLine(),
'code' => $e->getCode(),
]);
}
return 'true';
}
return 'false';
}
public function toggleLiveStatus(Entity\Station $station, $is_streamer_live = true): void
{
$station->setIsStreamerLive($is_streamer_live);
$this->em->persist($station);
$this->em->flush();
}
public function getWebStreamingUrl(Entity\Station $station, UriInterface $base_url): UriInterface
{
$stream_port = $this->getStreamPort($station);
$settings = (array)$station->getBackendConfig();
$djMount = $settings['dj_mount_point'] ?? '/';
return $base_url
->withScheme('wss')
->withPath($base_url->getPath() . '/radio/' . $stream_port . $djMount);
}
}