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