Merge commit '3de709270d80eda9806162246f1778fd78fa5b99'

This commit is contained in:
Buster "Silver Eagle" Neece 2022-06-03 22:39:02 -05:00
parent 361c1ce169
commit fac86b77f2
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
37 changed files with 1055 additions and 17 deletions

View File

@ -5,6 +5,13 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **HLS Support**: We now support the HTTP Live Streaming (HLS) format from directly within the AzuraCast web UI. Once
enabled, you can configure the various bitrates and formats of your HLS stream the same way you would configure mount
points; unlike mount points, however, your connecting listeners will automatically pick the one that suits their
bandwidth the best. While this technology was originally developed for Apple devices, it has seen widespread adoption
elsewhere. Note that because of how HLS is delivered, we cannot currently retrieve listener statistics for these
streams.
- **Integrated Stereo Tool Support**: We now support the popular premium sound processing tool, Stereo Tool. Because the
software is proprietary, you must first upload a copy of it via the System Administration page; you can then configure
Stereo Tool on a per-station level, including uploading your own custom `.sts` configuration file.

View File

@ -223,6 +223,12 @@ return function (App\Event\BuildStationMenu $e) {
'visible' => $frontend->supportsMounts(),
'permission' => StationPermissions::MountPoints,
],
'hls_streams' => [
'label' => __('HLS Streams'),
'url' => (string)$router->fromHere('stations:hls_streams:index'),
'visible' => $backend->supportsHls(),
'permission' => StationPermissions::MountPoints,
],
'remotes' => [
'label' => __('Remote Relays'),
'icon' => 'router',

View File

@ -312,6 +312,12 @@ return static function (RouteCollectorProxy $group) {
Controller\Api\Stations\FilesController::class,
StationPermissions::Media,
],
[
'hls_stream',
'hls_streams',
Controller\Api\Stations\HlsStreamsController::class,
StationPermissions::MountPoints,
],
[
'mount',
'mounts',

View File

@ -38,6 +38,10 @@ return static function (RouteCollectorProxy $app) {
->setName('stations:files:index')
->add(new Middleware\Permissions(StationPermissions::Media, true));
$group->get('/hls_streams', Controller\Stations\HlsStreamsAction::class)
->setName('stations:hls_streams:index')
->add(new Middleware\Permissions(StationPermissions::MountPoints, true));
$group->get('/ls_config', Controller\Stations\EditLiquidsoapConfigAction::class)
->setName('stations:util:ls_config')
->add(new Middleware\Permissions(StationPermissions::Broadcasting, true));

View File

@ -39,6 +39,7 @@
"gulp-run-command": "0.0.10",
"gulp-sourcemaps": "^3",
"gulp-uglify": "^3.0.2",
"hls.js": "^1.1.5",
"humanize-duration": "^3.27.0",
"imports-loader": "^3.0.0",
"jquery": "^3.6.0",
@ -5299,6 +5300,11 @@
"he": "bin/he"
}
},
"node_modules/hls.js": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.1.5.tgz",
"integrity": "sha512-mQX5TSNtJEzGo5HPpvcQgCu+BWoKDQM6YYtg/KbgWkmVAcqOCvSTi0SuqG2ZJLXxIzdnFcKU2z7Mrw/YQWhPOA=="
},
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
@ -13769,6 +13775,11 @@
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"hls.js": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.1.5.tgz",
"integrity": "sha512-mQX5TSNtJEzGo5HPpvcQgCu+BWoKDQM6YYtg/KbgWkmVAcqOCvSTi0SuqG2ZJLXxIzdnFcKU2z7Mrw/YQWhPOA=="
},
"homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",

View File

@ -40,6 +40,7 @@
"gulp-run-command": "0.0.10",
"gulp-sourcemaps": "^3",
"gulp-uglify": "^3.0.2",
"hls.js": "^1.1.5",
"humanize-duration": "^3.27.0",
"imports-loader": "^3.0.0",
"jquery": "^3.6.0",

View File

@ -70,6 +70,16 @@
</b-form-radio-group>
</template>
</b-wrapped-form-group>
<b-wrapped-form-checkbox class="col-md-12" id="edit_form_enable_hls"
:field="form.enable_hls">
<template #label="{lang}">
<translate :key="lang">Enable HTTP Live Streaming (HLS)</translate>
</template>
<template #description="{lang}">
<translate :key="lang">HTTP Live Streaming (HLS) is a new adaptive-bitrate technology supported by some clients. It does not use the standard broadcasting frontends.</translate>
</template>
</b-wrapped-form-checkbox>
</b-form-row>
</b-form-fieldset>

View File

@ -100,6 +100,7 @@ export default {
timezone: {},
enable_public_page: {},
enable_on_demand: {},
enable_hls: {},
default_album_art_url: {},
enable_on_demand_download: {},
frontend_type: {required},
@ -256,6 +257,7 @@ export default {
timezone: 'UTC',
enable_public_page: true,
enable_on_demand: false,
enable_hls: false,
default_album_art_url: '',
enable_on_demand_download: true,
frontend_type: FRONTEND_ICECAST,

View File

@ -8,6 +8,7 @@
import store from 'store';
import getLogarithmicVolume from '~/functions/getLogarithmicVolume.js';
import vueStore from '~/store.js';
import Hls from 'hls.js';
export default {
props: {
@ -116,22 +117,35 @@ export default {
this.audio.volume = getLogarithmicVolume(this.volume);
this.audio.src = this.current.url;
if (this.current.isHls) {
// HLS playback support
if (Hls.isSupported()) {
let hls = new Hls();
hls.loadSource(this.current.url);
hls.attachMedia(this.audio);
} else if (this.audio.canPlayType('application/vnd.apple.mpegurl')) {
this.audio.src = this.current.url;
}
} else {
// Standard streams
this.audio.src = this.current.url;
// Firefox caches the downloaded stream, this causes playback issues.
// Giving the browser a new url on each start bypasses the old cache/buffer
if (navigator.userAgent.includes("Firefox")) {
this.audio.src += "?refresh=" + Date.now();
// Firefox caches the downloaded stream, this causes playback issues.
// Giving the browser a new url on each start bypasses the old cache/buffer
if (navigator.userAgent.includes("Firefox")) {
this.audio.src += "?refresh=" + Date.now();
}
}
this.audio.load();
this.audio.play();
});
},
toggle(url, isStream) {
toggle(url, isStream, isHls) {
vueStore.commit('player/toggle', {
url: url,
isStream: isStream
isStream: isStream,
isHls: isHls,
});
},
getVolume() {

View File

@ -20,6 +20,10 @@ export default {
type: Boolean,
default: false
},
isHls: {
type: Boolean,
default: false
},
iconClass: String
},
computed: {
@ -53,7 +57,8 @@ export default {
toggle() {
store.commit('player/toggle', {
url: this.url,
isStream: this.isStream
isStream: this.isStream,
isHls: this.isHls
});
}
}

View File

@ -0,0 +1,118 @@
<template>
<div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title" key="lang_title" v-translate>HLS Streams</h2>
</b-card-header>
<info-card>
<p class="card-text">
<translate key="lang_card_info">HTTP Live Streaming (HLS) is a new adaptive-bitrate streaming technology. From this page, you can configure the individual bitrates and formats that are included in the combined HLS stream.</translate>
</p>
</info-card>
<b-card-body body-class="card-padding-sm">
<b-button variant="outline-primary" @click.prevent="doCreate">
<icon icon="add"></icon>
<translate key="lang_add_btn">Add HLS Stream</translate>
</b-button>
</b-card-body>
<data-table ref="datatable" id="station_hls_streams" :fields="fields" paginated
:api-url="listUrl">
<template #cell(name)="row">
<h5 class="m-0">{{ row.item.name }}</h5>
</template>
<template #cell(format)="row">
{{ row.item.format|upper }}
</template>
<template #cell(bitrate)="row">
{{ row.item.bitrate }}kbps
</template>
<template #cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links.self)">
<translate key="lang_btn_edit">Edit</translate>
</b-button>
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links.self)">
<translate key="lang_btn_delete">Delete</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</b-card>
<edit-modal ref="editModal" :create-url="listUrl" @relist="relist" @needs-restart="mayNeedRestart"></edit-modal>
</div>
</template>
<script>
import DataTable from '~/components/Common/DataTable';
import EditModal from './HlsStreams/EditModal';
import Icon from '~/components/Common/Icon';
import InfoCard from '~/components/Common/InfoCard';
export default {
name: 'StationHlsStreams',
components: {InfoCard, Icon, EditModal, DataTable},
props: {
listUrl: String,
restartStatusUrl: String
},
data() {
return {
fields: [
{key: 'name', isRowHeader: true, label: this.$gettext('Name'), sortable: true},
{key: 'format', label: this.$gettext('Format'), sortable: true},
{key: 'bitrate', label: this.$gettext('Bitrate'), sortable: true},
{key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink'}
]
};
},
filters: {
upper(data) {
let upper = [];
data.split(' ').forEach((word) => {
upper.push(word.toUpperCase());
});
return upper.join(' ');
}
},
methods: {
relist() {
this.$refs.datatable.refresh();
},
doCreate() {
this.$refs.editModal.create();
},
doEdit(url) {
this.$refs.editModal.edit(url);
},
doDelete(url) {
this.$confirmDelete({
title: this.$gettext('Delete HLS Stream?'),
}).then((result) => {
if (result.value) {
this.$wrapWithLoading(
this.axios.delete(url)
).then((resp) => {
this.$notifySuccess(resp.data.message);
this.needsRestart();
this.relist();
});
}
});
},
mayNeedRestart() {
this.axios.get(this.restartStatusUrl).then((resp) => {
if (resp.data.needs_restart) {
this.needsRestart();
}
});
},
needsRestart() {
document.dispatchEvent(new CustomEvent("station-needs-restart"));
}
}
};
</script>

View File

@ -0,0 +1,58 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit" @hidden="clearContents">
<b-tabs content-class="mt-3">
<form-basic-info :form="$v.form"></form-basic-info>
</b-tabs>
</modal-form>
</template>
<script>
import {required} from 'vuelidate/dist/validators.min.js';
import BaseEditModal from '~/components/Common/BaseEditModal';
import FormBasicInfo from './Form/BasicInfo';
import mergeExisting from "~/functions/mergeExisting";
export default {
name: 'EditModal',
emits: ['needs-restart'],
mixins: [BaseEditModal],
components: {FormBasicInfo},
validations() {
return {
form: {
name: {required},
format: {required},
bitrate: {required}
}
};
},
computed: {
langTitle() {
return this.isEditMode
? this.$gettext('Edit HLS Stream')
: this.$gettext('Add HLS Stream');
}
},
methods: {
resetForm() {
this.form = {
name: null,
format: 'aac',
bitrate: 128
};
},
populateForm(d) {
this.record = d;
this.form = mergeExisting(this.form, d);
},
onSubmitSuccess(response) {
this.$notifySuccess();
this.$emit('needs-restart');
this.$emit('relist');
this.close();
},
}
};
</script>

View File

@ -0,0 +1,86 @@
<template>
<b-tab :title="langTabTitle" active>
<b-form-group>
<b-form-row class="mb-3">
<b-wrapped-form-group class="col-md-12" id="edit_form_name" :field="form.name">
<template #label="{lang}">
<translate :key="lang">Programmatic Name</translate>
</template>
<template #description="{lang}">
<translate :key="lang">A name for this stream that will be used internally in code. Should only contain letters, numbers, and underscores (i.e. "stream_lofi").</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="edit_form_format" :field="form.format">
<template #label="{lang}">
<translate :key="lang">Audio Format</translate>
</template>
<template #default="props">
<b-form-radio-group
stacked
:id="props.id"
:state="props.state"
v-model="props.field.$model"
:options="formatOptions"
></b-form-radio-group>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="edit_form_bitrate" :field="form.bitrate">
<template #label="{lang}">
<translate :key="lang">Audio Bitrate (kbps)</translate>
</template>
<template #default="props">
<b-form-radio-group
stacked
:id="props.id"
:state="props.state"
v-model="props.field.$model"
:options="bitrateOptions"
></b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-form-row>
</b-form-group>
</b-tab>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import BWrappedFormCheckbox from "~/components/Form/BWrappedFormCheckbox";
export default {
name: 'HlsStreamFormBasicInfo',
components: {BWrappedFormCheckbox, BWrappedFormGroup},
props: {
form: Object,
stationFrontendType: String
},
computed: {
langTabTitle() {
return this.$gettext('Basic Info');
},
formatOptions() {
return [
{
value: 'aac',
text: 'AAC'
},
{
value: 'mp3',
text: 'MP3'
}
];
},
bitrateOptions() {
let options = [];
[32, 48, 64, 96, 128, 192, 256, 320].forEach((val) => {
options.push({
value: val,
text: val
});
});
return options;
},
}
};
</script>

View File

@ -64,6 +64,24 @@
</tr>
</tbody>
</template>
<template v-if="np.station.hls_enabled">
<thead>
<tr>
<th colspan="3" key="lang_streams_hls" v-translate>HTTP Live Streaming (HLS)</th>
</tr>
</thead>
<tbody>
<tr class="align-middle">
<td class="pr-1">
<play-button icon-class="outlined" :url="np.station.hls_url" is-stream is-hls></play-button>
</td>
<td class="pl-1" colspan="2">
<a v-bind:href="np.station.hls_url" target="_blank">{{ np.station.hls_url }}</a>
</td>
</tr>
</tbody>
</template>
</table>
<div class="card-actions">
<a class="btn btn-outline-primary" :href="np.station.playlist_pls_url">

View File

@ -0,0 +1,8 @@
import initBase from '~/base.js';
import '~/vendor/bootstrapVue.js';
import '~/vendor/sweetalert.js';
import HlsStreams from '~/components/Stations/HlsStreams.vue';
export default initBase(HlsStreams);

View File

@ -36,6 +36,7 @@ module.exports = {
StationsAutomation: '~/pages/Stations/Automation.js',
StationsBulkMedia: '~/pages/Stations/BulkMedia.js',
StationsFallback: '~/pages/Stations/Fallback.js',
StationsHlsStreams: '~/pages/Stations/HlsStreams.js',
StationsLiquidsoapConfig: '~/pages/Stations/LiquidsoapConfig.js',
StationsMedia: '~/pages/Stations/Media.js',
StationsMounts: '~/pages/Stations/Mounts.js',

View File

@ -314,16 +314,24 @@ class StationsController extends AbstractAdminApiCrudController
// Get the original values to check for changes.
$old_frontend = $original_record['frontend_type'];
$old_backend = $original_record['backend_type'];
$old_hls = (bool)$original_record['enable_hls'];
$frontend_changed = ($old_frontend !== $station->getFrontendType());
$backend_changed = ($old_backend !== $station->getBackendType());
$adapter_changed = $frontend_changed || $backend_changed;
$hls_changed = $old_hls !== $station->getEnableHls();
if ($frontend_changed) {
$frontend = $this->adapters->getFrontendAdapter($station);
$this->stationRepo->resetMounts($station, $frontend);
}
if ($hls_changed || $backend_changed) {
$backend = $this->adapters->getBackendAdapter($station);
$this->stationRepo->resetHls($station, $backend);
}
if ($adapter_changed || !$station->getIsEnabled()) {
try {
$this->configuration->writeConfiguration(

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations;
use App\Entity;
use App\Exception\StationUnsupportedException;
use App\Http\ServerRequest;
use App\OpenApi;
use OpenApi\Attributes as OA;
/** @extends AbstractStationApiCrudController<Entity\StationHlsStream> */
#[
OA\Get(
path: '/station/{station_id}/hls_streams',
operationId: 'getHlsStreams',
description: 'List all current HLS streams.',
security: OpenApi::API_KEY_SECURITY,
tags: ['Stations: HLS Streams'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
],
responses: [
new OA\Response(
response: 200,
description: 'Success',
content: new OA\JsonContent(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/StationMount')
)
),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
),
OA\Post(
path: '/station/{station_id}/hls_streams',
operationId: 'addHlsStream',
description: 'Create a new HLS stream.',
security: OpenApi::API_KEY_SECURITY,
requestBody: new OA\RequestBody(
content: new OA\JsonContent(ref: '#/components/schemas/StationMount')
),
tags: ['Stations: HLS Streams'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
],
responses: [
new OA\Response(
response: 200,
description: 'Success',
content: new OA\JsonContent(ref: '#/components/schemas/StationMount')
),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
),
OA\Get(
path: '/station/{station_id}/hls_stream/{id}',
operationId: 'getHlsStream',
description: 'Retrieve details for a single HLS stream.',
security: OpenApi::API_KEY_SECURITY,
tags: ['Stations: HLS Streams'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
new OA\Parameter(
name: 'id',
description: 'HLS Stream ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer', format: 'int64')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Success',
content: new OA\JsonContent(ref: '#/components/schemas/StationMount')
),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_NOT_FOUND, response: 404),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
),
OA\Put(
path: '/station/{station_id}/hls_stream/{id}',
operationId: 'editHlsStream',
description: 'Update details of a single HLS stream.',
security: OpenApi::API_KEY_SECURITY,
requestBody: new OA\RequestBody(
content: new OA\JsonContent(ref: '#/components/schemas/StationMount')
),
tags: ['Stations: HLS Streams'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
new OA\Parameter(
name: 'id',
description: 'HLS Stream ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer', format: 'int64')
),
],
responses: [
new OA\Response(ref: OpenApi::REF_RESPONSE_SUCCESS, response: 200),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_NOT_FOUND, response: 404),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
),
OA\Delete(
path: '/station/{station_id}/hls_stream/{id}',
operationId: 'deleteHlsStream',
description: 'Delete a single HLS stream.',
security: OpenApi::API_KEY_SECURITY,
tags: ['Stations: HLS Streams'],
parameters: [
new OA\Parameter(ref: OpenApi::REF_STATION_ID_REQUIRED),
new OA\Parameter(
name: 'id',
description: 'HLS Stream ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer', format: 'int64')
),
],
responses: [
new OA\Response(ref: OpenApi::REF_RESPONSE_SUCCESS, response: 200),
new OA\Response(ref: OpenApi::REF_RESPONSE_ACCESS_DENIED, response: 403),
new OA\Response(ref: OpenApi::REF_RESPONSE_NOT_FOUND, response: 404),
new OA\Response(ref: OpenApi::REF_RESPONSE_GENERIC_ERROR, response: 500),
]
)
]
final class HlsStreamsController extends AbstractStationApiCrudController
{
protected string $entityClass = Entity\StationHlsStream::class;
protected string $resourceRouteName = 'api:stations:hls_stream';
/**
* @inheritDoc
*/
protected function getStation(ServerRequest $request): Entity\Station
{
$station = parent::getStation($request);
$backend = $request->getStationBackend();
if (!$backend->supportsHls()) {
throw new StationUnsupportedException();
}
return $station;
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Controller\Stations;
use App\Entity\Repository\StationRepository;
use App\Exception\StationUnsupportedException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use Psr\Http\Message\ResponseInterface;
final class HlsStreamsAction
{
public function __construct(
private readonly StationRepository $stationRepo
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
$station = $request->getStation();
$backend = $request->getStationBackend();
if (!$backend->supportsHls()) {
throw new StationUnsupportedException();
}
$view = $request->getView();
if (!$station->getEnableHls()) {
$params = $request->getQueryParams();
if (isset($params['enable'])) {
$station->setEnableHls(true);
$em = $this->stationRepo->getEntityManager();
$em->persist($station);
$em->flush();
$this->stationRepo->resetHls($station, $request->getStationBackend());
$request->getFlash()->addMessage(
'<b>' . __('HLS enabled!') . '</b>',
Flash::SUCCESS
);
return $response->withRedirect((string)$request->getRouter()->fromHere('stations:hls:index'));
}
return $view->renderToResponse($response, 'stations/hls/disabled');
}
$router = $request->getRouter();
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_StationsHlsStreams',
id: 'station-hls-streams',
title: __('HLS Streams'),
props: [
'listUrl' => (string)$router->fromHere('api:stations:hls_streams'),
'restartStatusUrl' => (string)$router->fromHere('api:stations:restart-status'),
],
);
}
}

View File

@ -44,6 +44,7 @@ class StationRequiresRestart implements EventSubscriber
foreach ($collection as $entity) {
if (
($entity instanceof Entity\StationMount)
|| ($entity instanceof Entity\StationHlsStream)
|| ($entity instanceof Entity\StationRemote && $entity->isEditable())
|| ($entity instanceof Entity\StationPlaylist && $entity->getStation()->useManualAutoDJ())
) {

View File

@ -99,6 +99,19 @@ class Station implements ResolvableUrlInterface
#[OA\Property]
public array $remotes = [];
#[OA\Property(
description: 'If the station has HLS streaming enabled.',
example: true
)]
public bool $hls_enabled = false;
/** @var string|null|UriInterface */
#[OA\Property(
description: 'The full URL to listen to the HLS stream for the station.',
example: 'https://example.com/hls/azuratest_radio/live.m3u8'
)]
public $hls_url;
/**
* Re-resolve any Uri instances to reflect base URL changes.
*
@ -117,5 +130,9 @@ class Station implements ResolvableUrlInterface
$mount->resolveUrls($base);
}
}
$this->hls_url = (null !== $this->hls_url)
? (string)Router::resolveUri($base, $this->hls_url, true)
: null;
}
}

View File

@ -23,6 +23,7 @@ class StationApiGenerator
bool $showAllMounts = false
): Entity\Api\NowPlaying\Station {
$fa = $this->adapters->getFrontendAdapter($station);
$backend = $this->adapters->getBackendAdapter($station);
$remoteAdapters = $this->adapters->getRemoteAdapters($station);
$response = new Entity\Api\NowPlaying\Station();
@ -68,6 +69,9 @@ class StationApiGenerator
}
$response->remotes = $remotes;
$response->hls_enabled = $backend->supportsHls() && $station->getEnableHls();
$response->hls_url = $backend->getHlsUrl($station, $baseUri);
return $response;
}
}

View File

@ -20,6 +20,7 @@ class Station extends AbstractFixture
$station->setEnableRequests(true);
$station->setFrontendType(FrontendAdapters::Icecast->value);
$station->setBackendType(BackendAdapters::Liquidsoap->value);
$station->setEnableHls(true);
$station->setRadioBaseDir('/var/azuracast/stations/azuratest_radio');
$station->ensureDirectoriesExist();

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Entity\Fixture;
use App\Entity;
use App\Radio\Enums\StreamFormats;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class StationHlsStream extends AbstractFixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
/** @var Entity\Station $station */
$station = $this->getReference('station');
$mountLofi = new Entity\StationHlsStream($station);
$mountLofi->setName('aac_lofi');
$mountLofi->setFormat(StreamFormats::Aac->value);
$mountLofi->setBitrate(64);
$manager->persist($mountLofi);
$mountMidfi = new Entity\StationHlsStream($station);
$mountMidfi->setName('aac_midfi');
$mountMidfi->setFormat(StreamFormats::Aac->value);
$mountMidfi->setBitrate(128);
$manager->persist($mountMidfi);
$mountHifi = new Entity\StationHlsStream($station);
$mountHifi->setName('aac_hifi');
$mountHifi->setFormat(StreamFormats::Aac->value);
$mountHifi->setBitrate(256);
$manager->persist($mountHifi);
$manager->flush();
}
/**
* @return string[]
*/
public function getDependencies(): array
{
return [
Station::class,
];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220603065416 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add HLS fields.';
}
public function up(Schema $schema): void
{
$this->addSql(
'CREATE TABLE station_hls_streams (id INT AUTO_INCREMENT NOT NULL, station_id INT NOT NULL, name VARCHAR(100) NOT NULL, format VARCHAR(10) DEFAULT NULL, bitrate SMALLINT DEFAULT NULL, INDEX IDX_9ECC9CD021BDB235 (station_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'
);
$this->addSql(
'ALTER TABLE station_hls_streams ADD CONSTRAINT FK_9ECC9CD021BDB235 FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE'
);
$this->addSql('ALTER TABLE station ADD enable_hls TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE station_hls_streams');
$this->addSql('ALTER TABLE station DROP enable_hls');
}
}

View File

@ -9,6 +9,7 @@ use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Radio\Backend\AbstractBackend;
use App\Radio\Frontend\AbstractFrontend;
use App\Service\Flow\UploadedFile;
use Azura\Files\ExtendedFilesystemInterface;
@ -106,6 +107,22 @@ final class StationRepository extends Repository
$this->em->refresh($station);
}
public function resetHls(Entity\Station $station, AbstractBackend $backend): void
{
foreach ($station->getHlsStreams() as $hlsStream) {
$this->em->remove($hlsStream);
}
if ($station->getEnableHls() && $backend->supportsHls()) {
foreach ($backend->getDefaultHlsStreams($station) as $hlsStream) {
$this->em->persist($hlsStream);
}
}
$this->em->flush();
$this->em->refresh($station);
}
public function flushRelatedMedia(Entity\Station $station): void
{
$this->em->createQuery(

View File

@ -261,6 +261,16 @@ class Station implements Stringable, IdentifiableEntityInterface
]
protected bool $enable_on_demand_download = true;
#[
OA\Property(
description: "Whether HLS streaming is enabled.",
example: true
),
ORM\Column,
Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])
]
protected bool $enable_hls = false;
#[
ORM\Column,
Attributes\AuditIgnore
@ -387,6 +397,10 @@ class Station implements Stringable, IdentifiableEntityInterface
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationRemote::class)]
protected Collection $remotes;
/** @var Collection<int, StationHlsStream> */
#[ORM\OneToMany(mappedBy: 'station', targetEntity: StationHlsStream::class)]
protected Collection $hls_streams;
/** @var Collection<int, StationWebhook> */
#[ORM\OneToMany(
mappedBy: 'station',
@ -410,6 +424,7 @@ class Station implements Stringable, IdentifiableEntityInterface
$this->playlists = new ArrayCollection();
$this->mounts = new ArrayCollection();
$this->remotes = new ArrayCollection();
$this->hls_streams = new ArrayCollection();
$this->webhooks = new ArrayCollection();
$this->streamers = new ArrayCollection();
$this->sftp_users = new ArrayCollection();
@ -664,6 +679,7 @@ class Station implements Stringable, IdentifiableEntityInterface
$this->ensureDirectoryExists($this->getRadioPlaylistsDir());
$this->ensureDirectoryExists($this->getRadioConfigDir());
$this->ensureDirectoryExists($this->getRadioTempDir());
$this->ensureDirectoryExists($this->getRadioHlsDir());
if (null === $this->media_storage_location) {
$storageLocation = new StorageLocation(
@ -733,6 +749,11 @@ class Station implements Stringable, IdentifiableEntityInterface
return $this->radio_base_dir . '/temp';
}
public function getRadioHlsDir(): string
{
return $this->radio_base_dir . '/hls';
}
public function getNowplaying(): ?Api\NowPlaying\NowPlaying
{
if ($this->nowplaying instanceof Api\NowPlaying\NowPlaying) {
@ -877,6 +898,16 @@ class Station implements Stringable, IdentifiableEntityInterface
$this->enable_on_demand_download = $enable_on_demand_download;
}
public function getEnableHls(): bool
{
return $this->enable_hls;
}
public function setEnableHls(bool $enable_hls): void
{
$this->enable_hls = $enable_hls;
}
public function getIsEnabled(): bool
{
return $this->is_enabled;
@ -1113,6 +1144,14 @@ class Station implements Stringable, IdentifiableEntityInterface
return $this->remotes;
}
/**
* @return Collection<int, StationHlsStream>
*/
public function getHlsStreams(): Collection
{
return $this->hls_streams;
}
/**
* @return Collection<int, StationWebhook>
*/

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Radio\Enums\StreamFormats;
use App\Utilities\Strings;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Attributes as OA;
use Stringable;
use Symfony\Component\Validator\Constraints as Assert;
#[
OA\Schema(type: "object"),
ORM\Entity,
ORM\Table(name: 'station_hls_streams'),
Attributes\Auditable
]
class StationHlsStream implements
Stringable,
Interfaces\StationCloneAwareInterface,
Interfaces\IdentifiableEntityInterface
{
use Traits\HasAutoIncrementId;
use Traits\TruncateStrings;
use Traits\TruncateInts;
#[ORM\Column(nullable: false)]
protected int $station_id;
#[
ORM\ManyToOne(inversedBy: 'mounts'),
ORM\JoinColumn(name: 'station_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')
]
protected Station $station;
#[
OA\Property(example: "aac_lofi"),
ORM\Column(length: 100),
Assert\NotBlank
]
protected string $name = '';
#[
OA\Property(example: "aac"),
ORM\Column(length: 10, nullable: true)
]
protected ?string $format = 'mp3';
#[
OA\Property(example: 128),
ORM\Column(type: 'smallint', nullable: true)
]
protected ?int $bitrate = 128;
public function __construct(Station $station)
{
$this->station = $station;
}
public function getStation(): Station
{
return $this->station;
}
public function setStation(Station $station): void
{
$this->station = $station;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $new_name): void
{
// Ensure all mount point names start with a leading slash.
$this->name = $this->truncateString(Strings::getProgrammaticString($new_name), 100);
}
public function getFormat(): ?string
{
return $this->format;
}
public function getFormatEnum(): ?StreamFormats
{
return (null !== $this->format)
? StreamFormats::from(strtolower($this->format))
: null;
}
public function setFormat(?string $format): void
{
$this->format = $format;
}
public function getBitrate(): ?int
{
return $this->bitrate;
}
public function setBitrate(?int $bitrate): void
{
$this->bitrate = $bitrate;
}
public function __toString(): string
{
return $this->getStation() . ' HLS Stream: ' . $this->getName();
}
}

View File

@ -20,6 +20,7 @@ final class ConfigWriter implements EventSubscriberInterface
WriteNginxConfiguration::class => [
['writeRadioSection', 35],
['writeWebDjSection', 30],
['writeHlsSection', 25],
],
];
}
@ -79,4 +80,34 @@ final class ConfigWriter implements EventSubscriberInterface
NGINX
);
}
public function writeHlsSection(WriteNginxConfiguration $event): void
{
$station = $event->getStation();
if (!$station->getEnableHls()) {
return;
}
$hlsBaseUrl = CustomUrls::getHlsUrl($station);
$hlsFolder = $station->getRadioHlsDir();
$event->appendBlock(
<<<NGINX
# Reverse proxy the frontend broadcast.
location {$hlsBaseUrl} {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Cache-Control' 'no-cache';
alias {$hlsFolder};
try_files \$uri =404;
}
NGINX
);
}
}

View File

@ -18,6 +18,11 @@ final class CustomUrls
return '/webdj/' . $station->getShortName();
}
public static function getHlsUrl(Station $station): string
{
return '/hls/' . $station->getShortName();
}
/**
* Returns a custom path if X-Accel-Redirect is configured for the path provided.
*/

View File

@ -38,6 +38,7 @@ use OpenApi\Attributes as OA;
new OA\Tag(name: "Stations: Automation"),
new OA\Tag(name: "Stations: History"),
new OA\Tag(name: "Stations: HLS Streams"),
new OA\Tag(name: "Stations: Listeners"),
new OA\Tag(name: "Stations: Schedules"),
new OA\Tag(name: "Stations: Media"),

View File

@ -10,6 +10,7 @@ use App\Exception\Supervisor\AlreadyRunningException;
use App\Exception\Supervisor\BadNameException;
use App\Exception\Supervisor\NotRunningException;
use App\Exception\SupervisorException;
use App\Http\Router;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
@ -24,7 +25,8 @@ abstract class AbstractAdapter
protected EntityManagerInterface $em,
protected SupervisorInterface $supervisor,
protected EventDispatcherInterface $dispatcher,
protected LoggerInterface $logger
protected LoggerInterface $logger,
protected Router $router,
) {
}

View File

@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Radio\Backend;
use App\Entity;
use App\Nginx\CustomUrls;
use App\Radio\AbstractAdapter;
use App\Radio\Enums\StreamFormats;
use Psr\Http\Message\UriInterface;
abstract class AbstractBackend extends AbstractAdapter
{
@ -34,6 +37,39 @@ abstract class AbstractBackend extends AbstractAdapter
return false;
}
public function supportsHls(): bool
{
return false;
}
public function getDefaultHlsStreams(Entity\Station $station): array
{
return array_map(
function (string $name, int $bitrate) use ($station) {
$record = new Entity\StationHlsStream($station);
$record->setName($name);
$record->setFormat(StreamFormats::Aac->value);
$record->setBitrate($bitrate);
},
['aac_lofi', 'aac_midfi', 'aac_hifi'],
[64, 128, 256]
);
}
public function getHlsUrl(Entity\Station $station, UriInterface $baseUrl = null): UriInterface
{
if (!$this->supportsHls()) {
throw new \RuntimeException('Cannot generate HLS URL.');
}
$radio_port = $station->getFrontendConfig()->getPort();
$baseUrl ??= $this->router->getBaseUrl();
return $baseUrl->withPath(
$baseUrl->getPath() . CustomUrls::getHlsUrl($station) . '/live.m3u8'
);
}
public function getStreamPort(Entity\Station $station): ?int
{
return null;

View File

@ -40,6 +40,11 @@ class Liquidsoap extends AbstractBackend
return true;
}
public function supportsHls(): bool
{
return true;
}
/**
* @inheritDoc
*/

View File

@ -56,6 +56,7 @@ class ConfigWriter implements EventSubscriberInterface
['writeHarborConfiguration', 20],
['writePreBroadcastConfiguration', 10],
['writeLocalBroadcastConfiguration', 5],
['writeHlsBroadcastConfiguration', 2],
['writeRemoteBroadcastConfiguration', 0],
['writePostBroadcastConfiguration', -5],
],
@ -1028,6 +1029,89 @@ class ConfigWriter implements EventSubscriberInterface
$event->appendLines($ls_config);
}
public function writeHlsBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
{
$station = $event->getStation();
if (!$station->getEnableHls()) {
return;
}
$lsConfig = [
'# HLS Broadcasting',
];
// Configure the outbound broadcast.
$hlsStreams = [];
foreach ($station->getHlsStreams() as $hlsStream) {
$streamVarName = self::cleanUpVarName($hlsStream->getName());
$streamCodec = match ($hlsStream->getFormatEnum()) {
StreamFormats::Aac => 'aac',
StreamFormats::Mp3 => 'mp3',
default => null
};
if (null === $streamCodec) {
continue;
}
$streamBitrate = $hlsStream->getBitrate() ?? 128;
$lsConfig[] = <<<LS
{$streamVarName} = %ffmpeg(
format="mpegts",
%audio(
codec="{$streamCodec}",
channels=2,
b="{$streamBitrate}k"
)
)
LS;
$hlsStreams[] = $streamVarName;
}
if (empty($hlsStreams)) {
return;
}
$lsConfig[] = 'hls_streams = [' . implode(
', ',
array_map(
static fn($row) => '("' . $row . '", ' . $row . ')',
$hlsStreams
)
) . ']';
$event->appendLines($lsConfig);
$configDir = $station->getRadioConfigDir();
$hlsBaseDir = $station->getRadioHlsDir();
$event->appendBlock(
<<<LS
def hls_segment_name(~position,~extname,stream_name) =
timestamp = int_of_float(gettimeofday())
duration = 2
"#{stream_name}_#{duration}_#{timestamp}_#{position}.#{extname}"
end
output.file.hls(playlist="live.m3u8",
segment_duration=2.0,
segments=5,
segments_overhead=5,
segment_name=hls_segment_name,
persist_at="{$configDir}/hls.config",
"{$hlsBaseDir}",
hls_streams,
radio
)
LS
);
}
/**
* Given outbound broadcast information, produce a suitable LiquidSoap configuration line for the stream.
*/

View File

@ -7,6 +7,7 @@ namespace App\Radio\Frontend;
use App\Entity;
use App\Environment;
use App\Http\Router;
use App\Nginx\CustomUrls;
use App\Radio\AbstractAdapter;
use App\Radio\Enums\StreamFormats;
use App\Xml\Reader;
@ -26,18 +27,18 @@ use Supervisor\SupervisorInterface;
abstract class AbstractFrontend extends AbstractAdapter
{
public function __construct(
protected AdapterFactory $adapterFactory,
protected Client $http_client,
protected Router $router,
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Entity\Repository\StationMountRepository $stationMountRepo,
Environment $environment,
EntityManagerInterface $em,
SupervisorInterface $supervisor,
EventDispatcherInterface $dispatcher,
LoggerInterface $logger
LoggerInterface $logger,
Router $router,
protected AdapterFactory $adapterFactory,
protected Client $http_client,
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Entity\Repository\StationMountRepository $stationMountRepo,
) {
parent::__construct($environment, $em, $supervisor, $dispatcher, $logger);
parent::__construct($environment, $em, $supervisor, $dispatcher, $logger, $router);
}
/**
@ -116,7 +117,7 @@ abstract class AbstractFrontend extends AbstractAdapter
) {
// Web proxy support.
return $base_url
->withPath($base_url->getPath() . '/listen/' . $station->getShortName());
->withPath($base_url->getPath() . CustomUrls::getListenUrl($station));
}
// Remove port number and other decorations.

View File

@ -0,0 +1,9 @@
<?php
$this->layout('main', ['title' => __('HLS Streams')]) ?>
<p><?= __('HLS is currently disabled for this station. To enable HLS, click the button below.') ?></p>
<a class="btn btn-lg btn-success" role="button" href="<?= $router->fromHere(null, [], ['enable' => true]) ?>">
<?= __('Enable HLS') ?>
</a>