Files
azuracast-on-demand-hls/README.md
T

22 KiB
Raw Blame History

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:

/var/azuracast/www/plugins/azuracast-on-demand-hls

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 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:

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 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:

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.