Track and display HLS listeners.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-06-25 23:32:25 -05:00
parent c0e9089a94
commit bb256cc79f
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
14 changed files with 278 additions and 17 deletions

View File

@ -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>

View File

@ -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.
*

View File

@ -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;
}
}

View File

@ -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');
}
}

View File

@ -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();

View File

@ -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';
}
}

130
src/Nginx/HlsListeners.php Normal file
View File

@ -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')
);
}
}

View File

@ -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';

View File

@ -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

View File

@ -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;
}
}

View File

@ -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)"

View File

@ -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;

View File

@ -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

View File

@ -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;