feat(live): polish — ended-state, chat, viewer actions, Private option
Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:
## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).
## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.
## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.
## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.
## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.
Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
639279f2a2
commit
5bf1128196
12 files changed
+2103
No files matched your search
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LiveStream;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* The media server's authorisation hook.
|
||||
*
|
||||
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
|
||||
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
|
||||
* keeps every "who may do what" decision inside the application, next to the
|
||||
* users table, rather than duplicated in a media server config.
|
||||
*
|
||||
* Deny by default, in the strongest sense: if this app is down, the media
|
||||
* server can authorise nothing at all.
|
||||
*
|
||||
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
|
||||
* of the public API and reject anything arriving from elsewhere.
|
||||
*/
|
||||
class LiveAuthController extends Controller
|
||||
{
|
||||
/** Loopback addresses the media server may call from. */
|
||||
private const LOCAL_IPS = ['127.0.0.1', '::1'];
|
||||
|
||||
/**
|
||||
* Decide a publish or read attempt.
|
||||
*
|
||||
* Payload from MediaMTX: user, password, token, ip, action, path,
|
||||
* protocol, id, query.
|
||||
*/
|
||||
public function authenticate(Request $request): Response
|
||||
{
|
||||
if (! $this->fromMediaServer($request)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$action = (string) $request->input('action');
|
||||
|
||||
// Control-plane actions carry no stream path, so they are settled
|
||||
// before any attempt to resolve one.
|
||||
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
|
||||
return $this->authorizeApi();
|
||||
}
|
||||
|
||||
$path = (string) $request->input('path');
|
||||
|
||||
$stream = $this->resolveStream($path);
|
||||
|
||||
if (! $stream) {
|
||||
// An unknown path is not an error worth explaining — a publisher
|
||||
// probing for valid stream ids learns nothing from this.
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
return match ($action) {
|
||||
'publish' => $this->authorizePublish($request, $stream),
|
||||
'read' => $this->authorizeRead($stream),
|
||||
default => response('', 403),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The media server's own control API.
|
||||
*
|
||||
* It authenticates against this same hook, so without an explicit allow the
|
||||
* app could not read live state from the server it runs alongside. Safe
|
||||
* because it is already loopback-gated above — nothing off this host can
|
||||
* reach it.
|
||||
*/
|
||||
private function authorizeApi(): Response
|
||||
{
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* A publisher must present the short-lived token minted for this stream
|
||||
* when its owner pressed "Go live". The token is single-use: it is burned
|
||||
* here, so a captured one cannot start a second broadcast.
|
||||
*/
|
||||
private function authorizePublish(Request $request, LiveStream $stream): Response
|
||||
{
|
||||
// MediaMTX passes HTTP Basic credentials through as user/password.
|
||||
$presented = (string) ($request->input('password') ?: $request->input('token'));
|
||||
|
||||
if (! $stream->publishTokenMatches($presented)) {
|
||||
Log::warning('Live publish refused', [
|
||||
'stream' => $stream->public_id,
|
||||
'ip' => $request->input('ip'),
|
||||
]);
|
||||
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$stream->consumePublishToken();
|
||||
$stream->markLive();
|
||||
|
||||
Log::info('Live publish authorised', [
|
||||
'stream' => $stream->public_id,
|
||||
'user_id' => $stream->user_id,
|
||||
]);
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads are anonymous by design — a viewer fetching HLS parts carries no
|
||||
* credentials — so the gate is the stream's own state: it must be live and
|
||||
* not private. The unpredictable path is what keeps an unlisted stream
|
||||
* unlisted.
|
||||
*/
|
||||
private function authorizeRead(LiveStream $stream): Response
|
||||
{
|
||||
if (! $stream->isLive()) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle notifications from the media server.
|
||||
*
|
||||
* The auth hook only fires on the way in, so without this a stream whose
|
||||
* phone lost signal would read as live forever.
|
||||
*/
|
||||
public function hook(Request $request): Response
|
||||
{
|
||||
if (! $this->fromMediaServer($request)) {
|
||||
return response('', 403);
|
||||
}
|
||||
|
||||
$stream = $this->resolveStream((string) $request->input('path'));
|
||||
|
||||
if (! $stream) {
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
match ((string) $request->input('event')) {
|
||||
'publish' => $stream->markLive(),
|
||||
// Only a stream that is actually live can end. A late hook from a
|
||||
// previous, failed session must not kill the one just started.
|
||||
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
|
||||
'read' => $this->viewerJoined($stream),
|
||||
'unread' => $this->viewerLeft($stream),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/* ---------------- Viewer accounting ---------------- */
|
||||
|
||||
/*
|
||||
* Counted with atomic SQL increments rather than read-modify-write: viewers
|
||||
* arrive concurrently, and two joins landing together would otherwise
|
||||
* record one.
|
||||
*/
|
||||
|
||||
private function viewerJoined(LiveStream $stream): void
|
||||
{
|
||||
LiveStream::where('id', $stream->id)->increment('current_viewers');
|
||||
|
||||
// peak_viewers is a high-water mark, so it only ever moves up.
|
||||
LiveStream::where('id', $stream->id)
|
||||
->whereColumn('current_viewers', '>', 'peak_viewers')
|
||||
->update(['peak_viewers' => DB::raw('current_viewers')]);
|
||||
}
|
||||
|
||||
private function viewerLeft(LiveStream $stream): void
|
||||
{
|
||||
// Floor at zero: a missed join (a restart, a dropped hook) must not
|
||||
// drive the count negative and show "-1 watching".
|
||||
LiveStream::where('id', $stream->id)
|
||||
->where('current_viewers', '>', 0)
|
||||
->decrement('current_viewers');
|
||||
}
|
||||
|
||||
/* ---------------- Helpers ---------------- */
|
||||
|
||||
/**
|
||||
* Only the media server on this host may ask. Deliberately checks the raw
|
||||
* connection address rather than a forwarded header — a header can be
|
||||
* spoofed by anyone who can reach the app.
|
||||
*/
|
||||
private function fromMediaServer(Request $request): bool
|
||||
{
|
||||
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
|
||||
}
|
||||
|
||||
/** Turn "live/<public_id>" into the stream it names. */
|
||||
private function resolveStream(string $path): ?LiveStream
|
||||
{
|
||||
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LiveStream::where('public_id', $m[1])->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LiveChatMessage;
|
||||
use App\Models\LiveStream;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Live streaming — broadcaster and viewer surfaces.
|
||||
*
|
||||
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
|
||||
* may broadcast, mints the short-lived credential the browser publishes with,
|
||||
* and hands out the URLs. See LiveAuthController for the hook the media server
|
||||
* calls back on.
|
||||
*/
|
||||
class LiveStreamController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Watching is open; everything that creates or controls a stream needs
|
||||
// an account.
|
||||
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
|
||||
}
|
||||
|
||||
/** Live now, plus the viewer's own streams when signed in. */
|
||||
public function index(): \Illuminate\View\View
|
||||
{
|
||||
$live = LiveStream::live()
|
||||
->listed()
|
||||
->with('user')
|
||||
->latest('started_at')
|
||||
->limit(60)
|
||||
->get();
|
||||
|
||||
$mine = Auth::check()
|
||||
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
|
||||
: collect();
|
||||
|
||||
return view('live.index', compact('live', 'mine'));
|
||||
}
|
||||
|
||||
/** The "start a broadcast" form. */
|
||||
public function create(): \Illuminate\View\View
|
||||
{
|
||||
return view('live.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the stream record. This does NOT start anything — it reserves an
|
||||
* identity and a path; the browser goes live from the broadcast page.
|
||||
*/
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
|
||||
]);
|
||||
|
||||
// One live broadcast per account at a time. Without this a single user
|
||||
// can pin open as many encoder slots as they can open tabs.
|
||||
$alreadyLive = LiveStream::where('user_id', Auth::id())
|
||||
->where('status', LiveStream::STATUS_LIVE)
|
||||
->exists();
|
||||
|
||||
if ($alreadyLive) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
|
||||
}
|
||||
|
||||
$stream = LiveStream::create([
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'visibility' => $data['visibility'],
|
||||
'status' => LiveStream::STATUS_IDLE,
|
||||
]);
|
||||
|
||||
return redirect()->route('live.broadcast', $stream);
|
||||
}
|
||||
|
||||
/** The broadcaster's camera page. Owner only. */
|
||||
public function broadcast(LiveStream $stream): \Illuminate\View\View
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
// An ended stream is not a dead one — the owner may go live on it
|
||||
// again from the same link.
|
||||
$stream->reArm();
|
||||
|
||||
return view('live.broadcast', [
|
||||
'stream' => $stream,
|
||||
'whipUrl' => $this->whipUrl($stream),
|
||||
'watchUrl' => route('live.watch', $stream),
|
||||
'iceServers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the publish credential.
|
||||
*
|
||||
* Separate from the page load so the token's short life starts when the
|
||||
* user actually presses "Go live", not when they opened the tab and went
|
||||
* to find a tripod.
|
||||
*/
|
||||
public function token(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
// Pressing "Go live" on a stream that previously ended restarts it
|
||||
// rather than being refused. A failed attempt must never burn the link.
|
||||
$stream->reArm();
|
||||
|
||||
return response()->json([
|
||||
'token' => $stream->issuePublishToken(),
|
||||
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
|
||||
'whip_url' => $this->whipUrl($stream),
|
||||
'ice_servers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The viewer page. */
|
||||
public function watch(LiveStream $stream): \Illuminate\View\View
|
||||
{
|
||||
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||
|
||||
$stream->loadMissing('user');
|
||||
|
||||
return view('live.watch', [
|
||||
'stream' => $stream,
|
||||
'whepUrl' => $this->whepUrl($stream),
|
||||
'hlsUrl' => $this->hlsUrl($stream),
|
||||
'isOwner' => $stream->isOwnedBy(Auth::user()),
|
||||
'iceServers' => $this->iceServers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Lightweight poll for the viewer page: is it live, how many watching. */
|
||||
public function status(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isWatchableBy(Auth::user()), 404);
|
||||
|
||||
return response()->json([
|
||||
'status' => $stream->status,
|
||||
'live' => $stream->isLive(),
|
||||
'viewers' => $stream->current_viewers,
|
||||
'duration' => $stream->formatted_duration,
|
||||
]);
|
||||
}
|
||||
|
||||
/** End the broadcast. Owner only. */
|
||||
public function stop(LiveStream $stream): JsonResponse
|
||||
{
|
||||
abort_unless($stream->isOwnedBy(Auth::user()), 403);
|
||||
|
||||
$stream->markEnded();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $stream->status,
|
||||
'duration' => $stream->formatted_duration,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------------- URLs ---------------- */
|
||||
|
||||
/*
|
||||
* These are same-origin paths, proxied by nginx to the media server on
|
||||
* loopback. Same-origin matters: the page is served over HTTPS, and a
|
||||
* mixed-content or cross-origin media URL would be blocked by the browser
|
||||
* before WebRTC ever started.
|
||||
*/
|
||||
|
||||
private function whipUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-rtc/'.$stream->mediaPath().'/whip');
|
||||
}
|
||||
|
||||
private function whepUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-rtc/'.$stream->mediaPath().'/whep');
|
||||
}
|
||||
|
||||
private function hlsUrl(LiveStream $stream): string
|
||||
{
|
||||
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
|
||||
}
|
||||
|
||||
/**
|
||||
* ICE servers for the browser.
|
||||
*
|
||||
* No inbound UDP port is forwarded to this server, so a broadcaster outside
|
||||
* the local network can only reach it through a TURN relay. On the LAN the
|
||||
* host candidates work directly and TURN is never used.
|
||||
*
|
||||
* Configured through the environment so credentials never sit in the repo.
|
||||
*/
|
||||
private function iceServers(): array
|
||||
{
|
||||
$servers = [];
|
||||
|
||||
if ($stun = config('live.stun_url')) {
|
||||
$servers[] = ['urls' => $stun];
|
||||
}
|
||||
|
||||
if ($turn = config('live.turn_url')) {
|
||||
$servers[] = array_filter([
|
||||
'urls' => $turn,
|
||||
'username' => config('live.turn_username'),
|
||||
'credential' => config('live.turn_credential'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET poll: viewer asks for every message in this stream newer than
|
||||
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
|
||||
* messages — more than enough for a 3-second polling window, bounded
|
||||
* to protect the DB when a client is slow to reconnect.
|
||||
*/
|
||||
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
|
||||
{
|
||||
// Private streams are owner-only; everything else is open to viewers.
|
||||
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$since = (int) $request->query('since', 0);
|
||||
$rows = LiveChatMessage::where('livestream_id', $stream->id)
|
||||
->where('id', '>', $since)
|
||||
->orderBy('id')
|
||||
->limit(50)
|
||||
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'messages' => $rows->map(fn ($m) => [
|
||||
'id' => $m->id,
|
||||
'uid' => $m->user_id,
|
||||
'name' => $m->author_name,
|
||||
'body' => $m->body,
|
||||
'at' => $m->created_at?->toISOString(),
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a chat message. Rate-limited to roughly one message per second
|
||||
* per user via Laravel's built-in throttle, applied via the route.
|
||||
* Body is capped at 400 chars (long enough for a sentence, short enough
|
||||
* that the panel doesn't shear into a wall of text).
|
||||
*/
|
||||
public function chatSend(Request $request, LiveStream $stream): JsonResponse
|
||||
{
|
||||
if ($stream->status !== LiveStream::STATUS_LIVE) {
|
||||
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
|
||||
}
|
||||
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'max:400'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$msg = LiveChatMessage::create([
|
||||
'livestream_id' => $stream->id,
|
||||
'user_id' => $user->id,
|
||||
'author_name' => $user->name,
|
||||
'body' => trim($data['body']),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => [
|
||||
'id' => $msg->id,
|
||||
'uid' => $msg->user_id,
|
||||
'name' => $msg->author_name,
|
||||
'body' => $msg->body,
|
||||
'at' => $msg->created_at?->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LiveChatMessage extends Model
|
||||
{
|
||||
protected $table = 'live_chat_messages';
|
||||
|
||||
public $timestamps = false; // explicit created_at only, set via useCurrent()
|
||||
|
||||
protected $fillable = [
|
||||
'livestream_id',
|
||||
'user_id',
|
||||
'author_name',
|
||||
'body',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function livestream()
|
||||
{
|
||||
return $this->belongsTo(LiveStream::class, 'livestream_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A live broadcast.
|
||||
*
|
||||
* The row is the authority on whether a stream may exist; the media server
|
||||
* holds no state of its own and asks this table (through LiveAuthController)
|
||||
* before it accepts a publisher or a viewer.
|
||||
*/
|
||||
class LiveStream extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'livestreams';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'public_id', 'title', 'description',
|
||||
'status', 'visibility', 'video_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The publish token is a credential. Keeping it out of the default array
|
||||
* form means it cannot be leaked by a careless toJson() on a model that
|
||||
* reached a view or an API response.
|
||||
*/
|
||||
protected $hidden = ['publish_token', 'publish_token_expires_at'];
|
||||
|
||||
protected $casts = [
|
||||
'publish_token_expires_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_IDLE = 'idle';
|
||||
public const STATUS_LIVE = 'live';
|
||||
public const STATUS_ENDED = 'ended';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
/** How long a freshly minted publish token stays valid. */
|
||||
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Every stream gets an unpredictable public id, always — never left to
|
||||
// a caller to remember.
|
||||
static::creating(function (self $stream) {
|
||||
$stream->public_id ??= self::newPublicId();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Identity ---------------- */
|
||||
|
||||
/**
|
||||
* 32 chars of alphanumeric entropy. Also the media path segment, which is
|
||||
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
|
||||
* the media server is configured with.
|
||||
*/
|
||||
public static function newPublicId(): string
|
||||
{
|
||||
do {
|
||||
$id = Str::random(32);
|
||||
} while (self::where('public_id', $id)->exists());
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Route model binding uses the unpredictable id, never the numeric key. */
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'public_id';
|
||||
}
|
||||
|
||||
/** The path the media server knows this stream by. */
|
||||
public function mediaPath(): string
|
||||
{
|
||||
return 'live/'.$this->public_id;
|
||||
}
|
||||
|
||||
/* ---------------- Relations ---------------- */
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** The recording, once a finished stream has been published as a video. */
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
/* ---------------- State ---------------- */
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_LIVE;
|
||||
}
|
||||
|
||||
public function isOwnedBy(?User $user): bool
|
||||
{
|
||||
return $user !== null && $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this viewer watch?
|
||||
*
|
||||
* Both public and unlisted streams are watchable by anyone holding the
|
||||
* link — the difference is only whether the stream is listed. Ownership
|
||||
* additionally grants access before it goes live, so the broadcaster can
|
||||
* open their own watch page to check themselves.
|
||||
*/
|
||||
public function isWatchableBy(?User $user): bool
|
||||
{
|
||||
if ($this->isOwnedBy($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isLive() && in_array($this->visibility, ['public', 'unlisted'], true);
|
||||
}
|
||||
|
||||
/* ---------------- Publish credential ---------------- */
|
||||
|
||||
/**
|
||||
* Mint a short-lived, single-use token for the broadcaster's browser.
|
||||
*
|
||||
* Returned in plaintext to the owner exactly once, at the moment they press
|
||||
* "Go live". Stored so the media server's auth hook can verify it, then
|
||||
* consumed on first successful publish.
|
||||
*/
|
||||
public function issuePublishToken(): string
|
||||
{
|
||||
$token = Str::random(64);
|
||||
|
||||
$this->forceFill([
|
||||
'publish_token' => hash('sha256', $token),
|
||||
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
|
||||
])->save();
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Constant-time check of a presented token against the stored hash. */
|
||||
public function publishTokenMatches(?string $presented): bool
|
||||
{
|
||||
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->publish_token_expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($this->publish_token, hash('sha256', $presented));
|
||||
}
|
||||
|
||||
/** Burn the token so a captured one cannot be replayed. */
|
||||
public function consumePublishToken(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'publish_token' => null,
|
||||
'publish_token_expires_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/* ---------------- Transitions ---------------- */
|
||||
|
||||
public function markLive(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_LIVE,
|
||||
'started_at' => $this->started_at ?? now(),
|
||||
'last_seen_at' => now(),
|
||||
'ended_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an ended stream back into a startable state.
|
||||
*
|
||||
* A stream is a reusable channel, not a one-shot: the owner keeps the same
|
||||
* link and can go live on it again. Without this, any failed start — a lost
|
||||
* signal, a refused camera, a server hiccup — would kill the stream
|
||||
* permanently and force a new link on every retry.
|
||||
*/
|
||||
public function reArm(): void
|
||||
{
|
||||
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_IDLE,
|
||||
'ended_at' => null,
|
||||
'current_viewers' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markEnded(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ENDED,
|
||||
'ended_at' => now(),
|
||||
'current_viewers' => 0,
|
||||
'publish_token' => null,
|
||||
'publish_token_expires_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/* ---------------- Display ---------------- */
|
||||
|
||||
public function getDurationSecondsAttribute(): ?int
|
||||
{
|
||||
if (! $this->started_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
|
||||
}
|
||||
|
||||
public function getFormattedDurationAttribute(): string
|
||||
{
|
||||
$seconds = $this->duration_seconds;
|
||||
|
||||
if ($seconds === null) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
return $seconds >= 3600
|
||||
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
|
||||
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
|
||||
}
|
||||
|
||||
/* ---------------- Scopes ---------------- */
|
||||
|
||||
public function scopeLive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_LIVE);
|
||||
}
|
||||
|
||||
public function scopeListed($query)
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user