1253 lines
46 KiB
PHP
1253 lines
46 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\Backend\Liquidsoap;
|
|
use App\Radio\Enums\FrontendAdapters;
|
|
use App\Radio\Enums\StreamFormats;
|
|
use App\Radio\Enums\StreamProtocols;
|
|
use App\Radio\FallbackFile;
|
|
use Carbon\CarbonImmutable;
|
|
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 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 = match (true) {
|
|
$this->environment->isDockerStandalone() => '127.0.0.1',
|
|
$this->environment->isDocker() => '0.0.0.0',
|
|
default => '127.0.0.1',
|
|
};
|
|
$telnetPort = $this->liquidsoap->getTelnetPort($station);
|
|
|
|
$stationTz = self::cleanUpString($station->getTimezone());
|
|
$stationApiAuth = self::cleanUpString($station->getAdapterApiKey());
|
|
|
|
$stationApiUrl = self::cleanUpString(
|
|
(string)$this->environment->getUriToWeb()
|
|
->withPath('/api/internal/' . $station->getId())
|
|
);
|
|
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
init.daemon.set(false)
|
|
init.daemon.pidfile.path.set("${pidfile}")
|
|
log.stdout.set(true)
|
|
log.file.set(false)
|
|
settings.server.log.level.set(4)
|
|
settings.server.telnet.set(true)
|
|
settings.server.telnet.bind_addr.set("${telnetBindAddr}")
|
|
settings.server.telnet.port.set(${telnetPort})
|
|
settings.harbor.bind_addrs.set(["0.0.0.0"])
|
|
|
|
settings.tag.encodings.set(["UTF-8","ISO-8859-1"])
|
|
settings.encoder.metadata.export.set(["artist","title","album","song"])
|
|
|
|
setenv("TZ", "${stationTz}")
|
|
|
|
autodj_is_loading = ref(true)
|
|
ignore(autodj_is_loading)
|
|
|
|
autodj_ping_attempts = ref(0)
|
|
ignore(autodj_ping_attempts)
|
|
|
|
# Track live-enabled status script-wide for fades.
|
|
live_enabled = ref(false)
|
|
ignore(live_enabled)
|
|
|
|
azuracast_api_url = "${stationApiUrl}"
|
|
azuracast_api_key = "${stationApiAuth}"
|
|
|
|
def azuracast_api_call(~timeout=2, url, payload) =
|
|
full_url = "#{azuracast_api_url}/#{url}"
|
|
|
|
log("API #{url} - Sending POST request to '#{full_url}' with body: #{payload}")
|
|
try
|
|
response = http.post(full_url,
|
|
headers=[
|
|
("Content-Type", "application/json"),
|
|
("User-Agent", "Liquidsoap AzuraCast"),
|
|
("X-Liquidsoap-Api-Key", "#{azuracast_api_key}")
|
|
],
|
|
timeout=timeout,
|
|
data=payload
|
|
)
|
|
|
|
log("API #{url} - Response (#{response.status_code}): #{response}")
|
|
{success = response.status_code == 200, data = "#{response}"}
|
|
catch err do
|
|
log("API #{url} - Error: #{error.kind(err)} - #{error.message(err)}")
|
|
{success = false, data = ""}
|
|
end
|
|
end
|
|
EOF
|
|
);
|
|
|
|
$backendConfig = $station->getBackendConfig();
|
|
|
|
$perfMode = $backendConfig->getPerformanceModeEnum();
|
|
if ($perfMode !== Entity\Enums\StationBackendPerformanceModes::Disabled) {
|
|
$gcSpaceOverhead = match ($backendConfig->getPerformanceModeEnum()) {
|
|
Entity\Enums\StationBackendPerformanceModes::LessMemory => 20,
|
|
Entity\Enums\StationBackendPerformanceModes::LessCpu => 140,
|
|
Entity\Enums\StationBackendPerformanceModes::Balanced => 80,
|
|
Entity\Enums\StationBackendPerformanceModes::Disabled => 0,
|
|
};
|
|
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
# Optimize Performance
|
|
runtime.gc.set(runtime.gc.get().{
|
|
space_overhead = ${gcSpaceOverhead},
|
|
allocation_policy = 2
|
|
})
|
|
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;
|
|
|
|
$playlistConfigLines = [];
|
|
|
|
if (Entity\Enums\PlaylistSources::Songs === $playlist->getSourceEnum()) {
|
|
$playlistFilePath = $this->writePlaylistFile($playlist, false);
|
|
if (!$playlistFilePath) {
|
|
continue;
|
|
}
|
|
|
|
$playlistParams = [
|
|
'id="' . self::cleanUpString($playlistVarName) . '"',
|
|
'mime_type="audio/x-mpegurl"',
|
|
];
|
|
|
|
$playlistMode = match ($playlist->getOrderEnum()) {
|
|
Entity\Enums\PlaylistOrders::Sequential => 'normal',
|
|
Entity\Enums\PlaylistOrders::Shuffle => 'randomize',
|
|
Entity\Enums\PlaylistOrders::Random => 'random'
|
|
};
|
|
$playlistParams[] = 'mode="' . $playlistMode . '"';
|
|
|
|
if ($playlist->backendLoopPlaylistOnce()) {
|
|
$playlistParams[] = 'reload_mode="never"';
|
|
} else {
|
|
$playlistParams[] = 'reload_mode="watch"';
|
|
}
|
|
|
|
$playlistParams[] = '"' . $playlistFilePath . '"';
|
|
|
|
$playlistConfigLines[] = $playlistVarName . ' = playlist('
|
|
. implode(',', $playlistParams) . ')';
|
|
|
|
if ($playlist->backendMerge()) {
|
|
$playlistConfigLines[] = $playlistVarName . ' = merge_tracks(id="merge_'
|
|
. self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
|
|
}
|
|
|
|
$playlistConfigLines[] = $playlistVarName . ' = cue_cut(id="cue_'
|
|
. self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
|
|
} else {
|
|
switch ($playlist->getRemoteTypeEnum()) {
|
|
case Entity\Enums\PlaylistRemoteTypes::Playlist:
|
|
$playlistFunc = 'playlist("'
|
|
. self::cleanUpString($playlist->getRemoteUrl())
|
|
. '")';
|
|
$playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFunc;
|
|
break;
|
|
|
|
case Entity\Enums\PlaylistRemoteTypes::Stream:
|
|
default:
|
|
$remote_url = $playlist->getRemoteUrl();
|
|
if (null !== $remote_url) {
|
|
$buffer = $playlist->getRemoteBuffer();
|
|
$buffer = ($buffer < 1) ? Entity\StationPlaylist::DEFAULT_REMOTE_BUFFER : $buffer;
|
|
|
|
$playlistConfigLines[] = $playlistVarName . ' = mksafe(buffer(buffer=' . $buffer . '., input.http(max_buffer=' . $buffer . '., "' . self::cleanUpString(
|
|
$remote_url
|
|
) . '")))';
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($playlist->getIsJingle()) {
|
|
$playlistConfigLines[] = $playlistVarName . ' = drop_metadata(' . $playlistVarName . ')';
|
|
}
|
|
|
|
if (Entity\Enums\PlaylistTypes::Advanced === $playlist->getTypeEnum()) {
|
|
$playlistConfigLines[] = 'ignore(' . $playlistVarName . ')';
|
|
}
|
|
|
|
$event->appendLines($playlistConfigLines);
|
|
|
|
if ($playlist->backendPlaySingleTrack()) {
|
|
$playlistVarName = 'once(' . $playlistVarName . ')';
|
|
}
|
|
|
|
$scheduleItems = $playlist->getScheduleItems();
|
|
|
|
switch ($playlist->getTypeEnum()) {
|
|
case Entity\Enums\PlaylistTypes::Standard:
|
|
if ($scheduleItems->count() > 0) {
|
|
foreach ($scheduleItems as $scheduleItem) {
|
|
$play_time = $this->getScheduledPlaylistPlayTime($event, $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\Enums\PlaylistTypes::OncePerXSongs:
|
|
case Entity\Enums\PlaylistTypes::OncePerXMinutes:
|
|
if (Entity\Enums\PlaylistTypes::OncePerXSongs === $playlist->getTypeEnum()) {
|
|
$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($event, $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\Enums\PlaylistTypes::OncePerHour:
|
|
$minutePlayTime = $playlist->getPlayPerHourMinute() . 'm';
|
|
|
|
if ($scheduleItems->count() > 0) {
|
|
foreach ($scheduleItems as $scheduleItem) {
|
|
$playTime = '(' . $minutePlayTime . ') and ('
|
|
. $this->getScheduledPlaylistPlayTime($event, $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()) {
|
|
$event->appendBlock(
|
|
<<< EOF
|
|
# AutoDJ Next Song Script
|
|
def autodj_next_song() =
|
|
response = azuracast_api_call(
|
|
"nextsong",
|
|
""
|
|
)
|
|
if (response.success != true) or (response.data == "") or (string.match(pattern="Error", response.data)) then
|
|
null()
|
|
else
|
|
r = request.create(response.data)
|
|
if request.resolve(r) then
|
|
r
|
|
else
|
|
null()
|
|
end
|
|
end
|
|
end
|
|
|
|
# Delayed ping for AutoDJ Next Song
|
|
def wait_for_next_song(autodj)
|
|
autodj_ping_attempts := !autodj_ping_attempts + 1
|
|
|
|
if source.is_ready(autodj) then
|
|
log("AutoDJ is ready!")
|
|
autodj_is_loading := false
|
|
-1.0
|
|
elsif !autodj_ping_attempts > 200 then
|
|
log("AutoDJ could not be initialized within the specified timeout.")
|
|
autodj_is_loading := false
|
|
-1.0
|
|
else
|
|
0.5
|
|
end
|
|
end
|
|
|
|
dynamic = request.dynamic(id="next_song", timeout=20., retry_delay=2., autodj_next_song)
|
|
dynamic = cue_cut(id="cue_next_song", dynamic)
|
|
|
|
dynamic_startup = fallback(
|
|
id = "dynamic_startup",
|
|
track_sensitive = false,
|
|
[
|
|
dynamic,
|
|
source.available(
|
|
blank(id = "autodj_startup_blank", duration = 120.),
|
|
predicate.activates({!autodj_is_loading})
|
|
)
|
|
]
|
|
)
|
|
radio = fallback(id="autodj_fallback", track_sensitive = true, [dynamic_startup, radio])
|
|
|
|
ref_dynamic = ref(dynamic);
|
|
thread.run.recurrent(delay=0.25, { 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 = 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 WriteLiquidsoapConfiguration $event
|
|
* @param Entity\StationSchedule $playlistSchedule
|
|
* @return string
|
|
*/
|
|
protected function getScheduledPlaylistPlayTime(
|
|
WriteLiquidsoapConfiguration $event,
|
|
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;
|
|
}
|
|
|
|
// Handle start-date and end-date boundaries.
|
|
$startDate = $playlistSchedule->getStartDate();
|
|
$endDate = $playlistSchedule->getEndDate();
|
|
|
|
if (!empty($startDate) || !empty($endDate)) {
|
|
$tzObject = $event->getStation()->getTimezoneObject();
|
|
|
|
$customFunctionBody = [];
|
|
|
|
$scheduleMethod = 'schedule_' . $playlistSchedule->getIdRequired() . '_date_range';
|
|
$customFunctionBody[] = 'def ' . $scheduleMethod . '() =';
|
|
|
|
$conditions = [];
|
|
|
|
if (!empty($startDate)) {
|
|
$startDateObj = CarbonImmutable::createFromFormat('Y-m-d', $startDate, $tzObject);
|
|
|
|
if (false !== $startDateObj) {
|
|
$startDateObj = $startDateObj->setTime(0, 0);
|
|
|
|
$customFunctionBody[] = ' # ' . $startDateObj->__toString();
|
|
$customFunctionBody[] = ' range_start = ' . $startDateObj->getTimestamp() . '.';
|
|
$conditions[] = 'range_start <= current_time';
|
|
}
|
|
}
|
|
|
|
if (!empty($endDate)) {
|
|
$endDateObj = CarbonImmutable::createFromFormat('Y-m-d', $endDate, $tzObject);
|
|
|
|
if (false !== $endDateObj) {
|
|
$endDateObj = $endDateObj->setTime(23, 59, 59);
|
|
|
|
$customFunctionBody[] = ' # ' . $endDateObj->__toString();
|
|
$customFunctionBody[] = ' range_end = ' . $endDateObj->getTimestamp() . '.';
|
|
|
|
$conditions[] = 'current_time <= range_end';
|
|
}
|
|
}
|
|
|
|
$customFunctionBody[] = ' current_time = time()';
|
|
$customFunctionBody[] = ' ' . implode(' and ', $conditions);
|
|
$customFunctionBody[] = 'end';
|
|
$event->appendLines($customFunctionBody);
|
|
|
|
$play_time = $scheduleMethod . '() and ' . $play_time;
|
|
}
|
|
|
|
return $play_time;
|
|
}
|
|
|
|
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 ($settings->isCrossfadeEnabled()) {
|
|
$crossfadeIsSmart = (Entity\StationBackendConfiguration::CROSSFADE_SMART === $crossfade_type) ? 'true' : 'false';
|
|
$event->appendLines([
|
|
sprintf(
|
|
'radio = crossfade(smart=%1$s, duration=%2$s, fade_out=%3$s, fade_in=%3$s, radio)',
|
|
$crossfadeIsSmart,
|
|
self::toFloat($crossDuration),
|
|
self::toFloat($crossfade)
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
$event->appendBlock(
|
|
<<< EOF
|
|
# DJ Authentication
|
|
last_authenticated_dj = ref("")
|
|
live_dj = ref("")
|
|
|
|
live_record_path = ref("")
|
|
|
|
def dj_auth(login) =
|
|
auth_info =
|
|
if (login.user == "source" or login.user == "") and (string.match(pattern="(:|,)+", login.password)) then
|
|
auth_string = string.split(separator="(:|,)", login.password)
|
|
{user = list.nth(default="", auth_string, 0),
|
|
password = list.nth(default="", auth_string, 2)}
|
|
else
|
|
{user = login.user, password = login.password}
|
|
end
|
|
|
|
response = azuracast_api_call(
|
|
timeout=5,
|
|
"auth",
|
|
json.stringify(auth_info)
|
|
)
|
|
if response.success then
|
|
last_authenticated_dj := auth_info.user
|
|
true
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def live_connected(header) =
|
|
dj = !last_authenticated_dj
|
|
log("DJ Source connected! Last authenticated DJ: #{dj} - #{header}")
|
|
|
|
live_enabled := true
|
|
live_dj := dj
|
|
|
|
j = json()
|
|
j.add("user", dj)
|
|
|
|
response = azuracast_api_call(
|
|
timeout=5,
|
|
"djon",
|
|
json.stringify(j)
|
|
)
|
|
if response.success and string.contains(prefix="/", response.data) then
|
|
live_record_path := response.data
|
|
end
|
|
end
|
|
|
|
def live_disconnected() =
|
|
dj = !live_dj
|
|
|
|
j = json()
|
|
j.add("user", dj)
|
|
|
|
_ = azuracast_api_call(
|
|
timeout=5,
|
|
"djoff",
|
|
json.stringify(j)
|
|
)
|
|
|
|
live_enabled := false
|
|
last_authenticated_dj := ""
|
|
live_dj := ""
|
|
|
|
live_record_path := ""
|
|
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 = input.harbor(${harborParams})
|
|
|
|
def insert_missing(m) =
|
|
if m == [] then
|
|
[("title", "Live Broadcast")]
|
|
else
|
|
m
|
|
end
|
|
end
|
|
live = map_metadata(insert_missing, live)
|
|
|
|
radio = fallback(id="live_fallback", replay_metadata=true, track_sensitive=false, [live, radio])
|
|
EOF
|
|
);
|
|
|
|
if ($recordLiveStreams) {
|
|
$recordLiveStreamsFormat = $settings->getRecordStreamsFormatEnum() ?? StreamFormats::Mp3;
|
|
$recordLiveStreamsBitrate = (int)($settings['record_streams_bitrate'] ?? 128);
|
|
|
|
$formatString = $this->getOutputFormatString($recordLiveStreamsFormat, $recordLiveStreamsBitrate);
|
|
|
|
$event->appendBlock(
|
|
<<< EOF
|
|
# Record Live Broadcasts
|
|
output.file(
|
|
{$formatString},
|
|
fun () -> begin
|
|
path = !live_record_path
|
|
if (path != "") then
|
|
"#{path}.tmp"
|
|
else
|
|
""
|
|
end
|
|
end,
|
|
live,
|
|
fallible=true,
|
|
on_close=fun (tempPath) -> begin
|
|
path = string.replace(pattern=".tmp$", (fun(_) -> ""), tempPath)
|
|
|
|
log("Recording stopped: Switching from #{tempPath} to #{path}")
|
|
|
|
process.run("mv #{tempPath} #{path}")
|
|
()
|
|
end
|
|
)
|
|
EOF
|
|
);
|
|
}
|
|
}
|
|
|
|
public function writePreBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
|
|
{
|
|
$station = $event->getStation();
|
|
$settings = $station->getBackendConfig();
|
|
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
# 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)
|
|
EOF
|
|
);
|
|
|
|
// NRJ normalization
|
|
if ($settings->useNormalizer()) {
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
# Normalization and Compression
|
|
radio = normalize(target = 0., window = 0.03, gain_min = -16., gain_max = 0., radio)
|
|
radio = compress.exponential(radio, mu = 1.0)
|
|
EOF
|
|
);
|
|
}
|
|
|
|
// Replaygain metadata
|
|
if ($settings->useReplayGain()) {
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
# Replaygain Metadata
|
|
enable_replaygain_metadata()
|
|
radio = replaygain(radio)
|
|
EOF
|
|
);
|
|
}
|
|
|
|
// Write fallback to safety file to ensure infallible source for the broadcast outputs.
|
|
$errorFile = $this->environment->isDocker()
|
|
? '/usr/local/share/icecast/web/error.mp3'
|
|
: $this->environment->getBaseDirectory() . '/resources/error.mp3';
|
|
|
|
// Check for a custom station fallback file.
|
|
$stationFallback = $station->getFallbackPath();
|
|
if (!empty($stationFallback)) {
|
|
$fsConfig = (new StationFilesystems($station))->getConfigFilesystem();
|
|
if ($fsConfig->fileExists($stationFallback)) {
|
|
$errorFile = $fsConfig->getLocalPath($stationFallback);
|
|
}
|
|
}
|
|
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
radio = fallback(id="safe_fallback", track_sensitive = false, [radio, single(id="error_jingle", "${errorFile}")])
|
|
EOF
|
|
);
|
|
|
|
// Custom configuration
|
|
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_BROADCAST);
|
|
|
|
$event->appendBlock(
|
|
<<<EOF
|
|
# Handle "Jingle Mode" tracks by replaying the previous metadata.
|
|
last_title = ref("")
|
|
last_artist = ref("")
|
|
|
|
def handle_jingle_mode(m) =
|
|
if (m["jingle_mode"] == "true") then
|
|
[("title", !last_title), ("artist", !last_artist)]
|
|
else
|
|
last_title := m["title"]
|
|
last_artist := m["artist"]
|
|
m
|
|
end
|
|
end
|
|
radio = map_metadata(handle_jingle_mode, radio)
|
|
|
|
# Send metadata changes back to AzuraCast
|
|
def metadata_updated(m) =
|
|
def f() =
|
|
if (m["song_id"] != "") then
|
|
j = json()
|
|
j.add("song_id", m["song_id"])
|
|
j.add("media_id", m["media_id"])
|
|
j.add("playlist_id", m["playlist_id"])
|
|
|
|
_ = azuracast_api_call(
|
|
"feedback",
|
|
json.stringify(j)
|
|
)
|
|
end
|
|
end
|
|
|
|
thread.run(f)
|
|
end
|
|
|
|
radio.on_metadata(metadata_updated)
|
|
EOF
|
|
);
|
|
}
|
|
|
|
public function writeLocalBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
|
|
{
|
|
$station = $event->getStation();
|
|
|
|
if (FrontendAdapters::Remote === $station->getFrontendTypeEnum()) {
|
|
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();
|
|
|
|
$format = $mount->getAutodjFormatEnum() ?? StreamFormats::default();
|
|
$output_format = $this->getOutputFormatString(
|
|
$format,
|
|
$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());
|
|
|
|
$adapterType = $mount->getAutodjAdapterTypeEnum();
|
|
if (FrontendAdapters::Shoutcast === $adapterType) {
|
|
$password .= ':#' . $id;
|
|
}
|
|
|
|
$output_params[] = 'password = "' . $password . '"';
|
|
|
|
$protocol = $mount->getAutodjProtocolEnum();
|
|
if (!empty($mount->getAutodjMount())) {
|
|
if (StreamProtocols::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->value . '"';
|
|
}
|
|
|
|
if ($format->sendIcyMetadata()) {
|
|
$output_params[] = 'icy_metadata="true"';
|
|
}
|
|
|
|
$output_params[] = 'radio';
|
|
|
|
return 'output.icecast(' . implode(', ', $output_params) . ')';
|
|
}
|
|
|
|
protected function getOutputFormatString(StreamFormats $format, int $bitrate = 128): string
|
|
{
|
|
switch ($format) {
|
|
case StreamFormats::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 StreamFormats::Ogg:
|
|
return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';
|
|
|
|
case StreamFormats::Opus:
|
|
return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="constrained", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';
|
|
|
|
case StreamFormats::Flac:
|
|
return '%ogg(%flac(samplerate=48000, channels=2, compression=4, bits_per_sample=24))';
|
|
|
|
case StreamFormats::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;
|
|
}
|
|
}
|