middleware('super_admin')->except(['exitImpersonation']); } // Manually verify a user's email public function verifyUser(User $user) { if ($user->email_verified_at) { return back()->with('error', "{$user->name} is already verified."); } $user->email_verified_at = now(); $user->save(); return back()->with('success', "{$user->name}'s account has been verified."); } // Start impersonating a user public function impersonate(User $user) { if ($user->isSuperAdmin()) { return back()->with('error', 'You cannot impersonate another super admin.'); } $admin = \Auth::user(); AuditLog::record('admin.impersonate', [ 'user_id' => $admin->id, 'user_name' => $admin->name, 'subject_type' => 'User', 'subject_id' => (string) $user->id, 'subject_label' => $user->name, ]); session(['impersonator_id' => \Auth::id()]); \Auth::loginUsingId($user->id); return redirect()->route('home') ->with('success', "You are now impersonating {$user->name}. Use the banner to exit."); } // Exit impersonation and return to original admin account public function exitImpersonation() { $impersonatorId = session('impersonator_id'); if (! $impersonatorId) { return redirect()->route('home'); } $impersonatedUser = \Auth::user(); session()->forget('impersonator_id'); \Auth::loginUsingId($impersonatorId); AuditLog::record('admin.impersonate.exit', [ 'subject_type' => 'User', 'subject_id' => (string) $impersonatedUser->id, 'subject_label' => $impersonatedUser->name, ]); return redirect()->route('admin.users') ->with('success', 'Impersonation ended. You are back as yourself.'); } // Dashboard - Overview statistics public function dashboard() { $now = now(); $w0 = $now->copy()->subDays(7); // start of this week window $w1 = $now->copy()->subDays(14); // start of last week window // ── Core totals ──────────────────────────────────────────── $totalUsers = User::count(); $totalVideos = Video::count(); $totalViews = \DB::table('video_views')->count(); $totalLikes = \DB::table('video_likes')->count(); $totalComments = \DB::table('comments')->count(); // ── Week-over-week growth ────────────────────────────────── $usersThisWeek = User::where('created_at', '>=', $w0)->count(); $usersLastWeek = User::whereBetween('created_at', [$w1, $w0])->count(); $videosThisWeek = Video::where('created_at', '>=', $w0)->count(); $videosLastWeek = Video::whereBetween('created_at', [$w1, $w0])->count(); $viewsThisWeek = \DB::table('video_views')->where('watched_at', '>=', $w0)->count(); $viewsLastWeek = \DB::table('video_views')->whereBetween('watched_at', [$w1, $w0])->count(); $likesThisWeek = \DB::table('video_likes')->where('created_at', '>=', $w0)->count(); $likesLastWeek = \DB::table('video_likes')->whereBetween('created_at', [$w1, $w0])->count(); $commentsThisWeek = \DB::table('comments')->where('created_at', '>=', $w0)->count(); $growthUsers = $this->growthPct($usersLastWeek, $usersThisWeek); $growthVideos = $this->growthPct($videosLastWeek, $videosThisWeek); $growthViews = $this->growthPct($viewsLastWeek, $viewsThisWeek); $growthLikes = $this->growthPct($likesLastWeek, $likesThisWeek); $stats = compact( 'totalUsers','totalVideos','totalViews','totalLikes','totalComments', 'usersThisWeek','videosThisWeek','viewsThisWeek','likesThisWeek','commentsThisWeek', 'growthUsers','growthVideos','growthViews','growthLikes' ); // ── 30-day daily activity (for charts) ──────────────────── $days30 = collect(range(29, 0))->map(fn($d) => $now->copy()->subDays($d)->format('Y-m-d')); $rawUsers = User::selectRaw('DATE(created_at) as d, COUNT(*) as n') ->where('created_at', '>=', $now->copy()->subDays(30)) ->groupBy('d')->pluck('n', 'd'); $rawVideos = Video::selectRaw('DATE(created_at) as d, COUNT(*) as n') ->where('created_at', '>=', $now->copy()->subDays(30)) ->groupBy('d')->pluck('n', 'd'); $rawViews = \DB::table('video_views') ->selectRaw('DATE(watched_at) as d, COUNT(*) as n') ->where('watched_at', '>=', $now->copy()->subDays(30)) ->groupBy('d')->pluck('n', 'd'); $chartLabels = $days30->map(fn($d) => date('M j', strtotime($d)))->values()->toJson(); $chartDatesRaw = $days30->values()->toJson(); $chartUsersData = $days30->map(fn($d) => $rawUsers->get($d, 0))->values()->toJson(); $chartVideosData = $days30->map(fn($d) => $rawVideos->get($d, 0))->values()->toJson(); $chartViewsData = $days30->map(fn($d) => $rawViews->get($d, 0))->values()->toJson(); // ── Video status & visibility ────────────────────────────── $videosByStatus = Video::selectRaw('status, count(*) as n')->groupBy('status')->pluck('n', 'status'); $videosByVisibility = Video::selectRaw('visibility, count(*) as n')->groupBy('visibility')->pluck('n', 'visibility'); $videosByType = Video::selectRaw('type, count(*) as n')->groupBy('type')->pluck('n', 'type'); // ── Alerts ──────────────────────────────────────────────── $failedCount = $videosByStatus->get('failed', 0); $processingCount = $videosByStatus->get('processing', 0); $pendingCount = $videosByStatus->get('pending', 0); // ── Top content ─────────────────────────────────────────── $topVideos = \DB::table('videos') ->join('users', 'videos.user_id', '=', 'users.id') ->select( 'videos.id', 'videos.title', 'videos.thumbnail', 'videos.type', 'users.name as username', \DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'), \DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count') ) ->where('videos.status', 'ready') ->orderByDesc('view_count') ->take(5)->get(); $topUploaders = \DB::table('users') ->select('users.id','users.name','users.email','users.avatar', \DB::raw('COUNT(videos.id) as video_count'), \DB::raw('SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)) as total_views') ) ->leftJoin('videos', 'users.id', '=', 'videos.user_id') ->groupBy('users.id','users.name','users.email','users.avatar') ->orderByDesc('video_count') ->take(5)->get(); // ── Engagement ──────────────────────────────────────────── $readyVideos = $videosByStatus->get('ready', 0); $avgViewsPerVideo = $readyVideos > 0 ? round($totalViews / $readyVideos, 1) : 0; $likeToViewRatio = $totalViews > 0 ? round(($totalLikes / $totalViews) * 100, 1) : 0; $avgVideosPerUser = $totalUsers > 0 ? round($totalVideos / $totalUsers, 1) : 0; // ── Viewers by country ──────────────────────────────────── $viewsByCountry = \DB::table('video_views') ->whereNotNull('country') ->selectRaw('country, country_name, COUNT(*) as total') ->groupBy('country', 'country_name') ->orderByDesc('total') ->take(20) ->get(); // ── Recent ──────────────────────────────────────────────── $recentUsers = User::latest()->take(5)->get(); $recentVideos = Video::with('user')->latest()->take(5)->get(); // ── Storage ─────────────────────────────────────────────── $disk = Storage::disk('public'); $sizeVideos = $this->dirSize($disk, 'videos'); $sizeThumbnails = $this->dirSize($disk, 'thumbnails'); $sizeAvatars = $this->dirSize($disk, 'avatars'); $sizeImages = $this->dirSize($disk, 'images'); $totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages; $videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0; // Convert to MB $toMb = fn($b) => round($b / 1024 / 1024, 1); $storage = [ 'videos' => $toMb($sizeVideos), 'thumbnails' => $toMb($sizeThumbnails), 'avatars' => $toMb($sizeAvatars), 'images' => $toMb($sizeImages), 'total' => $toMb($totalPublicSize), ]; $videosDirSize = $storage['videos']; $totalPublicSizeMb = $storage['total']; return view('admin.dashboard', compact( 'stats', 'chartLabels','chartDatesRaw','chartUsersData','chartVideosData','chartViewsData', 'videosByStatus','videosByVisibility','videosByType', 'failedCount','processingCount','pendingCount', 'topVideos','topUploaders', 'avgViewsPerVideo','likeToViewRatio','avgVideosPerUser', 'recentUsers','recentVideos', 'storage','videosDirSize','totalPublicSizeMb','videosUsagePercent', 'viewsByCountry' )); } private function growthPct(int $prev, int $curr): array { if ($prev === 0) { return ['pct' => $curr > 0 ? 100 : 0, 'dir' => $curr > 0 ? 'up' : 'flat']; } $pct = round((($curr - $prev) / $prev) * 100, 1); return ['pct' => abs($pct), 'dir' => $pct >= 0 ? 'up' : 'down']; } private function dirSize($disk, string $dir): int { $size = 0; foreach ($disk->allFiles($dir) as $file) { try { $size += $disk->size($file); } catch (\Exception $e) {} } return $size; } // List all users with search/filter public function users(Request $request) { $query = User::query(); // Search by name or email if ($request->has('search') && $request->search) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%"); }); } // Filter by role if ($request->has('role') && $request->role) { $query->where('role', $request->role); } // Sort by $sort = $request->get('sort', 'latest'); switch ($sort) { case 'oldest': $query->oldest(); break; case 'name_asc': $query->orderBy('name', 'asc'); break; case 'name_desc': $query->orderBy('name', 'desc'); break; default: $query->latest(); } $users = $query->paginate(20); $users->appends($request->query()); return view('admin.users', compact('users')); } // Show edit user form public function editUser(User $user) { return view('admin.edit-user', compact('user')); } // Update user public function updateUser(Request $request, User $user) { $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email|max:255|unique:users,email,' . $user->id, 'role' => 'required|in:user,admin,super_admin', 'new_password' => 'nullable|min:8|confirmed', ]); $data = [ 'name' => $request->name, 'email' => $request->email, 'role' => $request->role, ]; // Update password if provided if ($request->new_password) { $data['password'] = Hash::make($request->new_password); } $user->update($data); return redirect()->route('admin.users')->with('success', 'User updated successfully!'); } // Delete user public function deleteUser(User $user) { // Prevent deleting yourself if (auth()->id() === $user->id) { return back()->with('error', 'You cannot delete your own account!'); } AuditLog::record('admin.user.deleted', [ 'subject_type' => 'User', 'subject_id' => (string) $user->id, 'subject_label' => $user->name, 'details' => ['email' => $user->email, 'video_count' => $user->videos->count()], ]); // Delete user's videos and associated files foreach ($user->videos as $video) { Storage::delete('public/videos/' . $video->filename); if ($video->thumbnail) { Storage::delete('public/thumbnails/' . $video->thumbnail); } } $user->videos()->delete(); // Delete user likes and views - use direct query since relationship is named 'viewers' \DB::table('video_likes')->where('user_id', $user->id)->delete(); \DB::table('video_views')->where('user_id', $user->id)->delete(); $user->delete(); return redirect()->route('admin.users')->with('success', 'User deleted successfully!'); } // List all videos public function videos(Request $request) { $query = Video::with('user'); // Search by title or description if ($request->has('search') && $request->search) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%"); }); } // Filter by status if ($request->has('status') && $request->status) { $query->where('status', $request->status); } // Filter by visibility if ($request->has('visibility') && $request->visibility) { $query->where('visibility', $request->visibility); } // Filter by type if ($request->has('type') && $request->type) { $query->where('type', $request->type); } // Sort by $sort = $request->get('sort', 'latest'); switch ($sort) { case 'oldest': $query->oldest(); break; case 'title_asc': $query->orderBy('title', 'asc'); break; case 'title_desc': $query->orderBy('title', 'desc'); break; case 'views': // Can't use withCount for views due to pivot table issue $query->latest(); break; case 'likes': $query->withCount('likes')->orderBy('likes_count', 'desc'); break; default: $query->latest(); } $videos = $query->paginate(20); $videos->appends($request->query()); return view('admin.videos', compact('videos')); } // Per-video analytics public function videoAnalytics(Video $video) { // ── Total view events (every watch counts) ────────────────────────── $totalViews = \DB::table('video_views')->where('video_id', $video->id)->count(); $totalLikes = \DB::table('video_likes')->where('video_id', $video->id)->count(); $totalComments = \DB::table('comments')->where('video_id', $video->id)->count(); // ── Views per day – last 30 days (raw events) ─────────────────────── $rawDaily = \DB::table('video_views') ->where('video_id', $video->id) ->where('watched_at', '>=', now()->subDays(29)->startOfDay()) ->selectRaw('DATE(watched_at) as date, COUNT(*) as total') ->groupBy('date') ->orderBy('date') ->get() ->keyBy('date'); $dailyLabels = []; $dailyViews = []; for ($i = 29; $i >= 0; $i--) { $d = now()->subDays($i); $dailyLabels[] = $d->format('M d'); $dailyViews[] = $rawDaily[$d->format('Y-m-d')]->total ?? 0; } // ── Fetch all view records once, join user profile data ───────────── // Ordered newest-first so the first occurrence per viewer is their // most recent record (country, etc. from their latest watch). $allViewRecords = \DB::table('video_views') ->leftJoin('users', 'video_views.user_id', '=', 'users.id') ->where('video_views.video_id', $video->id) ->select( 'video_views.id', 'video_views.user_id', 'video_views.ip_address', 'video_views.country', 'video_views.country_name', 'video_views.watched_at', 'users.name as viewer_name', 'users.avatar as viewer_avatar', 'users.birthday', 'users.gender' ) ->orderByDesc('video_views.watched_at') ->get(); // ── Deduplicate to one row per unique viewer ───────────────────────── // Auth users → keyed by user_id (u_123) // Guest users → keyed by ip_address (i_1.2.3.4) $seenKeys = []; $uniqueViewers = []; foreach ($allViewRecords as $row) { $key = $row->user_id ? 'u_' . $row->user_id : 'i_' . $row->ip_address; if (isset($seenKeys[$key])) continue; $seenKeys[$key] = true; $uniqueViewers[] = $row; } $totalUniqueViewers = count($uniqueViewers); $authViewers = count(array_filter($uniqueViewers, fn($v) => $v->user_id !== null)); $guestViewers = $totalUniqueViewers - $authViewers; // ── Countries – one count per unique viewer ────────────────────────── $countryMap = []; foreach ($uniqueViewers as $viewer) { if (!$viewer->country) continue; if (!isset($countryMap[$viewer->country])) { $countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0]; } $countryMap[$viewer->country]['total']++; } usort($countryMap, fn($a, $b) => $b['total'] - $a['total']); $viewsByCountry = collect(array_slice($countryMap, 0, 20))->map(fn($r) => (object) $r); // ── Age groups – one count per unique viewer ───────────────────────── $ageGroups = ['Under 18' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55+' => 0, 'Unknown' => 0]; $now = now(); foreach ($uniqueViewers as $viewer) { if (!$viewer->birthday) { $ageGroups['Unknown']++; continue; } $age = \Carbon\Carbon::parse($viewer->birthday)->diffInYears($now); if ($age < 18) $ageGroups['Under 18']++; elseif ($age < 25) $ageGroups['18–24']++; elseif ($age < 35) $ageGroups['25–34']++; elseif ($age < 45) $ageGroups['35–44']++; elseif ($age < 55) $ageGroups['45–54']++; else $ageGroups['55+']++; } // ── Gender – one count per unique viewer ───────────────────────────── $genderCounts = ['Male' => 0, 'Female' => 0, 'Prefer not to say' => 0]; foreach ($uniqueViewers as $viewer) { if ($viewer->gender === 'male') $genderCounts['Male']++; elseif ($viewer->gender === 'female') $genderCounts['Female']++; elseif ($viewer->gender === 'prefer_not_to_say') $genderCounts['Prefer not to say']++; } // ── Recent 20 individual view events ──────────────────────────────── $recentViews = $allViewRecords->take(20); return view('admin.video-analytics', compact( 'video', 'totalViews', 'totalUniqueViewers', 'authViewers', 'guestViewers', 'totalLikes', 'totalComments', 'dailyLabels', 'dailyViews', 'viewsByCountry', 'ageGroups', 'genderCounts', 'recentViews' )); } // Show edit video form public function editVideo(Video $video) { return view('admin.edit-video', compact('video')); } // Update video public function updateVideo(Request $request, Video $video) { $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'visibility' => 'required|in:public,unlisted,private', 'type' => 'required|in:generic,music,match', 'status' => 'required|in:pending,processing,ready,failed', 'is_shorts' => 'nullable|boolean', 'download_access' => 'nullable|in:disabled,everyone,registered,subscribers', ]); $data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']); $video->update($data); return redirect()->route('admin.videos')->with('success', 'Video updated successfully!'); } // Delete video public function deleteVideo(Video $video) { $videoTitle = $video->title; AuditLog::record('admin.video.deleted', [ 'subject_type' => 'Video', 'subject_id' => (string) $video->id, 'subject_label' => $videoTitle, 'details' => ['owner_id' => $video->user_id, 'type' => $video->type], ]); // Delete files Storage::delete('public/videos/' . $video->filename); if ($video->thumbnail) { Storage::delete('public/thumbnails/' . $video->thumbnail); } // Delete likes and views - use direct queries since relationships have timestamp issues \DB::table('video_likes')->where('video_id', $video->id)->delete(); \DB::table('video_views')->where('video_id', $video->id)->delete(); $video->delete(); return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!'); } /** * Manual cleanup orphaned videos (admin instant trigger) */ public function cleanupOrphanedVideos(Request $request) { $output = []; $returnCode = 0; Artisan::call('cleanup:orphaned-videos --force', [], $output); Log::channel('orphaned-videos')->info('Manual cleanup triggered by super admin'); $disk = Storage::disk('public'); $totalPublicSize = 0; foreach ($disk->allFiles() as $file) { $totalPublicSize += $disk->size($file); } $videosDirSize = 0; foreach ($disk->allFiles('videos') as $file) { $videosDirSize += $disk->size($file); } return response()->json([ 'success' => true, 'message' => 'Cleanup completed! Check logs for details.', 'stats' => [ 'return_code' => $returnCode, 'videos_dir_size_mb' => round($videosDirSize / 1024 / 1024, 1), 'total_public_size_mb' => round($totalPublicSize / 1024 / 1024, 1), ], ]); } public function modalData(Request $request) { $resource = $request->get('resource', ''); $limit = min((int) $request->get('limit', 30), 100); switch ($resource) { case 'users': $query = User::latest(); if ($request->date) $query->whereDate('created_at', $request->date); if ($request->filter === 'week') $query->where('created_at', '>=', now()->subDays(7)); $items = $query->take($limit)->get(); return response()->json(['type' => 'users', 'items' => $items->map(fn($u) => [ 'avatar' => $u->avatar_url, 'name' => $u->name, 'email' => $u->email, 'role' => $u->role ?? 'user', 'joined' => $u->created_at->diffForHumans(), 'url' => route('channel', $u->channel), ])]); case 'videos': $query = Video::with('user')->latest(); if ($request->status) $query->where('status', $request->status); if ($request->visibility) $query->where('visibility', $request->visibility); if ($request->type) $query->where('type', $request->type); if ($request->date) $query->whereDate('created_at', $request->date); if ($request->uploader_id) $query->where('user_id', $request->uploader_id); $items = $query->take($limit)->get(); return response()->json(['type' => 'videos', 'items' => $items->map(fn($v) => [ 'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null, 'title' => $v->title, 'owner' => $v->user->name ?? 'Unknown', 'status' => $v->status, 'type' => $v->type, 'views' => \DB::table('video_views')->where('video_id', $v->id)->count(), 'uploaded' => $v->created_at->diffForHumans(), 'url' => route('videos.show', $v), ])]); case 'top_videos': $items = \DB::table('videos') ->join('users', 'videos.user_id', '=', 'users.id') ->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username', \DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'), \DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count')) ->orderByDesc('view_count')->take($limit)->get(); return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [ 'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null, 'title' => $v->title, 'owner' => $v->username, 'views' => $v->view_count, 'likes' => $v->like_count, 'url' => route('videos.show', Video::encodeId($v->id)), ])]); case 'views_day': $items = \DB::table('video_views') ->join('videos', 'video_views.video_id', '=', 'videos.id') ->leftJoin('users', 'video_views.user_id', '=', 'users.id') ->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.country', 'video_views.watched_at') ->when($request->date, fn($q) => $q->whereDate('video_views.watched_at', $request->date)) ->orderByDesc('video_views.watched_at')->take($limit)->get(); return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [ 'video' => $v->title, 'viewer' => $v->viewer_name ?? 'Guest', 'country' => $v->country ?? '—', 'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(), 'url' => route('videos.show', Video::encodeId($v->video_id)), ])]); case 'likes': $items = \DB::table('videos') ->join('users', 'videos.user_id', '=', 'users.id') ->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username', \DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count')) ->having('like_count', '>', 0)->orderByDesc('like_count')->take($limit)->get(); return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [ 'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null, 'title' => $v->title, 'owner' => $v->username, 'views' => 0, 'likes' => $v->like_count, 'url' => route('videos.show', Video::encodeId($v->id)), ])]); case 'comments': $items = \DB::table('comments') ->join('users', 'comments.user_id', '=', 'users.id') ->join('videos', 'comments.video_id', '=', 'videos.id') ->select('comments.body', 'users.name as user_name', 'videos.title as video_title', 'videos.id as video_id', 'comments.created_at') ->orderByDesc('comments.created_at')->take($limit)->get(); return response()->json(['type' => 'comments', 'items' => $items->map(fn($c) => [ 'user' => $c->user_name, 'body' => \Str::limit($c->body, 120), 'video' => $c->video_title, 'time' => \Carbon\Carbon::parse($c->created_at)->diffForHumans(), 'url' => route('videos.show', Video::encodeId($c->video_id)), ])]); case 'country_viewers': $items = \DB::table('video_views') ->join('videos', 'video_views.video_id', '=', 'videos.id') ->leftJoin('users', 'video_views.user_id', '=', 'users.id') ->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.watched_at') ->where('video_views.country', $request->country) ->orderByDesc('video_views.watched_at')->take($limit)->get(); return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [ 'video' => $v->title, 'viewer' => $v->viewer_name ?? 'Guest', 'country' => $request->country, 'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(), 'url' => route('videos.show', Video::encodeId($v->video_id)), ])]); case 'uploaders': $items = \DB::table('users') ->select('users.id', 'users.name', 'users.avatar', \DB::raw('COUNT(videos.id) as video_count'), \DB::raw('COALESCE(SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)),0) as total_views')) ->leftJoin('videos', 'users.id', '=', 'videos.user_id') ->groupBy('users.id', 'users.name', 'users.avatar') ->orderByDesc('video_count')->take($limit)->get(); return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [ 'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id, 'name' => $u->name, 'videos' => $u->video_count, 'views' => $u->total_views, 'url' => route('channel', $u->channel), ])]); } return response()->json(['error' => 'Unknown resource'], 400); } public function logs(Request $request) { $logFile = storage_path('logs/laravel.log'); $lines = []; $filter = $request->get('filter', ''); $level = $request->get('level', ''); $limit = (int) $request->get('limit', 200); if (file_exists($logFile)) { $all = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); foreach ($all as $line) { if ($filter && stripos($line, $filter) === false) continue; if ($level && stripos($line, ".$level:") === false) continue; $lines[] = $line; if (count($lines) >= $limit) break; } } return view('admin.logs', compact('lines', 'filter', 'level', 'limit')); } public function auditLogs(Request $request) { $query = AuditLog::query()->latest('created_at'); if ($request->filled('action')) { $query->where('action', $request->action); } if ($request->filled('user')) { $query->where(function ($q) use ($request) { $q->where('user_name', 'like', '%'.$request->user.'%') ->orWhereHas('user', fn($u) => $u->where('email', 'like', '%'.$request->user.'%')); }); } if ($request->filled('ip')) { $query->where('ip_address', 'like', '%'.$request->ip.'%'); } if ($request->filled('subject')) { $query->where('subject_label', 'like', '%'.$request->subject.'%'); } if ($request->filled('date_from')) { $query->whereDate('created_at', '>=', $request->date_from); } if ($request->filled('date_to')) { $query->whereDate('created_at', '<=', $request->date_to); } $logs = $query->paginate(50)->withQueryString(); $actionTypes = AuditLog::query() ->select('action') ->distinct() ->orderBy('action') ->pluck('action'); return view('admin.audit-logs', compact('logs', 'actionTypes')); } // ── Settings ────────────────────────────────────────────────────────── public function settings() { $settings = [ 'gpu_enabled' => Setting::get('gpu_enabled', 'true'), 'gpu_device' => Setting::get('gpu_device', '0'), 'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'), 'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'), 'gpu_preset' => Setting::get('gpu_preset', 'p4'), 'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')), ]; $gpus = $this->probeGpus(); $nvencWorks = $this->probeNvenc(); return view('admin.settings', compact('settings', 'gpus', 'nvencWorks')); } public function updateSettings(Request $request) { $request->validate([ 'gpu_enabled' => 'required|in:true,false', 'gpu_device' => 'required|integer|min:0|max:15', 'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264', 'gpu_hwaccel' => 'required|in:cuda,none', 'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow', 'ffmpeg_binary' => 'required|string|max:255', ]); $binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg'; if (! file_exists($binary) || ! is_executable($binary)) { return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]); } Setting::set('gpu_enabled', $request->gpu_enabled); Setting::set('gpu_device', (string) $request->gpu_device); Setting::set('gpu_encoder', $request->gpu_encoder); Setting::set('gpu_hwaccel', $request->gpu_hwaccel); Setting::set('gpu_preset', $request->gpu_preset); Setting::set('ffmpeg_binary', $binary); return back()->with('success', 'Settings saved.'); } public function detectGpu() { return response()->json(['gpus' => $this->probeGpus()]); } private function probeGpus(): array { $gpus = []; exec( 'nvidia-smi --query-gpu=index,name,memory.total,memory.free,utilization.gpu,temperature.gpu,driver_version' . ' --format=csv,noheader,nounits 2>/dev/null', $lines, $exit ); if ($exit !== 0 || empty($lines)) return $gpus; foreach ($lines as $line) { $parts = array_map('trim', explode(',', $line)); if (count($parts) < 7) continue; $gpus[] = [ 'index' => (int) $parts[0], 'name' => $parts[1], 'mem_total' => (int) $parts[2], 'mem_free' => (int) $parts[3], 'util' => (int) $parts[4], 'temp' => (int) $parts[5], 'driver' => $parts[6], ]; } return $gpus; } /** * Quick smoke-test: encode one frame with h264_nvenc and return true if it succeeds. * This catches CUDA compat / driver-version mismatches that nvidia-smi can't detect. */ private function probeNvenc(): bool { $ffmpeg = Setting::ffmpegBinary(); $tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4'; $device = Setting::gpuDevice(); exec( escapeshellcmd($ffmpeg) . ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1' . " -c:v h264_nvenc -gpu {$device}" . ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null', $out, $exit ); $ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0); @unlink($tmp); return $ok; } public function nasStorage() { $nodes = config('nas-file-manager.schema', []); return view('admin.nas-storage', compact('nodes')); } }