fix: count active HLS playback renditions
This commit is contained in:
@@ -89,7 +89,7 @@ Configuration is read from the process environment when AzuraCast builds its con
|
||||
| `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_MAX_SESSIONS` | `10` | `1`–`100` | Maximum active playback renditions per principal hash within one station. |
|
||||
| `AZURACAST_ONDEMAND_HLS_CACHE_DIRECTORY` | `ondemand-hls` | 1–64 characters; starts with lowercase letter/digit, then lowercase letters, digits, `_`, `-` | Relative directory created beneath each station temp directory. |
|
||||
| `AZURACAST_ONDEMAND_HLS_FFMPEG_BINARY` | `ffmpeg` | 1–255 characters from letters, digits, `.`, `_`, `+`, `/`, `-`; must not contain `..` | FFmpeg executable name or absolute path. |
|
||||
| `AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE` | `128k` | `10k`–`9999k` in the exact form `[1-9][0-9]{1,3}k` | AAC target bitrate and part of the rendition cache key. Use a sensible audio bitrate despite the broad validation range. |
|
||||
@@ -242,7 +242,7 @@ No database tables are added. State lives below each station's radio temp direct
|
||||
- **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.
|
||||
- **Principal limit:** active-playback counting uses a hash of the authenticated user ID and request `REMOTE_ADDR`. A session counts only while a successfully authorized HLS asset request has occurred within the last 90 seconds; duplicate URLs for the same rendition count as one slot. This prevents player initialization/retry loops and abandoned URLs from exhausting the limit. It limits creation only; the playback token remains a transferable bearer credential.
|
||||
- **Delivery:** PHP validates the token, expiry, station binding, exact resource name, real path, and symlink status. It then emits an internal `X-Accel-Redirect`; Nginx serves the file without PHP streaming it.
|
||||
|
||||
### Cleanup
|
||||
|
||||
@@ -34,6 +34,11 @@ final readonly class AssetAuthorizer
|
||||
|
||||
$directory = $this->paths->asset($stationTempDirectory, $session->cacheKey);
|
||||
$path = ResourcePath::resolve($directory, $resource);
|
||||
return null === $path ? null : ['session' => $session, 'path' => $path];
|
||||
if (null === $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->sessions->markAccessed($stationTempDirectory, $token, $now);
|
||||
return ['session' => $session, 'path' => $path];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ use Throwable;
|
||||
|
||||
final readonly class SessionRepository
|
||||
{
|
||||
/**
|
||||
* HLS clients normally request a playlist or segment every few seconds.
|
||||
* This intentionally measures active playback rather than issued URLs.
|
||||
*/
|
||||
private const int ACTIVE_PLAYBACK_WINDOW = 90;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private CachePaths $paths,
|
||||
@@ -72,6 +78,7 @@ final readonly class SessionRepository
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException('Could not publish playback session.');
|
||||
}
|
||||
@touch($target, $now);
|
||||
|
||||
return ['token' => $token, 'session' => $session];
|
||||
} finally {
|
||||
@@ -114,7 +121,8 @@ final readonly class SessionRepository
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
/** @var array<string, true> $activeCacheKeys */
|
||||
$activeCacheKeys = [];
|
||||
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;
|
||||
@@ -124,15 +132,35 @@ final readonly class SessionRepository
|
||||
$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;
|
||||
if (!$session->isExpired($now)
|
||||
&& hash_equals($session->principalHash, $principalHash)
|
||||
&& $this->isRecentlyAccessed($file, $now)) {
|
||||
// Multiple URLs for the same rendition are usually client retries or
|
||||
// duplicate player initialization. They consume one playback slot.
|
||||
$activeCacheKeys[$session->cacheKey] = true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
return count($activeCacheKeys);
|
||||
}
|
||||
|
||||
public function markAccessed(string $stationTempDirectory, string $token, ?int $now = null): void
|
||||
{
|
||||
if (!OpaqueToken::isValid($token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->sessionPath($stationTempDirectory, OpaqueToken::id($token));
|
||||
if (is_link($path) || !is_file($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The session file contains no bearer token. Its mtime is a lightweight,
|
||||
// race-tolerant playback heartbeat and is refreshed only after authorization.
|
||||
@touch($path, $now ?? time());
|
||||
}
|
||||
|
||||
private function sessionPath(string $stationTempDirectory, string $id): string
|
||||
@@ -142,4 +170,10 @@ final readonly class SessionRepository
|
||||
}
|
||||
return $this->paths->sessions($stationTempDirectory) . '/' . $id . '.json';
|
||||
}
|
||||
|
||||
private function isRecentlyAccessed(\SplFileInfo $file, int $now): bool
|
||||
{
|
||||
$lastAccessedAt = $file->getMTime();
|
||||
return $lastAccessedAt >= $now - self::ACTIVE_PLAYBACK_WINDOW;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,79 @@ final class SessionRepositoryTest extends TemporaryDirectoryTestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testDuplicateUrlsForTheSameRenditionUseOnePlaybackSlot(): void
|
||||
{
|
||||
$repository = $this->repository(maxSessions: 1);
|
||||
$principalHash = str_repeat('c', 64);
|
||||
$cacheKey = str_repeat('b', 64);
|
||||
|
||||
$first = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
$principalHash,
|
||||
1000,
|
||||
);
|
||||
$second = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
$cacheKey,
|
||||
$principalHash,
|
||||
1001,
|
||||
);
|
||||
|
||||
self::assertNotSame($first['token'], $second['token']);
|
||||
self::assertSame(1, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1001));
|
||||
}
|
||||
|
||||
public function testInactiveSessionsDoNotConsumePlaybackSlots(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 1800, cacheTtl: 1800, maxSessionsPerPrincipal: 1);
|
||||
$repository = new SessionRepository($config, new CachePaths($config));
|
||||
$principalHash = str_repeat('c', 64);
|
||||
$repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
$principalHash,
|
||||
1000,
|
||||
);
|
||||
|
||||
self::assertSame(0, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1091));
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123457',
|
||||
str_repeat('d', 64),
|
||||
$principalHash,
|
||||
1091,
|
||||
);
|
||||
self::assertTrue(OpaqueToken::isValid($created['token']));
|
||||
}
|
||||
|
||||
public function testAuthorizedAssetAccessKeepsAPlaybackSlotActive(): void
|
||||
{
|
||||
$config = new Config(sessionTtl: 1800, cacheTtl: 1800, maxSessionsPerPrincipal: 1);
|
||||
$repository = new SessionRepository($config, new CachePaths($config));
|
||||
$principalHash = str_repeat('c', 64);
|
||||
$created = $repository->create(
|
||||
$this->temporaryDirectory,
|
||||
7,
|
||||
'abcdef123456abcdef123456',
|
||||
str_repeat('b', 64),
|
||||
$principalHash,
|
||||
1000,
|
||||
);
|
||||
|
||||
$repository->markAccessed($this->temporaryDirectory, $created['token'], 1080);
|
||||
|
||||
self::assertSame(1, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1169));
|
||||
self::assertSame(0, $repository->countActiveForPrincipal($this->temporaryDirectory, $principalHash, 1171));
|
||||
}
|
||||
|
||||
public function testParallelCreatorsCannotExceedSessionLimit(): void
|
||||
{
|
||||
$worker = $this->temporaryDirectory . '/session-worker.php';
|
||||
|
||||
Reference in New Issue
Block a user