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

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

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

View File

@@ -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')),

View File

@@ -7,6 +7,9 @@ authHTTPExclude:
- action: metrics
- action: pprof
api: yes
apiAddress: 127.0.0.1:9997
rtmp: yes
rtmpAddress: :19935

89
docs/viewer-counts.md Normal file
View File

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

View File

@@ -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({
<span>{channel.display_name}</span>
<span className="inline-flex items-center gap-1">
<Users className="size-4" />
{channel.viewer_count} viewers
{currentViewers} viewers
</span>
<span>
{channel.followers_count} followers

View File

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

View File

@@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Services\Streaming\MediaMtxViewerCountSynchronizer;
use App\Services\Streaming\PlaybackViewerSigner;
use App\Services\Streaming\ViewerCountStore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MediaMtxViewerCountSyncTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'streaming.mediamtx_api_url' => '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(),
),
);
}
}

View File

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