2019-12-07 01:57:50 +01:00
|
|
|
<?php
|
2020-10-15 00:19:31 +02:00
|
|
|
|
2021-07-19 07:53:45 +02:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
2018-08-05 00:05:14 +02:00
|
|
|
namespace App\Radio\Backend;
|
2016-05-06 10:57:34 +02:00
|
|
|
|
2019-08-07 06:33:55 +02:00
|
|
|
use App\Entity;
|
2018-09-22 13:52:43 +02:00
|
|
|
use App\Event\Radio\WriteLiquidsoapConfiguration;
|
2020-03-19 20:41:22 +01:00
|
|
|
use App\Exception;
|
2022-05-29 06:07:56 +02:00
|
|
|
use App\Nginx\CustomUrls;
|
2022-06-22 03:48:32 +02:00
|
|
|
use App\Radio\AbstractLocalAdapter;
|
2022-05-01 08:43:20 +02:00
|
|
|
use App\Radio\Enums\LiquidsoapQueues;
|
2022-05-08 20:05:02 +02:00
|
|
|
use LogicException;
|
2018-11-22 02:48:46 +01:00
|
|
|
use Psr\Http\Message\UriInterface;
|
2022-04-11 03:28:49 +02:00
|
|
|
use Symfony\Component\Process\Process;
|
2017-06-06 20:07:18 +02:00
|
|
|
|
2022-06-22 03:48:32 +02:00
|
|
|
class Liquidsoap extends AbstractLocalAdapter
|
2016-05-06 10:57:34 +02:00
|
|
|
{
|
2021-01-19 18:52:45 +01:00
|
|
|
/**
|
|
|
|
* @inheritDoc
|
|
|
|
*/
|
|
|
|
public function getConfigurationPath(Entity\Station $station): ?string
|
|
|
|
{
|
|
|
|
return $station->getRadioConfigDir() . '/liquidsoap.liq';
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @inheritDoc
|
|
|
|
*/
|
|
|
|
public function getCurrentConfiguration(Entity\Station $station): ?string
|
|
|
|
{
|
2022-04-13 10:29:50 +02:00
|
|
|
$event = new WriteLiquidsoapConfiguration($station, false, true);
|
2020-02-23 13:58:47 +01:00
|
|
|
$this->dispatcher->dispatch($event);
|
|
|
|
|
|
|
|
return $event->buildConfiguration();
|
|
|
|
}
|
|
|
|
|
2019-09-04 20:00:51 +02:00
|
|
|
/**
|
|
|
|
* Returns the port used for DJs/Streamers to connect to LiquidSoap for broadcasting.
|
|
|
|
*
|
|
|
|
* @param Entity\Station $station
|
2019-09-20 18:44:38 +02:00
|
|
|
*
|
2019-09-04 20:00:51 +02:00
|
|
|
* @return int The port number to use for this station.
|
|
|
|
*/
|
|
|
|
public function getStreamPort(Entity\Station $station): int
|
|
|
|
{
|
2021-06-08 08:40:49 +02:00
|
|
|
$djPort = $station->getBackendConfig()->getDjPort();
|
2020-05-02 09:58:59 +02:00
|
|
|
if (null !== $djPort) {
|
|
|
|
return $djPort;
|
2019-09-04 20:00:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Default to frontend port + 5
|
2020-05-02 09:58:59 +02:00
|
|
|
$frontend_config = $station->getFrontendConfig();
|
|
|
|
$frontend_port = $frontend_config->getPort() ?? (8000 + (($station->getId() - 1) * 10));
|
2019-09-04 20:00:51 +02:00
|
|
|
|
|
|
|
return $frontend_port + 5;
|
|
|
|
}
|
|
|
|
|
2020-04-11 08:55:00 +02:00
|
|
|
/**
|
|
|
|
* Assemble a list of annotations for LiquidSoap.
|
|
|
|
*
|
|
|
|
* Liquidsoap expects a string similar to:
|
|
|
|
* annotate:type="song",album="$ALBUM",display_desc="$FULLSHOWNAME",
|
|
|
|
* liq_start_next="2.5",liq_fade_in="3.5",liq_fade_out="3.5":$SONGPATH
|
|
|
|
*
|
|
|
|
* @param Entity\StationMedia $media
|
|
|
|
*
|
2020-10-15 00:19:31 +02:00
|
|
|
* @return mixed[]
|
2020-04-11 08:55:00 +02:00
|
|
|
*/
|
|
|
|
public function annotateMedia(Entity\StationMedia $media): array
|
|
|
|
{
|
|
|
|
$annotations = [];
|
|
|
|
$annotation_types = [
|
|
|
|
'title' => $media->getTitle(),
|
|
|
|
'artist' => $media->getArtist(),
|
|
|
|
'duration' => $media->getLength(),
|
2020-06-15 09:01:12 +02:00
|
|
|
'song_id' => $media->getSongId(),
|
2020-04-11 08:55:00 +02:00
|
|
|
'media_id' => $media->getId(),
|
2020-05-03 16:03:24 +02:00
|
|
|
'liq_amplify' => $media->getAmplify() ?? 0.0,
|
2020-04-11 08:55:00 +02:00
|
|
|
'liq_cross_duration' => $media->getFadeOverlap(),
|
|
|
|
'liq_fade_in' => $media->getFadeIn(),
|
|
|
|
'liq_fade_out' => $media->getFadeOut(),
|
|
|
|
'liq_cue_in' => $media->getCueIn(),
|
|
|
|
'liq_cue_out' => $media->getCueOut(),
|
|
|
|
];
|
|
|
|
|
|
|
|
// Safety checks for cue lengths.
|
|
|
|
if ($annotation_types['liq_cue_out'] < 0) {
|
|
|
|
$cue_out = abs($annotation_types['liq_cue_out']);
|
2020-09-01 00:32:30 +02:00
|
|
|
if (0.0 === $cue_out || $cue_out > $annotation_types['duration']) {
|
2020-04-11 08:55:00 +02:00
|
|
|
$annotation_types['liq_cue_out'] = null;
|
|
|
|
} else {
|
|
|
|
$annotation_types['liq_cue_out'] = max(0, $annotation_types['duration'] - $cue_out);
|
|
|
|
}
|
|
|
|
}
|
2020-11-27 03:43:51 +01:00
|
|
|
if ($annotation_types['liq_cue_out'] > $annotation_types['duration']) {
|
2020-04-11 08:55:00 +02:00
|
|
|
$annotation_types['liq_cue_out'] = null;
|
|
|
|
}
|
|
|
|
if ($annotation_types['liq_cue_in'] > $annotation_types['duration']) {
|
|
|
|
$annotation_types['liq_cue_in'] = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
foreach ($annotation_types as $annotation_name => $prop) {
|
|
|
|
if (null === $prop) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-07-19 07:53:45 +02:00
|
|
|
$prop = self::annotateString((string)$prop);
|
2020-04-11 08:55:00 +02:00
|
|
|
|
|
|
|
// Convert Liquidsoap-specific annotations to floats.
|
2021-04-24 00:12:47 +02:00
|
|
|
if ('duration' === $annotation_name || str_starts_with($annotation_name, 'liq')) {
|
2020-04-11 08:55:00 +02:00
|
|
|
$prop = Liquidsoap\ConfigWriter::toFloat($prop);
|
|
|
|
}
|
|
|
|
|
2020-04-15 07:23:11 +02:00
|
|
|
if ('liq_amplify' === $annotation_name) {
|
|
|
|
$prop .= 'dB';
|
|
|
|
}
|
|
|
|
|
2020-04-11 08:55:00 +02:00
|
|
|
$annotations[$annotation_name] = $prop;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $annotations;
|
|
|
|
}
|
|
|
|
|
2020-05-04 04:48:26 +02:00
|
|
|
public static function annotateString(string $str): string
|
|
|
|
{
|
|
|
|
$str = mb_convert_encoding($str, 'UTF-8');
|
2021-04-09 19:32:15 +02:00
|
|
|
return str_replace(['"', "\n", "\t", "\r"], ['\"', '', '', ''], $str);
|
2020-05-04 04:48:26 +02:00
|
|
|
}
|
|
|
|
|
2019-09-04 20:00:51 +02:00
|
|
|
/**
|
|
|
|
* Execute the specified remote command on LiquidSoap via the telnet API.
|
|
|
|
*
|
|
|
|
* @param Entity\Station $station
|
|
|
|
* @param string $command_str
|
2019-09-20 18:44:38 +02:00
|
|
|
*
|
2020-10-15 00:19:31 +02:00
|
|
|
* @return string[]
|
|
|
|
*
|
2020-03-19 20:41:22 +01:00
|
|
|
* @throws Exception
|
2019-09-04 20:00:51 +02:00
|
|
|
*/
|
2021-04-24 00:12:47 +02:00
|
|
|
public function command(Entity\Station $station, string $command_str): array
|
2019-09-04 20:00:51 +02:00
|
|
|
{
|
2022-05-03 21:11:44 +02:00
|
|
|
$socketPath = 'unix://' . $station->getRadioConfigDir() . '/liquidsoap.sock';
|
2022-01-28 02:33:07 +01:00
|
|
|
|
2020-10-15 00:19:31 +02:00
|
|
|
$fp = stream_socket_client(
|
2022-05-03 21:11:44 +02:00
|
|
|
$socketPath,
|
2020-10-15 00:19:31 +02:00
|
|
|
$errno,
|
|
|
|
$errstr,
|
|
|
|
20
|
|
|
|
);
|
2019-09-04 20:00:51 +02:00
|
|
|
|
|
|
|
if (!$fp) {
|
2020-03-19 20:41:22 +01:00
|
|
|
throw new Exception('Telnet failure: ' . $errstr . ' (' . $errno . ')');
|
2019-09-04 20:00:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
fwrite($fp, str_replace(["\\'", '&'], ["'", '&'], urldecode($command_str)) . "\nquit\n");
|
|
|
|
|
|
|
|
$response = [];
|
|
|
|
while (!feof($fp)) {
|
2021-07-19 07:53:45 +02:00
|
|
|
$response[] = trim(fgets($fp, 1024) ?: '');
|
2019-09-04 20:00:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
fclose($fp);
|
|
|
|
|
|
|
|
return $response;
|
|
|
|
}
|
|
|
|
|
2018-01-06 22:01:01 +01:00
|
|
|
/**
|
|
|
|
* @inheritdoc
|
|
|
|
*/
|
2018-09-22 13:52:43 +02:00
|
|
|
public function getCommand(Entity\Station $station): ?string
|
2016-05-19 16:56:21 +02:00
|
|
|
{
|
2021-01-19 18:52:45 +01:00
|
|
|
if ($binary = $this->getBinary()) {
|
2018-09-22 13:52:43 +02:00
|
|
|
$config_path = $station->getRadioConfigDir() . '/liquidsoap.liq';
|
2017-10-19 23:55:02 +02:00
|
|
|
return $binary . ' ' . $config_path;
|
2017-06-12 22:29:03 +02:00
|
|
|
}
|
2018-02-03 22:54:12 +01:00
|
|
|
|
2021-01-19 18:52:45 +01:00
|
|
|
return null;
|
2016-05-07 11:13:17 +02:00
|
|
|
}
|
2016-09-02 22:37:12 +02:00
|
|
|
|
2019-09-04 20:00:51 +02:00
|
|
|
/**
|
2020-10-15 00:19:31 +02:00
|
|
|
* @inheritDoc
|
2019-09-04 20:00:51 +02:00
|
|
|
*/
|
2021-01-19 18:52:45 +01:00
|
|
|
public function getBinary(): ?string
|
2019-09-04 20:00:51 +02:00
|
|
|
{
|
|
|
|
return '/usr/local/bin/liquidsoap';
|
|
|
|
}
|
|
|
|
|
2022-04-11 05:47:37 +02:00
|
|
|
public function getVersion(): ?string
|
|
|
|
{
|
|
|
|
$binary = $this->getBinary();
|
|
|
|
if (null === $binary) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
$process = new Process([$binary, '--version']);
|
|
|
|
$process->run();
|
|
|
|
|
|
|
|
if (!$process->isSuccessful()) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return preg_match('/^Liquidsoap (.+)$/im', $process->getOutput(), $matches)
|
|
|
|
? $matches[1]
|
|
|
|
: null;
|
|
|
|
}
|
|
|
|
|
2022-06-22 03:48:32 +02:00
|
|
|
public function getHlsUrl(Entity\Station $station, UriInterface $baseUrl = null): UriInterface
|
|
|
|
{
|
|
|
|
$baseUrl ??= $this->router->getBaseUrl();
|
|
|
|
return $baseUrl->withPath(
|
|
|
|
$baseUrl->getPath() . CustomUrls::getHlsUrl($station) . '/live.m3u8'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-05-01 08:43:20 +02:00
|
|
|
public function isQueueEmpty(
|
|
|
|
Entity\Station $station,
|
|
|
|
LiquidsoapQueues $queue
|
|
|
|
): bool {
|
|
|
|
$queueResult = $this->command(
|
2020-03-22 01:29:07 +01:00
|
|
|
$station,
|
2022-05-01 08:43:20 +02:00
|
|
|
sprintf('%s.queue', $queue->value)
|
2020-03-22 01:29:07 +01:00
|
|
|
);
|
2022-05-01 08:43:20 +02:00
|
|
|
return empty($queueResult[0]);
|
2020-03-21 03:54:20 +01:00
|
|
|
}
|
2018-05-01 09:32:31 +02:00
|
|
|
|
2020-10-15 00:19:31 +02:00
|
|
|
/**
|
|
|
|
* @return string[]
|
|
|
|
*/
|
2022-05-01 08:43:20 +02:00
|
|
|
public function enqueue(
|
|
|
|
Entity\Station $station,
|
|
|
|
LiquidsoapQueues $queue,
|
|
|
|
string $music_file
|
|
|
|
): array {
|
2020-03-22 01:29:07 +01:00
|
|
|
return $this->command(
|
|
|
|
$station,
|
2022-05-01 08:43:20 +02:00
|
|
|
sprintf('%s.push %s', $queue->value, $music_file)
|
2020-03-22 01:29:07 +01:00
|
|
|
);
|
2018-05-01 09:32:31 +02:00
|
|
|
}
|
|
|
|
|
2020-10-15 00:19:31 +02:00
|
|
|
/**
|
|
|
|
* @return string[]
|
|
|
|
*/
|
2019-05-12 16:04:29 +02:00
|
|
|
public function skip(Entity\Station $station): array
|
2016-09-03 09:35:21 +02:00
|
|
|
{
|
2018-10-24 04:24:29 +02:00
|
|
|
return $this->command(
|
|
|
|
$station,
|
2022-05-01 08:43:20 +02:00
|
|
|
'interrupting_fallback.skip'
|
2018-10-24 04:24:29 +02:00
|
|
|
);
|
2016-09-03 09:35:21 +02:00
|
|
|
}
|
|
|
|
|
2020-10-15 00:19:31 +02:00
|
|
|
/**
|
|
|
|
* @return string[]
|
|
|
|
*/
|
2020-05-04 04:48:26 +02:00
|
|
|
public function updateMetadata(Entity\Station $station, array $newMeta): array
|
|
|
|
{
|
|
|
|
$metaStr = [];
|
|
|
|
foreach ($newMeta as $metaKey => $metaVal) {
|
|
|
|
$metaStr[] = $metaKey . '="' . self::annotateString($metaVal) . '"';
|
|
|
|
}
|
|
|
|
|
|
|
|
return $this->command(
|
|
|
|
$station,
|
|
|
|
'custom_metadata.insert ' . implode(',', $metaStr),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2018-07-16 00:27:25 +02:00
|
|
|
/**
|
|
|
|
* Tell LiquidSoap to disconnect the current live streamer.
|
|
|
|
*
|
2018-09-22 13:52:43 +02:00
|
|
|
* @param Entity\Station $station
|
2019-09-20 18:44:38 +02:00
|
|
|
*
|
2020-10-15 00:19:31 +02:00
|
|
|
* @return string[]
|
2018-07-16 00:27:25 +02:00
|
|
|
*/
|
2019-05-12 16:04:29 +02:00
|
|
|
public function disconnectStreamer(Entity\Station $station): array
|
2018-07-16 00:27:25 +02:00
|
|
|
{
|
2018-10-27 02:14:32 +02:00
|
|
|
$current_streamer = $station->getCurrentStreamer();
|
2019-12-07 01:57:50 +01:00
|
|
|
$disconnect_timeout = $station->getDisconnectDeactivateStreamer();
|
2018-10-27 02:14:32 +02:00
|
|
|
|
2018-10-27 02:23:22 +02:00
|
|
|
if ($current_streamer instanceof Entity\StationStreamer && $disconnect_timeout > 0) {
|
|
|
|
$current_streamer->deactivateFor($disconnect_timeout);
|
2018-10-27 02:14:32 +02:00
|
|
|
|
|
|
|
$this->em->persist($current_streamer);
|
|
|
|
$this->em->flush();
|
|
|
|
}
|
|
|
|
|
2018-10-24 04:24:29 +02:00
|
|
|
return $this->command(
|
|
|
|
$station,
|
2021-02-02 05:05:13 +01:00
|
|
|
'input_streamer.stop'
|
2018-10-24 04:24:29 +02:00
|
|
|
);
|
2018-07-16 00:27:25 +02:00
|
|
|
}
|
|
|
|
|
2018-11-22 02:48:46 +01:00
|
|
|
public function getWebStreamingUrl(Entity\Station $station, UriInterface $base_url): UriInterface
|
|
|
|
{
|
2021-06-08 08:40:49 +02:00
|
|
|
$djMount = $station->getBackendConfig()->getDjMountPoint();
|
2019-11-21 04:15:04 +01:00
|
|
|
|
2018-11-22 02:48:46 +01:00
|
|
|
return $base_url
|
|
|
|
->withScheme('wss')
|
2022-05-29 06:07:56 +02:00
|
|
|
->withPath($base_url->getPath() . CustomUrls::getWebDjUrl($station) . $djMount);
|
2018-09-22 13:52:43 +02:00
|
|
|
}
|
2022-04-13 10:29:50 +02:00
|
|
|
|
|
|
|
public function verifyConfig(string $config): void
|
|
|
|
{
|
|
|
|
$binary = $this->getBinary();
|
|
|
|
|
|
|
|
$process = new Process([
|
|
|
|
$binary,
|
|
|
|
'--check',
|
|
|
|
'-',
|
|
|
|
]);
|
|
|
|
|
|
|
|
$process->setInput($config);
|
|
|
|
$process->run();
|
|
|
|
|
|
|
|
if (1 === $process->getExitCode()) {
|
2022-05-08 20:05:02 +02:00
|
|
|
throw new LogicException($process->getOutput());
|
2022-04-13 10:29:50 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-22 03:48:32 +02:00
|
|
|
|
|
|
|
public function getProgramName(Entity\Station $station): string
|
|
|
|
{
|
|
|
|
return 'station_' . $station->getIdRequired() . ':station_' . $station->getIdRequired() . '_backend';
|
|
|
|
}
|
2018-07-16 00:27:25 +02:00
|
|
|
}
|