start project
This commit is contained in:
58
app/Http/Controllers/AdminDashboardController.php
Normal file
58
app/Http/Controllers/AdminDashboardController.php
Normal 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,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/AdminModerationController.php
Normal file
89
app/Http/Controllers/AdminModerationController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/ChannelController.php
Normal file
87
app/Http/Controllers/ChannelController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/ChatMessageController.php
Normal file
28
app/Http/Controllers/ChatMessageController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
182
app/Http/Controllers/CreatorDashboardController.php
Normal file
182
app/Http/Controllers/CreatorDashboardController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/FollowController.php
Normal file
28
app/Http/Controllers/FollowController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/HomeController.php
Normal file
67
app/Http/Controllers/HomeController.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/MediaMtxController.php
Normal file
64
app/Http/Controllers/MediaMtxController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
17
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
17
app/Http/Middleware/EnsureUserIsAdmin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Models/AdminAuditLog.php
Normal file
26
app/Models/AdminAuditLog.php
Normal 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
54
app/Models/Broadcast.php
Normal 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
21
app/Models/Category.php
Normal 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
89
app/Models/Channel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
app/Models/ChatMessage.php
Normal file
29
app/Models/ChatMessage.php
Normal 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
21
app/Models/Follow.php
Normal 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
24
app/Models/StreamKey.php
Normal 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
42
app/Models/Vod.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
app/Services/Streaming/MediaMtxService.php
Normal file
207
app/Services/Streaming/MediaMtxService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Services/Streaming/StreamKeyManager.php
Normal file
35
app/Services/Streaming/StreamKeyManager.php
Normal 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'), '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user