mirror of
https://github.com/AzuraCast/AzuraCast.git
synced 2024-06-14 05:06:37 +00:00
0069df6d2d
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.
1061 lines
40 KiB
PHP
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;
|
|
}
|
|
} |