Split Playlist File writing into its own class; don't write playlist files for most LS changes; lint custom LS config as it's saved.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-04-13 03:29:50 -05:00
parent 2c428b12bb
commit 8c4e5251ac
No known key found for this signature in database
GPG Key ID: 9FC8B9E008872109
8 changed files with 240 additions and 151 deletions

View File

@ -194,6 +194,7 @@ return function (CallableEventDispatcherInterface $dispatcher) {
App\Radio\AutoDJ\Queue::class,
App\Radio\AutoDJ\Annotations::class,
App\Radio\Backend\Liquidsoap\ConfigWriter::class,
App\Radio\Backend\Liquidsoap\PlaylistFileWriter::class,
App\Sync\NowPlaying\Task\NowPlayingTask::class,
]
);

View File

@ -6,15 +6,15 @@ use App\Sync\Task;
use Symfony\Component\Mailer;
return [
Message\AddNewMediaMessage::class => Task\CheckMediaTask::class,
Message\AddNewMediaMessage::class => Task\CheckMediaTask::class,
Message\ReprocessMediaMessage::class => Task\CheckMediaTask::class,
Message\WritePlaylistFileMessage::class => Liquidsoap\ConfigWriter::class,
Message\WritePlaylistFileMessage::class => Liquidsoap\PlaylistFileWriter::class,
Message\BackupMessage::class => Task\RunBackupTask::class,
Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class,
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
Mailer\Messenger\SendEmailMessage::class => Mailer\Messenger\MessageHandler::class,
];

View File

@ -6,9 +6,12 @@ namespace App\Controller\Api\Stations\LiquidsoapConfig;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Backend\Liquidsoap\ConfigWriter;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
class PutAction
@ -16,7 +19,9 @@ class PutAction
public function __invoke(
ServerRequest $request,
Response $response,
ReloadableEntityManagerInterface $em
ReloadableEntityManagerInterface $em,
EventDispatcherInterface $eventDispatcher,
Liquidsoap $liquidsoap,
): ResponseInterface {
$body = (array)$request->getParsedBody();
@ -34,6 +39,16 @@ class PutAction
$em->persist($station);
$em->flush();
try {
$event = new WriteLiquidsoapConfiguration($station, false, false);
$eventDispatcher->dispatch($event);
$config = $event->buildConfiguration();
$liquidsoap->verifyConfig($config);
} catch (\Throwable $e) {
return $response->withStatus(500)->withJson(Entity\Api\Error::fromException($e));
}
return $response->withJson(Entity\Api\Status::updated());
}
}

View File

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Controller\Stations;
use App\Entity\Repository\SettingsRepository;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
class EditLiquidsoapConfigAction
@ -18,7 +20,8 @@ class EditLiquidsoapConfigAction
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
SettingsRepository $settingsRepo
SettingsRepository $settingsRepo,
EventDispatcherInterface $eventDispatcher,
): ResponseInterface {
$station = $request->getStation();
@ -28,20 +31,20 @@ class EditLiquidsoapConfigAction
}
$configSections = Liquidsoap\ConfigWriter::getCustomConfigurationSections();
$config = $backend->getEditableConfiguration($station);
$tokens = Liquidsoap\ConfigWriter::getDividerString();
$event = new WriteLiquidsoapConfiguration($station, true, false);
$eventDispatcher->dispatch($event);
$config = $event->buildConfiguration();
$areas = [];
$tok = strtok($config, $tokens);
$i = 0;
while ($tok !== false) {
$tok = trim($tok);
$i++;
if (in_array($tok, $configSections, true)) {
$areas[] = [
'is_field' => true,
'is_field' => true,
'field_name' => $tok,
];
} else {

View File

@ -13,7 +13,8 @@ class WriteLiquidsoapConfiguration extends Event
public function __construct(
protected Station $station,
protected bool $forEditing = false
protected bool $forEditing = false,
protected bool $writeToDisk = true
) {
}
@ -27,6 +28,11 @@ class WriteLiquidsoapConfiguration extends Event
return $this->forEditing;
}
public function shouldWriteToDisk(): bool
{
return $this->writeToDisk && !$this->forEditing;
}
/**
* Append one of more lines to the end of the configuration string.
*

View File

@ -45,17 +45,7 @@ class Liquidsoap extends AbstractBackend
*/
public function getCurrentConfiguration(Entity\Station $station): ?string
{
return $this->doGetConfiguration($station);
}
public function getEditableConfiguration(Entity\Station $station): string
{
return $this->doGetConfiguration($station, true);
}
protected function doGetConfiguration(Entity\Station $station, bool $forEditing = false): string
{
$event = new WriteLiquidsoapConfiguration($station, $forEditing);
$event = new WriteLiquidsoapConfiguration($station, false, true);
$this->dispatcher->dispatch($event);
return $event->buildConfiguration();
@ -328,4 +318,22 @@ class Liquidsoap extends AbstractBackend
->withScheme('wss')
->withPath($base_url->getPath() . '/radio/' . $stream_port . $djMount);
}
public function verifyConfig(string $config): void
{
$binary = $this->getBinary();
$process = new Process([
$binary,
'--check',
'-',
]);
$process->setInput($config);
$process->run();
if (1 === $process->getExitCode()) {
throw new \LogicException($process->getOutput());
}
}
}

View File

@ -6,11 +6,7 @@ namespace App\Radio\Backend\Liquidsoap;
use App\Entity;
use App\Environment;
use App\Event\Radio\AnnotateNextSong;
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;
@ -18,7 +14,6 @@ use App\Radio\Enums\StreamProtocols;
use App\Radio\FallbackFile;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\StorageAttributes;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@ -43,22 +38,6 @@ class ConfigWriter implements EventSubscriberInterface
) {
}
/**
* 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[]
*/
@ -213,7 +192,7 @@ class ConfigWriter implements EventSubscriberInterface
$event->appendBlock(
<<<EOF
station_media_dir = "${stationMediaDir}"
def azuracast_media_protocol(~rlog,~maxtime,arg) =
def azuracast_media_protocol(~rlog=_,~maxtime=_,arg) =
["#{station_media_dir}/#{arg}"]
end
EOF
@ -221,11 +200,13 @@ class ConfigWriter implements EventSubscriberInterface
} else {
$event->appendBlock(
<<<EOF
def azuracast_media_protocol(~rlog,~maxtime,arg) =
def azuracast_media_protocol(~rlog=_,~maxtime,arg) =
timeout = int_of_float(maxtime - time())
j = json()
j.add("uri", arg)
[azuracast_api_call(timeout=20, "cp", json.stringify(j))]
[azuracast_api_call(timeout=timeout, "cp", json.stringify(j))]
end
EOF
);
@ -271,37 +252,13 @@ class ConfigWriter implements EventSubscriberInterface
$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' => [
'# Once per x Songs Playlists',
],
'once_per_x_minutes' => [
@ -312,9 +269,12 @@ class ConfigWriter implements EventSubscriberInterface
$scheduleSwitches = [];
$scheduleSwitchesInterrupting = [];
foreach ($playlistObjects as $playlist) {
/** @var Entity\StationPlaylist $playlist */
$playlistVarName = self::cleanUpVarName('playlist_' . $playlist->getShortName());
foreach ($station->getPlaylists() as $playlist) {
if (!$playlist->getIsEnabled()) {
continue;
}
$playlistVarName = self::getPlaylistVariableName($playlist);
if (in_array($playlistVarName, $playlistVarNames, true)) {
$playlistVarName .= '_' . $playlist->getId();
@ -325,10 +285,7 @@ class ConfigWriter implements EventSubscriberInterface
$playlistConfigLines = [];
if (Entity\Enums\PlaylistSources::Songs === $playlist->getSourceEnum()) {
$playlistFilePath = $this->writePlaylistFile($playlist, false);
if (!$playlistFilePath) {
continue;
}
$playlistFilePath = PlaylistFileWriter::getPlaylistFilePath($playlist);
$playlistParams = [
'id="' . self::cleanUpString($playlistVarName) . '"',
@ -596,79 +553,6 @@ class ConfigWriter implements EventSubscriberInterface
);
}
/**
* 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();
$playlistPath = $station->getRadioPlaylistsDir();
$playlistVarName = 'playlist_' . $playlist->getShortName();
$this->logger->info(
'Writing playlist file to disk...',
[
'station' => $station->getName(),
'playlist' => $playlist->getName(),
]
);
$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) {
$event = new AnnotateNextSong(
station: $station,
media: $mediaFile,
playlist: $playlist,
asAutoDj: false
);
try {
$this->eventDispatcher->dispatch($event);
$playlistFile[] = $event->buildAnnotations();
} catch (\Throwable $e) {
}
}
$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.
@ -1278,4 +1162,9 @@ class ConfigWriter implements EventSubscriberInterface
return $str;
}
public static function getPlaylistVariableName(Entity\StationPlaylist $playlist): string
{
return 'playlist_' . $playlist->getShortName();
}
}

View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Radio\Backend\Liquidsoap;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Event\Radio\AnnotateNextSong;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception;
use App\Flysystem\StationFilesystems;
use App\Message;
use App\Radio\Backend\Liquidsoap;
use League\Flysystem\StorageAttributes;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Filesystem\Filesystem;
class PlaylistFileWriter implements EventSubscriberInterface
{
public function __construct(
protected LoggerInterface $logger,
protected EventDispatcherInterface $eventDispatcher,
protected ReloadableEntityManagerInterface $em,
protected Filesystem $fsUtils,
protected Liquidsoap $liquidsoap,
) {
}
/**
* 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);
}
}
}
public static function getSubscribedEvents(): array
{
return [
WriteLiquidsoapConfiguration::class => [
['writePlaylistsForLiquidsoap', 32],
],
];
}
public function writePlaylistsForLiquidsoap(WriteLiquidsoapConfiguration $event): void
{
if ($event->shouldWriteToDisk()) {
$this->writeAllPlaylistFiles($event->getStation());
}
}
public function writeAllPlaylistFiles(Entity\Station $station): void
{
// 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());
}
}
foreach ($station->getPlaylists() as $playlist) {
if (!$playlist->getIsEnabled()) {
continue;
}
$this->writePlaylistFile($playlist, false);
}
}
/**
* 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();
$this->logger->info(
'Writing playlist file to disk...',
[
'station' => $station->getName(),
'playlist' => $playlist->getName(),
]
);
$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) {
$event = new AnnotateNextSong(
station: $station,
media: $mediaFile,
playlist: $playlist,
asAutoDj: false
);
try {
$this->eventDispatcher->dispatch($event);
$playlistFile[] = $event->buildAnnotations();
} catch (\Throwable $e) {
}
}
$playlistVarName = ConfigWriter::getPlaylistVariableName($playlist);
$playlistFilePath = self::getPlaylistFilePath($playlist);
$this->fsUtils->dumpFile(
$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;
}
public static function getPlaylistFilePath(Entity\StationPlaylist $playlist): string
{
return $playlist->getStation()->getRadioPlaylistsDir() . '/'
. ConfigWriter::getPlaylistVariableName($playlist) . '.m3u';
}
}