implement powerful viewer count system

This commit is contained in:
2026-05-16 23:38:35 +03:30
parent 95d14f2cec
commit 799581ee52
19 changed files with 762 additions and 17 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Services\Streaming\MediaMtxViewerCountSynchronizer;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
use Throwable;
#[Signature('streaming:sync-viewer-counts {--once : Run one synchronization pass and exit} {--interval= : Seconds between MediaMTX polls}')]
#[Description('Sync live viewer counts from MediaMTX HLS sessions')]
class SyncMediaMtxViewerCounts extends Command implements Isolatable
{
public function handle(MediaMtxViewerCountSynchronizer $viewerCounts): int
{
$interval = max(1, (int) ($this->option('interval') ?: config('streaming.viewer_sync_interval', 2)));
if ($this->option('once')) {
$this->syncOnce($viewerCounts);
return self::SUCCESS;
}
while (true) {
try {
$this->syncOnce($viewerCounts);
} catch (Throwable $exception) {
report($exception);
$this->components->error($exception->getMessage());
}
sleep($interval);
}
return self::SUCCESS;
}
private function syncOnce(MediaMtxViewerCountSynchronizer $viewerCounts): void
{
$result = $viewerCounts->sync();
if ($this->output->isVerbose()) {
$this->components->info(
"Synced {$result['viewers']} viewers across {$result['channels']} live channels.",
);
}
}
}

View File

@@ -7,12 +7,13 @@ use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use App\Models\Vod;
use App\Services\Streaming\ViewerCountStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class AdminModerationController extends Controller
{
public function suspendChannel(Request $request, Channel $channel): RedirectResponse
public function suspendChannel(Request $request, Channel $channel, ViewerCountStore $viewerCounts): RedirectResponse
{
$channel->forceFill([
'suspended_at' => now(),
@@ -20,6 +21,7 @@ class AdminModerationController extends Controller
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$viewerCounts->forget($channel);
$this->audit($request, $channel, 'channel.suspended');
@@ -35,7 +37,7 @@ class AdminModerationController extends Controller
return back()->with('success', 'Channel restored.');
}
public function suspendUser(Request $request, User $user): RedirectResponse
public function suspendUser(Request $request, User $user, ViewerCountStore $viewerCounts): RedirectResponse
{
$user->forceFill(['suspended_at' => now()])->save();
$user->channel?->forceFill([
@@ -44,12 +46,16 @@ class AdminModerationController extends Controller
'viewer_count' => 0,
])->save();
if ($user->channel) {
$viewerCounts->forget($user->channel);
}
$this->audit($request, $user, 'user.suspended');
return back()->with('success', 'User suspended.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
{
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
@@ -61,6 +67,7 @@ class AdminModerationController extends Controller
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$viewerCounts->forget($broadcast->channel);
$this->audit($request, $broadcast, 'broadcast.stopped');

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Models\Channel;
use App\Models\ChatMessage;
use App\Models\Vod;
use App\Services\Streaming\PlaybackViewerSigner;
use App\Services\Streaming\ViewerCountStore;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
@@ -12,8 +14,12 @@ use Inertia\Response;
class ChannelController extends Controller
{
public function show(Request $request, Channel $channel): Response
{
public function show(
Request $request,
Channel $channel,
ViewerCountStore $viewerCounts,
PlaybackViewerSigner $playbackViewers,
): Response {
abort_if($channel->isSuspended(), 404);
$channel->load(['category', 'liveBroadcast', 'user']);
@@ -22,7 +28,7 @@ class ChannelController extends Controller
$currentBroadcast = $channel->liveBroadcast;
return Inertia::render('channels/show', [
'channel' => [
'channel' => fn () => [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
@@ -31,7 +37,7 @@ class ChannelController extends Controller
'banner_url' => $this->mediaUrl($channel->banner_path),
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
'followers_count' => $channel->follows_count,
'category' => $channel->category ? [
'id' => $channel->category->id,
@@ -43,14 +49,23 @@ class ChannelController extends Controller
'name' => $channel->user->name,
],
],
'currentBroadcast' => $currentBroadcast ? [
'currentBroadcast' => fn () => $currentBroadcast ? [
'id' => $currentBroadcast->id,
'title' => $currentBroadcast->title,
'hls_url' => $currentBroadcast->hls_url,
'hls_url' => $currentBroadcast->hls_url
? $playbackViewers->signedPlaybackUrl($currentBroadcast->hls_url, $channel, $currentBroadcast, $request)
: null,
'recording_enabled' => $currentBroadcast->recording_enabled,
'started_at' => $currentBroadcast->started_at?->toIso8601String(),
] : null,
'vods' => $channel->vods()
'viewerStats' => fn () => [
'current' => $channel->is_live ? $viewerCounts->current($channel) : 0,
'peak' => $currentBroadcast && $channel->is_live
? max($currentBroadcast->viewer_peak, $viewerCounts->current($channel))
: 0,
'refresh_interval_ms' => max(5000, (int) config('streaming.viewer_stats_poll_interval', 30000)),
],
'vods' => fn () => $channel->vods()
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
@@ -65,7 +80,7 @@ class ChannelController extends Controller
'published_at' => $vod->published_at?->toIso8601String(),
'expires_at' => $vod->expires_at?->toIso8601String(),
]),
'chatMessages' => ChatMessage::query()
'chatMessages' => fn () => ChatMessage::query()
->where('channel_id', $channel->id)
->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id))
->with('user')
@@ -84,9 +99,7 @@ class ChannelController extends Controller
'avatar_url' => $message->user->avatar,
],
]),
'isFollowing' => $request->user()
? $channel->follows()->where('user_id', $request->user()->id)->exists()
: false,
'isFollowing' => fn () => $request->user() && $channel->follows()->where('user_id', $request->user()->id)->exists(),
]);
}

View File

@@ -6,6 +6,7 @@ use App\Models\Broadcast;
use App\Models\Category;
use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager;
use App\Services\Streaming\ViewerCountStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -154,7 +155,7 @@ class CreatorDashboardController extends Controller
return back()->with('success', 'Broadcast is ready. Start streaming from OBS.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
{
abort_unless($broadcast->channel->user_id === $request->user()->id, 403);
@@ -168,6 +169,7 @@ class CreatorDashboardController extends Controller
'viewer_count' => 0,
'live_broadcast_id' => null,
])->save();
$viewerCounts->forget($broadcast->channel);
return back()->with('success', 'Broadcast ended.');
}

View File

@@ -37,6 +37,7 @@ class Broadcast extends Model
{
return [
'recording_enabled' => 'boolean',
'viewer_peak' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];

View File

@@ -33,6 +33,7 @@ class Channel extends Model
{
return [
'is_live' => 'boolean',
'viewer_count' => 'integer',
'suspended_at' => 'datetime',
];
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Streaming;
use Illuminate\Support\Facades\Http;
class MediaMtxApiClient
{
/**
* @return array<int, array{id: string, path: string, query: string, is_cdn: bool}>
*/
public function hlsSessions(): array
{
$sessions = [];
$page = 0;
do {
$payload = (array) Http::baseUrl($this->baseUrl())
->acceptJson()
->connectTimeout((float) config('streaming.mediamtx_api_connect_timeout', 1.0))
->timeout((float) config('streaming.mediamtx_api_timeout', 3.0))
->get('/v3/hlssessions/list', [
'page' => $page,
'itemsPerPage' => max(1, (int) config('streaming.mediamtx_api_items_per_page', 100)),
])
->throw()
->json();
foreach ((array) ($payload['items'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$path = trim((string) ($session['path'] ?? ''), '/');
$id = (string) ($session['id'] ?? '');
if ($path === '' || $id === '') {
continue;
}
$sessions[] = [
'id' => $id,
'path' => $path,
'query' => (string) ($session['query'] ?? ''),
'is_cdn' => (bool) ($session['isCDN'] ?? false),
];
}
$page++;
$pageCount = max(1, (int) ($payload['pageCount'] ?? $page));
} while ($page < $pageCount);
return $sessions;
}
private function baseUrl(): string
{
return rtrim((string) config('streaming.mediamtx_api_url'), '/');
}
}

View File

@@ -10,6 +10,8 @@ use Illuminate\Support\Str;
class MediaMtxService
{
public function __construct(private readonly ViewerCountStore $viewerCounts) {}
public function authorize(array $payload): bool
{
$action = (string) ($payload['action'] ?? '');
@@ -79,6 +81,8 @@ class MediaMtxService
'live_broadcast_id' => $broadcast->id,
])->save();
$this->viewerCounts->forget($channel);
return $broadcast;
}
@@ -107,6 +111,8 @@ class MediaMtxService
'live_broadcast_id' => null,
])->save();
$this->viewerCounts->forget($channel);
return $broadcast;
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services\Streaming;
use App\Models\Broadcast;
use App\Models\Channel;
class MediaMtxViewerCountSynchronizer
{
public function __construct(
private readonly MediaMtxApiClient $mediaMtx,
private readonly ViewerCountStore $viewerCounts,
private readonly PlaybackViewerSigner $playbackViewers,
) {}
/**
* @return array{channels: int, viewers: int}
*/
public function sync(): array
{
$sessionsByPath = $this->sessionsByPath($this->mediaMtx->hlsSessions());
$liveChannels = Channel::query()
->where('is_live', true)
->with('liveBroadcast:id,viewer_peak')
->get(['id', 'slug', 'live_broadcast_id', 'viewer_count']);
$totalViewers = 0;
foreach ($liveChannels as $channel) {
$count = $this->uniqueViewerCount($channel, $sessionsByPath[$channel->slug] ?? []);
$totalViewers += $count;
$this->viewerCounts->put($channel, $count);
if ($channel->viewer_count !== $count) {
$channel->forceFill(['viewer_count' => $count])->save();
}
if ($count > 0 && $channel->live_broadcast_id) {
Broadcast::query()
->whereKey($channel->live_broadcast_id)
->where('viewer_peak', '<', $count)
->update(['viewer_peak' => $count]);
}
}
Channel::query()
->where('is_live', false)
->where('viewer_count', '>', 0)
->get(['id', 'viewer_count'])
->each(function (Channel $channel): void {
$this->viewerCounts->forget($channel);
$channel->forceFill(['viewer_count' => 0])->save();
});
return [
'channels' => $liveChannels->count(),
'viewers' => $totalViewers,
];
}
/**
* @param array<int, array{id: string, path: string, query: string, is_cdn: bool}> $sessions
* @return array<string, array<int, array{id: string, path: string, query: string, is_cdn: bool}>>
*/
private function sessionsByPath(array $sessions): array
{
$grouped = [];
foreach ($sessions as $session) {
if ($session['is_cdn']) {
continue;
}
$grouped[$session['path']][] = $session;
}
return $grouped;
}
/**
* @param array<int, array{id: string, path: string, query: string, is_cdn: bool}> $sessions
*/
private function uniqueViewerCount(Channel $channel, array $sessions): int
{
$viewerKeys = [];
foreach ($sessions as $session) {
$viewerKey = $this->playbackViewers->viewerKeyFromQuery(
$session['query'],
$channel,
$channel->liveBroadcast,
) ?? "session:{$session['id']}";
$viewerKeys[$viewerKey] = true;
}
return count($viewerKeys);
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Services\Streaming;
use App\Models\Broadcast;
use App\Models\Channel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;
use Illuminate\Support\Uri;
class PlaybackViewerSigner
{
private const VIEWER_KEY = 'viewer';
private const EXPIRES_KEY = 'viewer_expires';
private const SIGNATURE_KEY = 'viewer_signature';
public function signedPlaybackUrl(string $hlsUrl, Channel $channel, Broadcast $broadcast, Request $request): string
{
return (string) Uri::of($hlsUrl)->withQuery(
$this->queryParametersForViewerKey(
$this->viewerKeyForRequest($request),
$channel,
$broadcast,
),
);
}
/**
* @return array{viewer: string, viewer_expires: int, viewer_signature: string}
*/
public function queryParametersForViewerKey(
string $viewerKey,
Channel $channel,
Broadcast $broadcast,
?int $expires = null,
): array {
$expires ??= now()->addSeconds($this->signatureTtl())->getTimestamp();
return [
self::VIEWER_KEY => $viewerKey,
self::EXPIRES_KEY => $expires,
self::SIGNATURE_KEY => $this->signature($viewerKey, $channel->slug, $broadcast->id, $expires),
];
}
public function viewerKeyFromQuery(string $query, Channel $channel, ?Broadcast $broadcast): ?string
{
if (! $broadcast) {
return null;
}
parse_str(ltrim($query, '?'), $parameters);
$viewerKey = (string) ($parameters[self::VIEWER_KEY] ?? '');
$expires = (int) ($parameters[self::EXPIRES_KEY] ?? 0);
$signature = (string) ($parameters[self::SIGNATURE_KEY] ?? '');
if (! $this->isValidViewerKey($viewerKey) || $expires < now()->getTimestamp() || $signature === '') {
return null;
}
$expected = $this->signature($viewerKey, $channel->slug, $broadcast->id, $expires);
return hash_equals($expected, $signature) ? $viewerKey : null;
}
private function viewerKeyForRequest(Request $request): string
{
if ($request->user()) {
return 'u:'.$this->hashIdentity((string) $request->user()->id);
}
return 'g:'.$this->hashIdentity($this->guestVisitorId($request));
}
private function guestVisitorId(Request $request): string
{
$cookieName = $this->cookieName();
$visitorId = (string) $request->cookie($cookieName, '');
if (! Str::isUuid($visitorId)) {
$visitorId = (string) Str::uuid();
Cookie::queue(Cookie::make(
$cookieName,
$visitorId,
$this->cookieLifetimeMinutes(),
(string) config('session.path', '/'),
config('session.domain'),
config('session.secure'),
true,
false,
config('session.same_site', 'lax'),
));
}
return $visitorId;
}
private function signature(string $viewerKey, string $channelSlug, int $broadcastId, int $expires): string
{
return hash_hmac(
'sha256',
"{$channelSlug}|{$broadcastId}|{$viewerKey}|{$expires}",
$this->secret(),
);
}
private function hashIdentity(string $identity): string
{
return hash_hmac('sha256', $identity, $this->secret());
}
private function isValidViewerKey(string $viewerKey): bool
{
return preg_match('/^[ug]:[a-f0-9]{64}$/', $viewerKey) === 1;
}
private function secret(): string
{
$key = (string) config('app.key');
if (str_starts_with($key, 'base64:')) {
return base64_decode(substr($key, 7), true) ?: $key;
}
return $key;
}
private function cookieName(): string
{
return (string) config('streaming.viewer_identity_cookie', 'nyone_visitor_id');
}
private function cookieLifetimeMinutes(): int
{
return max(1, (int) config('streaming.viewer_identity_cookie_lifetime_days', 365)) * 24 * 60;
}
private function signatureTtl(): int
{
return max(60, (int) config('streaming.viewer_identity_ttl', 86400));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Services\Streaming;
use App\Models\Channel;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class ViewerCountStore
{
public function current(Channel $channel): int
{
$cached = $this->cache()->get($this->cacheKey($channel));
return is_numeric($cached) ? max(0, (int) $cached) : max(0, (int) $channel->viewer_count);
}
public function put(Channel $channel, int $count): void
{
$this->cache()->put($this->cacheKey($channel), max(0, $count), $this->ttl());
}
public function forget(Channel $channel): void
{
$this->cache()->forget($this->cacheKey($channel));
}
private function cache(): Repository
{
$store = (string) config('streaming.viewer_count_cache_store', config('cache.default'));
return $store !== '' ? Cache::store($store) : Cache::store();
}
private function cacheKey(Channel $channel): string
{
return "streaming:viewers:channels:{$channel->id}";
}
private function ttl(): int
{
return max(1, (int) config('streaming.viewer_count_ttl', 10));
}
}