fix: count active HLS playback renditions

This commit is contained in:
root
2026-09-05 04:34:32 +02:00
parent 41a0791901
commit b299fb9f29
4 changed files with 119 additions and 7 deletions
+2 -2
View File
@@ -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
+6 -1
View File
@@ -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];
}
}
+38 -4
View File
@@ -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;
}
}
+73
View File
@@ -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';