Add new interruptable queue and "play immediately" function.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-05-01 01:43:20 -05:00
parent 70628d315a
commit 551e41164c
No known key found for this signature in database
GPG Key ID: 9FC8B9E008872109
10 changed files with 199 additions and 57 deletions

View File

@ -40,6 +40,7 @@
<media-toolbar :batch-url="batchUrl" :selected-items="selectedItems"
:current-directory="currentDirectory"
:supports-immediate-queue="supportsImmediateQueue"
:playlists="playlists" @add-playlist="onAddPlaylist"
@relist="onTriggerRelist"></media-toolbar>
</div>
@ -221,7 +222,8 @@ export default {
},
stationTimeZone: String,
showSftp: Boolean,
sftpUrl: String
sftpUrl: String,
supportsImmediateQueue: Boolean
},
data () {
let fields = [

View File

@ -53,6 +53,10 @@
<b-dropdown-item @click="doQueue" v-b-tooltip.hover :title="langQueue">
<translate key="lang_btn_queue">Queue</translate>
</b-dropdown-item>
<b-dropdown-item v-if="supportsImmediateQueue" @click="doImmediateQueue" v-b-tooltip.hover
:title="langImmediateQueue">
<translate key="lang_btn_immediate_queue">Play Now</translate>
</b-dropdown-item>
<b-dropdown-item @click="doReprocess" v-b-tooltip.hover :title="langReprocess">
<translate key="lang_btn_reprocess">Reprocess</translate>
</b-dropdown-item>
@ -83,7 +87,8 @@ export default {
currentDirectory: String,
selectedItems: Object,
playlists: Array,
batchUrl: String
batchUrl: String,
supportsImmediateQueue: Boolean
},
data () {
return {
@ -110,36 +115,42 @@ export default {
}
},
computed: {
langPlaylistDropdown () {
langPlaylistDropdown() {
return this.$gettext('Set or clear playlists from the selected media');
},
langNewPlaylist () {
langNewPlaylist() {
return this.$gettext('New Playlist');
},
langMore () {
langMore() {
return this.$gettext('More');
},
langQueue () {
langImmediateQueue() {
return this.$gettext('Make the selected media play immediately, interrupting existing media');
},
langQueue() {
return this.$gettext('Queue the selected media to play next');
},
langReprocess () {
langReprocess() {
return this.$gettext('Analyze and reprocess the selected media');
},
langErrors () {
langErrors() {
return this.$gettext('The request could not be processed.');
},
newPlaylistIsChecked () {
newPlaylistIsChecked() {
return this.newPlaylist !== '';
}
},
methods: {
doQueue (e) {
doImmediateQueue(e) {
this.doBatch('immediate', this.$gettext('Files played immediately:'));
},
doQueue(e) {
this.doBatch('queue', this.$gettext('Files queued for playback:'));
},
doReprocess (e) {
doReprocess(e) {
this.doBatch('reprocess', this.$gettext('Files marked for reprocessing:'));
},
doDelete (e) {
doDelete(e) {
let buttonConfirmText = this.$gettext('Delete %{ num } media files?');
let numFiles = this.selectedItems.all.length;

View File

@ -6,18 +6,23 @@ namespace App\Controller\Api\Stations\Files;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Event\Radio\AnnotateNextSong;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Media\BatchUtilities;
use App\Message;
use App\MessageQueue\QueueManagerInterface;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Enums\BackendAdapters;
use App\Radio\Enums\LiquidsoapQueues;
use App\Utilities\File;
use Azura\Files\ExtendedFilesystemInterface;
use Exception;
use InvalidArgumentException;
use League\Flysystem\StorageAttributes;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Messenger\MessageBus;
use Throwable;
@ -29,6 +34,8 @@ class BatchAction
protected ReloadableEntityManagerInterface $em,
protected MessageBus $messageBus,
protected QueueManagerInterface $queueManager,
protected Adapters $adapters,
protected EventDispatcherInterface $eventDispatcher,
protected Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo,
protected Entity\Repository\StationPlaylistFolderRepository $playlistFolderRepo,
protected Entity\Repository\StationQueueRepository $queueRepo,
@ -49,6 +56,7 @@ class BatchAction
'playlist' => $this->doPlaylist($request, $station, $storageLocation, $fsMedia),
'move' => $this->doMove($request, $station, $storageLocation, $fsMedia),
'queue' => $this->doQueue($request, $station, $storageLocation, $fsMedia),
'immediate' => $this->doPlayImmediately($request, $station, $storageLocation, $fsMedia),
'reprocess' => $this->doReprocess($request, $station, $storageLocation, $fsMedia),
default => throw new InvalidArgumentException('Invalid batch action specified.')
};
@ -289,6 +297,67 @@ class BatchAction
return $result;
}
public function doPlayImmediately(
ServerRequest $request,
Entity\Station $station,
Entity\StorageLocation $storageLocation,
ExtendedFilesystemInterface $fs
): Entity\Api\BatchResult {
$result = $this->parseRequest($request, $fs, true);
if (BackendAdapters::Liquidsoap !== $station->getBackendTypeEnum()) {
throw new \RuntimeException('This functionality can only be used on stations that use Liquidsoap.');
}
/** @var Liquidsoap $backend */
$backend = $this->adapters->getBackendAdapter($station);
if ($station->useManualAutoDJ()) {
foreach ($this->batchUtilities->iterateMedia($storageLocation, $result->files) as $media) {
/** @var Entity\Station $station */
$station = $this->em->find(Entity\Station::class, $station->getIdRequired());
$event = AnnotateNextSong::fromStationMedia($station, $media, true);
$this->eventDispatcher->dispatch($event);
$backend->enqueue(
$station,
LiquidsoapQueues::Interrupting,
$event->buildAnnotations()
);
}
} else {
$cuedTimestamp = time();
foreach ($this->batchUtilities->iterateMedia($storageLocation, $result->files) as $media) {
try {
/** @var Entity\Station $station */
$station = $this->em->find(Entity\Station::class, $station->getIdRequired());
$newQueue = Entity\StationQueue::fromMedia($station, $media);
$newQueue->setTimestampCued($cuedTimestamp);
$newQueue->setIsPlayed(true);
$this->em->persist($newQueue);
$event = AnnotateNextSong::fromStationQueue($newQueue, true);
$this->eventDispatcher->dispatch($event);
$backend->enqueue(
$station,
LiquidsoapQueues::Interrupting,
$event->buildAnnotations()
);
} catch (Throwable $e) {
$result->errors[] = $media->getPath() . ': ' . $e->getMessage();
}
$cuedTimestamp += 10;
}
}
return $result;
}
public function doReprocess(
ServerRequest $request,
Entity\Station $station,

View File

@ -35,27 +35,30 @@ class FilesAction
$router = $request->getRouter();
$backend = $request->getStationBackend();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsMedia',
id: 'media-manager',
title: __('Music Files'),
props: [
'listUrl' => (string)$router->fromHere('api:stations:files:list'),
'batchUrl' => (string)$router->fromHere('api:stations:files:batch'),
'uploadUrl' => (string)$router->fromHere('api:stations:files:upload'),
'listUrl' => (string)$router->fromHere('api:stations:files:list'),
'batchUrl' => (string)$router->fromHere('api:stations:files:batch'),
'uploadUrl' => (string)$router->fromHere('api:stations:files:upload'),
'listDirectoriesUrl' => (string)$router->fromHere('api:stations:files:directories'),
'mkdirUrl' => (string)$router->fromHere('api:stations:files:mkdir'),
'renameUrl' => (string)$router->fromHere('api:stations:files:rename'),
'quotaUrl' => (string)$router->fromHere('api:stations:quota', [
'mkdirUrl' => (string)$router->fromHere('api:stations:files:mkdir'),
'renameUrl' => (string)$router->fromHere('api:stations:files:rename'),
'quotaUrl' => (string)$router->fromHere('api:stations:quota', [
'type' => Entity\Enums\StorageLocationTypes::StationMedia->value,
]),
'initialPlaylists' => $playlists,
'customFields' => $customFieldRepo->fetchArray(),
'validMimeTypes' => MimeType::getProcessableTypes(),
'stationTimeZone' => $station->getTimezone(),
'showSftp' => SftpGo::isSupportedForStation($station),
'sftpUrl' => (string)$router->fromHere('stations:sftp_users:index'),
'initialPlaylists' => $playlists,
'customFields' => $customFieldRepo->fetchArray(),
'validMimeTypes' => MimeType::getProcessableTypes(),
'stationTimeZone' => $station->getTimezone(),
'showSftp' => SftpGo::isSupportedForStation($station),
'sftpUrl' => (string)$router->fromHere('stations:sftp_users:index'),
'supportsImmediateQueue' => $backend->supportsImmediateQueue(),
],
);
}

View File

@ -97,6 +97,18 @@ class AnnotateNextSong extends Event
return $this->songPath;
}
public static function fromStationMedia(
Entity\Station $station,
Entity\StationMedia $media,
bool $asAutoDj = false
): self {
return new self(
station: $station,
media: $media,
asAutoDj: $asAutoDj
);
}
public static function fromStationQueue(
Entity\StationQueue $queue,
bool $asAutoDj = false

View File

@ -29,6 +29,11 @@ abstract class AbstractBackend extends AbstractAdapter
return false;
}
public function supportsImmediateQueue(): bool
{
return false;
}
public function getStreamPort(Entity\Station $station): ?int
{
return null;

View File

@ -7,6 +7,7 @@ namespace App\Radio\Backend;
use App\Entity;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception;
use App\Radio\Enums\LiquidsoapQueues;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Process\Process;
@ -32,6 +33,11 @@ class Liquidsoap extends AbstractBackend
return true;
}
public function supportsImmediateQueue(): bool
{
return true;
}
/**
* @inheritDoc
*/
@ -236,23 +242,28 @@ class Liquidsoap extends AbstractBackend
: null;
}
public function isQueueEmpty(Entity\Station $station): bool
{
$queue = $this->command(
public function isQueueEmpty(
Entity\Station $station,
LiquidsoapQueues $queue
): bool {
$queueResult = $this->command(
$station,
'requests.queue'
sprintf('%s.queue', $queue->value)
);
return empty($queue[0]);
return empty($queueResult[0]);
}
/**
* @return string[]
*/
public function enqueue(Entity\Station $station, string $music_file): array
{
public function enqueue(
Entity\Station $station,
LiquidsoapQueues $queue,
string $music_file
): array {
return $this->command(
$station,
'requests.push ' . $music_file
sprintf('%s.push %s', $queue->value, $music_file)
);
}
@ -263,7 +274,7 @@ class Liquidsoap extends AbstractBackend
{
return $this->command(
$station,
'requests_fallback.skip'
'interrupting_fallback.skip'
);
}

View File

@ -9,6 +9,7 @@ use App\Environment;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Enums\FrontendAdapters;
use App\Radio\Enums\LiquidsoapQueues;
use App\Radio\Enums\StreamFormats;
use App\Radio\Enums\StreamProtocols;
use App\Radio\FallbackFile;
@ -108,11 +109,6 @@ class ConfigWriter implements EventSubscriberInterface
$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());
@ -126,9 +122,11 @@ class ConfigWriter implements EventSubscriberInterface
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.bind_addr.set("127.0.0.1")
settings.server.telnet.port.set(${telnetPort})
settings.harbor.bind_addrs.set(["0.0.0.0"])
settings.tag.encodings.set(["UTF-8","ISO-8859-1"])
@ -465,6 +463,23 @@ class ConfigWriter implements EventSubscriberInterface
}
}
if (!empty($scheduleSwitchesInterrupting)) {
$event->appendLines(['# Interrupting Schedule Switches']);
foreach (array_chunk($scheduleSwitchesInterrupting, 168, true) as $scheduleSwitchesChunk) {
$scheduleSwitchesChunk[] = '({true}, radio)';
$event->appendLines(
[
sprintf(
'radio = switch(id="schedule_switch", track_sensitive=false, [ %s ])',
implode(', ', $scheduleSwitchesChunk)
),
]
);
}
}
// Add in special playlists if necessary.
foreach ($specialPlaylists as $playlistConfigLines) {
if (count($playlistConfigLines) > 1) {
@ -532,26 +547,19 @@ class ConfigWriter implements EventSubscriberInterface
);
}
if (!empty($scheduleSwitchesInterrupting)) {
$scheduleSwitchesInterrupting[] = '({true}, radio)';
$event->appendLines(
[
'# Interrupting Schedule Switches',
sprintf(
'radio = switch(id="interrupt_switch", track_sensitive=false, [ %s ])',
implode(', ', $scheduleSwitchesInterrupting)
),
]
);
}
$requestsQueueName = LiquidsoapQueues::Requests->value;
$interruptingQueueName = LiquidsoapQueues::Interrupting->value;
$event->appendBlock(
<<< EOF
requests = request.queue(id="requests")
requests = cue_cut(id="cue_requests", requests)
requests = request.queue(id="${requestsQueueName}")
requests = cue_cut(id="cue_${requestsQueueName}", requests)
radio = fallback(id="requests_fallback", track_sensitive = true, [requests, radio])
interrupting_queue = request.queue(id="${interruptingQueueName}")
interrupting_queue = cue_cut(id="cue_${interruptingQueueName}", interrupting_queue)
radio = fallback(id="interrupting_fallback", track_sensitive = false, [interrupting_queue, radio])
add_skip_command(radio)
EOF
);

View File

@ -0,0 +1,18 @@
<?php
// phpcs:ignoreFile
declare(strict_types=1);
namespace App\Radio\Enums;
enum LiquidsoapQueues: string
{
case Requests = 'requests';
case Interrupting = 'interrupting_requests';
public static function default(): self
{
return self::Requests;
}
}

View File

@ -9,6 +9,7 @@ use App\Entity;
use App\Event\Radio\AnnotateNextSong;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Enums\LiquidsoapQueues;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
@ -84,14 +85,16 @@ class CheckRequestsTask extends AbstractTask
$track = $event->buildAnnotations();
// Queue request with Liquidsoap.
if (!$backend->isQueueEmpty($station)) {
$queue = LiquidsoapQueues::Requests;
if (!$backend->isQueueEmpty($station, $queue)) {
$this->logger->error('Skipping submitting request to Liquidsoap; current queue is occupied.');
return false;
}
$this->logger->debug('Submitting request to AutoDJ.', ['track' => $track]);
$response = $backend->enqueue($station, $track);
$response = $backend->enqueue($station, $queue, $track);
$this->logger->debug('AutoDJ request response', ['response' => $response]);
// Log the request as played.