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.jsonrequires^8.4) - Symfony Process 8.x
- AzuraCast classes and events used by
events.php, includingBuildRoutes,GetSyncTasks,WriteNginxConfiguration, station feature/permission middleware, andApp\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-hlsso AzuraCast maps it to thePlugin\AzuraCastOnDemandHlsnamespace.
AzuraCast must see the complete checkout at:
/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.
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:
./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):
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:
cd /var/azuracast
./docker.sh cli azuracast:radio:restart STATION_SHORT_NAME
Omit the short name to rewrite/restart all stations:
./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 playback renditions per principal hash within one station. |
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. |
Cross-origin HLS playback (CORS)
Cross-origin playback is disabled by default. Configure an explicit allowlist per station with the plugin commands; do not edit generated Nginx files. The setting is stored in the AzuraCast database by the plugin, and every modifying command rewrites that station's generated Nginx configuration and reloads Nginx only when the file changed.
First, run the plugin migration once after installing/upgrading the plugin:
cd /var/azuracast
./docker.sh cli migrations:migrate --no-interaction
Then configure the station (the station argument accepts its ID or short name):
# Replace the entire allowlist with the two approved web origins.
./docker.sh cli azuracast:ondemand-hls:cors:set 11 \
https://aifrequency.org \
https://www.aifrequency.org
# Inspect the current allowlist.
./docker.sh cli azuracast:ondemand-hls:cors:list 11
# Add or remove one origin without changing the others.
./docker.sh cli azuracast:ondemand-hls:cors:add 11 https://player.example.org
./docker.sh cli azuracast:ondemand-hls:cors:remove 11 https://player.example.org
# Disable cross-origin playback for this station again.
./docker.sh cli azuracast:ondemand-hls:cors:clear 11
Origins must be absolute http:// or https:// origins without paths, credentials, query strings, or fragments. They are normalized before being stored. The plugin emits an exact Access-Control-Allow-Origin value for the requesting allowed origin; it never joins multiple origins and never substitutes *. It also emits Vary: Origin, supports GET, HEAD, and token-authorized OPTIONS preflight requests (including the Range request header), and leaves requests with no Origin header unchanged.
The allowlist is generated into the plugin's protected internal Nginx HLS location, after the authorized X-Accel-Redirect. It therefore covers master.m3u8, media.m3u8, and HLS .ts segments rather than only the initial PHP authorization response. The bearer URL remains required for every resource request; allowing an origin does not make a resource public or extend its expiry.
Example Docker override fragment:
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
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.
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:
{
"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:
<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:
<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 use0700. - Principal limit: active-playback counting uses a hash of the authenticated user ID and request
REMOTE_ADDR. A session counts only while a successfully authorized HLS asset request has occurred within the last 90 seconds; duplicate URLs for the same rendition count as one slot. This prevents player initialization/retry loops and abandoned URLs from exhausting the limit. 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:
- before each session-creation attempt;
- on approximately 1% of successfully authorized asset requests; and
- 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_TTLshort enough for the use case and keepMAX_SESSIONSconservative. - 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 offblock 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, andsegmentfollowed by 5–9 digits and.tsare 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-Accelmapping. A missing protected mapping fails closed with HTTP500rather 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 useno-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-Redirectprocessing 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.
- Cross-origin playback is disabled until an explicit station allowlist is configured with
azuracast:ondemand-hls:cors:*. Prefer same-origin; if enabling CORS, allow only required HTTPS origins and still disable caching/logging. Do not add wildcard headers at a proxy, because that would override the plugin's explicit-origin policy. - Avoid redirects to another hostname: the full token appears in the
Locationrequest 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 playback403responses.
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:
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, cleanup behavior, CORS origin validation, exact allowlist matching for both approved origins, rejected/missing origins, preflight headers, and generated Nginx CORS rules.
Troubleshooting
Route returns 404
- Confirm the mount exists inside the web container at the exact required path.
- Confirm
COMPOSER_PLUGIN_MODE=trueand that containers were recreated. - Refresh the AzuraCast root Composer autoloader and restart web/worker processes.
- Confirm
AZURACAST_ONDEMAND_HLS_ENABLEDis 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
403by 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: 10means 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_BINARYis executable in the web container and FFmpeg has an AAC encoder and HLS muxer. - Increase
TRANSCODE_TIMEOUTonly 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. - For a browser hosted at
https://aifrequency.org, configure both required origins if applicable and verify them with:The output must list./docker.sh cli azuracast:ondemand-hls:cors:list 11https://aifrequency.organd, for thewwwsite,https://www.aifrequency.orgexactly. - Run the CORS command again after plugin installation/upgrades if a station's generated Nginx configuration was removed; each modifying command regenerates it automatically.
- The asset rate limit is 150 requests per 5 seconds; investigate retry loops or unusually short segments if it is reached.
- Ensure proxies preserve relative playlist resolution and do not cache assets.
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
- Back up the plugin configuration and record the deployed revision.
- Update the host checkout atomically; do not overwrite it while requests are executing.
- Review
composer.json, environment-variable validation, route changes, and Nginx changes. - Refresh the AzuraCast root Composer autoloader and restart containers/processes.
- Regenerate/restart each affected station if Nginx integration changed.
- 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
- Stop issuing new playback sessions.
- Remove the plugin's volume mount and its environment variables from
docker-compose.override.yml. - Recreate/restart AzuraCast so routes, services, and scheduled tasks are unloaded.
- Restart/regenerate all affected stations to remove the plugin's Nginx location.
- 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. - 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.