feat: add secure on-demand HLS playback plugin
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/vendor/
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
/composer.lock
|
||||
/.idea/
|
||||
/.vscode/
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "lexian-droid/azuracast-on-demand-hls",
|
||||
"description": "Protected, expiring on-demand HLS playback for AzuraCast station media.",
|
||||
"type": "azuracast-plugin",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"psr/log": "^3.0",
|
||||
"symfony/process": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Plugin\\AzuraCastOnDemandHls\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Plugin\\AzuraCastOnDemandHls\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"lint": "find src tests -name '*.php' -print0 | xargs -0 -n1 php -l"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\CallableEventDispatcherInterface;
|
||||
use App\Enums\StationPermissions;
|
||||
use App\Enums\StationFeatures;
|
||||
use App\Event;
|
||||
use App\Middleware;
|
||||
use Plugin\AzuraCastOnDemandHls\Controller\CreatePlaybackAction;
|
||||
use Plugin\AzuraCastOnDemandHls\Controller\ServePlaybackAssetAction;
|
||||
use Plugin\AzuraCastOnDemandHls\EventHandler\NginxConfiguration;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\ScheduledCleanupTask;
|
||||
|
||||
return static function (CallableEventDispatcherInterface $dispatcher): void {
|
||||
$dispatcher->addListener(
|
||||
Event\BuildRoutes::class,
|
||||
static function (Event\BuildRoutes $event): void {
|
||||
$app = $event->getApp();
|
||||
|
||||
$app->post(
|
||||
'/api/station/{station_id}/ondemand-hls/{media_id:[a-zA-Z0-9-]+}',
|
||||
CreatePlaybackAction::class
|
||||
)
|
||||
->setName('ondemand-hls:create')
|
||||
->add(new Middleware\RateLimit('ondemand_hls_create', 60, 10))
|
||||
->add(new Middleware\Permissions(StationPermissions::Media, true))
|
||||
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
|
||||
->add(Middleware\RequireStation::class)
|
||||
->add(Middleware\GetStation::class)
|
||||
->add(Middleware\Module\Api::class)
|
||||
->add(Middleware\Auth\ApiAuth::class)
|
||||
->add(Middleware\InjectSession::class);
|
||||
|
||||
$app->get(
|
||||
'/api/station/{station_id}/ondemand-hls/playback/{token:[A-Za-z0-9_-]+}/{resource:[A-Za-z0-9._-]+}',
|
||||
ServePlaybackAssetAction::class
|
||||
)
|
||||
->setName('ondemand-hls:asset')
|
||||
->add(new Middleware\RateLimit('ondemand_hls_asset', 5, 150))
|
||||
->add(Middleware\RequireStation::class)
|
||||
->add(Middleware\GetStation::class)
|
||||
->add(Middleware\Module\Api::class)
|
||||
->add(Middleware\Auth\PublicAuth::class);
|
||||
}
|
||||
);
|
||||
|
||||
$dispatcher->addCallableListener(
|
||||
Event\Nginx\WriteNginxConfiguration::class,
|
||||
NginxConfiguration::class,
|
||||
priority: 20
|
||||
);
|
||||
|
||||
$dispatcher->addListener(
|
||||
Event\GetSyncTasks::class,
|
||||
static fn(Event\GetSyncTasks $event) => $event->addTask(ScheduledCleanupTask::class)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.0/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache">
|
||||
<testsuites>
|
||||
<testsuite name="AzuraCast On-Demand HLS">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
|
||||
return [
|
||||
Config::class => static fn(): Config => Config::fromEnvironment(),
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class Config
|
||||
{
|
||||
public function __construct(
|
||||
public bool $enabled = true,
|
||||
public int $sessionTtl = 1800,
|
||||
public int $segmentDuration = 6,
|
||||
public int $cacheTtl = 86400,
|
||||
public int $maxSessionsPerPrincipal = 10,
|
||||
public string $cacheDirectory = 'ondemand-hls',
|
||||
public string $ffmpegBinary = 'ffmpeg',
|
||||
public string $audioBitrate = '128k',
|
||||
public int $transcodeTimeout = 600,
|
||||
) {
|
||||
if ($sessionTtl < 60 || $sessionTtl > 86400) {
|
||||
throw new InvalidArgumentException('Session TTL must be between 60 and 86400 seconds.');
|
||||
}
|
||||
if ($segmentDuration < 2 || $segmentDuration > 20) {
|
||||
throw new InvalidArgumentException('Segment duration must be between 2 and 20 seconds.');
|
||||
}
|
||||
if ($cacheTtl < $sessionTtl || $cacheTtl > 2592000) {
|
||||
throw new InvalidArgumentException('Cache TTL must be at least the session TTL and no more than 30 days.');
|
||||
}
|
||||
if ($maxSessionsPerPrincipal < 1 || $maxSessionsPerPrincipal > 100) {
|
||||
throw new InvalidArgumentException('Maximum sessions must be between 1 and 100.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/D', $cacheDirectory)) {
|
||||
throw new InvalidArgumentException('Cache directory must be a safe relative directory name.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9._+\/-]{1,255}$/D', $ffmpegBinary) || str_contains($ffmpegBinary, '..')) {
|
||||
throw new InvalidArgumentException('Invalid FFmpeg binary path.');
|
||||
}
|
||||
if (1 !== preg_match('/^[1-9][0-9]{1,3}k$/D', $audioBitrate)) {
|
||||
throw new InvalidArgumentException('Audio bitrate must look like 128k.');
|
||||
}
|
||||
if ($transcodeTimeout < 30 || $transcodeTimeout > 3600) {
|
||||
throw new InvalidArgumentException('Transcode timeout must be between 30 and 3600 seconds.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromEnvironment(): self
|
||||
{
|
||||
return new self(
|
||||
enabled: self::bool('AZURACAST_ONDEMAND_HLS_ENABLED', true),
|
||||
sessionTtl: self::int('AZURACAST_ONDEMAND_HLS_SESSION_TTL', 1800),
|
||||
segmentDuration: self::int('AZURACAST_ONDEMAND_HLS_SEGMENT_DURATION', 6),
|
||||
cacheTtl: self::int('AZURACAST_ONDEMAND_HLS_CACHE_TTL', 86400),
|
||||
maxSessionsPerPrincipal: self::int('AZURACAST_ONDEMAND_HLS_MAX_SESSIONS', 10),
|
||||
cacheDirectory: self::string('AZURACAST_ONDEMAND_HLS_CACHE_DIRECTORY', 'ondemand-hls'),
|
||||
ffmpegBinary: self::string('AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY', 'ffmpeg'),
|
||||
audioBitrate: self::string('AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE', '128k'),
|
||||
transcodeTimeout: self::int('AZURACAST_ONDEMAND_HLS_TRANSCODE_TIMEOUT', 600),
|
||||
);
|
||||
}
|
||||
|
||||
private static function string(string $name, string $default): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
return false === $value || '' === trim($value) ? $default : trim($value);
|
||||
}
|
||||
|
||||
private static function int(string $name, int $default): int
|
||||
{
|
||||
$value = getenv($name);
|
||||
return false === $value || '' === trim($value) ? $default : (int)$value;
|
||||
}
|
||||
|
||||
private static function bool(string $name, bool $default): bool
|
||||
{
|
||||
$value = getenv($name);
|
||||
if (false === $value || '' === trim($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
if (null === $parsed) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be a boolean.', $name));
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Controller;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\StationMediaRepository;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeBusyException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\Principal;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\HlsCache;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\OnDemandEligibility;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class CreatePlaybackAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private StationMediaRepository $mediaRepository,
|
||||
private OnDemandEligibility $eligibility,
|
||||
private HlsCache $cache,
|
||||
private SessionRepository $sessions,
|
||||
private CleanupService $cleanup,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params,
|
||||
): ResponseInterface {
|
||||
if (!$this->config->enabled) {
|
||||
return $response->withJson(['error' => 'On-demand HLS is disabled.'], 404);
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$mediaId = (string)($params['media_id'] ?? '');
|
||||
$media = $this->mediaRepository->requireForStation($mediaId, $station);
|
||||
|
||||
if (!$this->eligibility->isEligible($media)) {
|
||||
return $response->withJson(['error' => 'Media is not available for on-demand playback.'], 404);
|
||||
}
|
||||
|
||||
$stationTemp = $station->getRadioTempDir();
|
||||
try {
|
||||
$this->cleanup->run($stationTemp);
|
||||
$cacheKey = $this->cache->ensure($station, $media);
|
||||
$created = $this->sessions->create(
|
||||
stationTempDirectory: $stationTemp,
|
||||
stationId: $station->id,
|
||||
mediaId: $media->unique_id,
|
||||
cacheKey: $cacheKey,
|
||||
principalHash: Principal::fromRequest($request),
|
||||
);
|
||||
} catch (TranscodeBusyException) {
|
||||
return $response
|
||||
->withJson(['error' => 'HLS generation is already in progress. Retry shortly.'], 503)
|
||||
->withHeader('Retry-After', '10');
|
||||
} catch (TranscodeException $exception) {
|
||||
$this->logger->error('On-demand HLS generation failed.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'HLS media is temporarily unavailable.'], 503);
|
||||
} catch (SessionLimitException $exception) {
|
||||
return $response->withJson(['error' => $exception->getMessage()], 429);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->logger->error('On-demand HLS session creation failed.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'Could not create playback session.'], 500);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('Unexpected on-demand HLS playback failure.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return $response->withJson(['error' => 'HLS media is temporarily unavailable.'], 503);
|
||||
}
|
||||
|
||||
$session = $created['session'];
|
||||
$url = $request->getRouter()->named(
|
||||
'ondemand-hls:asset',
|
||||
[
|
||||
'station_id' => $station->id,
|
||||
'token' => $created['token'],
|
||||
'resource' => 'master.m3u8',
|
||||
],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
$this->logger->info('Created on-demand HLS playback session.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'session_id_prefix' => substr($session->id, 0, 12),
|
||||
'expires_at' => $session->expiresAt,
|
||||
]);
|
||||
|
||||
return $response->withJson([
|
||||
'url' => $url,
|
||||
'expires_at' => $session->expiresAt,
|
||||
'media_id' => $media->unique_id,
|
||||
], 201)->withHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Controller;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class ServePlaybackAssetAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private AssetAuthorizer $authorizer,
|
||||
private CleanupService $cleanup,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params,
|
||||
): ResponseInterface {
|
||||
if (!$this->config->enabled) {
|
||||
return $this->denied($response);
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$token = (string)($params['token'] ?? '');
|
||||
$resource = (string)($params['resource'] ?? '');
|
||||
$authorized = $this->authorizer->authorize(
|
||||
$station->getRadioTempDir(),
|
||||
$station->id,
|
||||
$token,
|
||||
$resource,
|
||||
);
|
||||
|
||||
if (null === $authorized) {
|
||||
$this->logger->warning('Rejected on-demand HLS asset request.', [
|
||||
'station_id' => $station->id,
|
||||
'resource' => $resource,
|
||||
'token_fingerprint' => '' !== $token ? substr(hash('sha256', $token), 0, 12) : null,
|
||||
]);
|
||||
return $this->denied($response);
|
||||
}
|
||||
|
||||
// Keep cleanup request-driven without adding latency to every segment request.
|
||||
if (0 === random_int(0, 99)) {
|
||||
try {
|
||||
$this->cleanup->run($station->getRadioTempDir());
|
||||
} catch (Throwable $exception) {
|
||||
// Delivery is already authorized; maintenance failures must not
|
||||
// interrupt playback or turn a recoverable cleanup issue into 5xx.
|
||||
$this->logger->warning('Request-driven on-demand HLS cleanup failed.', [
|
||||
'station_id' => $station->id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$accelPath = '/internal/ondemand-hls/' . $station->id
|
||||
. '/' . $authorized['session']->cacheKey . '/' . $resource;
|
||||
|
||||
$contentType = str_ends_with($resource, '.m3u8')
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: 'video/mp2t';
|
||||
|
||||
return $response
|
||||
->withHeader('Content-Type', $contentType)
|
||||
->withHeader('Content-Disposition', 'inline')
|
||||
->withHeader('Cache-Control', 'private, no-store, max-age=0')
|
||||
->withHeader('Pragma', 'no-cache')
|
||||
->withHeader('X-Content-Type-Options', 'nosniff')
|
||||
->withHeader('X-Accel-Redirect', $accelPath);
|
||||
}
|
||||
|
||||
private function denied(Response $response): ResponseInterface
|
||||
{
|
||||
return $response
|
||||
->withJson(['error' => 'Invalid or expired playback session.'], 403)
|
||||
->withHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Domain;
|
||||
|
||||
use JsonException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final readonly class PlaybackSession
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public int $stationId,
|
||||
public string $mediaId,
|
||||
public string $cacheKey,
|
||||
public string $principalHash,
|
||||
public int $createdAt,
|
||||
public int $expiresAt,
|
||||
) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $id)) {
|
||||
throw new UnexpectedValueException('Invalid session ID.');
|
||||
}
|
||||
if ($stationId < 1 || 1 !== preg_match('/^[a-zA-Z0-9-]{1,64}$/D', $mediaId)) {
|
||||
throw new UnexpectedValueException('Invalid session media binding.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $cacheKey)) {
|
||||
throw new UnexpectedValueException('Invalid cache key.');
|
||||
}
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $principalHash)) {
|
||||
throw new UnexpectedValueException('Invalid principal hash.');
|
||||
}
|
||||
if ($createdAt < 1 || $expiresAt <= $createdAt) {
|
||||
throw new UnexpectedValueException('Invalid session timestamps.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isExpired(int $now): bool
|
||||
{
|
||||
return $now >= $this->expiresAt;
|
||||
}
|
||||
|
||||
public function belongsToStation(int $stationId): bool
|
||||
{
|
||||
return $this->stationId === $stationId;
|
||||
}
|
||||
|
||||
/** @return array<string, int|string> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'station_id' => $this->stationId,
|
||||
'media_id' => $this->mediaId,
|
||||
'cache_key' => $this->cacheKey,
|
||||
'principal_hash' => $this->principalHash,
|
||||
'created_at' => $this->createdAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$required = ['id', 'station_id', 'media_id', 'cache_key', 'principal_hash', 'created_at', 'expires_at'];
|
||||
foreach ($required as $key) {
|
||||
if (!array_key_exists($key, $data)) {
|
||||
throw new UnexpectedValueException('Malformed session record.');
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
(string)$data['id'],
|
||||
(int)$data['station_id'],
|
||||
(string)$data['media_id'],
|
||||
(string)$data['cache_key'],
|
||||
(string)$data['principal_hash'],
|
||||
(int)$data['created_at'],
|
||||
(int)$data['expires_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\EventHandler;
|
||||
|
||||
use App\Event\Nginx\WriteNginxConfiguration;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
|
||||
final readonly class NginxConfiguration
|
||||
{
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(WriteNginxConfiguration $event): void
|
||||
{
|
||||
if (!$this->config->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$station = $event->getStation();
|
||||
$stationId = $station->id;
|
||||
$assetDirectory = rtrim($station->getRadioTempDir(), '/')
|
||||
. '/' . $this->config->cacheDirectory . '/assets/';
|
||||
$transcodeTimeout = $this->config->transcodeTimeout;
|
||||
|
||||
$event->appendBlock(<<<NGINX
|
||||
# Protected on-demand HLS. Handle this route directly in PHP-FPM so denied
|
||||
# bearer-token requests never leave this access_log-off location.
|
||||
location ^~ /api/station/{$stationId}/ondemand-hls/playback/ {
|
||||
access_log off;
|
||||
|
||||
include fastcgi_params;
|
||||
fastcgi_read_timeout {$transcodeTimeout};
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME \$realpath_root/index.php;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PHP_SELF /index.php;
|
||||
fastcgi_param DOCUMENT_ROOT \$realpath_root;
|
||||
fastcgi_pass php-fpm-www;
|
||||
}
|
||||
|
||||
# Successful authorization redirects internally here. Keeping access logs
|
||||
# disabled in the final X-Accel location prevents the original bearer URL
|
||||
# from being logged after Nginx performs the internal redirect.
|
||||
location ^~ /internal/ondemand-hls/{$stationId}/ {
|
||||
internal;
|
||||
access_log off;
|
||||
add_header Cache-Control "private, no-store, max-age=0" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
alias {$assetDirectory};
|
||||
}
|
||||
NGINX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SessionLimitException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Maximum concurrent playback sessions reached.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
final class TranscodeBusyException extends TranscodeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class TranscodeException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
final class OpaqueToken
|
||||
{
|
||||
public const int BYTES = 32;
|
||||
public const int ENCODED_LENGTH = 43;
|
||||
|
||||
public static function generate(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(self::BYTES)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function isValid(string $token): bool
|
||||
{
|
||||
return self::ENCODED_LENGTH === strlen($token)
|
||||
&& 1 === preg_match('/^[A-Za-z0-9_-]{43}$/D', $token);
|
||||
}
|
||||
|
||||
public static function id(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
use App\Http\ServerRequest;
|
||||
use Throwable;
|
||||
|
||||
final class Principal
|
||||
{
|
||||
public static function fromRequest(ServerRequest $request): string
|
||||
{
|
||||
try {
|
||||
$userId = (string)$request->getUser()->id;
|
||||
} catch (Throwable) {
|
||||
$userId = 'anonymous';
|
||||
}
|
||||
|
||||
$server = $request->getServerParams();
|
||||
$ip = (string)($server['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
return hash('sha256', $userId . "\0" . $ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Security;
|
||||
|
||||
final class ResourcePath
|
||||
{
|
||||
public static function isAllowed(string $resource): bool
|
||||
{
|
||||
return 1 === preg_match(
|
||||
'/\A(?:master\.m3u8|media\.m3u8|segment[0-9]{5,9}\.ts)\z/D',
|
||||
$resource
|
||||
);
|
||||
}
|
||||
|
||||
public static function resolve(string $assetDirectory, string $resource): ?string
|
||||
{
|
||||
if (!self::isAllowed($resource) || is_link($assetDirectory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = realpath($assetDirectory);
|
||||
if (false === $base || !is_dir($base)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = $base . DIRECTORY_SEPARATOR . $resource;
|
||||
if (is_link($candidate) || !is_file($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = realpath($candidate);
|
||||
if (false === $real || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $real;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
|
||||
final readonly class AssetAuthorizer
|
||||
{
|
||||
public function __construct(
|
||||
private SessionRepository $sessions,
|
||||
private CachePaths $paths,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{session:PlaybackSession,path:string}|null */
|
||||
public function authorize(
|
||||
string $stationTempDirectory,
|
||||
int $stationId,
|
||||
string $token,
|
||||
string $resource,
|
||||
?int $now = null,
|
||||
): ?array {
|
||||
if (!ResourcePath::isAllowed($resource)) {
|
||||
return null;
|
||||
}
|
||||
$session = $this->sessions->find($stationTempDirectory, $token);
|
||||
$now ??= time();
|
||||
if (null === $session || $session->isExpired($now) || !$session->belongsToStation($stationId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$directory = $this->paths->asset($stationTempDirectory, $session->cacheKey);
|
||||
$path = ResourcePath::resolve($directory, $resource);
|
||||
return null === $path ? null : ['session' => $session, 'path' => $path];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use RuntimeException;
|
||||
|
||||
final readonly class CachePaths
|
||||
{
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function root(string $stationTempDirectory): string
|
||||
{
|
||||
return rtrim($stationTempDirectory, DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR . $this->config->cacheDirectory;
|
||||
}
|
||||
|
||||
public function sessions(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/sessions';
|
||||
}
|
||||
|
||||
public function assets(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/assets';
|
||||
}
|
||||
|
||||
public function locks(string $stationTempDirectory): string
|
||||
{
|
||||
return $this->root($stationTempDirectory) . '/locks';
|
||||
}
|
||||
|
||||
public function asset(string $stationTempDirectory, string $cacheKey): string
|
||||
{
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $cacheKey)) {
|
||||
throw new RuntimeException('Unsafe cache key.');
|
||||
}
|
||||
return $this->assets($stationTempDirectory) . '/' . $cacheKey;
|
||||
}
|
||||
|
||||
public function ensure(string $stationTempDirectory): void
|
||||
{
|
||||
$directories = [
|
||||
$this->root($stationTempDirectory) => 0755,
|
||||
$this->sessions($stationTempDirectory) => 0700,
|
||||
$this->assets($stationTempDirectory) => 0755,
|
||||
$this->locks($stationTempDirectory) => 0700,
|
||||
];
|
||||
foreach ($directories as $directory => $mode) {
|
||||
if (is_link($directory)) {
|
||||
throw new RuntimeException('Refusing to use a symlinked HLS cache directory.');
|
||||
}
|
||||
// Another request may create the same directory after is_dir().
|
||||
// Suppress that benign E_WARNING, then verify the final state.
|
||||
if (!is_dir($directory) && !@mkdir($directory, $mode, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Could not create the HLS cache directory.');
|
||||
}
|
||||
chmod($directory, $mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class CleanupService
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{sessions:int,assets:int} */
|
||||
public function run(string $stationTempDirectory, ?int $now = null): array
|
||||
{
|
||||
$now ??= time();
|
||||
$this->paths->ensure($stationTempDirectory);
|
||||
$activeCaches = [];
|
||||
$removedSessions = 0;
|
||||
$removedAssets = 0;
|
||||
|
||||
foreach (new \FilesystemIterator($this->paths->sessions($stationTempDirectory), \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if ($file->isLink() || !$file->isFile() || 1 !== preg_match('/^[a-f0-9]{64}\.json$/D', $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$raw = file_get_contents($file->getPathname());
|
||||
$data = false !== $raw ? json_decode($raw, true, 32, JSON_THROW_ON_ERROR) : null;
|
||||
$session = is_array($data) ? PlaybackSession::fromArray($data) : null;
|
||||
if (null === $session || $session->isExpired($now)) {
|
||||
@unlink($file->getPathname());
|
||||
++$removedSessions;
|
||||
} else {
|
||||
$activeCaches[$session->cacheKey] = true;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
@unlink($file->getPathname());
|
||||
++$removedSessions;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (new \FilesystemIterator($this->paths->assets($stationTempDirectory), \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$name = $item->getFilename();
|
||||
$validCache = 1 === preg_match('/^[a-f0-9]{64}$/D', $name);
|
||||
$staleBuild = str_starts_with($name, '.build-') && $item->getMTime() < $now - 3600;
|
||||
$expiredCache = $validCache
|
||||
&& !isset($activeCaches[$name])
|
||||
&& $item->getMTime() < $now - $this->config->cacheTtl;
|
||||
if ($staleBuild || $expiredCache) {
|
||||
$this->removeTree($item->getPathname());
|
||||
++$removedAssets;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedSessions > 0 || $removedAssets > 0) {
|
||||
$this->logger->info('Cleaned on-demand HLS cache.', [
|
||||
'sessions' => $removedSessions,
|
||||
'assets' => $removedAssets,
|
||||
]);
|
||||
}
|
||||
return ['sessions' => $removedSessions, 'assets' => $removedAssets];
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$this->removeTree($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeBusyException;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final readonly class HlsCache
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
private StationFilesystems $stationFilesystems,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(Station $station, StationMedia $media): string
|
||||
{
|
||||
return hash('sha256', implode(':', [
|
||||
'v1',
|
||||
$station->id,
|
||||
$media->storage_location_id,
|
||||
$media->unique_id,
|
||||
$media->mtime,
|
||||
$this->config->segmentDuration,
|
||||
$this->config->audioBitrate,
|
||||
]));
|
||||
}
|
||||
|
||||
public function ensure(Station $station, StationMedia $media): string
|
||||
{
|
||||
$stationTemp = $station->getRadioTempDir();
|
||||
$this->paths->ensure($stationTemp);
|
||||
$cacheKey = $this->key($station, $media);
|
||||
$target = $this->paths->asset($stationTemp, $cacheKey);
|
||||
|
||||
if ($this->isComplete($target)) {
|
||||
@touch($target);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
$lockPath = $this->paths->locks($stationTemp) . '/' . $cacheKey . '.lock';
|
||||
$lock = fopen($lockPath, 'c');
|
||||
if (false === $lock) {
|
||||
throw new TranscodeException('Could not open HLS generation lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
throw new TranscodeBusyException('This HLS rendition is already being generated.');
|
||||
}
|
||||
if ($this->isComplete($target)) {
|
||||
@touch($target);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
// Limit synchronous FFmpeg work to one process per station. This keeps
|
||||
// authenticated bursts from exhausting PHP workers and host CPU.
|
||||
$stationLock = fopen($this->paths->locks($stationTemp) . '/transcode.lock', 'c');
|
||||
if (false === $stationLock) {
|
||||
throw new TranscodeException('Could not open station transcoding lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($stationLock, LOCK_EX | LOCK_NB)) {
|
||||
throw new TranscodeBusyException('Another HLS rendition is being generated for this station.');
|
||||
}
|
||||
|
||||
$temporary = $this->paths->assets($stationTemp)
|
||||
. '/.build-' . $cacheKey . '-' . bin2hex(random_bytes(6));
|
||||
if (!mkdir($temporary, 0755, true) && !is_dir($temporary)) {
|
||||
throw new TranscodeException('Could not create temporary HLS build directory.');
|
||||
}
|
||||
|
||||
try {
|
||||
$filesystem = $this->stationFilesystems->getMediaFilesystem($station);
|
||||
if (!$filesystem->fileExists($media->path)) {
|
||||
throw new TranscodeException('The source media file does not exist.');
|
||||
}
|
||||
|
||||
$filesystem->withLocalFile(
|
||||
$media->path,
|
||||
fn(string $source): bool => $this->transcode($source, $temporary)
|
||||
);
|
||||
$this->writeMasterPlaylist($temporary);
|
||||
$this->validateBuild($temporary);
|
||||
|
||||
if (is_dir($target)) {
|
||||
$this->removeTree($target);
|
||||
}
|
||||
if (!rename($temporary, $target)) {
|
||||
throw new TranscodeException('Could not publish generated HLS assets.');
|
||||
}
|
||||
chmod($target, 0755);
|
||||
} catch (Throwable $exception) {
|
||||
$this->removeTree($temporary);
|
||||
if ($exception instanceof TranscodeException) {
|
||||
throw $exception;
|
||||
}
|
||||
throw new TranscodeException('HLS generation failed.', 0, $exception);
|
||||
}
|
||||
} finally {
|
||||
flock($stationLock, LOCK_UN);
|
||||
fclose($stationLock);
|
||||
}
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
|
||||
$this->logger->info('Generated on-demand HLS cache.', [
|
||||
'station_id' => $station->id,
|
||||
'media_id' => $media->unique_id,
|
||||
'cache_key_prefix' => substr($cacheKey, 0, 12),
|
||||
]);
|
||||
return $cacheKey;
|
||||
}
|
||||
|
||||
private function transcode(string $source, string $destination): bool
|
||||
{
|
||||
if (!is_file($source)) {
|
||||
throw new TranscodeException('Resolved media source is not a regular file.');
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
$this->config->ffmpegBinary,
|
||||
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
|
||||
'-i', $source,
|
||||
'-map', '0:a:0', '-vn',
|
||||
'-c:a', 'aac', '-b:a', $this->config->audioBitrate,
|
||||
'-f', 'hls',
|
||||
'-hls_time', (string)$this->config->segmentDuration,
|
||||
'-hls_playlist_type', 'vod',
|
||||
'-hls_flags', 'independent_segments+temp_file',
|
||||
'-hls_segment_filename', $destination . '/segment%05d.ts',
|
||||
$destination . '/media.m3u8',
|
||||
]);
|
||||
$process->setTimeout($this->config->transcodeTimeout);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $exception) {
|
||||
throw new TranscodeException('FFmpeg timed out.', 0, $exception);
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$detail = trim(substr($process->getErrorOutput(), 0, 1000));
|
||||
$this->logger->error('FFmpeg failed to generate on-demand HLS.', [
|
||||
'exit_code' => $process->getExitCode(),
|
||||
'error' => $detail,
|
||||
]);
|
||||
throw new TranscodeException('FFmpeg failed to generate HLS output.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function writeMasterPlaylist(string $directory): void
|
||||
{
|
||||
$bitsPerSecond = ((int)rtrim($this->config->audioBitrate, 'k')) * 1100;
|
||||
$playlist = "#EXTM3U\n#EXT-X-VERSION:3\n"
|
||||
. '#EXT-X-STREAM-INF:BANDWIDTH=' . $bitsPerSecond . ',CODECS="mp4a.40.2"' . "\n"
|
||||
. "media.m3u8\n";
|
||||
if (false === file_put_contents($directory . '/master.m3u8', $playlist, LOCK_EX)) {
|
||||
throw new TranscodeException('Could not write the HLS master playlist.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateBuild(string $directory): void
|
||||
{
|
||||
foreach (['master.m3u8', 'media.m3u8'] as $playlist) {
|
||||
if (null === ResourcePath::resolve($directory, $playlist)) {
|
||||
throw new TranscodeException('FFmpeg produced an incomplete HLS package.');
|
||||
}
|
||||
}
|
||||
|
||||
$segments = glob($directory . '/segment*.ts') ?: [];
|
||||
if ([] === $segments) {
|
||||
throw new TranscodeException('FFmpeg produced no HLS segments.');
|
||||
}
|
||||
|
||||
foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if ($file->isLink() || !$file->isFile() || !ResourcePath::isAllowed($file->getFilename())) {
|
||||
throw new TranscodeException('FFmpeg produced an unexpected HLS cache entry.');
|
||||
}
|
||||
chmod($file->getPathname(), 0644);
|
||||
}
|
||||
|
||||
$manifest = file_get_contents($directory . '/media.m3u8');
|
||||
if (false === $manifest || str_contains($manifest, '/') || str_contains($manifest, '\\')) {
|
||||
throw new TranscodeException('Generated media playlist contains an unsafe resource path.');
|
||||
}
|
||||
foreach (preg_split('/\R/', $manifest) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ('' !== $line && '#' !== $line[0] && !ResourcePath::isAllowed($line)) {
|
||||
throw new TranscodeException('Generated media playlist references an unexpected resource.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isComplete(string $directory): bool
|
||||
{
|
||||
return null !== ResourcePath::resolve($directory, 'master.m3u8')
|
||||
&& null !== ResourcePath::resolve($directory, 'media.m3u8')
|
||||
&& [] !== (glob($directory . '/segment*.ts') ?: []);
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (!file_exists($path) && !is_link($path)) {
|
||||
return;
|
||||
}
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$this->removeTree($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\StationMedia;
|
||||
use App\Entity\StationPlaylistMedia;
|
||||
|
||||
final class OnDemandEligibility
|
||||
{
|
||||
public function isEligible(StationMedia $media): bool
|
||||
{
|
||||
foreach ($media->playlists as $playlistMedia) {
|
||||
if (!$playlistMedia instanceof StationPlaylistMedia) {
|
||||
continue;
|
||||
}
|
||||
$playlist = $playlistMedia->playlist;
|
||||
if ($playlist->is_enabled && $playlist->include_in_on_demand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Sync\Task\AbstractTask;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Throwable;
|
||||
|
||||
final class ScheduledCleanupTask extends AbstractTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly CleanupService $cleanup,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSchedulePattern(): string
|
||||
{
|
||||
return '17 * * * *';
|
||||
}
|
||||
|
||||
public function run(bool $force = false): void
|
||||
{
|
||||
if (!$this->config->enabled && !$force) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->iterateStations() as $station) {
|
||||
try {
|
||||
/** @var Station $station */
|
||||
$this->cleanup->run($station->getRadioTempDir());
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error('Scheduled on-demand HLS cleanup failed.', [
|
||||
'station_id' => $station->id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Service;
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class SessionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{token:string, session:PlaybackSession} */
|
||||
public function create(
|
||||
string $stationTempDirectory,
|
||||
int $stationId,
|
||||
string $mediaId,
|
||||
string $cacheKey,
|
||||
string $principalHash,
|
||||
?int $now = null,
|
||||
): array {
|
||||
$now ??= time();
|
||||
$this->paths->ensure($stationTempDirectory);
|
||||
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $principalHash)) {
|
||||
throw new RuntimeException('Unsafe principal identifier.');
|
||||
}
|
||||
$lockPath = $this->paths->locks($stationTempDirectory) . '/sessions-' . $principalHash . '.lock';
|
||||
$lock = fopen($lockPath, 'c');
|
||||
if (false === $lock) {
|
||||
throw new RuntimeException('Could not open the session creation lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('Could not acquire the session creation lock.');
|
||||
}
|
||||
if ($this->countActiveForPrincipal($stationTempDirectory, $principalHash, $now)
|
||||
>= $this->config->maxSessionsPerPrincipal) {
|
||||
throw new SessionLimitException();
|
||||
}
|
||||
|
||||
$token = OpaqueToken::generate();
|
||||
$id = OpaqueToken::id($token);
|
||||
$session = new PlaybackSession(
|
||||
id: $id,
|
||||
stationId: $stationId,
|
||||
mediaId: $mediaId,
|
||||
cacheKey: $cacheKey,
|
||||
principalHash: $principalHash,
|
||||
createdAt: $now,
|
||||
expiresAt: $now + $this->config->sessionTtl,
|
||||
);
|
||||
|
||||
$target = $this->sessionPath($stationTempDirectory, $id);
|
||||
$temporary = $target . '.tmp-' . bin2hex(random_bytes(6));
|
||||
$json = json_encode($session->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
if (false === file_put_contents($temporary, $json, LOCK_EX)) {
|
||||
throw new RuntimeException('Could not persist playback session.');
|
||||
}
|
||||
chmod($temporary, 0600);
|
||||
if (!rename($temporary, $target)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException('Could not publish playback session.');
|
||||
}
|
||||
|
||||
return ['token' => $token, 'session' => $session];
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
public function find(string $stationTempDirectory, string $token): ?PlaybackSession
|
||||
{
|
||||
if (!OpaqueToken::isValid($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->sessionPath($stationTempDirectory, OpaqueToken::id($token));
|
||||
if (is_link($path) || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = file_get_contents($path);
|
||||
if (false === $raw || strlen($raw) > 4096) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
$session = PlaybackSession::fromArray($data);
|
||||
return hash_equals($session->id, OpaqueToken::id($token)) ? $session : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function countActiveForPrincipal(string $stationTempDirectory, string $principalHash, int $now): int
|
||||
{
|
||||
$directory = $this->paths->sessions($stationTempDirectory);
|
||||
if (!is_dir($directory)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if (!$file->isFile() || $file->isLink() || 1 !== preg_match('/^[a-f0-9]{64}\.json$/D', $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$raw = file_get_contents($file->getPathname());
|
||||
$data = false !== $raw ? json_decode($raw, true, 32, JSON_THROW_ON_ERROR) : null;
|
||||
if (is_array($data)) {
|
||||
$session = PlaybackSession::fromArray($data);
|
||||
if (!$session->isExpired($now) && hash_equals($session->principalHash, $principalHash)) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function sessionPath(string $stationTempDirectory, string $id): string
|
||||
{
|
||||
if (1 !== preg_match('/^[a-f0-9]{64}$/D', $id)) {
|
||||
throw new RuntimeException('Unsafe session ID.');
|
||||
}
|
||||
return $this->paths->sessions($stationTempDirectory) . '/' . $id . '.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
|
||||
#[CoversClass(Config::class)]
|
||||
final class ConfigTest extends TestCase
|
||||
{
|
||||
public function testDefaultsAreProductionSafe(): void
|
||||
{
|
||||
$config = new Config();
|
||||
self::assertTrue($config->enabled);
|
||||
self::assertSame(1800, $config->sessionTtl);
|
||||
self::assertSame(6, $config->segmentDuration);
|
||||
self::assertGreaterThanOrEqual($config->sessionTtl, $config->cacheTtl);
|
||||
}
|
||||
|
||||
public function testTraversalCannotBeUsedAsCacheDirectory(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new Config(cacheDirectory: '../outside');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Security;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
|
||||
|
||||
#[CoversClass(OpaqueToken::class)]
|
||||
final class OpaqueTokenTest extends TestCase
|
||||
{
|
||||
public function testGeneratedTokensAreOpaqueValidAndUnique(): void
|
||||
{
|
||||
$tokens = [];
|
||||
for ($i = 0; $i < 100; ++$i) {
|
||||
$token = OpaqueToken::generate();
|
||||
self::assertTrue(OpaqueToken::isValid($token));
|
||||
self::assertSame(43, strlen($token));
|
||||
$tokens[] = $token;
|
||||
}
|
||||
self::assertCount(100, array_unique($tokens));
|
||||
}
|
||||
|
||||
public function testMalformedTokensAreRejected(): void
|
||||
{
|
||||
foreach (['', 'short', str_repeat('a', 42), str_repeat('a', 44), str_repeat('.', 43), '../' . str_repeat('a', 40), str_repeat('a', 42) . '='] as $token) {
|
||||
self::assertFalse(OpaqueToken::isValid($token), $token);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMutationChangesStoredIdentifier(): void
|
||||
{
|
||||
$token = OpaqueToken::generate();
|
||||
$replacement = 'A' === $token[0] ? 'B' : 'A';
|
||||
$mutated = $replacement . substr($token, 1);
|
||||
self::assertNotSame(OpaqueToken::id($token), OpaqueToken::id($mutated));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Security;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
|
||||
#[CoversClass(ResourcePath::class)]
|
||||
final class ResourcePathTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testOnlyExpectedHlsNamesAreAllowed(): void
|
||||
{
|
||||
self::assertTrue(ResourcePath::isAllowed('master.m3u8'));
|
||||
self::assertTrue(ResourcePath::isAllowed('media.m3u8'));
|
||||
self::assertTrue(ResourcePath::isAllowed('segment00000.ts'));
|
||||
self::assertTrue(ResourcePath::isAllowed('segment99999.ts'));
|
||||
}
|
||||
|
||||
#[DataProvider('traversalProvider')]
|
||||
public function testTraversalAndUnexpectedPathsAreRejected(string $path): void
|
||||
{
|
||||
self::assertFalse(ResourcePath::isAllowed($path));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function traversalProvider(): iterable
|
||||
{
|
||||
yield 'dot-dot slash' => ['../master.m3u8'];
|
||||
yield 'dot-dot backslash' => ['..\\master.m3u8'];
|
||||
yield 'absolute Unix' => ['/etc/passwd'];
|
||||
yield 'absolute Windows' => ['C:\\Windows\\win.ini'];
|
||||
yield 'URL encoded' => ['%2e%2e%2fmaster.m3u8'];
|
||||
yield 'double encoded' => ['%252e%252e%252fmaster.m3u8'];
|
||||
yield 'null byte' => ["master.m3u8\0.ts"];
|
||||
yield 'nested segment' => ['folder/segment00000.ts'];
|
||||
yield 'bad extension' => ['segment00000.php'];
|
||||
yield 'unbounded digits' => ['segment1.ts'];
|
||||
yield 'query text' => ['media.m3u8?x=1'];
|
||||
}
|
||||
|
||||
public function testLongMediaSegmentNumbersRemainValid(): void
|
||||
{
|
||||
self::assertTrue(ResourcePath::isAllowed('segment100000.ts'));
|
||||
self::assertTrue(ResourcePath::isAllowed('segment999999999.ts'));
|
||||
self::assertFalse(ResourcePath::isAllowed('segment1000000000.ts'));
|
||||
}
|
||||
|
||||
public function testResolveRejectsSymlinksAndMissingFiles(): void
|
||||
{
|
||||
file_put_contents($this->temporaryDirectory . '/master.m3u8', '#EXTM3U');
|
||||
self::assertSame(
|
||||
realpath($this->temporaryDirectory . '/master.m3u8'),
|
||||
ResourcePath::resolve($this->temporaryDirectory, 'master.m3u8')
|
||||
);
|
||||
self::assertNull(ResourcePath::resolve($this->temporaryDirectory, 'media.m3u8'));
|
||||
|
||||
file_put_contents($this->temporaryDirectory . '/outside', 'secret');
|
||||
symlink($this->temporaryDirectory . '/outside', $this->temporaryDirectory . '/media.m3u8');
|
||||
self::assertNull(ResourcePath::resolve($this->temporaryDirectory, 'media.m3u8'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
|
||||
#[CoversClass(AssetAuthorizer::class)]
|
||||
final class AssetAuthorizerTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testPlaylistAndSegmentRequireValidUnexpiredStationBoundSession(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$assetDirectory = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($assetDirectory, 0755, true);
|
||||
file_put_contents($assetDirectory . '/master.m3u8', '#EXTM3U');
|
||||
file_put_contents($assetDirectory . '/media.m3u8', '#EXTM3U');
|
||||
file_put_contents($assetDirectory . '/segment00000.ts', 'segment');
|
||||
|
||||
$created = $sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$authorizer = new AssetAuthorizer($sessions, $paths);
|
||||
|
||||
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1059));
|
||||
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'segment00000.ts', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 8, $created['token'], 'master.m3u8', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1060));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, 'x' . substr($created['token'], 1), 'master.m3u8', 1059));
|
||||
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], '../master.m3u8', 1059));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
#[CoversClass(CleanupService::class)]
|
||||
final class CleanupServiceTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testExpiredSessionsAndUnreferencedAssetsAreRemoved(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$created = $sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($asset, 0755, true);
|
||||
file_put_contents($asset . '/master.m3u8', '#EXTM3U');
|
||||
touch($asset, 1000);
|
||||
|
||||
$cleanup = new CleanupService($config, $paths, new NullLogger());
|
||||
$result = $cleanup->run($this->temporaryDirectory, 1061);
|
||||
|
||||
self::assertSame(['sessions' => 1, 'assets' => 1], $result);
|
||||
self::assertNull($sessions->find($this->temporaryDirectory, $created['token']));
|
||||
self::assertDirectoryDoesNotExist($asset);
|
||||
}
|
||||
|
||||
public function testActiveSessionPreventsCacheDeletion(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 120, cacheTtl: 120);
|
||||
$paths = new CachePaths($config);
|
||||
$sessions = new SessionRepository($config, $paths);
|
||||
$cacheKey = str_repeat('a', 64);
|
||||
$sessions->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
|
||||
mkdir($asset, 0755, true);
|
||||
touch($asset, 1);
|
||||
|
||||
$cleanup = new CleanupService($config, $paths, new NullLogger());
|
||||
self::assertSame(['sessions' => 0, 'assets' => 0], $cleanup->run($this->temporaryDirectory, 1050));
|
||||
self::assertDirectoryExists($asset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationMedia;
|
||||
use App\Flysystem\StationFilesystems;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\HlsCache;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
#[CoversClass(HlsCache::class)]
|
||||
final class HlsCacheTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testGeneratesAValidatedPackageAndReusesIt(): void
|
||||
{
|
||||
$source = $this->temporaryDirectory . '/source.mp3';
|
||||
$counter = $this->temporaryDirectory . '/ffmpeg-calls';
|
||||
file_put_contents($source, 'input');
|
||||
$script = $this->makeFfmpegScript($counter, false);
|
||||
$cache = $this->cache($source, $script);
|
||||
$station = new Station(7, $this->temporaryDirectory);
|
||||
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
|
||||
|
||||
$key = $cache->ensure($station, $media);
|
||||
self::assertSame($key, $cache->ensure($station, $media));
|
||||
self::assertSame("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=140800,CODECS=\"mp4a.40.2\"\nmedia.m3u8\n", file_get_contents($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/master.m3u8'));
|
||||
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/media.m3u8');
|
||||
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/segment00000.ts');
|
||||
self::assertSame("1\n", file_get_contents($counter));
|
||||
}
|
||||
|
||||
public function testRejectsMalformedFfmpegOutputWithoutPublishingIt(): void
|
||||
{
|
||||
$source = $this->temporaryDirectory . '/source.mp3';
|
||||
file_put_contents($source, 'input');
|
||||
$script = $this->makeFfmpegScript($this->temporaryDirectory . '/ffmpeg-calls', true);
|
||||
$cache = $this->cache($source, $script);
|
||||
$station = new Station(7, $this->temporaryDirectory);
|
||||
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
|
||||
|
||||
$this->expectException(TranscodeException::class);
|
||||
try {
|
||||
$cache->ensure($station, $media);
|
||||
} finally {
|
||||
self::assertDirectoryDoesNotExist($this->temporaryDirectory . '/ondemand-hls/assets/' . $cache->key($station, $media));
|
||||
}
|
||||
}
|
||||
|
||||
private function cache(string $source, string $ffmpeg): HlsCache
|
||||
{
|
||||
$config = new Config(cacheTtl: 1800, ffmpegBinary: $ffmpeg);
|
||||
$filesystem = new class($source) {
|
||||
public function __construct(private string $source)
|
||||
{
|
||||
}
|
||||
|
||||
public function fileExists(string $path): bool
|
||||
{
|
||||
return 'source.mp3' === $path;
|
||||
}
|
||||
|
||||
public function withLocalFile(string $path, callable $callback): mixed
|
||||
{
|
||||
return $callback($this->source);
|
||||
}
|
||||
};
|
||||
|
||||
return new HlsCache(
|
||||
$config,
|
||||
new CachePaths($config),
|
||||
new StationFilesystems($filesystem),
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
private function makeFfmpegScript(string $counter, bool $malformed): string
|
||||
{
|
||||
$script = $this->temporaryDirectory . '/fake-ffmpeg-' . bin2hex(random_bytes(4)) . '.sh';
|
||||
$segment = $malformed
|
||||
? "printf '#EXTM3U\\n../outside.ts\\n' > \"\$output\"\n"
|
||||
: "printf '#EXTM3U\\n#EXT-X-ENDLIST\\nsegment00000.ts\\n' > \"\$output\"\nsegment_file=\$(printf \"\$segment\" 0)\nprintf segment > \"\$segment_file\"\n";
|
||||
file_put_contents($script, "#!/bin/sh\nset -eu\noutput=''\nsegment=''\nprevious=''\nfor argument in \"\$@\"; do\n if [ \"\$previous\" = '-hls_segment_filename' ]; then segment=\"\$argument\"; fi\n previous=\"\$argument\"\n output=\"\$argument\"\ndone\nprintf '1\\n' >> " . escapeshellarg($counter) . "\n" . $segment);
|
||||
chmod($script, 0700);
|
||||
return $script;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
|
||||
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
#[CoversClass(SessionRepository::class)]
|
||||
#[CoversClass(PlaybackSession::class)]
|
||||
final class SessionRepositoryTest extends TemporaryDirectoryTestCase
|
||||
{
|
||||
public function testValidSessionCreationAndLookup(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
|
||||
self::assertTrue(OpaqueToken::isValid($created['token']));
|
||||
self::assertSame(1060, $created['session']->expiresAt);
|
||||
self::assertEquals($created['session'], $repository->find($this->temporaryDirectory, $created['token']));
|
||||
}
|
||||
|
||||
public function testInvalidAndModifiedTokensCannotLoadSession(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
$token = $created['token'];
|
||||
$mutated = ('A' === $token[0] ? 'B' : 'A') . substr($token, 1);
|
||||
|
||||
self::assertNull($repository->find($this->temporaryDirectory, 'invalid'));
|
||||
self::assertNull($repository->find($this->temporaryDirectory, $mutated));
|
||||
}
|
||||
|
||||
public function testConcurrentSessionLimitIsEnforced(): void
|
||||
{
|
||||
$repository = $this->repository(maxSessions: 2);
|
||||
for ($i = 0; $i < 2; ++$i) {
|
||||
$repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
hash('sha256', 'cache-' . $i),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Maximum concurrent playback sessions reached.');
|
||||
$repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('d', 64),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
public function testParallelCreatorsCannotExceedSessionLimit(): void
|
||||
{
|
||||
$worker = $this->temporaryDirectory . '/session-worker.php';
|
||||
$autoload = dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
file_put_contents($worker, <<<'PHP'
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require $argv[1];
|
||||
|
||||
use Plugin\AzuraCastOnDemandHls\Config;
|
||||
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
|
||||
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
|
||||
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: 1);
|
||||
$repository = new SessionRepository($config, new CachePaths($config));
|
||||
touch($argv[5] . '/ready-' . $argv[6]);
|
||||
while (!is_file($argv[4])) {
|
||||
usleep(1000);
|
||||
}
|
||||
|
||||
try {
|
||||
$repository->create(
|
||||
$argv[2],
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
hash('sha256', 'cache-' . $argv[6]),
|
||||
str_repeat('c', 64),
|
||||
1000,
|
||||
);
|
||||
echo 'created';
|
||||
} catch (SessionLimitException) {
|
||||
echo 'limited';
|
||||
}
|
||||
PHP);
|
||||
|
||||
$marker = $this->temporaryDirectory . '/start';
|
||||
$processes = [];
|
||||
foreach (['one', 'two'] as $id) {
|
||||
$process = new Process([
|
||||
PHP_BINARY,
|
||||
$worker,
|
||||
$autoload,
|
||||
$this->temporaryDirectory,
|
||||
$marker,
|
||||
$this->temporaryDirectory,
|
||||
$id,
|
||||
]);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while ((!is_file($this->temporaryDirectory . '/ready-one')
|
||||
|| !is_file($this->temporaryDirectory . '/ready-two'))
|
||||
&& microtime(true) < $deadline) {
|
||||
usleep(1000);
|
||||
}
|
||||
self::assertFileExists($this->temporaryDirectory . '/ready-one');
|
||||
self::assertFileExists($this->temporaryDirectory . '/ready-two');
|
||||
touch($marker);
|
||||
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
self::assertSame(0, $process->wait(), $process->getErrorOutput());
|
||||
$results[] = trim($process->getOutput());
|
||||
}
|
||||
sort($results);
|
||||
self::assertSame(['created', 'limited'], $results);
|
||||
|
||||
$sessionFiles = glob($this->temporaryDirectory . '/ondemand-hls/sessions/*.json') ?: [];
|
||||
self::assertCount(1, $sessionFiles);
|
||||
}
|
||||
|
||||
private function repository(int $maxSessions = 10): SessionRepository
|
||||
{
|
||||
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: $maxSessions);
|
||||
return new SessionRepository($config, new CachePaths($config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
final class Station
|
||||
{
|
||||
public function __construct(public int $id, private string $radioTempDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
public function getRadioTempDir(): string
|
||||
{
|
||||
return $this->radioTempDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
final class StationMedia
|
||||
{
|
||||
public function __construct(
|
||||
public int $storage_location_id,
|
||||
public string $unique_id,
|
||||
public int $mtime,
|
||||
public string $path,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
use App\Entity\Station;
|
||||
|
||||
final class StationFilesystems
|
||||
{
|
||||
public function __construct(private object $filesystem)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMediaFilesystem(Station $station): object
|
||||
{
|
||||
return $this->filesystem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugin\AzuraCastOnDemandHls\Tests\Support;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
abstract class TemporaryDirectoryTestCase extends TestCase
|
||||
{
|
||||
protected string $temporaryDirectory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->temporaryDirectory = sys_get_temp_dir() . '/ondemand-hls-test-' . bin2hex(random_bytes(8));
|
||||
mkdir($this->temporaryDirectory, 0700, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeTree($this->temporaryDirectory);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$this->removeTree($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require __DIR__ . '/Support/AzuraCastStubs.php';
|
||||
Reference in New Issue
Block a user