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