start project

This commit is contained in:
2026-05-15 16:33:34 +03:30
parent 553238b0fb
commit d756661b45
50 changed files with 3523 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use App\Models\Vod;
use Inertia\Inertia;
use Inertia\Response;
class AdminDashboardController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('admin/dashboard', [
'stats' => [
'users' => User::query()->count(),
'channels' => Channel::query()->count(),
'live_channels' => Channel::query()->where('is_live', true)->count(),
'vods' => Vod::query()->count(),
],
'liveBroadcasts' => Broadcast::query()
->where('status', Broadcast::STATUS_LIVE)
->with('channel.user')
->latest('started_at')
->limit(20)
->get()
->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id,
'title' => $broadcast->title,
'started_at' => $broadcast->started_at?->toIso8601String(),
'channel' => [
'id' => $broadcast->channel->id,
'slug' => $broadcast->channel->slug,
'display_name' => $broadcast->channel->display_name,
'owner' => $broadcast->channel->user->name,
],
]),
'channels' => Channel::query()
->with('user')
->latest()
->limit(25)
->get()
->map(fn (Channel $channel) => [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'is_live' => $channel->is_live,
'suspended_at' => $channel->suspended_at?->toIso8601String(),
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
]),
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Models\AdminAuditLog;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use App\Models\Vod;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class AdminModerationController extends Controller
{
public function suspendChannel(Request $request, Channel $channel): RedirectResponse
{
$channel->forceFill([
'suspended_at' => now(),
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $channel, 'channel.suspended');
return back()->with('success', 'Channel suspended.');
}
public function restoreChannel(Request $request, Channel $channel): RedirectResponse
{
$channel->forceFill(['suspended_at' => null])->save();
$this->audit($request, $channel, 'channel.restored');
return back()->with('success', 'Channel restored.');
}
public function suspendUser(Request $request, User $user): RedirectResponse
{
$user->forceFill(['suspended_at' => now()])->save();
$user->channel?->forceFill([
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $user, 'user.suspended');
return back()->with('success', 'User suspended.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
{
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
$broadcast->channel->forceFill([
'is_live' => false,
'live_broadcast_id' => null,
'viewer_count' => 0,
])->save();
$this->audit($request, $broadcast, 'broadcast.stopped');
return back()->with('success', 'Broadcast stopped.');
}
public function deleteVod(Request $request, Vod $vod): RedirectResponse
{
$vod->delete();
$this->audit($request, $vod, 'vod.deleted');
return back()->with('success', 'VOD deleted.');
}
private function audit(Request $request, object $subject, string $action): void
{
AdminAuditLog::query()->create([
'admin_user_id' => $request->user()?->id,
'subject_type' => $subject::class,
'subject_id' => $subject->id,
'action' => $action,
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use App\Models\ChatMessage;
use App\Models\Vod;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ChannelController extends Controller
{
public function show(Request $request, Channel $channel): Response
{
abort_if($channel->isSuspended(), 404);
$channel->load(['category', 'liveBroadcast', 'user']);
$channel->loadCount('follows');
$currentBroadcast = $channel->liveBroadcast;
return Inertia::render('channels/show', [
'channel' => [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
],
'currentBroadcast' => $currentBroadcast ? [
'id' => $currentBroadcast->id,
'title' => $currentBroadcast->title,
'hls_url' => $currentBroadcast->hls_url,
'recording_enabled' => $currentBroadcast->recording_enabled,
'started_at' => $currentBroadcast->started_at?->toIso8601String(),
] : null,
'vods' => $channel->vods()
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest('published_at')
->limit(12)
->get()
->map(fn (Vod $vod) => [
'id' => $vod->id,
'title' => $vod->title,
'playback_url' => $vod->playback_url,
'duration_seconds' => $vod->duration_seconds,
'published_at' => $vod->published_at?->toIso8601String(),
'expires_at' => $vod->expires_at?->toIso8601String(),
]),
'chatMessages' => ChatMessage::query()
->where('channel_id', $channel->id)
->when($currentBroadcast, fn ($query) => $query->where('broadcast_id', $currentBroadcast->id))
->with('user')
->latest()
->limit(50)
->get()
->reverse()
->values()
->map(fn (ChatMessage $message) => [
'id' => $message->id,
'body' => $message->body,
'created_at' => $message->created_at?->toIso8601String(),
'user' => [
'id' => $message->user->id,
'name' => $message->user->name,
],
]),
'isFollowing' => $request->user()
? $channel->follows()->where('user_id', $request->user()->id)->exists()
: false,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ChatMessageController extends Controller
{
public function store(Request $request, Channel $channel): RedirectResponse
{
abort_if($request->user()->isSuspended() || $channel->isSuspended(), 403);
abort_unless($channel->is_live && $channel->live_broadcast_id, 422, 'Chat is available only while the channel is live.');
$validated = $request->validate([
'body' => ['required', 'string', 'max:500'],
]);
$channel->chatMessages()->create([
'broadcast_id' => $channel->live_broadcast_id,
'user_id' => $request->user()->id,
'body' => trim($validated['body']),
]);
return back();
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers;
use App\Models\Broadcast;
use App\Models\Category;
use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class CreatorDashboardController extends Controller
{
public function show(Request $request, StreamKeyManager $streamKeys): Response
{
$channel = $request->user()
->channel()
->with(['category', 'liveBroadcast', 'streamKey'])
->first();
return Inertia::render('dashboard', [
'channel' => $channel ? $this->channelPayload($channel) : null,
'plainStreamKey' => session('plain_stream_key'),
'streaming' => [
'ingestServer' => $streamKeys->ingestServer(),
'tokenizedPath' => $channel && session('plain_stream_key')
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
: null,
],
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
'recentBroadcasts' => $channel
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id,
'title' => $broadcast->title,
'status' => $broadcast->status,
'recording_enabled' => $broadcast->recording_enabled,
'started_at' => $broadcast->started_at?->toIso8601String(),
'ended_at' => $broadcast->ended_at?->toIso8601String(),
'has_vod' => $broadcast->vod !== null,
])
: [],
]);
}
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
{
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
$validated = $request->validate([
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
]);
$channel = $request->user()->channel()->create([
...$validated,
'display_name' => trim($validated['display_name']),
'slug' => Str::slug($validated['slug']),
]);
$plainStreamKey = $streamKeys->rotate($channel);
return redirect()
->route('dashboard')
->with('plain_stream_key', $plainStreamKey)
->with('success', 'Channel created. Save the stream key now; it will not be shown again.');
}
public function updateChannel(Request $request): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
$validated = $request->validate([
'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'],
]);
$channel->update([
...$validated,
'display_name' => trim($validated['display_name']),
'slug' => Str::slug($validated['slug']),
]);
return back()->with('success', 'Channel updated.');
}
public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
$plainStreamKey = $streamKeys->rotate($channel);
return back()
->with('plain_stream_key', $plainStreamKey)
->with('success', 'Stream key rotated. Update OBS before going live again.');
}
public function storeBroadcast(Request $request): RedirectResponse
{
$channel = $request->user()->channel;
abort_unless($channel, 404);
abort_if($channel->isSuspended(), 403);
abort_if(
$channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(),
422,
'Finish the current broadcast before creating another.',
);
$validated = $request->validate([
'title' => ['required', 'string', 'max:120'],
'recording_enabled' => ['nullable', 'boolean'],
]);
$channel->broadcasts()->create([
'title' => $validated['title'],
'status' => Broadcast::STATUS_PENDING,
'recording_enabled' => (bool) ($validated['recording_enabled'] ?? false),
'mediamtx_path' => $channel->slug,
]);
return back()->with('success', 'Broadcast is ready. Start streaming from OBS.');
}
public function stopBroadcast(Request $request, Broadcast $broadcast): RedirectResponse
{
abort_unless($broadcast->channel->user_id === $request->user()->id, 403);
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
$broadcast->channel->forceFill([
'is_live' => false,
'viewer_count' => 0,
'live_broadcast_id' => null,
])->save();
return back()->with('success', 'Broadcast ended.');
}
private function channelPayload(Channel $channel): array
{
return [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count,
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
'category_id' => $channel->category_id,
'live_broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,
'status' => $channel->liveBroadcast->status,
'hls_url' => $channel->liveBroadcast->hls_url,
'recording_enabled' => $channel->liveBroadcast->recording_enabled,
'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(),
] : null,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class FollowController extends Controller
{
public function store(Request $request, Channel $channel): RedirectResponse
{
abort_if($channel->isSuspended(), 404);
$channel->follows()->firstOrCreate([
'user_id' => $request->user()->id,
]);
return back();
}
public function destroy(Request $request, Channel $channel): RedirectResponse
{
$channel->follows()->where('user_id', $request->user()->id)->delete();
return back();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Channel;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class HomeController extends Controller
{
public function __invoke(Request $request): Response
{
$liveChannels = Channel::query()
->where('is_live', true)
->whereNull('suspended_at')
->with(['category', 'liveBroadcast', 'user'])
->withCount('follows')
->orderByDesc('viewer_count')
->latest()
->limit(24)
->get()
->map(fn (Channel $channel) => $this->channelCard($channel));
return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()),
'liveChannels' => $liveChannels,
'categories' => Category::query()
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
]);
}
private function channelCard(Channel $channel): array
{
return [
'id' => $channel->id,
'slug' => $channel->slug,
'display_name' => $channel->display_name,
'description' => $channel->description,
'thumbnail_path' => $channel->thumbnail_path,
'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count ?? 0,
'category' => $channel->category ? [
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title,
'started_at' => $channel->liveBroadcast->started_at?->toIso8601String(),
] : null,
'owner' => [
'id' => $channel->user->id,
'name' => $channel->user->name,
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Services\Streaming\MediaMtxService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class MediaMtxController extends Controller
{
public function auth(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
return $mediaMtx->authorize($request->all())
? response()->noContent()
: response('Unauthorized', 403);
}
public function ready(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->markReady(
(string) $request->input('path', $request->query('path', '')),
(string) $request->input('source_type', $request->query('source_type', '')),
(string) $request->input('source_id', $request->query('source_id', '')),
);
return response()->noContent();
}
public function notReady(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->markNotReady((string) $request->input('path', $request->query('path', '')));
return response()->noContent();
}
public function recordingComplete(Request $request, MediaMtxService $mediaMtx): Response
{
$this->ensureSecret($request);
$mediaMtx->recordSegmentComplete(
(string) $request->input('path', $request->query('path', '')),
(string) $request->input('segment_path', $request->query('segment_path', '')),
$request->input('duration', $request->query('duration')),
);
return response()->noContent();
}
private function ensureSecret(Request $request): void
{
$expected = (string) config('streaming.mediamtx_shared_secret');
$provided = (string) ($request->header('X-MediaMTX-Secret')
?: $request->query('secret')
?: $request->input('secret'));
abort_if($expected === '' || ! hash_equals($expected, $provided), 403);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin, 403);
return $next($request);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['admin_user_id', 'subject_type', 'subject_id', 'action', 'metadata', 'created_at'])]
class AdminAuditLog extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_user_id');
}
}

54
app/Models/Broadcast.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Database\Factories\BroadcastFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'channel_id',
'title',
'status',
'recording_enabled',
'mediamtx_path',
'source_type',
'source_id',
'hls_url',
'viewer_peak',
'started_at',
'ended_at',
])]
class Broadcast extends Model
{
/** @use HasFactory<BroadcastFactory> */
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_LIVE = 'live';
public const STATUS_ENDED = 'ended';
protected function casts(): array
{
return [
'recording_enabled' => 'boolean',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function vod(): HasOne
{
return $this->hasOne(Vod::class);
}
}

21
app/Models/Category.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'description'])]
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory;
public function channels(): HasMany
{
return $this->hasMany(Channel::class);
}
}

89
app/Models/Channel.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Database\Factories\ChannelFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'user_id',
'category_id',
'live_broadcast_id',
'slug',
'display_name',
'description',
'avatar_path',
'banner_path',
'thumbnail_path',
'is_live',
'viewer_count',
'suspended_at',
])]
class Channel extends Model
{
/** @use HasFactory<ChannelFactory> */
use HasFactory;
protected function casts(): array
{
return [
'is_live' => 'boolean',
'suspended_at' => 'datetime',
];
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function streamKey(): HasOne
{
return $this->hasOne(StreamKey::class);
}
public function broadcasts(): HasMany
{
return $this->hasMany(Broadcast::class);
}
public function liveBroadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class, 'live_broadcast_id');
}
public function vods(): HasMany
{
return $this->hasMany(Vod::class);
}
public function follows(): HasMany
{
return $this->hasMany(Follow::class);
}
public function chatMessages(): HasMany
{
return $this->hasMany(ChatMessage::class);
}
public function isSuspended(): bool
{
return $this->suspended_at !== null || $this->user?->suspended_at !== null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable(['channel_id', 'broadcast_id', 'user_id', 'body'])]
class ChatMessage extends Model
{
use SoftDeletes;
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

21
app/Models/Follow.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['channel_id', 'user_id'])]
class Follow extends Model
{
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

24
app/Models/StreamKey.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['channel_id', 'key_hash', 'last_used_at', 'revoked_at'])]
class StreamKey extends Model
{
protected function casts(): array
{
return [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
}

42
app/Models/Vod.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'broadcast_id',
'channel_id',
'title',
'storage_path',
'playback_url',
'duration_seconds',
'size_bytes',
'published_at',
'expires_at',
])]
class Vod extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'published_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Services\Streaming;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\Vod;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class MediaMtxService
{
public function authorize(array $payload): bool
{
$action = (string) ($payload['action'] ?? '');
$path = $this->extractPath($payload);
$channel = $this->channelForPath($path);
if (! $channel || $channel->isSuspended()) {
return false;
}
if (in_array($action, ['read', 'playback'], true)) {
return true;
}
if ($action !== 'publish') {
return false;
}
$token = (string) ($payload['token'] ?? $this->queryValue((string) ($payload['query'] ?? ''), 'token'));
if ($token === '' || ! $channel->streamKey || $channel->streamKey->revoked_at) {
return false;
}
$authorized = Hash::check($token, $channel->streamKey->key_hash);
if ($authorized) {
$channel->streamKey->forceFill(['last_used_at' => now()])->save();
}
return $authorized;
}
public function markReady(string $path, ?string $sourceType = null, ?string $sourceId = null): ?Broadcast
{
$channel = $this->channelForPath($path);
if (! $channel || $channel->isSuspended()) {
return null;
}
$broadcast = $channel->broadcasts()
->where('status', Broadcast::STATUS_PENDING)
->latest()
->first();
if (! $broadcast) {
$broadcast = $channel->broadcasts()->create([
'title' => 'Untitled live stream',
'status' => Broadcast::STATUS_PENDING,
'recording_enabled' => false,
]);
}
$broadcast->forceFill([
'status' => Broadcast::STATUS_LIVE,
'mediamtx_path' => $channel->slug,
'source_type' => $sourceType,
'source_id' => $sourceId,
'hls_url' => $this->hlsUrl($channel),
'started_at' => $broadcast->started_at ?? now(),
'ended_at' => null,
])->save();
$channel->forceFill([
'is_live' => true,
'live_broadcast_id' => $broadcast->id,
])->save();
return $broadcast;
}
public function markNotReady(string $path): ?Broadcast
{
$channel = $this->channelForPath($path);
if (! $channel) {
return null;
}
$broadcast = $channel->live_broadcast_id
? Broadcast::find($channel->live_broadcast_id)
: $channel->broadcasts()->where('status', Broadcast::STATUS_LIVE)->latest()->first();
if ($broadcast) {
$broadcast->forceFill([
'status' => Broadcast::STATUS_ENDED,
'ended_at' => $broadcast->ended_at ?? now(),
])->save();
}
$channel->forceFill([
'is_live' => false,
'viewer_count' => 0,
'live_broadcast_id' => null,
])->save();
return $broadcast;
}
public function recordSegmentComplete(string $path, string $segmentPath, ?string $duration = null): ?Vod
{
$channel = $this->channelForPath($path);
if (! $channel) {
return null;
}
$broadcast = $channel->broadcasts()
->whereIn('status', [Broadcast::STATUS_LIVE, Broadcast::STATUS_ENDED])
->latest('started_at')
->first();
if (! $broadcast || ! $broadcast->recording_enabled) {
$this->deleteSegmentIfSafe($segmentPath);
return null;
}
return Vod::query()->firstOrCreate(
['broadcast_id' => $broadcast->id],
[
'channel_id' => $channel->id,
'title' => $broadcast->title,
'storage_path' => $segmentPath,
'duration_seconds' => $this->durationSeconds($duration),
'size_bytes' => is_file($segmentPath) ? filesize($segmentPath) : null,
'published_at' => now(),
'expires_at' => now()->addDays((int) config('streaming.vod_retention_days')),
],
);
}
public function hlsUrl(Channel $channel): string
{
return rtrim((string) config('streaming.hls_public_url'), '/').'/'.rawurlencode($channel->slug).'/index.m3u8';
}
public function extractPath(array $payload): string
{
$path = trim((string) ($payload['path'] ?? ''), '/');
if ($path === '') {
return '';
}
return Str::afterLast($path, '/');
}
private function channelForPath(string $path): ?Channel
{
if ($path === '') {
return null;
}
return Channel::query()
->where('slug', $path)
->with(['streamKey', 'user'])
->first();
}
private function queryValue(string $query, string $key): ?string
{
parse_str($query, $values);
return isset($values[$key]) ? (string) $values[$key] : null;
}
private function durationSeconds(?string $duration): ?int
{
if ($duration === null || $duration === '') {
return null;
}
preg_match('/[\d.]+/', $duration, $matches);
return isset($matches[0]) ? (int) round((float) $matches[0]) : null;
}
private function deleteSegmentIfSafe(string $segmentPath): void
{
if ($segmentPath === '' || ! is_file($segmentPath)) {
return;
}
$recordingsRoot = realpath((string) config('streaming.recordings_path'));
$segmentRealPath = realpath($segmentPath);
if (! $recordingsRoot || ! $segmentRealPath || ! Str::startsWith($segmentRealPath, $recordingsRoot)) {
return;
}
@unlink($segmentRealPath);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services\Streaming;
use App\Models\Channel;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class StreamKeyManager
{
public function rotate(Channel $channel): string
{
$plainTextKey = Str::random(48);
$channel->streamKey()->updateOrCreate(
['channel_id' => $channel->id],
[
'key_hash' => Hash::make($plainTextKey),
'revoked_at' => null,
],
);
return $plainTextKey;
}
public function tokenizedPath(Channel $channel, string $plainTextKey): string
{
return "{$channel->slug}?token={$plainTextKey}";
}
public function ingestServer(): string
{
return rtrim((string) config('streaming.rtmp_ingest_url'), '/');
}
}