454 lines
15 KiB
PHP
454 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Radio;
|
|
|
|
use App\Container\EntityManagerAwareTrait;
|
|
use App\Container\EnvironmentAwareTrait;
|
|
use App\Entity\Enums\PlaylistTypes;
|
|
use App\Entity\Repository\StationPlaylistMediaRepository;
|
|
use App\Entity\Station;
|
|
use App\Entity\StationPlaylist;
|
|
use App\Exception;
|
|
use App\Radio\Enums\BackendAdapters;
|
|
use App\Radio\Enums\FrontendAdapters;
|
|
use RuntimeException;
|
|
use Supervisor\Exception\SupervisorException;
|
|
use Supervisor\SupervisorInterface;
|
|
|
|
final class Configuration
|
|
{
|
|
use EntityManagerAwareTrait;
|
|
use EnvironmentAwareTrait;
|
|
|
|
public const DEFAULT_PORT_MIN = 8000;
|
|
public const DEFAULT_PORT_MAX = 8499;
|
|
public const PROTECTED_PORTS = [
|
|
3306, // MariaDB
|
|
6010, // Nginx internal
|
|
6379, // Redis
|
|
8080, // Common debug port
|
|
80, // HTTP
|
|
443, // HTTPS
|
|
2022, // SFTP
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly Adapters $adapters,
|
|
private readonly SupervisorInterface $supervisor,
|
|
private readonly StationPlaylistMediaRepository $spmRepo,
|
|
) {
|
|
}
|
|
|
|
public function initializeConfiguration(Station $station): void
|
|
{
|
|
// Ensure default values for frontend/backend config exist.
|
|
$station->setFrontendConfig($station->getFrontendConfig());
|
|
$station->setBackendConfig($station->getBackendConfig());
|
|
|
|
// Ensure port configuration exists
|
|
$this->assignRadioPorts($station);
|
|
|
|
// Clear station caches and generate API adapter key if none exists.
|
|
if (empty($station->getAdapterApiKey())) {
|
|
$station->generateAdapterApiKey();
|
|
}
|
|
|
|
// Ensure all directories exist.
|
|
$station->ensureDirectoriesExist();
|
|
|
|
// Check for at least one playlist, and create one if it doesn't exist.
|
|
$defaultPlaylists = $station->getPlaylists()->filter(
|
|
function (StationPlaylist $row) {
|
|
return $row->getIsEnabled() && PlaylistTypes::default() === $row->getType();
|
|
}
|
|
);
|
|
|
|
if (0 === $defaultPlaylists->count()) {
|
|
$defaultPlaylist = new StationPlaylist($station);
|
|
$defaultPlaylist->setName('default');
|
|
$this->em->persist($defaultPlaylist);
|
|
}
|
|
|
|
$this->em->persist($station);
|
|
foreach ($station->getAllStorageLocations() as $storageLocation) {
|
|
$this->em->persist($storageLocation);
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
$this->spmRepo->resetAllQueues($station);
|
|
}
|
|
|
|
/**
|
|
* Write all configuration changes to the filesystem and reload supervisord.
|
|
*/
|
|
public function writeConfiguration(
|
|
Station $station,
|
|
bool $reloadSupervisor = true,
|
|
bool $forceRestart = false,
|
|
bool $attemptReload = true
|
|
): void {
|
|
if ($this->environment->isTesting()) {
|
|
return;
|
|
}
|
|
|
|
$this->initializeConfiguration($station);
|
|
|
|
// Initialize adapters.
|
|
$supervisorConfig = [];
|
|
$supervisorConfigFile = $this->getSupervisorConfigFile($station);
|
|
|
|
$frontendEnum = $station->getFrontendType();
|
|
$backendEnum = $station->getBackendType();
|
|
|
|
$frontend = $this->adapters->getFrontendAdapter($station);
|
|
$backend = $this->adapters->getBackendAdapter($station);
|
|
|
|
// If no processes need to be managed, remove any existing config.
|
|
if (
|
|
(null === $frontend || !$frontend->hasCommand($station))
|
|
&& (null === $backend || !$backend->hasCommand($station))
|
|
) {
|
|
$this->unlinkAndStopStation($station, $reloadSupervisor, true);
|
|
throw new RuntimeException('Station has no local services.');
|
|
}
|
|
|
|
if (!$station->getHasStarted()) {
|
|
$this->unlinkAndStopStation($station, $reloadSupervisor);
|
|
throw new RuntimeException('Station has not started yet.');
|
|
}
|
|
|
|
if (!$station->getIsEnabled()) {
|
|
$this->unlinkAndStopStation($station, $reloadSupervisor);
|
|
throw new RuntimeException('Station is disabled.');
|
|
}
|
|
|
|
// Write group section of config
|
|
$programNames = [];
|
|
$programs = [];
|
|
|
|
if (null !== $backend && $backend->hasCommand($station)) {
|
|
$programName = $backend->getSupervisorProgramName($station);
|
|
|
|
$programs[$programName] = $backend;
|
|
$programNames[] = $programName;
|
|
}
|
|
|
|
if (null !== $frontend && $frontend->hasCommand($station)) {
|
|
$programName = $frontend->getSupervisorProgramName($station);
|
|
|
|
$programs[$programName] = $frontend;
|
|
$programNames[] = $programName;
|
|
}
|
|
|
|
$stationGroup = self::getSupervisorGroupName($station);
|
|
|
|
$supervisorConfig[] = '[group:' . $stationGroup . ']';
|
|
$supervisorConfig[] = 'programs=' . implode(',', $programNames);
|
|
$supervisorConfig[] = '';
|
|
|
|
foreach ($programs as $programName => $adapter) {
|
|
$configLines = [
|
|
'user' => 'azuracast',
|
|
'priority' => 950,
|
|
'startsecs' => 10,
|
|
'startretries' => 5,
|
|
'command' => $adapter->getCommand($station),
|
|
'directory' => $station->getRadioConfigDir(),
|
|
'environment' => 'TZ="' . $station->getTimezone() . '"',
|
|
'stdout_logfile' => $adapter->getLogPath($station),
|
|
'stdout_logfile_maxbytes' => '5MB',
|
|
'stdout_logfile_backups' => '5',
|
|
'redirect_stderr' => 'true',
|
|
'stdout_events_enabled' => 'true',
|
|
'stderr_events_enabled' => 'true',
|
|
];
|
|
|
|
$supervisorConfig[] = '[program:' . $programName . ']';
|
|
foreach ($configLines as $configKey => $configValue) {
|
|
$supervisorConfig[] = $configKey . '=' . $configValue;
|
|
}
|
|
$supervisorConfig[] = '';
|
|
}
|
|
|
|
// Write config contents
|
|
$supervisorConfigData = implode("\n", $supervisorConfig);
|
|
file_put_contents($supervisorConfigFile, $supervisorConfigData);
|
|
|
|
// Write supporting configurations.
|
|
$frontend?->write($station);
|
|
$backend?->write($station);
|
|
|
|
$this->markAsStarted($station);
|
|
|
|
// Reload Supervisord and process groups
|
|
if ($reloadSupervisor) {
|
|
$affectedGroups = $this->reloadSupervisor();
|
|
$wasRestarted = in_array($stationGroup, $affectedGroups, true);
|
|
|
|
if (!$wasRestarted && $forceRestart) {
|
|
try {
|
|
if ($attemptReload && ($backendEnum->isEnabled() || $frontendEnum->supportsReload())) {
|
|
$backend?->reload($station);
|
|
$frontend?->reload($station);
|
|
} else {
|
|
$this->supervisor->stopProcessGroup($stationGroup);
|
|
$this->supervisor->startProcessGroup($stationGroup);
|
|
}
|
|
} catch (SupervisorException) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getSupervisorConfigFile(Station $station): string
|
|
{
|
|
$configDir = $station->getRadioConfigDir();
|
|
return $configDir . '/supervisord.conf';
|
|
}
|
|
|
|
private function unlinkAndStopStation(
|
|
Station $station,
|
|
bool $reloadSupervisor = true,
|
|
bool $isRemoteOnly = false
|
|
): void {
|
|
$station->setHasStarted($isRemoteOnly);
|
|
$station->setNeedsRestart(false);
|
|
$station->setCurrentStreamer(null);
|
|
$station->setCurrentSong(null);
|
|
|
|
$this->em->persist($station);
|
|
$this->em->flush();
|
|
|
|
$supervisorConfigFile = $this->getSupervisorConfigFile($station);
|
|
@unlink($supervisorConfigFile);
|
|
if ($reloadSupervisor) {
|
|
$this->stopForStation($station);
|
|
}
|
|
}
|
|
|
|
private function stopForStation(Station $station): void
|
|
{
|
|
$this->markAsStarted($station);
|
|
|
|
$stationGroup = 'station_' . $station->getId();
|
|
$affectedGroups = $this->reloadSupervisor();
|
|
|
|
if (!in_array($stationGroup, $affectedGroups, true)) {
|
|
try {
|
|
$this->supervisor->stopProcessGroup($stationGroup, false);
|
|
} catch (SupervisorException) {
|
|
}
|
|
}
|
|
}
|
|
|
|
private function markAsStarted(Station $station): void
|
|
{
|
|
$station->setHasStarted(true);
|
|
$station->setNeedsRestart(false);
|
|
$station->setCurrentStreamer(null);
|
|
$station->setCurrentSong(null);
|
|
|
|
$this->em->persist($station);
|
|
$this->em->flush();
|
|
}
|
|
|
|
/**
|
|
* Trigger a supervisord reload and restart all relevant services.
|
|
*/
|
|
private function reloadSupervisor(): array
|
|
{
|
|
return $this->supervisor->reloadAndApplyConfig()->getAffected();
|
|
}
|
|
|
|
/**
|
|
* Assign the first available port range to this station, or ensure it already is configured properly.
|
|
*/
|
|
public function assignRadioPorts(Station $station, bool $force = false): void
|
|
{
|
|
if (
|
|
$station->getFrontendType()->isEnabled()
|
|
|| $station->getBackendType()->isEnabled()
|
|
) {
|
|
$frontendConfig = $station->getFrontendConfig();
|
|
$backendConfig = $station->getBackendConfig();
|
|
|
|
$basePort = $frontendConfig->getPort();
|
|
if ($force || null === $basePort) {
|
|
$basePort = $this->getFirstAvailableRadioPort($station);
|
|
|
|
$frontendConfig->setPort($basePort);
|
|
$station->setFrontendConfig($frontendConfig);
|
|
}
|
|
|
|
$djPort = $backendConfig->getDjPort();
|
|
if ($force || null === $djPort) {
|
|
$backendConfig->setDjPort($basePort + 5);
|
|
$station->setBackendConfig($backendConfig);
|
|
}
|
|
|
|
$telnetPort = $backendConfig->getTelnetPort();
|
|
if ($force || null === $telnetPort) {
|
|
$backendConfig->setTelnetPort($basePort + 4);
|
|
$station->setBackendConfig($backendConfig);
|
|
}
|
|
|
|
$this->em->persist($station);
|
|
$this->em->flush();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine the first available 10-port block that has no stations occupying it.
|
|
*/
|
|
public function getFirstAvailableRadioPort(Station $station = null): int
|
|
{
|
|
$usedPorts = $this->getUsedPorts($station);
|
|
|
|
// Iterate from port 8000 to 9000, in increments of 10
|
|
$protectedPorts = self::PROTECTED_PORTS;
|
|
|
|
$portMin = $this->environment->getAutoAssignPortMin();
|
|
$portMax = $this->environment->getAutoAssignPortMax();
|
|
|
|
for ($port = $portMin; $port <= $portMax; $port += 10) {
|
|
if (in_array($port, $protectedPorts, true)) {
|
|
continue;
|
|
}
|
|
|
|
$rangeInUse = false;
|
|
for ($i = $port; $i < $port + 10; $i++) {
|
|
if (isset($usedPorts[$i])) {
|
|
$rangeInUse = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$rangeInUse) {
|
|
return $port;
|
|
}
|
|
}
|
|
|
|
throw new Exception('This installation has no available ports for new radio stations.');
|
|
}
|
|
|
|
/**
|
|
* Get an array of all used ports across the system, except the ones used by the station specified (if specified).
|
|
*/
|
|
public function getUsedPorts(Station $exceptStation = null): array
|
|
{
|
|
static $usedPorts;
|
|
|
|
if (null === $usedPorts) {
|
|
$usedPorts = [];
|
|
|
|
// Get all station used ports.
|
|
$stationConfigs = $this->em->createQuery(
|
|
<<<'DQL'
|
|
SELECT s.id, s.name, s.frontend_type, s.frontend_config, s.backend_type, s.backend_config
|
|
FROM App\Entity\Station s
|
|
DQL
|
|
)->getArrayResult();
|
|
|
|
foreach ($stationConfigs as $row) {
|
|
$stationReference = ['id' => $row['id'], 'name' => $row['name']];
|
|
|
|
if ($row['frontend_type'] !== FrontendAdapters::Remote->value) {
|
|
$frontendConfig = (array)$row['frontend_config'];
|
|
|
|
if (!empty($frontendConfig['port'])) {
|
|
$port = (int)$frontendConfig['port'];
|
|
$usedPorts[$port] = $stationReference;
|
|
}
|
|
}
|
|
|
|
if ($row['backend_type'] !== BackendAdapters::None->value) {
|
|
$backendConfig = (array)$row['backend_config'];
|
|
|
|
// For DJ port, consider both the assigned port and port+1 to be reserved and in-use.
|
|
if (!empty($backendConfig['dj_port'])) {
|
|
$port = (int)$backendConfig['dj_port'];
|
|
$usedPorts[$port] = $stationReference;
|
|
$usedPorts[$port + 1] = $stationReference;
|
|
}
|
|
if (!empty($backendConfig['telnet_port'])) {
|
|
$port = (int)$backendConfig['telnet_port'];
|
|
$usedPorts[$port] = $stationReference;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (null !== $exceptStation && null !== $exceptStation->getId()) {
|
|
return array_filter(
|
|
$usedPorts,
|
|
static function ($stationReference) use ($exceptStation) {
|
|
return ($stationReference['id'] !== $exceptStation->getId());
|
|
}
|
|
);
|
|
}
|
|
|
|
return $usedPorts;
|
|
}
|
|
|
|
/**
|
|
* Remove configuration (i.e. prior to station removal) and trigger a Supervisor refresh.
|
|
*
|
|
* @param Station $station
|
|
*/
|
|
public function removeConfiguration(Station $station): void
|
|
{
|
|
if ($this->environment->isTesting()) {
|
|
return;
|
|
}
|
|
|
|
$stationGroup = 'station_' . $station->getId();
|
|
|
|
// Try forcing the group to stop, but don't hard-fail if it doesn't.
|
|
try {
|
|
$this->supervisor->stopProcessGroup($stationGroup);
|
|
$this->supervisor->removeProcessGroup($stationGroup);
|
|
} catch (SupervisorException) {
|
|
}
|
|
|
|
$supervisorConfigPath = $this->getSupervisorConfigFile($station);
|
|
@unlink($supervisorConfigPath);
|
|
|
|
$this->reloadSupervisor();
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
public static function enumerateDefaultPorts(
|
|
int $rangeMin = self::DEFAULT_PORT_MIN,
|
|
int $rangeMax = self::DEFAULT_PORT_MAX,
|
|
): array {
|
|
$defaultPorts = [];
|
|
|
|
for ($i = $rangeMin; $i < $rangeMax; $i += 10) {
|
|
if (in_array($i, self::PROTECTED_PORTS, true)) {
|
|
continue;
|
|
}
|
|
|
|
$defaultPorts[] = $i;
|
|
$defaultPorts[] = $i + 5;
|
|
$defaultPorts[] = $i + 6;
|
|
}
|
|
|
|
return $defaultPorts;
|
|
}
|
|
|
|
public static function getSupervisorGroupName(Station $station): string
|
|
{
|
|
return 'station_' . $station->getIdRequired();
|
|
}
|
|
|
|
public static function getSupervisorProgramName(Station $station, string $category): string
|
|
{
|
|
return 'station_' . $station->getIdRequired() . '_' . $category;
|
|
}
|
|
}
|