Files
ghassanandClaude Opus 4.7 8fef257c86 test(live): 7 tests for the Live polish work
Covers Private visibility acceptance, ended-state Watch page shows
"Stream ended" (and omits the "Waiting for the broadcast" placeholder
from the connection JS), channel-name link resolves to the user's
channel URL, chat poll returns messages newer than ?since=, chat send
rejects guests (401), chat send rejects offline streams (422), and
private-stream chat 404s for non-owners.

Also broadens LiveStream::isWatchableBy to keep public/unlisted
streams permalinked after they end — viewers now see the Stream Ended
card and the Watch Replay CTA instead of a hard 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:45:47 +03:00

262 lines
7.5 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;
}
// Visibility first — private streams stay owner-only no matter what.
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
return false;
}
// Live streams are watchable while they're on; ended streams remain
// permalinked so viewers see "Stream ended" and the replay CTA
// instead of a hard 404.
return $this->isLive() || $this->status === self::STATUS_ENDED;
}
/* ---------------- 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');
}
}