implement powerful viewer count system
This commit is contained in:
@@ -66,6 +66,14 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
|
|
||||||
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
STREAMING_RTMP_INGEST_URL=rtmp://nyone.net:19935
|
||||||
STREAMING_HLS_PUBLIC_URL=https://nyone-hls.net
|
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_VOD_RETENTION_DAYS=30
|
||||||
STREAMING_RECORDINGS_PATH=storage/app/private/recordings
|
STREAMING_RECORDINGS_PATH=storage/app/private/recordings
|
||||||
MEDIAMTX_SHARED_SECRET=change-this-secret
|
MEDIAMTX_SHARED_SECRET=change-this-secret
|
||||||
|
|||||||
50
app/Console/Commands/SyncMediaMtxViewerCounts.php
Normal file
50
app/Console/Commands/SyncMediaMtxViewerCounts.php
Normal 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,13 @@ use App\Models\Broadcast;
|
|||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Vod;
|
use App\Models\Vod;
|
||||||
|
use App\Services\Streaming\ViewerCountStore;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class AdminModerationController extends Controller
|
class AdminModerationController extends Controller
|
||||||
{
|
{
|
||||||
public function suspendChannel(Request $request, Channel $channel): RedirectResponse
|
public function suspendChannel(Request $request, Channel $channel, ViewerCountStore $viewerCounts): RedirectResponse
|
||||||
{
|
{
|
||||||
$channel->forceFill([
|
$channel->forceFill([
|
||||||
'suspended_at' => now(),
|
'suspended_at' => now(),
|
||||||
@@ -20,6 +21,7 @@ class AdminModerationController extends Controller
|
|||||||
'live_broadcast_id' => null,
|
'live_broadcast_id' => null,
|
||||||
'viewer_count' => 0,
|
'viewer_count' => 0,
|
||||||
])->save();
|
])->save();
|
||||||
|
$viewerCounts->forget($channel);
|
||||||
|
|
||||||
$this->audit($request, $channel, 'channel.suspended');
|
$this->audit($request, $channel, 'channel.suspended');
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class AdminModerationController extends Controller
|
|||||||
return back()->with('success', 'Channel restored.');
|
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->forceFill(['suspended_at' => now()])->save();
|
||||||
$user->channel?->forceFill([
|
$user->channel?->forceFill([
|
||||||
@@ -44,12 +46,16 @@ class AdminModerationController extends Controller
|
|||||||
'viewer_count' => 0,
|
'viewer_count' => 0,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($user->channel) {
|
||||||
|
$viewerCounts->forget($user->channel);
|
||||||
|
}
|
||||||
|
|
||||||
$this->audit($request, $user, 'user.suspended');
|
$this->audit($request, $user, 'user.suspended');
|
||||||
|
|
||||||
return back()->with('success', '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([
|
$broadcast->forceFill([
|
||||||
'status' => Broadcast::STATUS_ENDED,
|
'status' => Broadcast::STATUS_ENDED,
|
||||||
@@ -61,6 +67,7 @@ class AdminModerationController extends Controller
|
|||||||
'live_broadcast_id' => null,
|
'live_broadcast_id' => null,
|
||||||
'viewer_count' => 0,
|
'viewer_count' => 0,
|
||||||
])->save();
|
])->save();
|
||||||
|
$viewerCounts->forget($broadcast->channel);
|
||||||
|
|
||||||
$this->audit($request, $broadcast, 'broadcast.stopped');
|
$this->audit($request, $broadcast, 'broadcast.stopped');
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\ChatMessage;
|
use App\Models\ChatMessage;
|
||||||
use App\Models\Vod;
|
use App\Models\Vod;
|
||||||
|
use App\Services\Streaming\PlaybackViewerSigner;
|
||||||
|
use App\Services\Streaming\ViewerCountStore;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -12,8 +14,12 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class ChannelController extends Controller
|
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);
|
abort_if($channel->isSuspended(), 404);
|
||||||
|
|
||||||
$channel->load(['category', 'liveBroadcast', 'user']);
|
$channel->load(['category', 'liveBroadcast', 'user']);
|
||||||
@@ -22,7 +28,7 @@ class ChannelController extends Controller
|
|||||||
$currentBroadcast = $channel->liveBroadcast;
|
$currentBroadcast = $channel->liveBroadcast;
|
||||||
|
|
||||||
return Inertia::render('channels/show', [
|
return Inertia::render('channels/show', [
|
||||||
'channel' => [
|
'channel' => fn () => [
|
||||||
'id' => $channel->id,
|
'id' => $channel->id,
|
||||||
'slug' => $channel->slug,
|
'slug' => $channel->slug,
|
||||||
'display_name' => $channel->display_name,
|
'display_name' => $channel->display_name,
|
||||||
@@ -31,7 +37,7 @@ class ChannelController extends Controller
|
|||||||
'banner_url' => $this->mediaUrl($channel->banner_path),
|
'banner_url' => $this->mediaUrl($channel->banner_path),
|
||||||
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
|
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
|
||||||
'is_live' => $channel->is_live,
|
'is_live' => $channel->is_live,
|
||||||
'viewer_count' => $channel->viewer_count,
|
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
|
||||||
'followers_count' => $channel->follows_count,
|
'followers_count' => $channel->follows_count,
|
||||||
'category' => $channel->category ? [
|
'category' => $channel->category ? [
|
||||||
'id' => $channel->category->id,
|
'id' => $channel->category->id,
|
||||||
@@ -43,14 +49,23 @@ class ChannelController extends Controller
|
|||||||
'name' => $channel->user->name,
|
'name' => $channel->user->name,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'currentBroadcast' => $currentBroadcast ? [
|
'currentBroadcast' => fn () => $currentBroadcast ? [
|
||||||
'id' => $currentBroadcast->id,
|
'id' => $currentBroadcast->id,
|
||||||
'title' => $currentBroadcast->title,
|
'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,
|
'recording_enabled' => $currentBroadcast->recording_enabled,
|
||||||
'started_at' => $currentBroadcast->started_at?->toIso8601String(),
|
'started_at' => $currentBroadcast->started_at?->toIso8601String(),
|
||||||
] : null,
|
] : 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) {
|
->where(function ($query) {
|
||||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
})
|
})
|
||||||
@@ -65,7 +80,7 @@ class ChannelController extends Controller
|
|||||||
'published_at' => $vod->published_at?->toIso8601String(),
|
'published_at' => $vod->published_at?->toIso8601String(),
|
||||||
'expires_at' => $vod->expires_at?->toIso8601String(),
|
'expires_at' => $vod->expires_at?->toIso8601String(),
|
||||||
]),
|
]),
|
||||||
'chatMessages' => ChatMessage::query()
|
'chatMessages' => fn () => ChatMessage::query()
|
||||||
->where('channel_id', $channel->id)
|
->where('channel_id', $channel->id)
|
||||||
->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id))
|
->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id))
|
||||||
->with('user')
|
->with('user')
|
||||||
@@ -84,9 +99,7 @@ class ChannelController extends Controller
|
|||||||
'avatar_url' => $message->user->avatar,
|
'avatar_url' => $message->user->avatar,
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
'isFollowing' => $request->user()
|
'isFollowing' => fn () => $request->user() && $channel->follows()->where('user_id', $request->user()->id)->exists(),
|
||||||
? $channel->follows()->where('user_id', $request->user()->id)->exists()
|
|
||||||
: false,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Broadcast;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Services\Streaming\StreamKeyManager;
|
use App\Services\Streaming\StreamKeyManager;
|
||||||
|
use App\Services\Streaming\ViewerCountStore;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -154,7 +155,7 @@ class CreatorDashboardController extends Controller
|
|||||||
return back()->with('success', 'Broadcast is ready. Start streaming from OBS.');
|
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);
|
abort_unless($broadcast->channel->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'viewer_count' => 0,
|
'viewer_count' => 0,
|
||||||
'live_broadcast_id' => null,
|
'live_broadcast_id' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
$viewerCounts->forget($broadcast->channel);
|
||||||
|
|
||||||
return back()->with('success', 'Broadcast ended.');
|
return back()->with('success', 'Broadcast ended.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Broadcast extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'recording_enabled' => 'boolean',
|
'recording_enabled' => 'boolean',
|
||||||
|
'viewer_peak' => 'integer',
|
||||||
'started_at' => 'datetime',
|
'started_at' => 'datetime',
|
||||||
'ended_at' => 'datetime',
|
'ended_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class Channel extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'is_live' => 'boolean',
|
'is_live' => 'boolean',
|
||||||
|
'viewer_count' => 'integer',
|
||||||
'suspended_at' => 'datetime',
|
'suspended_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
60
app/Services/Streaming/MediaMtxApiClient.php
Normal file
60
app/Services/Streaming/MediaMtxApiClient.php
Normal 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'), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class MediaMtxService
|
class MediaMtxService
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ViewerCountStore $viewerCounts) {}
|
||||||
|
|
||||||
public function authorize(array $payload): bool
|
public function authorize(array $payload): bool
|
||||||
{
|
{
|
||||||
$action = (string) ($payload['action'] ?? '');
|
$action = (string) ($payload['action'] ?? '');
|
||||||
@@ -79,6 +81,8 @@ class MediaMtxService
|
|||||||
'live_broadcast_id' => $broadcast->id,
|
'live_broadcast_id' => $broadcast->id,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$this->viewerCounts->forget($channel);
|
||||||
|
|
||||||
return $broadcast;
|
return $broadcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +111,8 @@ class MediaMtxService
|
|||||||
'live_broadcast_id' => null,
|
'live_broadcast_id' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$this->viewerCounts->forget($channel);
|
||||||
|
|
||||||
return $broadcast;
|
return $broadcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
app/Services/Streaming/MediaMtxViewerCountSynchronizer.php
Normal file
100
app/Services/Streaming/MediaMtxViewerCountSynchronizer.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Services/Streaming/PlaybackViewerSigner.php
Normal file
147
app/Services/Streaming/PlaybackViewerSigner.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Services/Streaming/ViewerCountStore.php
Normal file
44
app/Services/Streaming/ViewerCountStore.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,17 @@
|
|||||||
return [
|
return [
|
||||||
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.net:19935'),
|
'rtmp_ingest_url' => env('STREAMING_RTMP_INGEST_URL', 'rtmp://nyone.net:19935'),
|
||||||
'hls_public_url' => env('STREAMING_HLS_PUBLIC_URL', 'https://nyone-hls.net'),
|
'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'),
|
'mediamtx_shared_secret' => env('MEDIAMTX_SHARED_SECRET', 'local-dev-secret'),
|
||||||
'vod_retention_days' => (int) env('STREAMING_VOD_RETENTION_DAYS', 30),
|
'vod_retention_days' => (int) env('STREAMING_VOD_RETENTION_DAYS', 30),
|
||||||
'recordings_path' => env('STREAMING_RECORDINGS_PATH', storage_path('app/private/recordings')),
|
'recordings_path' => env('STREAMING_RECORDINGS_PATH', storage_path('app/private/recordings')),
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ authHTTPExclude:
|
|||||||
- action: metrics
|
- action: metrics
|
||||||
- action: pprof
|
- action: pprof
|
||||||
|
|
||||||
|
api: yes
|
||||||
|
apiAddress: 127.0.0.1:9997
|
||||||
|
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :19935
|
rtmpAddress: :19935
|
||||||
|
|
||||||
|
|||||||
89
docs/viewer-counts.md
Normal file
89
docs/viewer-counts.md
Normal 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.
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
|
import {
|
||||||
|
Head,
|
||||||
|
Link,
|
||||||
|
router,
|
||||||
|
useForm,
|
||||||
|
usePage,
|
||||||
|
usePoll,
|
||||||
|
} from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
Crown,
|
Crown,
|
||||||
Heart,
|
Heart,
|
||||||
@@ -36,12 +43,14 @@ import type {
|
|||||||
BroadcastSummary,
|
BroadcastSummary,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ViewerStats,
|
||||||
VodSummary,
|
VodSummary,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
channel: ChannelDetail;
|
channel: ChannelDetail;
|
||||||
currentBroadcast: BroadcastSummary | null;
|
currentBroadcast: BroadcastSummary | null;
|
||||||
|
viewerStats: ViewerStats;
|
||||||
vods: VodSummary[];
|
vods: VodSummary[];
|
||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
isFollowing: boolean;
|
isFollowing: boolean;
|
||||||
@@ -69,6 +78,7 @@ const chatEmojis = [
|
|||||||
export default function ChannelShow({
|
export default function ChannelShow({
|
||||||
channel,
|
channel,
|
||||||
currentBroadcast,
|
currentBroadcast,
|
||||||
|
viewerStats,
|
||||||
vods,
|
vods,
|
||||||
chatMessages,
|
chatMessages,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
@@ -76,6 +86,18 @@ export default function ChannelShow({
|
|||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
const chatForm = useForm({ body: '' });
|
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) {
|
function submitChat(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -138,7 +160,7 @@ export default function ChannelShow({
|
|||||||
<span>{channel.display_name}</span>
|
<span>{channel.display_name}</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
{channel.viewer_count} viewers
|
{currentViewers} viewers
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{channel.followers_count} followers
|
{channel.followers_count} followers
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export type BroadcastSummary = {
|
|||||||
has_vod?: boolean;
|
has_vod?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ViewerStats = {
|
||||||
|
current: number;
|
||||||
|
peak: number;
|
||||||
|
refresh_interval_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type VodSummary = {
|
export type VodSummary = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
105
tests/Feature/MediaMtxViewerCountSyncTest.php
Normal file
105
tests/Feature/MediaMtxViewerCountSyncTest.php
Normal 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\Broadcast;
|
use App\Models\Broadcast;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Streaming\ViewerCountStore;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -70,6 +71,65 @@ class ViewerInteractionTest extends TestCase
|
|||||||
])->assertRedirect(route('login'));
|
])->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
|
public function test_channel_page_exposes_display_media_props(): void
|
||||||
{
|
{
|
||||||
$channel = Channel::factory()->create([
|
$channel = Channel::factory()->create([
|
||||||
@@ -88,4 +148,14 @@ class ViewerInteractionTest extends TestCase
|
|||||||
->where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
|
->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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user