From 6fae6a3a430410145501abbe57b2cc027fd2eaa0 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 4 Sep 2026 20:23:10 +0200 Subject: [PATCH] feat: add secure on-demand HLS playback plugin --- .gitignore | 7 + LICENSE | 21 ++ README.md | 351 +++++++++++++++++++ composer.json | 31 ++ events.php | 58 +++ phpunit.xml.dist | 12 + services.php | 9 + src/Config.php | 88 +++++ src/Controller/CreatePlaybackAction.php | 120 +++++++ src/Controller/ServePlaybackAssetAction.php | 91 +++++ src/Domain/PlaybackSession.php | 82 +++++ src/EventHandler/NginxConfiguration.php | 56 +++ src/Exception/SessionLimitException.php | 15 + src/Exception/TranscodeBusyException.php | 9 + src/Exception/TranscodeException.php | 11 + src/Security/OpaqueToken.php | 27 ++ src/Security/Principal.php | 25 ++ src/Security/ResourcePath.php | 40 +++ src/Service/AssetAuthorizer.php | 39 +++ src/Service/CachePaths.php | 65 ++++ src/Service/CleanupService.php | 86 +++++ src/Service/HlsCache.php | 234 +++++++++++++ src/Service/OnDemandEligibility.php | 25 ++ src/Service/ScheduledCleanupTask.php | 43 +++ src/Service/SessionRepository.php | 145 ++++++++ tests/ConfigTest.php | 29 ++ tests/Security/OpaqueTokenTest.php | 40 +++ tests/Security/ResourcePathTest.php | 65 ++++ tests/Service/AssetAuthorizerTest.php | 47 +++ tests/Service/CleanupServiceTest.php | 67 ++++ tests/Service/HlsCacheTest.php | 93 +++++ tests/Service/SessionRepositoryTest.php | 162 +++++++++ tests/Support/AzuraCastStubs.php | 44 +++ tests/Support/TemporaryDirectoryTestCase.php | 40 +++ tests/bootstrap.php | 6 + 35 files changed, 2283 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 events.php create mode 100644 phpunit.xml.dist create mode 100644 services.php create mode 100644 src/Config.php create mode 100644 src/Controller/CreatePlaybackAction.php create mode 100644 src/Controller/ServePlaybackAssetAction.php create mode 100644 src/Domain/PlaybackSession.php create mode 100644 src/EventHandler/NginxConfiguration.php create mode 100644 src/Exception/SessionLimitException.php create mode 100644 src/Exception/TranscodeBusyException.php create mode 100644 src/Exception/TranscodeException.php create mode 100644 src/Security/OpaqueToken.php create mode 100644 src/Security/Principal.php create mode 100644 src/Security/ResourcePath.php create mode 100644 src/Service/AssetAuthorizer.php create mode 100644 src/Service/CachePaths.php create mode 100644 src/Service/CleanupService.php create mode 100644 src/Service/HlsCache.php create mode 100644 src/Service/OnDemandEligibility.php create mode 100644 src/Service/ScheduledCleanupTask.php create mode 100644 src/Service/SessionRepository.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/Security/OpaqueTokenTest.php create mode 100644 tests/Security/ResourcePathTest.php create mode 100644 tests/Service/AssetAuthorizerTest.php create mode 100644 tests/Service/CleanupServiceTest.php create mode 100644 tests/Service/HlsCacheTest.php create mode 100644 tests/Service/SessionRepositoryTest.php create mode 100644 tests/Support/AzuraCastStubs.php create mode 100644 tests/Support/TemporaryDirectoryTestCase.php create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3606a2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/.phpunit.cache/ +/.phpunit.result.cache +/composer.lock +/.idea/ +/.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dcb72fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lexian-droid contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..79b67b0 --- /dev/null +++ b/README.md @@ -0,0 +1,351 @@ +# AzuraCast On-Demand HLS + +Protected, expiring on-demand HLS playback for media in an AzuraCast station. + +The plugin adds an authenticated endpoint that creates a short-lived playback session for eligible station media. On the first request for a rendition, FFmpeg generates an audio-only AAC/HLS package. Later sessions reuse that package while every playlist and segment request is authorized by an opaque bearer token. PHP performs authorization; AzuraCast's protected Nginx `X-Accel-Redirect` mapping serves the bytes. + +## Compatibility and requirements + +This implementation targets the current AzuraCast plugin APIs used by the Rolling Release codebase: + +- PHP **8.4** (`composer.json` requires `^8.4`) +- Symfony Process **8.x** +- AzuraCast classes and events used by `events.php`, including `BuildRoutes`, `GetSyncTasks`, `WriteNginxConfiguration`, station feature/permission middleware, and `App\Nginx\CustomUrls` +- FFmpeg available to the AzuraCast web container/process, with AAC encoding and HLS muxing support +- A local AzuraCast station temp directory underneath AzuraCast's protected station-files mapping (the normal Docker layout under `/var/azuracast/stations`) +- The station's **On-Demand Streaming** feature enabled +- Media assigned to at least one enabled playlist whose **Include in On-Demand** option is enabled + +There is no declared minimum AzuraCast release or compatibility shim for older plugin APIs. Pin and test a known AzuraCast release before production deployment; older Stable releases using PHP below 8.4 or different middleware/event interfaces are not compatible. + +The implementation produces a single audio rendition: the first audio stream is transcoded to AAC at the configured bitrate. It does not preserve video or create an adaptive bitrate ladder. + +## Installation + +> The directory basename is significant: it must be `azuracast-on-demand-hls` so AzuraCast maps it to the `Plugin\AzuraCastOnDemandHls` namespace. + +AzuraCast must see the complete checkout at: + +```text +/var/azuracast/www/plugins/azuracast-on-demand-hls +``` + +### Docker installation (recommended) + +Keep the checkout on the host, for example at `/var/azuracast/plugins/azuracast-on-demand-hls`, and mount it into the web container. Merge the following into `/var/azuracast/docker-compose.override.yml`; do not replace unrelated existing override settings. + +```yaml +services: + web: + environment: + COMPOSER_PLUGIN_MODE: "true" + volumes: + - ./plugins/azuracast-on-demand-hls:/var/azuracast/www/plugins/azuracast-on-demand-hls:ro +``` + +Add configuration variables under the same `web.environment` mapping if desired. From `/var/azuracast`, recreate the containers so the mount and environment are applied: + +```bash +./docker.sh restart +``` + +AzuraCast's root Composer configuration merges `plugins/*/composer.json`. After the plugin is visible inside the container, refresh the parent autoloader (do **not** create a separate production `vendor/` directory inside this plugin): + +```bash +docker compose exec --user azuracast web composer dump-autoload +./docker.sh restart +``` + +The second restart reloads the web and long-running worker processes so `services.php`, `events.php`, and the refreshed autoloader are active. + +### Direct/non-Docker installation + +Place the checkout directly at `/var/azuracast/www/plugins/azuracast-on-demand-hls`, set the environment variables for the AzuraCast web and worker processes, run `composer dump-autoload` from the AzuraCast application root, and restart those processes. Direct installs must provide PHP 8.4, FFmpeg, writable station temp directories, and AzuraCast's internal station-files Nginx location. + +### Regenerate station Nginx configuration + +The plugin contributes a per-station Nginx block that disables access logging for playback-token URLs. It is not present until station configuration is rewritten. Restart each affected station from the AzuraCast UI, or run the current AzuraCast CLI command using the station **short name**: + +```bash +cd /var/azuracast +./docker.sh cli azuracast:radio:restart STATION_SHORT_NAME +``` + +Omit the short name to rewrite/restart all stations: + +```bash +./docker.sh cli azuracast:radio:restart +``` + +A radio restart is service-affecting; schedule it appropriately. Repeat this step after plugin upgrades that change `NginxConfiguration.php`, after enabling/disabling the plugin, and after changing station configuration. Confirm the generated station Nginx configuration contains the protected `ondemand-hls/playback` location with `access_log off`. + +## Configuration + +Configuration is read from the process environment when AzuraCast builds its container. Blank values use the defaults. Restart/recreate AzuraCast processes after changes, and regenerate station Nginx configuration if `AZURACAST_ONDEMAND_HLS_ENABLED` changes. + +| Variable | Default | Accepted value / range | Purpose | +|---|---:|---|---| +| `AZURACAST_ONDEMAND_HLS_ENABLED` | `true` | PHP boolean strings such as `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` | Enables route behavior, scheduled cleanup, and generation of the station Nginx block. | +| `AZURACAST_ONDEMAND_HLS_SESSION_TTL` | `1800` | `60`–`86400` seconds | Lifetime of a playback bearer URL. | +| `AZURACAST_ONDEMAND_HLS_SEGMENT_DURATION` | `6` | `2`–`20` seconds | FFmpeg HLS target segment duration. Actual segment duration can vary at codec boundaries. | +| `AZURACAST_ONDEMAND_HLS_CACHE_TTL` | `86400` | At least `SESSION_TTL`, at most `2592000` seconds (30 days) | Minimum idle age before an unreferenced rendition can be removed. | +| `AZURACAST_ONDEMAND_HLS_MAX_SESSIONS` | `10` | `1`–`100` | Maximum active sessions per principal hash within one station's session store. | +| `AZURACAST_ONDEMAND_HLS_CACHE_DIRECTORY` | `ondemand-hls` | 1–64 characters; starts with lowercase letter/digit, then lowercase letters, digits, `_`, `-` | Relative directory created beneath each station temp directory. | +| `AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY` | `ffmpeg` | 1–255 characters from letters, digits, `.`, `_`, `+`, `/`, `-`; must not contain `..` | FFmpeg executable name or absolute path. | +| `AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE` | `128k` | `10k`–`9999k` in the exact form `[1-9][0-9]{1,3}k` | AAC target bitrate and part of the rendition cache key. Use a sensible audio bitrate despite the broad validation range. | +| `AZURACAST_ONDEMAND_HLS_TRANSCODE_TIMEOUT` | `600` | `30`–`3600` seconds | Maximum synchronous FFmpeg run time. | + +Example Docker override fragment: + +```yaml +services: + web: + environment: + COMPOSER_PLUGIN_MODE: "true" + AZURACAST_ONDEMAND_HLS_SESSION_TTL: "900" + AZURACAST_ONDEMAND_HLS_CACHE_TTL: "86400" + AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE: "128k" + AZURACAST_ONDEMAND_HLS_TRANSCODE_TIMEOUT: "600" +``` + +Invalid values fail configuration construction and can prevent AzuraCast from booting. Integer variables are cast by PHP before range validation, so use plain base-10 integer strings. + +## API + +### Create a playback session + +```http +POST /api/station/{station_id}/ondemand-hls/{media_id} +Authorization: Bearer +``` + +`station_id` is the AzuraCast station ID or identifier accepted by AzuraCast's station middleware. `media_id` is the media `unique_id` (letters, digits, and hyphens), not a filesystem path. + +The API key's user must have the station **Media** permission. AzuraCast also accepts `X-API-Key`, but `Authorization: Bearer` is preferred. Do not call this authenticated endpoint from untrusted browser code, because that would expose the long-lived AzuraCast API key; call it from your backend and return only the short-lived playback URL to the client. + +```bash +curl --fail-with-body \ + -X POST \ + -H "Authorization: Bearer ${AZURACAST_API_KEY}" \ + "https://radio.example.com/api/station/1/ondemand-hls/abcdef123456abcdef123456" +``` + +A successful request returns HTTP `201 Created`, `Cache-Control: no-store`, and JSON: + +```json +{ + "url": "https://radio.example.com/api/station/1/ondemand-hls/playback/OPAQUE_TOKEN/master.m3u8", + "expires_at": 1788490440, + "media_id": "abcdef123456abcdef123456" +} +``` + +`expires_at` is a Unix timestamp. The returned absolute URL is the HLS master playlist. Its relative `media.m3u8` and segment references retain the same token automatically. + +Creation is limited by AzuraCast middleware to 10 requests per 60-second interval. Asset delivery is limited to 150 requests per 5-second interval. Relevant application responses include: + +| Status | Meaning | +|---:|---| +| `201` | Session created. | +| `404` | Plugin disabled, station lacks On-Demand support, media is missing, or media is not in an enabled on-demand playlist. | +| `429` | AzuraCast request-rate limit or the configured active-session limit was reached. | +| `503` | This rendition/another station rendition is being generated, or FFmpeg failed. A generation-lock conflict includes `Retry-After: 10`. | +| `500` | Session persistence or another runtime operation failed. | + +The first request for a cache key performs FFmpeg transcoding synchronously and may take time. On `503` with `Retry-After`, retry with bounded exponential backoff rather than sending parallel requests. + +### Play the returned URL + +Safari and other clients with native HLS support can assign the URL directly to an `