From fac86b77f221581c3e17cd5f1f949a06a53b30c2 Mon Sep 17 00:00:00 2001 From: "Buster \"Silver Eagle\" Neece" Date: Fri, 3 Jun 2022 22:39:02 -0500 Subject: [PATCH] Merge commit '3de709270d80eda9806162246f1778fd78fa5b99' --- CHANGELOG.md | 7 + config/menus/station.php | 6 + config/routes/api_station.php | 6 + config/routes/stations.php | 4 + frontend/npm-shrinkwrap.json | 11 ++ frontend/package.json | 1 + .../Admin/Stations/Form/BackendForm.vue | 10 ++ .../components/Admin/Stations/StationForm.vue | 2 + .../vue/components/Common/AudioPlayer.vue | 28 +++- frontend/vue/components/Common/PlayButton.vue | 7 +- .../vue/components/Stations/HlsStreams.vue | 118 +++++++++++++ .../Stations/HlsStreams/EditModal.vue | 58 +++++++ .../Stations/HlsStreams/Form/BasicInfo.vue | 86 ++++++++++ .../Stations/Profile/StreamsPanel.vue | 18 ++ frontend/vue/pages/Stations/HlsStreams.js | 8 + frontend/webpack.config.js | 1 + .../Api/Admin/StationsController.php | 8 + .../Api/Stations/HlsStreamsController.php | 155 ++++++++++++++++++ src/Controller/Stations/HlsStreamsAction.php | 70 ++++++++ src/Doctrine/Event/StationRequiresRestart.php | 1 + src/Entity/Api/NowPlaying/Station.php | 17 ++ .../ApiGenerator/StationApiGenerator.php | 4 + src/Entity/Fixture/Station.php | 1 + src/Entity/Fixture/StationHlsStream.php | 50 ++++++ .../Migration/Version20220603065416.php | 33 ++++ src/Entity/Repository/StationRepository.php | 17 ++ src/Entity/Station.php | 39 +++++ src/Entity/StationHlsStream.php | 114 +++++++++++++ src/Nginx/ConfigWriter.php | 31 ++++ src/Nginx/CustomUrls.php | 5 + src/OpenApi.php | 1 + src/Radio/AbstractAdapter.php | 4 +- src/Radio/Backend/AbstractBackend.php | 36 ++++ src/Radio/Backend/Liquidsoap.php | 5 + src/Radio/Backend/Liquidsoap/ConfigWriter.php | 84 ++++++++++ src/Radio/Frontend/AbstractFrontend.php | 17 +- templates/stations/hls/disabled.phtml | 9 + 37 files changed, 1055 insertions(+), 17 deletions(-) create mode 100644 frontend/vue/components/Stations/HlsStreams.vue create mode 100644 frontend/vue/components/Stations/HlsStreams/EditModal.vue create mode 100644 frontend/vue/components/Stations/HlsStreams/Form/BasicInfo.vue create mode 100644 frontend/vue/pages/Stations/HlsStreams.js create mode 100644 src/Controller/Api/Stations/HlsStreamsController.php create mode 100644 src/Controller/Stations/HlsStreamsAction.php create mode 100644 src/Entity/Fixture/StationHlsStream.php create mode 100644 src/Entity/Migration/Version20220603065416.php create mode 100644 src/Entity/StationHlsStream.php create mode 100644 templates/stations/hls/disabled.phtml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6013e28..d240e6c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/config/menus/station.php b/config/menus/station.php index da0261172..2e5bcc93a 100644 --- a/config/menus/station.php +++ b/config/menus/station.php @@ -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', diff --git a/config/routes/api_station.php b/config/routes/api_station.php index 638a1f2a4..9b1322dd0 100644 --- a/config/routes/api_station.php +++ b/config/routes/api_station.php @@ -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', diff --git a/config/routes/stations.php b/config/routes/stations.php index fd6f954f5..2335b3096 100644 --- a/config/routes/stations.php +++ b/config/routes/stations.php @@ -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)); diff --git a/frontend/npm-shrinkwrap.json b/frontend/npm-shrinkwrap.json index 56d66283a..a5e42ce29 100644 --- a/frontend/npm-shrinkwrap.json +++ b/frontend/npm-shrinkwrap.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 72f875d03..1f8cb1189 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/vue/components/Admin/Stations/Form/BackendForm.vue b/frontend/vue/components/Admin/Stations/Form/BackendForm.vue index a63ea4ed6..b013fc0fe 100644 --- a/frontend/vue/components/Admin/Stations/Form/BackendForm.vue +++ b/frontend/vue/components/Admin/Stations/Form/BackendForm.vue @@ -70,6 +70,16 @@ + + + + + diff --git a/frontend/vue/components/Admin/Stations/StationForm.vue b/frontend/vue/components/Admin/Stations/StationForm.vue index e2e547107..25c44fbf7 100644 --- a/frontend/vue/components/Admin/Stations/StationForm.vue +++ b/frontend/vue/components/Admin/Stations/StationForm.vue @@ -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, diff --git a/frontend/vue/components/Common/AudioPlayer.vue b/frontend/vue/components/Common/AudioPlayer.vue index f88eefc79..53f4a537c 100644 --- a/frontend/vue/components/Common/AudioPlayer.vue +++ b/frontend/vue/components/Common/AudioPlayer.vue @@ -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() { diff --git a/frontend/vue/components/Common/PlayButton.vue b/frontend/vue/components/Common/PlayButton.vue index 12968cb08..29481f2b3 100644 --- a/frontend/vue/components/Common/PlayButton.vue +++ b/frontend/vue/components/Common/PlayButton.vue @@ -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 }); } } diff --git a/frontend/vue/components/Stations/HlsStreams.vue b/frontend/vue/components/Stations/HlsStreams.vue new file mode 100644 index 000000000..39b11f546 --- /dev/null +++ b/frontend/vue/components/Stations/HlsStreams.vue @@ -0,0 +1,118 @@ + + + diff --git a/frontend/vue/components/Stations/HlsStreams/EditModal.vue b/frontend/vue/components/Stations/HlsStreams/EditModal.vue new file mode 100644 index 000000000..b2636c8c1 --- /dev/null +++ b/frontend/vue/components/Stations/HlsStreams/EditModal.vue @@ -0,0 +1,58 @@ + + diff --git a/frontend/vue/components/Stations/HlsStreams/Form/BasicInfo.vue b/frontend/vue/components/Stations/HlsStreams/Form/BasicInfo.vue new file mode 100644 index 000000000..a1b7e5996 --- /dev/null +++ b/frontend/vue/components/Stations/HlsStreams/Form/BasicInfo.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/vue/components/Stations/Profile/StreamsPanel.vue b/frontend/vue/components/Stations/Profile/StreamsPanel.vue index effb6f9c9..672375886 100644 --- a/frontend/vue/components/Stations/Profile/StreamsPanel.vue +++ b/frontend/vue/components/Stations/Profile/StreamsPanel.vue @@ -64,6 +64,24 @@ + +
diff --git a/frontend/vue/pages/Stations/HlsStreams.js b/frontend/vue/pages/Stations/HlsStreams.js new file mode 100644 index 000000000..00ae09951 --- /dev/null +++ b/frontend/vue/pages/Stations/HlsStreams.js @@ -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); diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 19c62179a..fabf89736 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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', diff --git a/src/Controller/Api/Admin/StationsController.php b/src/Controller/Api/Admin/StationsController.php index 5a817263d..b2f59e4b8 100644 --- a/src/Controller/Api/Admin/StationsController.php +++ b/src/Controller/Api/Admin/StationsController.php @@ -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( diff --git a/src/Controller/Api/Stations/HlsStreamsController.php b/src/Controller/Api/Stations/HlsStreamsController.php new file mode 100644 index 000000000..f89279f5a --- /dev/null +++ b/src/Controller/Api/Stations/HlsStreamsController.php @@ -0,0 +1,155 @@ + */ +#[ + 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; + } +} diff --git a/src/Controller/Stations/HlsStreamsAction.php b/src/Controller/Stations/HlsStreamsAction.php new file mode 100644 index 000000000..e1eccea23 --- /dev/null +++ b/src/Controller/Stations/HlsStreamsAction.php @@ -0,0 +1,70 @@ +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( + '' . __('HLS enabled!') . '', + 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'), + ], + ); + } +} diff --git a/src/Doctrine/Event/StationRequiresRestart.php b/src/Doctrine/Event/StationRequiresRestart.php index 0a6279a55..e8fe9e76f 100644 --- a/src/Doctrine/Event/StationRequiresRestart.php +++ b/src/Doctrine/Event/StationRequiresRestart.php @@ -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()) ) { diff --git a/src/Entity/Api/NowPlaying/Station.php b/src/Entity/Api/NowPlaying/Station.php index 7d8eb855d..72d606697 100644 --- a/src/Entity/Api/NowPlaying/Station.php +++ b/src/Entity/Api/NowPlaying/Station.php @@ -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; } } diff --git a/src/Entity/ApiGenerator/StationApiGenerator.php b/src/Entity/ApiGenerator/StationApiGenerator.php index 1aa121b35..0b93b54a9 100644 --- a/src/Entity/ApiGenerator/StationApiGenerator.php +++ b/src/Entity/ApiGenerator/StationApiGenerator.php @@ -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; } } diff --git a/src/Entity/Fixture/Station.php b/src/Entity/Fixture/Station.php index 949433830..1ea465459 100644 --- a/src/Entity/Fixture/Station.php +++ b/src/Entity/Fixture/Station.php @@ -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(); diff --git a/src/Entity/Fixture/StationHlsStream.php b/src/Entity/Fixture/StationHlsStream.php new file mode 100644 index 000000000..90203576f --- /dev/null +++ b/src/Entity/Fixture/StationHlsStream.php @@ -0,0 +1,50 @@ +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, + ]; + } +} diff --git a/src/Entity/Migration/Version20220603065416.php b/src/Entity/Migration/Version20220603065416.php new file mode 100644 index 000000000..6af58249d --- /dev/null +++ b/src/Entity/Migration/Version20220603065416.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/src/Entity/Repository/StationRepository.php b/src/Entity/Repository/StationRepository.php index 8e56c424c..9ec113056 100644 --- a/src/Entity/Repository/StationRepository.php +++ b/src/Entity/Repository/StationRepository.php @@ -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( diff --git a/src/Entity/Station.php b/src/Entity/Station.php index be1754f9a..b54c2f8d4 100644 --- a/src/Entity/Station.php +++ b/src/Entity/Station.php @@ -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 */ + #[ORM\OneToMany(mappedBy: 'station', targetEntity: StationHlsStream::class)] + protected Collection $hls_streams; + /** @var Collection */ #[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 + */ + public function getHlsStreams(): Collection + { + return $this->hls_streams; + } + /** * @return Collection */ diff --git a/src/Entity/StationHlsStream.php b/src/Entity/StationHlsStream.php new file mode 100644 index 000000000..15a98e919 --- /dev/null +++ b/src/Entity/StationHlsStream.php @@ -0,0 +1,114 @@ +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(); + } +} diff --git a/src/Nginx/ConfigWriter.php b/src/Nginx/ConfigWriter.php index d1526e9cc..d09dbf845 100644 --- a/src/Nginx/ConfigWriter.php +++ b/src/Nginx/ConfigWriter.php @@ -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( + <<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. */ diff --git a/src/OpenApi.php b/src/OpenApi.php index 36814ff33..54b08f94d 100644 --- a/src/OpenApi.php +++ b/src/OpenApi.php @@ -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"), diff --git a/src/Radio/AbstractAdapter.php b/src/Radio/AbstractAdapter.php index 389a25954..5763c6d11 100644 --- a/src/Radio/AbstractAdapter.php +++ b/src/Radio/AbstractAdapter.php @@ -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, ) { } diff --git a/src/Radio/Backend/AbstractBackend.php b/src/Radio/Backend/AbstractBackend.php index 74db6819c..503b8d976 100644 --- a/src/Radio/Backend/AbstractBackend.php +++ b/src/Radio/Backend/AbstractBackend.php @@ -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; diff --git a/src/Radio/Backend/Liquidsoap.php b/src/Radio/Backend/Liquidsoap.php index 0daf095b4..43cbf9201 100644 --- a/src/Radio/Backend/Liquidsoap.php +++ b/src/Radio/Backend/Liquidsoap.php @@ -40,6 +40,11 @@ class Liquidsoap extends AbstractBackend return true; } + public function supportsHls(): bool + { + return true; + } + /** * @inheritDoc */ diff --git a/src/Radio/Backend/Liquidsoap/ConfigWriter.php b/src/Radio/Backend/Liquidsoap/ConfigWriter.php index d6bcff04d..5cbbd3bba 100644 --- a/src/Radio/Backend/Liquidsoap/ConfigWriter.php +++ b/src/Radio/Backend/Liquidsoap/ConfigWriter.php @@ -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[] = << '("' . $row . '", ' . $row . ')', + $hlsStreams + ) + ) . ']'; + + $event->appendLines($lsConfig); + + $configDir = $station->getRadioConfigDir(); + $hlsBaseDir = $station->getRadioHlsDir(); + + $event->appendBlock( + <<withPath($base_url->getPath() . '/listen/' . $station->getShortName()); + ->withPath($base_url->getPath() . CustomUrls::getListenUrl($station)); } // Remove port number and other decorations. diff --git a/templates/stations/hls/disabled.phtml b/templates/stations/hls/disabled.phtml new file mode 100644 index 000000000..563105ae2 --- /dev/null +++ b/templates/stations/hls/disabled.phtml @@ -0,0 +1,9 @@ +layout('main', ['title' => __('HLS Streams')]) ?> + +

+ +
+ +