Allow user uploaded intro files for mount points.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-08-01 05:00:42 -05:00
parent 0a2fafa5ee
commit 7aefbb6d6e
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
16 changed files with 477 additions and 14 deletions

View File

@ -16,6 +16,8 @@ release channel, you can take advantage of these new features and fixes.
- You can now embed the "Schedule" panel from the station's profile into your own web page as an embeddabl component.
- Mount point updates:
- You can now upload an introduction file that will be played to listeners when they initially connect. This file
must match the bitrate and format of the stream itself, and is thus uploaded on a per-mount-point basis.
- You can now broadcast in Ogg FLAC format.
- You can now specify a maximum connected time in seconds, after which listeners are automatically disconnected.

View File

@ -477,6 +477,32 @@ return function (App $app) {
->add(Middleware\Module\StationFiles::class)
->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
$group->post(
'/mounts/intro',
Controller\Api\Stations\Mounts\Intro\PostIntroAction::class
)->setName('api:stations:mounts:new-intro')
->add(new Middleware\Permissions(Acl::STATION_MOUNTS, true));
$group->group(
'/mount/{id}',
function (RouteCollectorProxy $group) {
$group->get(
'/intro',
Controller\Api\Stations\Mounts\Intro\GetIntroAction::class
)->setName('api:stations:mounts:intro');
$group->post(
'/intro',
Controller\Api\Stations\Mounts\Intro\PostIntroAction::class
);
$group->delete(
'/intro',
Controller\Api\Stations\Mounts\Intro\DeleteIntroAction::class
);
}
)->add(new Middleware\Permissions(Acl::STATION_MOUNTS, true));
$group->get(
'/playlists/schedule',
Controller\Api\Stations\PlaylistsController::class . ':scheduleAction'

View File

@ -51,7 +51,8 @@
</data-table>
</b-card>
<edit-modal ref="editModal" :create-url="listUrl" :enable-advanced-features="enableAdvancedFeatures"
<edit-modal ref="editModal" :create-url="listUrl" :new-intro-url="newIntroUrl"
:enable-advanced-features="enableAdvancedFeatures"
:station-frontend-type="stationFrontendType" @relist="relist"></edit-modal>
</div>
</template>
@ -69,6 +70,7 @@ export default {
components: { InfoCard, Icon, EditModal, DataTable },
props: {
listUrl: String,
newIntroUrl: String,
stationFrontendType: String,
enableAdvancedFeatures: Boolean
},

View File

@ -4,9 +4,15 @@
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
<b-form class="form" @submit.prevent="doSubmit">
<b-tabs content-class="mt-3">
<mount-form-basic-info :form="$v.form" :station-frontend-type="stationFrontendType"></mount-form-basic-info>
<mount-form-auto-dj :form="$v.form" :station-frontend-type="stationFrontendType"></mount-form-auto-dj>
<mount-form-advanced v-if="enableAdvancedFeatures" :form="$v.form" :station-frontend-type="stationFrontendType"></mount-form-advanced>
<mount-form-basic-info :form="$v.form"
:station-frontend-type="stationFrontendType"></mount-form-basic-info>
<mount-form-auto-dj :form="$v.form"
:station-frontend-type="stationFrontendType"></mount-form-auto-dj>
<mount-form-intro v-model="$v.form.intro_file.$model" :record-has-intro="record.intro_path !== null"
:new-intro-url="newIntroUrl"
:edit-intro-url="record.links.intro"></mount-form-intro>
<mount-form-advanced v-if="enableAdvancedFeatures" :form="$v.form"
:station-frontend-type="stationFrontendType"></mount-form-advanced>
</b-tabs>
<invisible-submit-button/>
@ -27,23 +33,35 @@ import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import BaseEditModal from '../../Common/BaseEditModal';
import { FRONTEND_ICECAST, FRONTEND_SHOUTCAST } from '../../Entity/RadioAdapters';
import {FRONTEND_ICECAST, FRONTEND_SHOUTCAST} from '../../Entity/RadioAdapters';
import MountFormBasicInfo from './Form/BasicInfo';
import MountFormAutoDj from './Form/AutoDj';
import MountFormAdvanced from './Form/Advanced';
import MountFormIntro from "./Form/Intro";
export default {
name: 'EditModal',
mixins: [BaseEditModal],
components: { MountFormAdvanced, MountFormAutoDj, MountFormBasicInfo, InvisibleSubmitButton },
components: {MountFormIntro, MountFormAdvanced, MountFormAutoDj, MountFormBasicInfo, InvisibleSubmitButton},
props: {
stationFrontendType: String,
newIntroUrl: String,
enableAdvancedFeatures: Boolean
},
validations () {
data() {
return {
record: {
intro_path: null,
links: {
intro: null
}
}
}
},
validations() {
let validations = {
form: {
name: { required },
name: {required},
display_name: {},
is_visible_on_public_pages: {},
is_default: {},
@ -53,7 +71,8 @@ export default {
autodj_format: {},
autodj_bitrate: {},
custom_listen_url: {},
max_listener_duration: { required }
max_listener_duration: {required},
intro_file: {}
}
};
@ -76,6 +95,12 @@ export default {
},
methods: {
resetForm () {
this.record = {
intro_path: null,
links: {
intro: null
}
};
this.form = {
name: null,
display_name: null,
@ -90,10 +115,12 @@ export default {
authhash: null,
fallback_mount: '/error.mp3',
max_listener_duration: 0,
frontend_config: null
frontend_config: null,
intro_file: null
};
},
populateForm (d) {
this.record = d;
this.form = {
'name': d.name,
'display_name': d.display_name,
@ -108,7 +135,8 @@ export default {
'authhash': d.authhash,
'fallback_mount': d.fallback_mount,
'max_listener_duration': d.max_listener_duration,
'frontend_config': d.frontend_config
'frontend_config': d.frontend_config,
'intro_file': null
};
}
}

View File

@ -0,0 +1,97 @@
<template>
<b-tab :title="langTitle">
<b-form-group>
<b-row>
<b-form-group class="col-md-6" label-for="intro_file">
<template #label>
<translate key="intro_file">Select Intro File</translate>
</template>
<template #description>
<translate key="intro_file_desc">This introduction file should exactly match the bitrate and format of the mount point itself.</translate>
</template>
<flow-upload :target-url="targetUrl" :valid-mime-types="acceptMimeTypes"
@success="onFileSuccess"></flow-upload>
</b-form-group>
<b-form-group class="col-md-6">
<template #label>
<translate key="existing_intro">Current Intro File</translate>
</template>
<div v-if="hasIntro">
<div class="buttons pt-3">
<b-button v-if="editIntroUrl" block variant="bg" :href="editIntroUrl" target="_blank">
<translate key="btn_download">Download</translate>
</b-button>
<b-button block variant="danger" @click="deleteIntro">
<translate key="btn_delete_intro">Clear File</translate>
</b-button>
</div>
</div>
<div v-else>
<translate key="no_existing_intro">There is no existing intro file associated with this mount point.</translate>
</div>
</b-form-group>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
import FlowUpload from '../../../Common/FlowUpload';
export default {
name: 'MountFormIntro',
components: {FlowUpload},
props: {
value: Object,
recordHasIntro: Boolean,
editIntroUrl: String,
newIntroUrl: String
},
data() {
return {
hasIntro: this.recordHasIntro,
acceptMimeTypes: ['audio/*']
};
},
watch: {
recordHasIntro(newValue) {
this.hasIntro = newValue;
}
},
computed: {
langTitle() {
return this.$gettext('Intro');
},
targetUrl() {
return (this.editIntroUrl)
? this.editIntroUrl
: this.newIntroUrl;
}
},
methods: {
onFileSuccess(file, message) {
this.hasIntro = true;
if (!this.editIntroUrl) {
this.$emit('input', message);
}
},
deleteIntro() {
if (this.editIntroUrl) {
axios.delete(this.editIntroUrl).then((resp) => {
this.hasIntro = false;
}).catch((err) => {
handleAxiosError(err);
});
} else {
this.hasIntro = false;
this.$emit('input', null);
}
}
}
};
</script>

View File

@ -52,17 +52,22 @@ export default {
editMediaUrl: String,
newMediaUrl: String
},
data () {
data() {
return {
hasMedia: this.recordHasMedia,
acceptMimeTypes: ['audio/x-m4a', 'audio/mpeg']
};
},
watch: {
recordHasMedia(newValue) {
this.hasMedia = newValue;
}
},
computed: {
langTitle () {
langTitle() {
return this.$gettext('Media');
},
targetUrl () {
targetUrl() {
return (this.editMediaUrl)
? this.editMediaUrl
: this.newMediaUrl;

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Mounts\Intro;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DeleteIntroAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\StationMountRepository $mountRepo,
int $id
): ResponseInterface {
$station = $request->getStation();
$mount = $mountRepo->find($station, $id);
if (null === $mount) {
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
$mountRepo->clearIntro($mount);
return $response->withJson(new Entity\Api\Status());
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Mounts\Intro;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class GetIntroAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\StationMountRepository $mountRepo,
int $id
): ResponseInterface {
set_time_limit(600);
$station = $request->getStation();
$mount = $mountRepo->find($station, $id);
if ($mount instanceof Entity\StationMount) {
$introPath = $mount->getIntroPath();
if (!empty($introPath)) {
$fsConfig = (new StationFilesystems($station))->getConfigFilesystem();
if ($fsConfig->fileExists($introPath)) {
return $response->streamFilesystemFile(
$fsConfig,
$introPath,
basename($introPath)
);
}
}
}
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Mounts\Intro;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
class PostIntroAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\StationMountRepository $mountRepo,
?int $id = null
): ResponseInterface {
$station = $request->getStation();
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $id) {
$mount = $mountRepo->find($station, $id);
if (null === $mount) {
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
$mountRepo->setIntro($mount, $flowResponse);
return $response->withJson(new Entity\Api\Status());
}
return $response->withJson($flowResponse);
}
}

View File

@ -6,9 +6,15 @@ namespace App\Controller\Api\Stations;
use App\Entity;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\Router;
use App\Http\ServerRequest;
use App\Service\Flow\UploadedFile;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Annotations as OA;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @extends AbstractStationApiCrudController<Entity\StationMount>
@ -18,6 +24,15 @@ class MountsController extends AbstractStationApiCrudController
protected string $entityClass = Entity\StationMount::class;
protected string $resourceRouteName = 'api:stations:mount';
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
protected Entity\Repository\StationMountRepository $mountRepo
) {
parent::__construct($em, $serializer, $validator);
}
/**
* @OA\Get(path="/station/{station_id}/mounts",
* tags={"Stations: Mount Points"},
@ -104,12 +119,19 @@ class MountsController extends AbstractStationApiCrudController
protected function viewRecord(object $record, ServerRequest $request): mixed
{
/** @var Entity\StationMount $record */
$return = parent::viewRecord($record, $request);
$station = $request->getStation();
$frontend = $request->getStationFrontend();
$router = $request->getRouter();
$return['links']['intro'] = (string)$router->fromHere(
route_name: 'api:stations:mounts:intro',
route_params: ['id' => $record->getId()],
absolute: true
);
$return['links']['listen'] = (string)Router::resolveUri(
$router->getBaseUrl(),
$frontend->getUrlForMount($station, $record),
@ -119,6 +141,44 @@ class MountsController extends AbstractStationApiCrudController
return $return;
}
public function createAction(
ServerRequest $request,
Response $response
): ResponseInterface {
$station = $request->getStation();
$parsedBody = (array)$request->getParsedBody();
$record = $this->editRecord(
$parsedBody,
new Entity\StationMount($station)
);
if (!empty($parsedBody['intro_file'])) {
$intro = UploadedFile::fromArray($parsedBody['intro_file'], $station->getRadioTempDir());
$this->mountRepo->setIntro($record, $intro);
}
return $response->withJson($this->viewRecord($record, $request));
}
public function deleteAction(
ServerRequest $request,
Response $response,
int $station_id,
int $id
): ResponseInterface {
$record = $this->getRecord($this->getStation($request), $id);
if (null === $record) {
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
$this->mountRepo->destroy($record);
return $response->withJson(new Entity\Api\Status(true, __('Record deleted successfully.')));
}
/**
* @inheritDoc
*/

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20210801020848 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add intro file support to station mounts.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE station_mounts ADD intro_path VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE station_mounts DROP intro_path');
}
}

View File

@ -6,9 +6,82 @@ namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Service\Flow\UploadedFile;
use Azura\Files\ExtendedFilesystemInterface;
/**
* @extends Repository<Entity\StationMount>
*/
class StationMountRepository extends Repository
{
public function find(Entity\Station $station, int $id): ?Entity\StationMount
{
return $this->repository->findOneBy(
[
'station' => $station,
'id' => $id,
]
);
}
public function setIntro(
Entity\StationMount $mount,
UploadedFile $file,
?ExtendedFilesystemInterface $fs = null
): void {
$fs ??= (new StationFilesystems($mount->getStation()))->getConfigFilesystem();
if (!empty($mount->getIntroPath())) {
$this->doDeleteIntro($mount, $fs);
$mount->setIntroPath(null);
}
$originalPath = $file->getOriginalFilename();
$originalExt = pathinfo($originalPath, PATHINFO_EXTENSION);
$introPath = 'mount_' . $mount->getIdRequired() . '_intro.' . $originalExt;
$fs->uploadAndDeleteOriginal($file->getUploadedPath(), $introPath);
$mount->setIntroPath($introPath);
$this->em->persist($mount);
$this->em->flush();
}
protected function doDeleteIntro(
Entity\StationMount $mount,
?ExtendedFilesystemInterface $fs = null
): void {
$fs ??= (new StationFilesystems($mount->getStation()))->getConfigFilesystem();
$introPath = $mount->getIntroPath();
if (empty($introPath)) {
return;
}
$fs->delete($introPath);
}
public function clearIntro(
Entity\StationMount $mount,
?ExtendedFilesystemInterface $fs = null
): void {
$this->doDeleteIntro($mount, $fs);
$mount->setIntroPath(null);
$this->em->persist($mount);
$this->em->flush();
}
public function destroy(
Entity\StationMount $mount
): void {
$this->doDeleteIntro($mount);
$this->em->remove($mount);
$this->em->flush();
}
/**
* @param Entity\Station $station
*

View File

@ -88,6 +88,9 @@ class StationMount implements
#[ORM\Column(length: 255, nullable: true)]
protected ?string $custom_listen_url = null;
#[ORM\Column(length: 255, nullable: true)]
protected ?string $intro_path = null;
/** @OA\Property(type="array", @OA\Items()) */
#[ORM\Column(type: 'text', nullable: true)]
protected ?string $frontend_config = null;
@ -296,6 +299,16 @@ class StationMount implements
$this->listeners_total = $listeners_total;
}
public function getIntroPath(): ?string
{
return $this->intro_path;
}
public function setIntroPath(?string $intro_path): void
{
$this->intro_path = $intro_path;
}
public function getAutodjHost(): ?string
{
return '127.0.0.1';

View File

@ -188,6 +188,12 @@ class Icecast extends AbstractFrontend
$mount['hidden'] = 1;
}
if (!empty($mount_row->getIntroPath())) {
$introPath = $mount_row->getIntroPath();
// The intro path is appended to webroot, hence the 5 ../es. Amazingly, this works!
$mount['intro'] = '../../../../../' . $station->getRadioConfigDir() . '/' . $introPath;
}
if (!empty($mount_row->getFallbackMount())) {
$mount['fallback-mount'] = $mount_row->getFallbackMount();
$mount['fallback-override'] = 1;

View File

@ -147,6 +147,11 @@ class SHOUTcast extends AbstractFrontend
$config['streamid_' . $i] = $i;
$config['streampath_' . $i] = $mount_row->getName();
if (!empty($mount_row->getIntroPath())) {
$introPath = $mount_row->getIntroPath();
$config['streamintrofile_' . $i] = $station->getRadioConfigDir() . '/' . $introPath;
}
if ($mount_row->getRelayUrl()) {
$config['streamrelayurl_' . $i] = $mount_row->getRelayUrl();
}

View File

@ -11,6 +11,7 @@ $this->layout(
/** @var App\Http\RouterInterface $router */
$props = [
'listUrl' => (string)$router->fromHere('api:stations:mounts'),
'newIntroUrl' => (string)$router->fromHere('api:stations:mounts:new-intro'),
'stationFrontendType' => $station->getFrontendType(),
'enableAdvancedFeatures' => $enableAdvancedFeatures,
];