Merge commit '3de709270d80eda9806162246f1778fd78fa5b99'
This commit is contained in:
parent
361c1ce169
commit
fac86b77f2
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
|
@ -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',
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -40,6 +40,11 @@ class Liquidsoap extends AbstractBackend
|
|||
return true;
|
||||
}
|
||||
|
||||
public function supportsHls(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue