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]>
254 lines
7.2 KiB
PHP
254 lines
7.2 KiB
PHP
<?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');
|
|
}
|
|
}
|