Track and display HLS listeners.
This commit is contained in:
parent
c0e9089a94
commit
bb256cc79f
|
@ -76,9 +76,13 @@
|
|||
<td class="pr-1">
|
||||
<play-button icon-class="outlined" :url="np.station.hls_url" is-stream is-hls></play-button>
|
||||
</td>
|
||||
<td class="pl-1" colspan="2">
|
||||
<td class="pl-1">
|
||||
<a v-bind:href="np.station.hls_url" target="_blank">{{ np.station.hls_url }}</a>
|
||||
</td>
|
||||
<td class="pl-1 text-right">
|
||||
<icon class="sm align-middle" icon="headset"></icon>
|
||||
<span class="listeners-total">{{ np.station.hls_listeners }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
|
|
|
@ -108,6 +108,12 @@ class Station implements ResolvableUrlInterface
|
|||
)]
|
||||
public string|UriInterface|null $hls_url = null;
|
||||
|
||||
#[OA\Property(
|
||||
description: 'HLS Listeners',
|
||||
example: 1
|
||||
)]
|
||||
public int $hls_listeners = 0;
|
||||
|
||||
/**
|
||||
* Re-resolve any Uri instances to reflect base URL changes.
|
||||
*
|
||||
|
|
|
@ -78,6 +78,12 @@ class StationApiGenerator
|
|||
? $backend->getHlsUrl($station, $baseUri)
|
||||
: null;
|
||||
|
||||
$hlsListeners = 0;
|
||||
foreach ($station->getHlsStreams() as $hlsStream) {
|
||||
$hlsListeners += $hlsStream->getListeners();
|
||||
}
|
||||
$response->hls_listeners = $hlsListeners;
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20220626024436 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add listeners to HLS streams.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE station_hls_streams ADD listeners INT NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE station_hls_streams DROP listeners');
|
||||
}
|
||||
}
|
|
@ -54,6 +54,12 @@ class StationHlsStream implements
|
|||
]
|
||||
protected ?int $bitrate = 128;
|
||||
|
||||
#[
|
||||
ORM\Column,
|
||||
Attributes\AuditIgnore
|
||||
]
|
||||
protected int $listeners = 0;
|
||||
|
||||
public function __construct(Station $station)
|
||||
{
|
||||
$this->station = $station;
|
||||
|
@ -107,6 +113,16 @@ class StationHlsStream implements
|
|||
$this->bitrate = $bitrate;
|
||||
}
|
||||
|
||||
public function getListeners(): int
|
||||
{
|
||||
return $this->listeners;
|
||||
}
|
||||
|
||||
public function setListeners(int $listeners): void
|
||||
{
|
||||
$this->listeners = $listeners;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getStation() . ' HLS Stream: ' . $this->getName();
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Nginx;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Event\Nginx\WriteNginxConfiguration;
|
||||
use App\Radio\Enums\BackendAdapters;
|
||||
use App\Radio\Enums\FrontendAdapters;
|
||||
|
@ -89,6 +90,8 @@ final class ConfigWriter implements EventSubscriberInterface
|
|||
$hlsBaseUrl = CustomUrls::getHlsUrl($station);
|
||||
$hlsFolder = $station->getRadioHlsDir();
|
||||
|
||||
$hlsLogPath = self::getHlsLogFile($station);
|
||||
|
||||
$event->appendBlock(
|
||||
<<<NGINX
|
||||
# Reverse proxy the frontend broadcast.
|
||||
|
@ -98,6 +101,10 @@ final class ConfigWriter implements EventSubscriberInterface
|
|||
video/mp2t ts;
|
||||
}
|
||||
|
||||
location ~ \.m3u8$ {
|
||||
access_log {$hlsLogPath} hls_json;
|
||||
}
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Cache-Control' 'no-cache';
|
||||
|
||||
|
@ -107,4 +114,9 @@ final class ConfigWriter implements EventSubscriberInterface
|
|||
NGINX
|
||||
);
|
||||
}
|
||||
|
||||
public static function getHlsLogFile(Station $station): string
|
||||
{
|
||||
return $station->getRadioConfigDir() . '/hls.log';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Nginx;
|
||||
|
||||
use App\Entity\Station;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use NowPlaying\Result\Client;
|
||||
use NowPlaying\Result\Result;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class HlsListeners
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
public function updateNowPlaying(
|
||||
Result $np,
|
||||
Station $station,
|
||||
bool $includeClients = false
|
||||
): Result {
|
||||
if (!$station->getEnableHls()) {
|
||||
return $np;
|
||||
}
|
||||
|
||||
$hlsStreams = $station->getHlsStreams();
|
||||
if (0 === $hlsStreams->count()) {
|
||||
$this->logger->error('No HLS streams.');
|
||||
return $np;
|
||||
}
|
||||
|
||||
$thresholdSecs = $station->getBackendConfig()->getHlsSegmentLength() * 2;
|
||||
$timestamp = time() - $thresholdSecs;
|
||||
|
||||
$hlsLogFile = ConfigWriter::getHlsLogFile($station);
|
||||
$hlsLogBackup = $hlsLogFile . '.1';
|
||||
|
||||
if (!is_file($hlsLogFile)) {
|
||||
$this->logger->error('No HLS log file available.');
|
||||
return $np;
|
||||
}
|
||||
|
||||
$logContents = file($hlsLogFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
if (is_file($hlsLogBackup) && filemtime($hlsLogBackup) >= $timestamp) {
|
||||
$logContents = array_merge(
|
||||
$logContents,
|
||||
file($hlsLogBackup, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
$clientsByStream = [];
|
||||
foreach ($hlsStreams as $hlsStream) {
|
||||
$clientsByStream[$hlsStream->getName()] = 0;
|
||||
}
|
||||
|
||||
$allClients = [];
|
||||
foreach ($logContents as $logRow) {
|
||||
$client = $this->parseRow($logRow, $timestamp);
|
||||
if (
|
||||
null !== $client
|
||||
&& isset($clientsByStream[$client->mount])
|
||||
&& !isset($allClients[$client->uid])
|
||||
) {
|
||||
$allClients[$client->uid] = $client;
|
||||
$clientsByStream[$client->mount]++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($hlsStreams as $hlsStream) {
|
||||
$numClients = (int)$clientsByStream[$hlsStream->getName()];
|
||||
$hlsStream->setListeners($numClients);
|
||||
$this->em->persist($hlsStream);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$result = Result::blank();
|
||||
$result->listeners->total = $result->listeners->unique = count($allClients);
|
||||
|
||||
$result->clients = ($includeClients)
|
||||
? $allClients
|
||||
: [];
|
||||
|
||||
$this->logger->debug('HLS response', ['response' => $result]);
|
||||
|
||||
return $np->merge($result);
|
||||
}
|
||||
|
||||
private function parseRow(
|
||||
string $row,
|
||||
int $threshold
|
||||
): ?Client {
|
||||
try {
|
||||
$rowJson = json_decode($row, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Structure:
|
||||
* {
|
||||
* "msec": "1656205963.806",
|
||||
* "ua": "Mozilla/5.0 (Windows NT 10.0;...Chrome/102.0.0.0 Safari/537.36",
|
||||
* "ip": "192.168.48.1",
|
||||
* "ip_xff": "",
|
||||
* "uri": "/hls/azuratest_radio/aac_hifi.m3u8"
|
||||
* }
|
||||
*/
|
||||
|
||||
$timestamp = (int)(explode('.', $rowJson['msec'], 2)[0]);
|
||||
if ($timestamp < $threshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip = (!empty($rowJson['ip_xff'])) ? $rowJson['ip_xff'] : $rowJson['ip'];
|
||||
$ua = $rowJson['ua'];
|
||||
|
||||
return new Client(
|
||||
uid: md5($ip . '_' . $ua),
|
||||
ip: $ip,
|
||||
userAgent: $ua,
|
||||
connectedSeconds: 1,
|
||||
mount: basename($rowJson['uri'], '.m3u8')
|
||||
);
|
||||
}
|
||||
}
|
|
@ -62,6 +62,11 @@ final class Nginx
|
|||
$this->supervisor->signalProcess(self::PROCESS_NAME, 'HUP');
|
||||
}
|
||||
|
||||
public function reopenLogs(): void
|
||||
{
|
||||
$this->supervisor->signalProcess(self::PROCESS_NAME, 'USR1');
|
||||
}
|
||||
|
||||
private function getConfigPath(Station $station): string
|
||||
{
|
||||
return $station->getRadioConfigDir() . '/nginx.conf';
|
||||
|
|
|
@ -12,6 +12,7 @@ use App\Environment;
|
|||
use App\Event\Radio\GenerateRawNowPlaying;
|
||||
use App\Http\RouterInterface;
|
||||
use App\Message;
|
||||
use App\Nginx\HlsListeners;
|
||||
use App\Radio\Adapters;
|
||||
use DeepCopy\DeepCopy;
|
||||
use Exception;
|
||||
|
@ -35,6 +36,7 @@ class NowPlayingTask implements NowPlayingTaskInterface, EventSubscriberInterfac
|
|||
protected Entity\ApiGenerator\NowPlayingApiGenerator $nowPlayingApiGenerator,
|
||||
protected ReloadableEntityManagerInterface $em,
|
||||
protected LoggerInterface $logger,
|
||||
protected HlsListeners $hlsListeners,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -51,6 +53,7 @@ class NowPlayingTask implements NowPlayingTaskInterface, EventSubscriberInterfac
|
|||
GenerateRawNowPlaying::class => [
|
||||
['loadRawFromFrontend', 10],
|
||||
['addToRawFromRemotes', 0],
|
||||
['addToRawFromHls', -10],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -146,6 +149,21 @@ class NowPlayingTask implements NowPlayingTaskInterface, EventSubscriberInterfac
|
|||
$event->setResult($result);
|
||||
}
|
||||
|
||||
public function addToRawFromHls(GenerateRawNowPlaying $event): void
|
||||
{
|
||||
try {
|
||||
$event->setResult(
|
||||
$this->hlsListeners->updateNowPlaying(
|
||||
$event->getResult(),
|
||||
$event->getStation(),
|
||||
$event->includeClients()
|
||||
)
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error(sprintf('HLS error: %s', $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatchWebhooks(
|
||||
Entity\Station $station,
|
||||
NowPlaying $npOriginal
|
||||
|
|
|
@ -7,23 +7,27 @@ namespace App\Sync\Task;
|
|||
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Nginx\ConfigWriter;
|
||||
use App\Nginx\Nginx;
|
||||
use App\Radio\Adapters;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Supervisor\SupervisorInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Throwable;
|
||||
|
||||
class RotateLogsTask extends AbstractTask
|
||||
{
|
||||
public function __construct(
|
||||
ReloadableEntityManagerInterface $em,
|
||||
LoggerInterface $logger,
|
||||
protected Environment $environment,
|
||||
protected Adapters $adapters,
|
||||
protected SupervisorInterface $supervisor,
|
||||
protected Entity\Repository\SettingsRepository $settingsRepo,
|
||||
protected Entity\Repository\StorageLocationRepository $storageLocationRepo,
|
||||
ReloadableEntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
protected Nginx $nginx,
|
||||
) {
|
||||
parent::__construct($em, $logger);
|
||||
}
|
||||
|
@ -36,6 +40,8 @@ class RotateLogsTask extends AbstractTask
|
|||
public function run(bool $force = false): void
|
||||
{
|
||||
// Rotate logs for individual stations.
|
||||
$hlsRotated = false;
|
||||
|
||||
foreach ($this->iterateStations() as $station) {
|
||||
$this->logger->info(
|
||||
'Rotating logs for station.',
|
||||
|
@ -43,7 +49,11 @@ class RotateLogsTask extends AbstractTask
|
|||
);
|
||||
|
||||
try {
|
||||
$this->rotateStationLogs($station);
|
||||
$this->cleanUpIcecastLog($station);
|
||||
|
||||
if ($station->getEnableHls() && $this->rotateHlsLog($station)) {
|
||||
$hlsRotated = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), [
|
||||
'station' => (string)$station,
|
||||
|
@ -51,6 +61,10 @@ class RotateLogsTask extends AbstractTask
|
|||
}
|
||||
}
|
||||
|
||||
if ($hlsRotated) {
|
||||
$this->nginx->reopenLogs();
|
||||
}
|
||||
|
||||
// Rotate the automated backups.
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
|
||||
|
@ -101,17 +115,6 @@ class RotateLogsTask extends AbstractTask
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate logs that are not automatically rotated (currently Liquidsoap only).
|
||||
*
|
||||
* @param Entity\Station $station
|
||||
*
|
||||
*/
|
||||
public function rotateStationLogs(Entity\Station $station): void
|
||||
{
|
||||
$this->cleanUpIcecastLog($station);
|
||||
}
|
||||
|
||||
protected function cleanUpIcecastLog(Entity\Station $station): void
|
||||
{
|
||||
$config_path = $station->getRadioConfigDir();
|
||||
|
@ -131,4 +134,23 @@ class RotateLogsTask extends AbstractTask
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function rotateHlsLog(Entity\Station $station): bool
|
||||
{
|
||||
$hlsLogFile = ConfigWriter::getHlsLogFile($station);
|
||||
$hlsLogBackup = $hlsLogFile . '.1';
|
||||
|
||||
if (!file_exists($hlsLogFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fsUtils = new Filesystem();
|
||||
|
||||
if (file_exists($hlsLogBackup)) {
|
||||
$fsUtils->remove([$hlsLogBackup]);
|
||||
}
|
||||
|
||||
$fsUtils->rename($hlsLogFile, $hlsLogBackup);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ else
|
|||
fi
|
||||
|
||||
APP_ENV="${APP_ENV:-production}"
|
||||
UPDATE_REVISION="${UPDATE_REVISION:-89}"
|
||||
UPDATE_REVISION="${UPDATE_REVISION:-90}"
|
||||
|
||||
echo "Updating AzuraCast (Environment: $APP_ENV, Update revision: $UPDATE_REVISION)"
|
||||
|
||||
|
|
|
@ -52,6 +52,14 @@ http {
|
|||
# Logging Settings
|
||||
##
|
||||
|
||||
log_format hls_json escape=json '{'
|
||||
'"msec": "$msec",'
|
||||
'"ua": "$http_user_agent",'
|
||||
'"ip": "$remote_addr",'
|
||||
'"ip_xff": "$http_x_forwarded_for",'
|
||||
'"uri": "$request_uri"'
|
||||
'}';
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
when: update_revision|int < 87
|
||||
|
||||
- role: "nginx"
|
||||
when: update_revision|int < 89
|
||||
when: update_revision|int < 90
|
||||
|
||||
- role: "redis"
|
||||
when: update_revision|int < 87
|
||||
|
|
|
@ -20,6 +20,14 @@ http {
|
|||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
log_format hls_json escape=json '{'
|
||||
'"msec": "$msec",'
|
||||
'"ua": "$http_user_agent",'
|
||||
'"ip": "$remote_addr",'
|
||||
'"ip_xff": "$http_x_forwarded_for",'
|
||||
'"uri": "$request_uri"'
|
||||
'}';
|
||||
|
||||
access_log off;
|
||||
server_tokens off;
|
||||
keepalive_timeout 65;
|
||||
|
|
Loading…
Reference in New Issue