Files
takeone-youtube-clone/routes/web.php
T
ghassanandClaude Opus 4.7 5bf1128196 feat(live): polish — ended-state, chat, viewer actions, Private option
Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:

## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).

## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.

## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.

## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.

## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.

Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.

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

396 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Http\Controllers\CommentController;
use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\MatchEventController;
use App\Http\Controllers\MediaController;
use App\Http\Controllers\SportsMatchController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SuperAdminController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VideoController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
// Media asset routes — NAS-aware image serving (public, no auth)
Route::get('/media/thumbnails/{filename}', [MediaController::class, 'thumbnail'])->name('media.thumbnail')->where('filename', '.+');
Route::get('/media/avatars/{filename}', [MediaController::class, 'avatar'])->name('media.avatar')->where('filename', '.+');
Route::get('/media/banners/{filename}', [MediaController::class, 'banner'])->name('media.banner')->where('filename', '.+');
Route::get('/media/post-images/{filename}', [MediaController::class, 'postImage'])->name('media.post-image')->where('filename', '.+');
Route::get('/media/sports/{filename}', [MediaController::class, 'sportsImage'])->name('media.sports-image')->where('filename', '.+');
// Root route - show videos
Route::get('/', [VideoController::class, 'index'])->name('home');
// Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
// Site-wide search. /videos/search is the historical URL; keep it as a
// signed-forward redirect so bookmarks and external links don't break.
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
return redirect()->route('search', $request->only('q'), 301);
})->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
// on the branded errors/404 page.
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
Route::get('/videos/{numericId}', function ($numericId) {
$video = \App\Models\Video::findOrFail((int) $numericId);
$url = route('videos.show', $video);
if (request()->getQueryString()) {
$url .= '?' . request()->getQueryString();
}
return redirect($url, 301);
})->where('numericId', '[0-9]+');
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/vs-preview', function (\App\Models\Video $video) {
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
return view('videos.vs-preview', ['video' => $video]);
})->name('videos.vsPreview');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
Route::get('/videos/{video}/download-mp3', [VideoController::class, 'downloadMp3'])->name('videos.downloadMp3');
Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slideshowGenerate'])->name('videos.slideshow.generate');
Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/lyrics/generate', [VideoController::class, 'generateLyrics'])->name('videos.lyrics.generate')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsProgress'])->name('videos.lyrics.progress');
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
// vs-og-frame is the Browsershot-only, no-animation, 1200×630 render used by
// the OG image generator. Distinct from /vs-preview (line 43) which is a
// full-page interactive preview of the arena intro overlay.
Route::get('/videos/{video}/vs-og-frame', [VideoController::class, 'vsPreview'])->name('videos.vsOgFrame');
// Admin-only gallery of all generated OG preview PNGs (the composed VS
// cards that WhatsApp/Facebook see when a match video URL is shared).
Route::get('/admin/og-previews', function () {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
$files = collect(glob(storage_path('app/og-cache/match-*.{jpg,png}'), GLOB_BRACE) ?: [])
->map(fn ($f) => [
'name' => basename($f),
'size' => filesize($f),
'mtime' => filemtime($f),
'id' => (int) (explode('-', pathinfo($f, PATHINFO_FILENAME))[1] ?? 0),
])
->sortByDesc('mtime')
->values();
return view('admin.og-previews', ['files' => $files]);
})->name('admin.og-previews');
Route::get('/admin/og-previews/{name}', function (string $name) {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
abort_unless(preg_match('/^match-\d+-\d+\.(jpg|png)$/', $name), 404);
$path = storage_path('app/og-cache/' . $name);
abort_unless(is_file($path), 404);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return response()->file($path, ['Content-Type' => $ext === 'png' ? 'image/png' : 'image/jpeg']);
})->name('admin.og-previews.file');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');
Route::get('/videos/{video}/insights/day/{date}', [VideoController::class, 'insightsDay'])->name('videos.insights.day')->middleware('auth');
Route::get('/videos/{video}/insights/downloader/{userId}', [VideoController::class, 'insightsDownloaderHistory'])->name('videos.insights.downloader')->middleware('auth');
Route::get('/videos/{video}/insights/viewer/{who}', [VideoController::class, 'insightsViewer'])->where('who', '[ug]:[A-Za-z0-9\.:_-]+')->name('videos.insights.viewer')->middleware('auth');
Route::get('/videos/{video}/insights/share/{token}', [VideoController::class, 'insightsShare'])->where('token', '[A-Za-z0-9]+')->name('videos.insights.share')->middleware('auth');
Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->name('videos.identify');
// Video routes - auth + verified required
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
Route::post('/videos/upload-chunk', [VideoController::class, 'uploadChunk'])->name('videos.uploadChunk');
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
// Music-video merge — fold {source} into {target}. Owner-only.
Route::get('/videos/{target}/merge/candidates', [\App\Http\Controllers\VideoMergeController::class, 'candidates'])->name('videos.merge.candidates');
Route::get('/videos/{target}/merge/{source}/preview',[\App\Http\Controllers\VideoMergeController::class, 'preview'])->name('videos.merge.preview');
Route::post('/videos/{target}/merge/{source}', [\App\Http\Controllers\VideoMergeController::class, 'merge'])->name('videos.merge');
// Like/unlike routes
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
});
// Subscribe + notifications — auth but NOT email verification required
Route::middleware(['auth'])->group(function () {
Route::post('/channel/{user}/subscribe', [UserController::class, 'toggleSubscribe'])->name('channel.subscribe');
Route::get('/notifications/count', [UserController::class, 'notificationCount'])->name('notifications.count');
Route::get('/notifications/fetch', [UserController::class, 'fetchNotifications'])->name('notifications.fetch');
Route::post('/notifications/{id}/read', [UserController::class, 'markNotificationRead'])->name('notifications.read');
Route::post('/notifications/read-all', [UserController::class, 'markAllNotificationsRead'])->name('notifications.read-all');
Route::get('/users/search', [UserController::class, 'searchUsers'])->name('users.search');
});
// View progress + profile-visit tracking — accept both guests and authed users
Route::post('/videos/{video}/view-progress', [VideoController::class, 'trackProgress'])->name('videos.viewProgress');
Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisit'])->name('profile.visit');
// Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
});
// User routes
Route::middleware(['auth', 'verified'])->group(function () {
// Profile — redirect to own channel
Route::get('/profile', fn() => redirect()->route('channel', Auth::user()->channel))->name('profile');
Route::put('/profile', [UserController::class, 'updateProfile'])->name('profile.update');
// Settings
Route::get('/settings', [UserController::class, 'settings'])->name('settings');
Route::put('/settings', [UserController::class, 'updateSettings'])->name('settings.update');
Route::post('/settings/notifications', [UserController::class, 'updateNotificationPreferences'])->name('settings.notifications');
// History & Liked
Route::get('/history', [UserController::class, 'history'])->name('history');
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Creator studio
Route::get('/studio', [\App\Http\Controllers\StudioController::class, 'index'])->name('studio');
Route::post('/studio/bulk/visibility', [\App\Http\Controllers\StudioController::class, 'bulkVisibility'])->name('studio.bulkVisibility');
Route::post('/studio/bulk/delete', [\App\Http\Controllers\StudioController::class, 'bulkDelete'])->name('studio.bulkDelete');
// Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
// Image upload (cropper)
Route::post('/image-upload', [ImageUploadController::class, 'upload'])->name('image.upload');
Route::post('/profile/update-avatar', [UserController::class, 'updateAvatar'])->name('profile.updateAvatar');
Route::post('/profile/update-banner', [UserController::class, 'updateBanner'])->name('profile.updateBanner');
});
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
->name('channel.videos.more');
// YouTube-style @handle alias. For now `handle` is just the existing
// `username` slug — if the two ever diverge, flip the controller lookup
// in UserController::channel() to prefer a dedicated `handle` column.
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
// — Laravel's router accepts both because `@` is allowed in URI path segments.
// ─── Live streaming ─────────────────────────────────────
use App\Http\Controllers\LiveAuthController;
use App\Http\Controllers\LiveStreamController;
Route::get('/live', [LiveStreamController::class, 'index'])->name('live.index');
Route::get('/live/new', [LiveStreamController::class, 'create'])->name('live.create');
Route::post('/live', [LiveStreamController::class, 'store'])->name('live.store')->middleware('throttle:10,1');
Route::get('/live/{stream}/broadcast', [LiveStreamController::class, 'broadcast'])->name('live.broadcast');
Route::post('/live/{stream}/token', [LiveStreamController::class, 'token'])->name('live.token')->middleware('throttle:30,1');
Route::post('/live/{stream}/stop', [LiveStreamController::class, 'stop'])->name('live.stop')->middleware('throttle:30,1');
Route::get('/live/{stream}', [LiveStreamController::class, 'watch'])->name('live.watch');
Route::get('/live/{stream}/status', [LiveStreamController::class, 'status'])->name('live.status')->middleware('throttle:120,1');
// Live chat — polling by viewers, send by signed-in users (rate-limited).
Route::get('/live/{stream}/chat', [LiveStreamController::class, 'chatPoll'])
->name('live.chat.poll')->middleware('throttle:120,1');
Route::post('/live/{stream}/chat', [LiveStreamController::class, 'chatSend'])
->name('live.chat.send')->middleware('throttle:60,1');
Route::post('/api/live/auth', [LiveAuthController::class, 'authenticate'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Route::post('/api/live/hook', [LiveAuthController::class, 'hook'])->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Route::get('/@{handle}', [UserController::class, 'channel'])
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
->name('channel.handle');
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
// Shorts, etc. These bridge to the single-page channel view by forwarding
// the tab name as a hash — the channel.blade.php JS already handles
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
if (! in_array($tab, $allowed, true)) {
abort(404);
}
$user = \App\Models\User::where('username', $handle)->firstOrFail();
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
->name('channel.handle.tab');
// Post routes - auth required
Route::middleware(['auth'])->group(function () {
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
Route::post('/posts/{post}/react', [PostController::class, 'react'])->name('posts.react');
});
// Playlist routes
use App\Http\Controllers\PlaylistController;
// Get user playlists (for dropdown) - MUST be before /playlists/{playlist} route
Route::get('/user/playlists', [PlaylistController::class, 'userPlaylists'])->name('playlists.userPlaylists');
// Public playlist routes
Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index');
Route::get('/playlists/share/{token}', [PlaylistController::class, 'showByToken'])->name('playlists.showByToken');
Route::get('/playlists/{playlist}/og-image', [PlaylistController::class, 'ogImage'])->name('playlists.ogImage');
Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show');
Route::post('/playlists/{playlist}/share', [PlaylistController::class, 'recordShare'])->name('playlists.recordShare');
Route::get('/ps/{token}', [PlaylistController::class, 'accessShare'])->name('playlists.accessShare');
// Authenticated playlist routes
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/playlists/create', [PlaylistController::class, 'create'])->name('playlists.create');
Route::post('/playlists', [PlaylistController::class, 'store'])->name('playlists.store');
Route::get('/playlists/{playlist}/edit', [PlaylistController::class, 'edit'])->name('playlists.edit');
Route::put('/playlists/{playlist}', [PlaylistController::class, 'update'])->name('playlists.update');
Route::delete('/playlists/{playlist}', [PlaylistController::class, 'destroy'])->name('playlists.destroy');
// Playlist video management
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
// Body-based remove (mirror of addVideo) — used by the add-to-playlist modal
// where the front-end only has the numeric video id, not the encoded route key.
Route::delete('/playlists/{playlist}/videos', [PlaylistController::class, 'removeVideoByBody'])->name('playlists.removeVideoByBody');
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
// Playlist actions
Route::get('/playlists/{playlist}/play', [PlaylistController::class, 'playAll'])->name('playlists.playAll');
Route::get('/playlists/{playlist}/shuffle', [PlaylistController::class, 'shuffle'])->name('playlists.shuffle');
// Watch Later
Route::post('/videos/{video}/watch-later', [PlaylistController::class, 'watchLater'])->name('videos.watchLater');
});
// Two-Factor Authentication
use App\Http\Controllers\TwoFactorController;
Route::get('/2fa/challenge', [TwoFactorController::class, 'showChallenge'])->name('2fa.challenge');
Route::post('/2fa/challenge', [TwoFactorController::class, 'verifyChallenge'])->name('2fa.verify');
Route::middleware(['auth'])->group(function () {
Route::post('/2fa/setup', [TwoFactorController::class, 'setup'])->name('2fa.setup');
Route::post('/2fa/enable', [TwoFactorController::class, 'enable'])->name('2fa.enable');
Route::post('/2fa/disable', [TwoFactorController::class, 'disable'])->name('2fa.disable');
});
// Sports match records (progressive-disclosure creation flow)
Route::middleware(['auth'])->group(function () {
Route::post('/sports-matches', [SportsMatchController::class, 'store'])->name('sports-matches.store');
Route::get('/sports-matches/{sportsMatch}/edit', [SportsMatchController::class, 'edit'])->name('sports-matches.edit');
Route::put('/sports-matches/{sportsMatch}', [SportsMatchController::class, 'update'])->name('sports-matches.update');
});
// Signed confirmation link — no auth required (user may click from a different device/browser)
Route::get('/2fa/disable/confirm', [TwoFactorController::class, 'confirmDisable'])->name('2fa.disable.confirm');
// Authentication Routes
require __DIR__.'/auth.php';
// Super Admin Routes
Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->group(function () {
// Dashboard
Route::get('/dashboard', [SuperAdminController::class, 'dashboard'])->name('dashboard');
Route::get('/dashboard/modal', [SuperAdminController::class, 'modalData'])->name('dashboard.modal');
// User Management
Route::get('/users', [SuperAdminController::class, 'users'])->name('users');
Route::get('/users/{user}/edit', [SuperAdminController::class, 'editUser'])->name('users.edit');
Route::put('/users/{user}', [SuperAdminController::class, 'updateUser'])->name('users.update');
Route::delete('/users/{user}', [SuperAdminController::class, 'deleteUser'])->name('users.delete');
Route::post('/users/{user}/impersonate', [SuperAdminController::class, 'impersonate'])->name('users.impersonate');
Route::post('/users/{user}/verify', [SuperAdminController::class, 'verifyUser'])->name('users.verify');
// Video Management
Route::get('/videos', [SuperAdminController::class, 'videos'])->name('videos');
Route::get('/videos/{video}/analytics', [SuperAdminController::class, 'videoAnalytics'])->name('videos.analytics');
Route::get('/videos/{video}/edit', [SuperAdminController::class, 'editVideo'])->name('videos.edit');
Route::put('/videos/{video}', [SuperAdminController::class, 'updateVideo'])->name('videos.update');
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
// Orphan cleanup
Route::post('/cleanup-orphans', [SuperAdminController::class, 'cleanupOrphanedVideos'])->name('admin.cleanup.orphans');
// Log viewer
Route::get('/logs', [SuperAdminController::class, 'logs'])->name('logs');
// Audit log center
Route::get('/audit', [SuperAdminController::class, 'auditLogs'])->name('audit');
// System settings
Route::get('/settings', [SuperAdminController::class, 'settings'])->name('settings');
Route::post('/settings', [SuperAdminController::class, 'updateSettings'])->name('settings.update');
Route::get('/settings/detect-gpu', [SuperAdminController::class, 'detectGpu'])->name('settings.detect-gpu');
Route::post('/settings/llm-test', [SuperAdminController::class, 'llmProviderTest'])->name('settings.llm-test');
// Lyrics Pipeline toggles (own page)
Route::get('/lyrics', [SuperAdminController::class, 'lyrics'])->name('lyrics');
// GPU Accelerator (own page)
Route::get('/gpu', [SuperAdminController::class, 'gpu'])->name('gpu');
// Backup & Restore (own page)
Route::get('/backup', [SuperAdminController::class, 'backup'])->name('backup');
// NAS Storage
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');
Route::post('/nas-repair', [SuperAdminController::class, 'nasRepair'])->name('admin.nas.repair');
Route::post('/nas-delete', [SuperAdminController::class, 'nasDelete'])->name('nas.delete');
// NAS Disable Flow
Route::post('/nas/disable', [SuperAdminController::class, 'nasDisable'])->name('nas.disable');
Route::get('/nas/migrate-progress', [SuperAdminController::class, 'nasMigrateProgress'])->name('nas.migrate-progress');
// Backup & Restore
Route::get('/backup/users-settings', [SuperAdminController::class, 'backupUsersSettings'])->name('backup.users-settings');
Route::post('/backup/restore', [SuperAdminController::class, 'restoreUsersSettings'])->name('backup.restore');
});
// Exit impersonation — accessible by any authenticated user (the impersonated user hitting exit)
Route::post('/impersonate/exit', [SuperAdminController::class, 'exitImpersonation'])
->middleware('auth')
->name('impersonate.exit');
// Match Events Routes (removed auth requirement for demo purposes)
// In production, wrap with: Route::middleware('auth')->group(function () {
// Get match data - public for viewing
Route::get('/videos/{video}/match-data', [MatchEventController::class, 'getMatchData'])->name('match.getData');
// Round CRUD
Route::post('/videos/{video}/rounds', [MatchEventController::class, 'storeRound'])->name('match.storeRound');
Route::put('/rounds/{round}', [MatchEventController::class, 'updateRound'])->name('match.updateRound');
Route::delete('/rounds/{round}', [MatchEventController::class, 'destroyRound'])->name('match.destroyRound');
// Point CRUD
Route::post('/videos/{video}/points', [MatchEventController::class, 'storePoint'])->name('match.storePoint');
Route::put('/points/{point}', [MatchEventController::class, 'updatePoint'])->name('match.updatePoint');
Route::delete('/points/{point}', [MatchEventController::class, 'destroyPoint'])->name('match.destroyPoint');
// Coach Review CRUD
Route::post('/videos/{video}/reviews', [MatchEventController::class, 'storeReview'])->name('match.storeReview');
Route::put('/reviews/{review}', [MatchEventController::class, 'updateReview'])->name('match.updateReview');
Route::delete('/reviews/{review}', [MatchEventController::class, 'destroyReview'])->name('match.destroyReview');