feat: add secure on-demand HLS playback plugin

This commit is contained in:
root
2026-09-04 20:23:10 +02:00
commit 6fae6a3a43
35 changed files with 2283 additions and 0 deletions
+351
View File
@@ -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 <AZURACAST_API_KEY>
```
`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 `<audio>` element. Other modern browsers can use [hls.js](https://github.com/video-dev/hls.js):
```html
<audio id="player" controls></audio>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
// Obtain this from your own backend; never expose the AzuraCast API key here.
const playbackUrl = "/your-backend/on-demand-url";
const audio = document.getElementById("player");
fetch(playbackUrl, { credentials: "same-origin" })
.then((response) => {
if (!response.ok) throw new Error(`Playback session failed: ${response.status}`);
return response.json();
})
.then(({ url, expires_at }) => {
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
audio.src = url;
} else if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(audio);
} else {
throw new Error("HLS is not supported by this browser");
}
console.debug("Playback URL expires at", new Date(expires_at * 1000));
});
</script>
```
The asset route is intentionally public at the AzuraCast authentication layer: possession of the opaque URL token is the authorization. Expired, malformed, modified, wrong-station, missing-file, and disallowed-resource requests all return `403` with a generic error.
## Architecture and lifecycle
No database tables are added. State lives below each station's radio temp directory:
```text
<CACHE_DIRECTORY>/
├── assets/<64-character-cache-key>/
│ ├── master.m3u8
│ ├── media.m3u8
│ └── segment00000.ts ...
├── sessions/<sha256-of-token>.json
└── locks/
```
- **Eligibility:** a media item is accepted only when linked to an enabled playlist marked for on-demand inclusion.
- **Cache identity:** SHA-256 covers a format version, station ID, storage-location ID, media unique ID, media modification time, segment duration, and audio bitrate. A source change or rendition-setting change creates a new cache key.
- **Generation:** the station media filesystem resolves the source to a local file. FFmpeg selects `0:a:0`, drops video, encodes AAC, writes VOD HLS segments through temporary files, and the plugin writes the master playlist.
- **Concurrency:** a non-blocking per-rendition lock prevents duplicate work. A second non-blocking station-wide lock permits only one FFmpeg transcode per station, limiting CPU and PHP-worker exhaustion.
- **Publication:** output is built under a random `.build-*` directory, validated against the filename allowlist and playlist rules, then renamed atomically into place. Complete cache hits touch the rendition directory to refresh its idle age.
- **Sessions:** a cryptographically random 32-byte token is base64url encoded to 43 characters. Only its SHA-256 identifier is stored. Session files are written atomically with mode `0600`; session and lock directories use `0700`.
- **Principal limit:** active-session counting uses a hash of the authenticated user ID and request `REMOTE_ADDR`. It limits creation only; the playback token remains a transferable bearer credential.
- **Delivery:** PHP validates the token, expiry, station binding, exact resource name, real path, and symlink status. It then emits an internal `X-Accel-Redirect`; Nginx serves the file without PHP streaming it.
### Cleanup
Cleanup runs:
1. before each session-creation attempt;
2. on approximately 1% of successfully authorized asset requests; and
3. as an AzuraCast sync task at minute 17 of every hour (`17 * * * *`).
It removes expired or malformed session records, `.build-*` directories older than one hour, and rendition directories that have no active session and whose directory modification time is older than `CACHE_TTL`. An active session always protects its cache, which is why `CACHE_TTL` must be at least `SESSION_TTL`.
Disabling the plugin stops normal scheduled/request cleanup and does not delete existing files. Re-enable it for a cleanup pass or remove its cache directories manually during uninstall.
## Security and threat model
The design protects station media from direct filesystem access and prevents path traversal, but the returned playback URL is a **bearer credential**:
- Anyone who obtains the URL can use it until expiration; playback is not rebound to the creator's IP or user.
- Use HTTPS only. Keep `SESSION_TTL` short enough for the use case and keep `MAX_SESSIONS` conservative.
- Never log, persist in analytics, paste into tickets, or expose the full playback URL. The plugin logs only a SHA-256 token fingerprint on denied requests and a session-ID prefix on creation.
- Regenerate station Nginx configuration so the plugin's `access_log off` block is active. This suppresses token-bearing paths in that Nginx location, but it cannot redact logs written by load balancers, WAFs, ingress proxies, CDNs, browser tooling, service workers, or client applications upstream/downstream.
- Configure every upstream proxy to redact the path after `/playback/`, or disable request logging for this route. Do not put the token in query parameters, headers copied to logs, analytics events, or error reports.
- Only `master.m3u8`, `media.m3u8`, and `segment` followed by 5–9 digits and `.ts` are allowed. Realpath containment checks and symlink rejection apply to cache directories and resources.
- Session records store only the token hash, station/media/cache binding, principal hash, and timestamps—not the raw token.
- The cache is not a public alias. Asset bytes are reachable only after PHP authorization through AzuraCast's internal station-files `X-Accel` mapping. A missing protected mapping fails closed with HTTP `500` rather than streaming from PHP.
- Creation requires AzuraCast API authentication, station Media permission, On-Demand feature support, and eligible media. The playback route intentionally requires only its short-lived token.
This plugin does not provide DRM, encrypted HLS, revocation of an individual active session, per-segment one-time tokens, or protection after a legitimate client records the decoded audio.
## Reverse proxy and CDN cautions
The safest deployment is same-origin playback through AzuraCast without a CDN in front of this route.
- Do **not** cache `/api/station/*/ondemand-hls/*`. Creation and asset responses use `no-store`; ensure a proxy/CDN honors it and never overrides it. A shared cache could bypass expiry or leak media between users.
- Preserve the complete path and do not normalize or rewrite token/resource components.
- Preserve AzuraCast's `X-Accel-Redirect` processing at its internal Nginx layer. An external proxy should forward the resulting body; it must not expose or independently interpret `/internal/stations`.
- Redact bearer URLs from proxy/CDN/WAF logs as described above.
- The plugin does not emit `Access-Control-Allow-Origin`. Cross-origin players will fail unless CORS is added deliberately at a trusted proxy. Prefer same-origin; if enabling CORS, allow only required origins and still disable caching/logging.
- Avoid redirects to another hostname: the full token appears in the `Location` request path and may be logged. Set AzuraCast's external/base URL correctly so the API returns the final HTTPS origin.
- Keep proxy read timeouts above the expected synchronous first-generation time, up to `TRANSCODE_TIMEOUT`, or create/warm sessions from a backend job before giving the URL to a client.
## Operations
### Capacity planning
Each distinct cache key stores one AAC rendition for the full track. Estimate storage from bitrate and duration (for example, 128 kbit/s is roughly 57.6 MB/hour before container overhead). The first request consumes one PHP worker while FFmpeg runs; only one transcode runs per station, but different stations may transcode concurrently.
Monitor:
- free space and inode use in station temp directories;
- PHP request duration/timeouts for the creation endpoint;
- CPU and FFmpeg process duration;
- AzuraCast logs for `Generated on-demand HLS cache`, `FFmpeg failed`, `generation is already in progress`, cleanup results, and rejected asset requests;
- rates of `429`, `503`, and playback `403` responses.
Tune `SESSION_TTL`, `CACHE_TTL`, bitrate, and timeout together. Changing segment duration or bitrate naturally creates new cache keys; old renditions age out after they become unreferenced.
### Development checks
In a development checkout with Composer available:
```bash
composer install
composer lint
composer test
```
The test suite covers configuration safety, opaque-token format/mutation, resource allowlisting and traversal/symlink rejection, station/expiry authorization, session limits, and cleanup behavior.
## Troubleshooting
### Route returns 404
- Confirm the mount exists inside the web container at the exact required path.
- Confirm `COMPOSER_PLUGIN_MODE=true` and that containers were recreated.
- Refresh the AzuraCast root Composer autoloader and restart web/worker processes.
- Confirm `AZURACAST_ONDEMAND_HLS_ENABLED` is true.
- Confirm the station supports On-Demand and the media belongs to an enabled playlist marked **Include in On-Demand**.
- Use the media `unique_id`, not a storage path or numeric row ID.
### Playback URL returns 403
- Check `expires_at`; create a new session after expiry.
- Ensure the player did not truncate, decode, append to, or rewrite the token/path.
- Ensure playlist-relative requests retain `/playback/{token}/`.
- A station ID mismatch, missing cache file, malformed session, symlink, or filename outside the allowlist also fails with the same generic `403` by design.
### Playback returns 500: “HLS delivery is not configured”
The authorized cache file is outside a path recognized by `App\Nginx\CustomUrls::getXAccelPath()`. Use AzuraCast's normal station temp location under `/var/azuracast/stations`; do not relocate the station temp/cache tree outside the protected mapping. Regenerate station Nginx configuration and inspect AzuraCast logs.
### `503` during creation
- `Retry-After: 10` means a per-rendition or station-wide transcode lock is busy. Wait and retry; do not fan out requests.
- Otherwise inspect AzuraCast logs for FFmpeg exit output, missing source files, unsupported/corrupt audio, permission failures, or timeout.
- Verify `AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY` is executable in the web container and FFmpeg has an AAC encoder and HLS muxer.
- Increase `TRANSCODE_TIMEOUT` only after confirming normal large files legitimately need it.
### Player loads the master playlist but fails on media/segments
- Check browser developer tools for `403`, `429`, CORS, mixed-content, or proxy-cache errors.
- The asset rate limit is 150 requests per 5 seconds; investigate retry loops or unusually short segments if it is reached.
- Use same-origin URLs unless CORS is explicitly configured.
- Ensure proxies preserve relative playlist resolution and do not cache assets.
- Confirm the station Nginx config was regenerated after installation.
### Sessions remain at the limit
The limit counts unexpired session files for the same user-ID/IP hash. Wait for `SESSION_TTL` and the next cleanup/session-creation pass. If records persist beyond expiry, verify the hourly AzuraCast sync runner is healthy and station temp storage is writable.
### Cache grows unexpectedly
Verify the sync runner executes the task at minute 17, check cleanup errors in AzuraCast logs, and confirm directory mtimes are not continually refreshed by cache hits. Assets with active sessions are intentionally retained. Very long `CACHE_TTL`, frequent source `mtime` changes, or rendition-setting changes create additional cached packages.
## Upgrades
1. Back up the plugin configuration and record the deployed revision.
2. Update the host checkout atomically; do not overwrite it while requests are executing.
3. Review `composer.json`, environment-variable validation, route changes, and Nginx changes.
4. Refresh the AzuraCast root Composer autoloader and restart containers/processes.
5. Regenerate/restart each affected station if Nginx integration changed.
6. Smoke-test session creation, `master.m3u8`, `media.m3u8`, and one segment without placing the bearer URL in logs.
Cached renditions and sessions are file-format implementation details, not a stable upgrade API. The current cache key includes a `v1` format marker; incompatible future versions should naturally use a new marker or require clearing the cache.
## Uninstall
1. Stop issuing new playback sessions.
2. Remove the plugin's volume mount and its environment variables from `docker-compose.override.yml`.
3. Recreate/restart AzuraCast so routes, services, and scheduled tasks are unloaded.
4. Restart/regenerate all affected stations to remove the plugin's Nginx location.
5. After active playback URLs have expired, remove the checkout and each station's `<radio-temp>/<CACHE_DIRECTORY>` tree if you want to reclaim cached media and session files.
6. Refresh the AzuraCast root Composer autoloader if the plugin had been included when it was last generated.
Be careful when manually deleting cache data: resolve the station temp path and configured cache-directory name first; never use an unexpanded or empty shell variable in a recursive removal command.
## License
MIT. See [LICENSE](LICENSE).