Add new interruptable queue and "play immediately" function.
This commit is contained in:
parent
70628d315a
commit
551e41164c
|
@ -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 = [
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue