From 799581ee520ae77c73c36b550c213b014d2842b1 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Sat, 16 May 2026 23:38:35 +0330 Subject: [PATCH] implement powerful viewer count system --- .env.example | 8 + .../Commands/SyncMediaMtxViewerCounts.php | 50 ++++++ .../Controllers/AdminModerationController.php | 13 +- app/Http/Controllers/ChannelController.php | 35 +++-- .../CreatorDashboardController.php | 4 +- app/Models/Broadcast.php | 1 + app/Models/Channel.php | 1 + app/Services/Streaming/MediaMtxApiClient.php | 60 +++++++ app/Services/Streaming/MediaMtxService.php | 6 + .../MediaMtxViewerCountSynchronizer.php | 100 ++++++++++++ .../Streaming/PlaybackViewerSigner.php | 147 ++++++++++++++++++ app/Services/Streaming/ViewerCountStore.php | 44 ++++++ config/streaming.php | 11 ++ deploy/mediamtx.yml | 3 + docs/viewer-counts.md | 89 +++++++++++ resources/js/pages/channels/show.tsx | 26 +++- resources/js/types/streaming.ts | 6 + tests/Feature/MediaMtxViewerCountSyncTest.php | 105 +++++++++++++ tests/Feature/ViewerInteractionTest.php | 70 +++++++++ 19 files changed, 762 insertions(+), 17 deletions(-) create mode 100644 app/Console/Commands/SyncMediaMtxViewerCounts.php create mode 100644 app/Services/Streaming/MediaMtxApiClient.php create mode 100644 app/Services/Streaming/MediaMtxViewerCountSynchronizer.php create mode 100644 app/Services/Streaming/PlaybackViewerSigner.php create mode 100644 app/Services/Streaming/ViewerCountStore.php create mode 100644 docs/viewer-counts.md create mode 100644 tests/Feature/MediaMtxViewerCountSyncTest.php diff --git a/.env.example b/.env.example index cd7faa7..02e0e48 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,14 @@ VITE_APP_NAME="${APP_NAME}" STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935 STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net +STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997 +STREAMING_VIEWER_COUNT_CACHE_STORE=redis +STREAMING_VIEWER_COUNT_TTL=10 +STREAMING_VIEWER_SYNC_INTERVAL=2 +STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id +STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365 +STREAMING_VIEWER_IDENTITY_TTL=86400 +STREAMING_VIEWER_STATS_POLL_INTERVAL=30000 STREAMING_VOD_RETENTION_DAYS=30 STREAMING_RECORDINGS_PATH=storage/app/private/recordings MEDIAMTX_SHARED_SECRET=change-this-secret diff --git a/app/Console/Commands/SyncMediaMtxViewerCounts.php b/app/Console/Commands/SyncMediaMtxViewerCounts.php new file mode 100644 index 0000000..a8671ff --- /dev/null +++ b/app/Console/Commands/SyncMediaMtxViewerCounts.php @@ -0,0 +1,50 @@ +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.", + ); + } + } +} diff --git a/app/Http/Controllers/AdminModerationController.php b/app/Http/Controllers/AdminModerationController.php index a2b1887..aa3453d 100644 --- a/app/Http/Controllers/AdminModerationController.php +++ b/app/Http/Controllers/AdminModerationController.php @@ -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'); diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php index cc8826d..cac4225 100644 --- a/app/Http/Controllers/ChannelController.php +++ b/app/Http/Controllers/ChannelController.php @@ -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(), ]); } diff --git a/app/Http/Controllers/CreatorDashboardController.php b/app/Http/Controllers/CreatorDashboardController.php index 2bb3d45..666ee9f 100644 --- a/app/Http/Controllers/CreatorDashboardController.php +++ b/app/Http/Controllers/CreatorDashboardController.php @@ -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.'); } diff --git a/app/Models/Broadcast.php b/app/Models/Broadcast.php index e7f7bed..acbe8b3 100644 --- a/app/Models/Broadcast.php +++ b/app/Models/Broadcast.php @@ -37,6 +37,7 @@ class Broadcast extends Model { return [ 'recording_enabled' => 'boolean', + 'viewer_peak' => 'integer', 'started_at' => 'datetime', 'ended_at' => 'datetime', ]; diff --git a/app/Models/Channel.php b/app/Models/Channel.php index f035cb6..d19b726 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -33,6 +33,7 @@ class Channel extends Model { return [ 'is_live' => 'boolean', + 'viewer_count' => 'integer', 'suspended_at' => 'datetime', ]; } diff --git a/app/Services/Streaming/MediaMtxApiClient.php b/app/Services/Streaming/MediaMtxApiClient.php new file mode 100644 index 0000000..2b6b8fc --- /dev/null +++ b/app/Services/Streaming/MediaMtxApiClient.php @@ -0,0 +1,60 @@ + + */ + 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'), '/'); + } +} diff --git a/app/Services/Streaming/MediaMtxService.php b/app/Services/Streaming/MediaMtxService.php index 17b881e..b952dcf 100644 --- a/app/Services/Streaming/MediaMtxService.php +++ b/app/Services/Streaming/MediaMtxService.php @@ -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; } diff --git a/app/Services/Streaming/MediaMtxViewerCountSynchronizer.php b/app/Services/Streaming/MediaMtxViewerCountSynchronizer.php new file mode 100644 index 0000000..9a19679 --- /dev/null +++ b/app/Services/Streaming/MediaMtxViewerCountSynchronizer.php @@ -0,0 +1,100 @@ +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 $sessions + * @return array> + */ + private function sessionsByPath(array $sessions): array + { + $grouped = []; + + foreach ($sessions as $session) { + if ($session['is_cdn']) { + continue; + } + + $grouped[$session['path']][] = $session; + } + + return $grouped; + } + + /** + * @param array $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); + } +} diff --git a/app/Services/Streaming/PlaybackViewerSigner.php b/app/Services/Streaming/PlaybackViewerSigner.php new file mode 100644 index 0000000..b6c3791 --- /dev/null +++ b/app/Services/Streaming/PlaybackViewerSigner.php @@ -0,0 +1,147 @@ +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)); + } +} diff --git a/app/Services/Streaming/ViewerCountStore.php b/app/Services/Streaming/ViewerCountStore.php new file mode 100644 index 0000000..1593c0e --- /dev/null +++ b/app/Services/Streaming/ViewerCountStore.php @@ -0,0 +1,44 @@ +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)); + } +} diff --git a/config/streaming.php b/config/streaming.php index 70a291b..3154764 100644 --- a/config/streaming.php +++ b/config/streaming.php @@ -3,6 +3,17 @@ return [ 'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.net:19935'), 'hls_public_url' => env('STREAMING_HLS_PUBLIC_URL', 'https://nyone-hls.net'), + 'mediamtx_api_url' => env('STREAMING_MEDIAMTX_API_URL', 'http://127.0.0.1:9997'), + 'mediamtx_api_connect_timeout' => (float) env('STREAMING_MEDIAMTX_API_CONNECT_TIMEOUT', 1.0), + 'mediamtx_api_timeout' => (float) env('STREAMING_MEDIAMTX_API_TIMEOUT', 3.0), + 'mediamtx_api_items_per_page' => (int) env('STREAMING_MEDIAMTX_API_ITEMS_PER_PAGE', 100), + 'viewer_count_cache_store' => env('STREAMING_VIEWER_COUNT_CACHE_STORE', env('CACHE_STORE', 'database')), + 'viewer_count_ttl' => (int) env('STREAMING_VIEWER_COUNT_TTL', 10), + 'viewer_sync_interval' => (int) env('STREAMING_VIEWER_SYNC_INTERVAL', 2), + 'viewer_identity_cookie' => env('STREAMING_VIEWER_IDENTITY_COOKIE', 'nyone_visitor_id'), + 'viewer_identity_cookie_lifetime_days' => (int) env('STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS', 365), + 'viewer_identity_ttl' => (int) env('STREAMING_VIEWER_IDENTITY_TTL', 86400), + 'viewer_stats_poll_interval' => (int) env('STREAMING_VIEWER_STATS_POLL_INTERVAL', 30000), 'mediamtx_shared_secret' => env('MEDIAMTX_SHARED_SECRET', 'local-dev-secret'), 'vod_retention_days' => (int) env('STREAMING_VOD_RETENTION_DAYS', 30), 'recordings_path' => env('STREAMING_RECORDINGS_PATH', storage_path('app/private/recordings')), diff --git a/deploy/mediamtx.yml b/deploy/mediamtx.yml index 570fe98..5fd8f7f 100644 --- a/deploy/mediamtx.yml +++ b/deploy/mediamtx.yml @@ -7,6 +7,9 @@ authHTTPExclude: - action: metrics - action: pprof +api: yes +apiAddress: 127.0.0.1:9997 + rtmp: yes rtmpAddress: :19935 diff --git a/docs/viewer-counts.md b/docs/viewer-counts.md new file mode 100644 index 0000000..8c51aa9 --- /dev/null +++ b/docs/viewer-counts.md @@ -0,0 +1,89 @@ +# Viewer Count Setup + +Viewer counts are read from MediaMTX HLS sessions, deduplicated by Laravel-signed visitor identity, cached in Redis, and materialized into `channels.viewer_count` for list sorting and page display. + +Laravel signs each live HLS URL with a short query string. Logged-in users get an opaque account-based viewer key, while guests get an encrypted `nyone_visitor_id` browser cookie and an opaque browser-based viewer key. MediaMTX still proves that playback is active; Laravel does not receive viewer heartbeats. + +## MediaMTX Control API + +MediaMTX must expose its Control API on a private address that Laravel can reach: + +```yaml +api: yes +apiAddress: 127.0.0.1:9997 +``` + +Keep this API private. Do not expose `127.0.0.1:9997` through a public firewall rule or reverse proxy. + +## Laravel Environment + +Configure Laravel with the MediaMTX API URL and Redis cache store: + +```env +STREAMING_MEDIAMTX_API_URL=http://127.0.0.1:9997 +STREAMING_VIEWER_COUNT_CACHE_STORE=redis +STREAMING_VIEWER_COUNT_TTL=10 +STREAMING_VIEWER_SYNC_INTERVAL=2 +STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id +STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365 +STREAMING_VIEWER_IDENTITY_TTL=86400 +STREAMING_VIEWER_STATS_POLL_INTERVAL=30000 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +``` + +After changing these values, reload Laravel config: + +```bash +php artisan config:clear +``` + +## Verify Sync + +With MediaMTX running and at least one active HLS viewer, run one sync pass: + +```bash +php artisan streaming:sync-viewer-counts --once -v +``` + +The command should report the number of viewers and live channels it synced. + +Multiple HLS sessions with the same valid signed viewer key count as one viewer. Direct HLS clients without a valid signed viewer key are still counted, but each MediaMTX session is counted separately because Laravel has no browser or user identity for those clients. + +## Run Continuously + +Run the sync worker under Supervisor: + +```ini +[program:nyone-viewer-counts] +process_name=%(program_name)s +command=php /var/www/nyone/artisan streaming:sync-viewer-counts --isolated +directory=/var/www/nyone +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +redirect_stderr=true +stdout_logfile=/var/www/nyone/storage/logs/viewer-counts.log +stopwaitsecs=10 +``` + +Reload Supervisor: + +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl status nyone-viewer-counts +``` + +## Troubleshooting + +If counts stay at `0`, check: + +- MediaMTX has active HLS sessions. +- `STREAMING_MEDIAMTX_API_URL` is reachable from Laravel. +- Redis is running and Laravel can write to `STREAMING_VIEWER_COUNT_CACHE_STORE`. +- `storage/logs/viewer-counts.log` has no connection errors. diff --git a/resources/js/pages/channels/show.tsx b/resources/js/pages/channels/show.tsx index 2840392..268a510 100644 --- a/resources/js/pages/channels/show.tsx +++ b/resources/js/pages/channels/show.tsx @@ -1,4 +1,11 @@ -import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { + Head, + Link, + router, + useForm, + usePage, + usePoll, +} from '@inertiajs/react'; import { Crown, Heart, @@ -36,12 +43,14 @@ import type { BroadcastSummary, ChannelDetail, ChatMessage, + ViewerStats, VodSummary, } from '@/types'; type Props = { channel: ChannelDetail; currentBroadcast: BroadcastSummary | null; + viewerStats: ViewerStats; vods: VodSummary[]; chatMessages: ChatMessage[]; isFollowing: boolean; @@ -69,6 +78,7 @@ const chatEmojis = [ export default function ChannelShow({ channel, currentBroadcast, + viewerStats, vods, chatMessages, isFollowing, @@ -76,6 +86,18 @@ export default function ChannelShow({ const { auth } = usePage().props; const [chatOpen, setChatOpen] = useState(false); const chatForm = useForm({ body: '' }); + const currentViewers = viewerStats.current; + const viewerStatsRefreshInterval = viewerStats.refresh_interval_ms ?? 30000; + + usePoll( + viewerStatsRefreshInterval, + { + only: ['viewerStats'], + }, + { + autoStart: channel.is_live && Boolean(currentBroadcast), + }, + ); function submitChat(event: FormEvent) { event.preventDefault(); @@ -138,7 +160,7 @@ export default function ChannelShow({ {channel.display_name} - {channel.viewer_count} viewers + {currentViewers} viewers {channel.followers_count} followers diff --git a/resources/js/types/streaming.ts b/resources/js/types/streaming.ts index 761a076..5318ed3 100644 --- a/resources/js/types/streaming.ts +++ b/resources/js/types/streaming.ts @@ -56,6 +56,12 @@ export type BroadcastSummary = { has_vod?: boolean; }; +export type ViewerStats = { + current: number; + peak: number; + refresh_interval_ms: number; +}; + export type VodSummary = { id: number; title: string; diff --git a/tests/Feature/MediaMtxViewerCountSyncTest.php b/tests/Feature/MediaMtxViewerCountSyncTest.php new file mode 100644 index 0000000..b1ef52a --- /dev/null +++ b/tests/Feature/MediaMtxViewerCountSyncTest.php @@ -0,0 +1,105 @@ + 'http://mediamtx.test', + 'streaming.viewer_count_cache_store' => 'array', + 'streaming.viewer_count_ttl' => 10, + ]); + + Cache::store('array')->flush(); + } + + public function test_it_syncs_hls_session_counts_from_mediamtx(): void + { + $channel = Channel::factory()->create([ + 'slug' => 'demo', + 'is_live' => true, + 'viewer_count' => 1, + ]); + $broadcast = Broadcast::factory()->for($channel)->live()->create(['viewer_peak' => 2]); + $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); + + $emptyLiveChannel = Channel::factory()->create([ + 'slug' => 'quiet', + 'is_live' => true, + 'viewer_count' => 7, + ]); + $emptyBroadcast = Broadcast::factory()->for($emptyLiveChannel)->live()->create(['viewer_peak' => 4]); + $emptyLiveChannel->forceFill(['live_broadcast_id' => $emptyBroadcast->id])->save(); + + $offlineChannel = Channel::factory()->create([ + 'slug' => 'offline', + 'is_live' => false, + 'viewer_count' => 5, + ]); + $viewerOneQuery = $this->viewerQuery('g:'.str_repeat('a', 64), $channel, $broadcast); + $viewerTwoQuery = $this->viewerQuery('u:'.str_repeat('b', 64), $channel, $broadcast); + $wrongBroadcast = Broadcast::factory()->for($channel)->live()->create(); + $invalidViewerQuery = $this->viewerQuery('g:'.str_repeat('c', 64), $channel, $wrongBroadcast); + + Http::fake([ + 'mediamtx.test/*' => Http::sequence() + ->push([ + 'pageCount' => 2, + 'items' => [ + ['id' => 'session-1', 'path' => 'demo', 'query' => $viewerOneQuery, 'isCDN' => false], + ['id' => 'session-2', 'path' => 'demo', 'query' => $viewerOneQuery, 'isCDN' => false], + ['id' => 'session-3', 'path' => 'demo', 'query' => $viewerTwoQuery, 'isCDN' => false], + ['id' => 'cdn-session', 'path' => 'demo', 'query' => $viewerTwoQuery, 'isCDN' => true], + ['id' => 'unknown-session', 'path' => 'missing', 'query' => $viewerOneQuery, 'isCDN' => false], + ], + ]) + ->push([ + 'pageCount' => 2, + 'items' => [ + ['id' => 'session-4', 'path' => 'demo', 'query' => $invalidViewerQuery, 'isCDN' => false], + ], + ]), + ]); + + $result = app(MediaMtxViewerCountSynchronizer::class)->sync(); + + $this->assertSame(['channels' => 2, 'viewers' => 3], $result); + $this->assertSame(3, $channel->refresh()->viewer_count); + $this->assertSame(3, $broadcast->refresh()->viewer_peak); + $this->assertSame(0, $emptyLiveChannel->refresh()->viewer_count); + $this->assertSame(4, $emptyBroadcast->refresh()->viewer_peak); + $this->assertSame(0, $offlineChannel->refresh()->viewer_count); + $this->assertSame(3, app(ViewerCountStore::class)->current($channel)); + $this->assertSame(0, app(ViewerCountStore::class)->current($emptyLiveChannel)); + + Http::assertSentCount(2); + } + + private function viewerQuery(string $viewerKey, Channel $channel, Broadcast $broadcast): string + { + return http_build_query( + app(PlaybackViewerSigner::class)->queryParametersForViewerKey( + $viewerKey, + $channel, + $broadcast, + now()->addHour()->getTimestamp(), + ), + ); + } +} diff --git a/tests/Feature/ViewerInteractionTest.php b/tests/Feature/ViewerInteractionTest.php index 2750d5f..2f08a09 100644 --- a/tests/Feature/ViewerInteractionTest.php +++ b/tests/Feature/ViewerInteractionTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Models\Broadcast; use App\Models\Channel; use App\Models\User; +use App\Services\Streaming\ViewerCountStore; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia as Assert; use Tests\TestCase; @@ -70,6 +71,65 @@ class ViewerInteractionTest extends TestCase ])->assertRedirect(route('login')); } + public function test_channel_page_exposes_cached_viewer_stats(): void + { + config(['streaming.viewer_count_cache_store' => 'array']); + + $channel = Channel::factory()->create([ + 'slug' => 'demo', + 'is_live' => true, + 'viewer_count' => 1, + ]); + $broadcast = Broadcast::factory()->for($channel)->live()->create(['viewer_peak' => 4]); + $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); + + app(ViewerCountStore::class)->put($channel, 6); + + $this->get(route('channels.show', $channel)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('channels/show') + ->where('channel.viewer_count', 6) + ->where('viewerStats.current', 6) + ->where('viewerStats.peak', 6), + ); + } + + public function test_live_channel_page_signs_playback_url_for_guest_visitors(): void + { + $channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]); + $broadcast = Broadcast::factory()->for($channel)->live()->create([ + 'hls_url' => 'http://localhost:8888/demo/index.m3u8', + ]); + $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); + + $this->get(route('channels.show', $channel)) + ->assertOk() + ->assertCookie('nyone_visitor_id') + ->assertInertia(fn (Assert $page) => $page + ->component('channels/show') + ->where('currentBroadcast.hls_url', fn (string $url): bool => $this->playbackUrlHasSignedViewer($url, 'g:')), + ); + } + + public function test_live_channel_page_signs_playback_url_for_authenticated_users(): void + { + $viewer = User::factory()->create(); + $channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]); + $broadcast = Broadcast::factory()->for($channel)->live()->create([ + 'hls_url' => 'http://localhost:8888/demo/index.m3u8', + ]); + $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); + + $this->actingAs($viewer) + ->get(route('channels.show', $channel)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('channels/show') + ->where('currentBroadcast.hls_url', fn (string $url): bool => $this->playbackUrlHasSignedViewer($url, 'u:')), + ); + } + public function test_channel_page_exposes_display_media_props(): void { $channel = Channel::factory()->create([ @@ -88,4 +148,14 @@ class ViewerInteractionTest extends TestCase ->where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'), ); } + + private function playbackUrlHasSignedViewer(string $url, string $prefix): bool + { + parse_str((string) parse_url($url, PHP_URL_QUERY), $parameters); + + return str_starts_with((string) ($parameters['viewer'] ?? ''), $prefix) + && is_numeric($parameters['viewer_expires'] ?? null) + && is_string($parameters['viewer_signature'] ?? null) + && strlen((string) $parameters['viewer_signature']) === 64; + } }