feat: add secure on-demand HLS playback plugin

This commit is contained in:
root
2026-09-04 20:23:10 +02:00
commit 6fae6a3a43
35 changed files with 2283 additions and 0 deletions
+88
View File
@@ -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;
}
}
+120
View File
@@ -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');
}
}
+82
View File
@@ -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'],
);
}
}
+56
View File
@@ -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);
}
}
+15
View File
@@ -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.');
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Exception;
final class TranscodeBusyException extends TranscodeException
{
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Exception;
use RuntimeException;
final class TranscodeException extends RuntimeException
{
}
+27
View File
@@ -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);
}
}
+25
View File
@@ -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);
}
}
+40
View File
@@ -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;
}
}
+39
View File
@@ -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];
}
}
+65
View File
@@ -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);
}
}
}
+86
View File
@@ -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);
}
}
+234
View File
@@ -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);
}
}
+25
View File
@@ -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;
}
}
+43
View File
@@ -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,
]);
}
}
}
}
+145
View File
@@ -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';
}
}