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!'); } // Returns true if admin is already within the 30-min verified window private function adminIsVerified(): bool { $admin = auth()->user(); if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) { return true; } $verifiedAt = session('admin_2fa_verified_at'); return $verifiedAt && now()->timestamp - $verifiedAt < 1800; } // Validates OTP and stamps the session on success private function verify2fa(Request $request): bool { $admin = auth()->user(); if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) { return true; } if ($this->adminIsVerified()) { return true; } $code = $request->input('otp_code', ''); $google2fa = app('pragmarx.google2fa'); if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) { session(['admin_2fa_verified_at' => now()->timestamp]); return true; } return false; } // Delete user public function deleteUser(Request $request, User $user) { // Prevent deleting yourself if (auth()->id() === $user->id) { return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422); } if (! $this->verify2fa($request)) { return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422); } 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($video->path); if ($video->thumbnail) { Storage::delete($video->thumbnailStorageKey()); } } $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 response()->json(['success' => true, 'message' => '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', ]); $oldTitle = $video->title; $data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']); $video->update($data); if (($data['title'] ?? $oldTitle) !== $oldTitle) { try { $nas = app(\App\Services\NasSyncService::class); if ($nas->isEnabled()) { $nas->renameVideoDir($video->fresh()); } } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage()); } } return redirect()->route('admin.videos')->with('success', 'Video updated successfully!'); } // Delete video public function deleteVideo(Request $request, Video $video) { if (! $this->verify2fa($request)) { return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422); } $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($video->path); if ($video->thumbnail) { Storage::delete($video->thumbnailStorageKey()); } // 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 response()->json(['success' => true, 'message' => '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 = [ 'llm_enabled' => Setting::get('llm_enabled', 'false'), 'llm_clean_lyrics' => Setting::get('llm_clean_lyrics', 'true'), 'llm_decorate_lyrics' => Setting::get('llm_decorate_lyrics', 'false'), 'llm_providers' => json_decode((string) Setting::get('llm_providers', '[]'), true) ?: [], 'llm_active_id' => (string) Setting::get('llm_active_id', ''), ]; return view('admin.settings', compact('settings')); } /** * Settings save handler — accepts partial submissions from any of the * separated admin pages (GPU, NAS, Backup, AI/LLM). Only updates the keys * that appear in the request. */ public function updateSettings(Request $request) { // ── GPU section ────────────────────────────────────────────────────── if ($request->has('gpu_enabled')) { $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); Setting::flushGpuProbe(); } // ── Lyrics pipeline section ────────────────────────────────────────── if ($request->has('lyrics_section')) { foreach ([ 'lyrics_enabled', // master switch 'lyrics_use_description', // align to description text 'lyrics_vad_enabled', // Silero VAD filter 'lyrics_vocal_region_gapfill', // snap gap-filled lines to vocal regions 'lyrics_demucs_enabled', // vocal isolation (Demucs) 'lyrics_llm_decorate', // post-bake emojis via LLM ] as $k) { Setting::set($k, $request->input($k, 'false') === 'true' ? 'true' : 'false'); } } // ── AI / LLM section ───────────────────────────────────────────────── if ($request->has('llm_section')) { Setting::set('llm_enabled', $request->input('llm_enabled', 'false') === 'true' ? 'true' : 'false'); Setting::set('llm_clean_lyrics', $request->input('llm_clean_lyrics', 'false') === 'true' ? 'true' : 'false'); Setting::set('llm_decorate_lyrics', $request->input('llm_decorate_lyrics', 'false') === 'true' ? 'true' : 'false'); $this->saveLlmProviders($request); } return back()->with('success', 'Settings saved.'); } /** * Probe an LLM provider endpoint: verify the connection and list * available models. Used by the AI / LLM settings page. * * Accepts kind / endpoint / api_key from the form, plus an optional * provider id so we can fall back to the saved key when the admin * left the password field blank (placeholder ••••••••). */ public function llmProviderTest(Request $request) { $kind = (string) $request->input('kind', 'ollama'); $endpoint = trim((string) $request->input('endpoint', '')) ?: self::defaultEndpoint($kind); $endpoint = rtrim($endpoint, '/'); $apiKey = (string) $request->input('api_key', ''); $id = (string) $request->input('id', ''); if ($apiKey === '' && $id !== '') { $providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: []; foreach ($providers as $p) { if (($p['id'] ?? '') === $id) { $apiKey = (string) ($p['api_key'] ?? ''); break; } } } if (! in_array($kind, ['ollama', 'anthropic', 'openai'], true)) { return response()->json(['ok' => false, 'message' => 'Unknown provider kind.'], 422); } if ($kind !== 'ollama' && $apiKey === '') { return response()->json(['ok' => false, 'message' => 'API key required for ' . $kind . '.'], 422); } try { $models = match ($kind) { 'ollama' => $this->fetchOllamaModels($endpoint), 'anthropic' => $this->fetchAnthropicModels($endpoint, $apiKey), 'openai' => $this->fetchOpenAIModels($endpoint, $apiKey), }; } catch (\Throwable $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()]); } sort($models, SORT_NATURAL | SORT_FLAG_CASE); return response()->json([ 'ok' => true, 'count' => count($models), 'models' => $models, ]); } private function fetchOllamaModels(string $endpoint): array { $resp = \Illuminate\Support\Facades\Http::timeout(10)->acceptJson()->get($endpoint . '/api/tags'); if (! $resp->successful()) { throw new \RuntimeException('Ollama returned HTTP ' . $resp->status()); } $j = $resp->json(); return array_values(array_filter(array_map( fn ($m) => (string) ($m['name'] ?? ''), $j['models'] ?? [] ))); } private function fetchAnthropicModels(string $endpoint, string $apiKey): array { $resp = \Illuminate\Support\Facades\Http::timeout(15)->withHeaders([ 'x-api-key' => $apiKey, 'anthropic-version' => '2023-06-01', ])->get($endpoint . '/v1/models'); if (! $resp->successful()) { $body = $resp->json(); throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status())); } $j = $resp->json(); return array_values(array_filter(array_map( fn ($m) => (string) ($m['id'] ?? ''), $j['data'] ?? [] ))); } private function fetchOpenAIModels(string $endpoint, string $apiKey): array { $resp = \Illuminate\Support\Facades\Http::timeout(15) ->withToken($apiKey)->acceptJson() ->get($endpoint . '/v1/models'); if (! $resp->successful()) { $body = $resp->json(); throw new \RuntimeException($body['error']['message'] ?? ('HTTP ' . $resp->status())); } $j = $resp->json(); return array_values(array_filter(array_map( fn ($m) => (string) ($m['id'] ?? ''), $j['data'] ?? [] ))); } public function lyrics() { $settings = [ 'lyrics_enabled' => Setting::get('lyrics_enabled', 'true'), 'lyrics_use_description' => Setting::get('lyrics_use_description', 'true'), 'lyrics_vad_enabled' => Setting::get('lyrics_vad_enabled', 'true'), 'lyrics_vocal_region_gapfill' => Setting::get('lyrics_vocal_region_gapfill', 'true'), 'lyrics_demucs_enabled' => Setting::get('lyrics_demucs_enabled', 'false'), 'lyrics_llm_decorate' => Setting::get('lyrics_llm_decorate', Setting::get('llm_decorate_lyrics', 'false')), ]; return view('admin.lyrics', compact('settings')); } public function gpu() { $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.gpu', compact('settings', 'gpus', 'nvencWorks')); } public function backup() { return view('admin.backup'); } public function detectGpu() { return response()->json(['gpus' => $this->probeGpus()]); } /** * Persist the LLM provider list from the multi-provider form. Each row * carries id / name / kind (ollama|anthropic|openai) / endpoint / model / * api_key. An empty api_key means "keep the previously stored value" so the * admin doesn't have to retype it on every save. */ private function saveLlmProviders(Request $request): void { $existing = collect(json_decode((string) Setting::get('llm_providers', '[]'), true) ?: []) ->keyBy(fn ($p) => $p['id'] ?? ''); $kinds = ['ollama', 'anthropic', 'openai']; $rows = (array) $request->input('providers', []); $out = []; foreach ($rows as $row) { $name = trim((string) ($row['name'] ?? '')); $kind = (string) ($row['kind'] ?? 'ollama'); if (! in_array($kind, $kinds, true)) $kind = 'ollama'; if ($name === '') continue; $id = (string) ($row['id'] ?? '') ?: (string) \Illuminate\Support\Str::uuid(); $endpoint = trim((string) ($row['endpoint'] ?? '')) ?: self::defaultEndpoint($kind); $model = trim((string) ($row['model'] ?? '')); $apiKeyIn = (string) ($row['api_key'] ?? ''); // Blank input → keep the previously-stored key for this id (admin // didn't retype it). Non-blank → use the new value verbatim. $apiKey = $apiKeyIn !== '' ? $apiKeyIn : (string) ($existing[$id]['api_key'] ?? ''); $out[] = [ 'id' => $id, 'name' => $name, 'kind' => $kind, 'endpoint' => $endpoint, 'model' => $model, 'api_key' => $apiKey, ]; } Setting::set('llm_providers', json_encode($out, JSON_UNESCAPED_UNICODE)); $activeId = trim((string) $request->input('llm_active_id', '')); $validIds = array_column($out, 'id'); if ($activeId !== '' && in_array($activeId, $validIds, true)) { Setting::set('llm_active_id', $activeId); } elseif (count($validIds) === 1) { Setting::set('llm_active_id', $validIds[0]); } elseif (! in_array((string) Setting::get('llm_active_id', ''), $validIds, true)) { Setting::set('llm_active_id', ''); } } private static function defaultEndpoint(string $kind): string { return match ($kind) { 'anthropic' => 'https://api.anthropic.com', 'openai' => 'https://api.openai.com', default => 'http://localhost:11434', }; } 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 { // Single source of truth lives on the Setting model; force the NVENC encoder so the // admin indicator always reflects GPU capability regardless of the configured encoder. return Setting::probeGpu('h264_nvenc'); } public function nasStorage() { $nodes = config('nas-file-manager.schema', []); $settings = [ 'nas_sync_enabled' => Setting::get('nas_sync_enabled', 'false'), ]; return view('admin.nas-storage', compact('nodes', 'settings')); } public function nasDelete(Request $request) { $path = trim($request->input('path', '')); $type = $request->input('type', 'dir'); if ($path === '') { return response()->json(['success' => false, 'message' => 'Path is required.'], 422); } $nas = app(\App\Services\NasSyncService::class); if (! $nas->isEnabled()) { return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422); } try { if ($type === 'dir') { $nas->deleteNasTree($path); } else { $nas->deleteFile($path); } return response()->json(['success' => true]); } catch (\Throwable $e) { return response()->json(['success' => false, 'message' => $e->getMessage()]); } } public function nasRepair(Request $request) { $nas = app(\App\Services\NasSyncService::class); if (! $nas->isEnabled()) { return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422); } // ── Collect stuck items ─────────────────────────────────────────────── $stuckVideos = $this->collectStuckVideos(); $stuckAvatars = $this->collectStuckAvatars(); $stuckBanners = $this->collectStuckBanners(); $stuckThumbs = $this->collectStuckLegacyThumbnails(); $nasOrphans = $nas->scanNasOrphans(); $totalStuck = $stuckVideos->count() + $stuckAvatars->count() + $stuckBanners->count() + $stuckThumbs->count() + count($nasOrphans); // Scan-only mode ─────────────────────────────────────────────────────── if ($request->boolean('scan_only')) { $details = []; foreach ($stuckVideos as $item) { $details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']); } foreach ($stuckAvatars as $item) { $details[] = "[avatar] {$item['user']->username}: {$item['file']}"; } foreach ($stuckBanners as $item) { $details[] = "[banner] {$item['user']->username}: {$item['file']}"; } foreach ($stuckThumbs as $item) { $details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})"; } foreach ($nasOrphans as $orphan) { $label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json'; $details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)"; } $cacheBytes = $nas->nasCacheSize(); if ($cacheBytes > 0) { $cacheMb = round($cacheBytes / 1048576, 1); $details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)"; $totalStuck++; } return response()->json(['stuck' => $totalStuck, 'details' => $details]); } // Repair mode ───────────────────────────────────────────────────────── $repaired = 0; $failed = 0; $details = []; foreach ($stuckVideos as $item) { $video = $item['video']; try { $nas->syncVideo($video); $nas->deleteLocalAssets($video); if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video); $nas->pruneLocalVideoDir($video); $repaired++; $details[] = "✓ [video] #{$video->id}: {$video->title}"; \Log::info("nas:repair: fixed video #{$video->id}"); } catch (\Throwable $e) { $failed++; $details[] = "✗ [video] #{$video->id}: {$e->getMessage()}"; \Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage()); } } foreach ($stuckAvatars as $item) { try { $nas->syncAvatar($item['user'], $item['path']); $nas->deleteLocalAvatar($item['user']); $repaired++; $details[] = "✓ [avatar] {$item['user']->username}"; } catch (\Throwable $e) { $failed++; $details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}"; \Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage()); } } foreach ($stuckBanners as $item) { try { $nas->syncCover($item['user'], $item['path']); $nas->deleteLocalBanner($item['user']); $repaired++; $details[] = "✓ [banner] {$item['user']->username}"; } catch (\Throwable $e) { $failed++; $details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}"; \Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage()); } } foreach ($stuckThumbs as $item) { try { if ($item['type'] === 'thumbnail' && $item['video']) { $nas->syncVideo($item['video']); } elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) { $dir = $nas->resolveVideoDir($item['video']); $ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg'; $nas->mkdirp("{$dir}/slides"); $nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}"); } @unlink($item['path']); $repaired++; $details[] = "✓ [{$item['type']}] {$item['file']}"; } catch (\Throwable $e) { $failed++; $details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}"; \Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage()); } } // Delete NAS orphan folders foreach ($nasOrphans as $orphan) { try { $nas->deleteNasTree($orphan['dir']); $repaired++; $details[] = "✓ [nas-orphan] deleted {$orphan['dir']}"; \Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]); } catch (\Throwable $e) { $failed++; $details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}"; \Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]); } } // Evict NAS stream cache (24h TTL by default) $evicted = $nas->clearNasCache(24); if ($evicted > 0) { $details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)"; $repaired += $evicted; } $this->pruneLocalStorageDirs(); if ($totalStuck === 0) { return response()->json([ 'success' => true, 'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.', 'repaired' => 0, 'failed' => 0, 'details' => [], ]); } return response()->json([ 'success' => $failed === 0, 'message' => $failed === 0 ? "Repaired {$repaired} item(s) successfully." : "Repaired {$repaired}, failed {$failed} — check logs.", 'repaired' => $repaired, 'failed' => $failed, 'details' => $details, ]); } private function collectStuckVideos(): \Illuminate\Support\Collection { return \App\Models\Video::with(['user', 'slides'])->get() ->filter(fn ($v) => str_starts_with($v->path, 'users/')) ->map(function ($video) { $files = []; if (file_exists(storage_path('app/' . $video->path))) $files[] = basename($video->path) . ' (video)'; if ($video->thumbnail && str_contains($video->thumbnail, '/') && file_exists(storage_path('app/' . $video->thumbnail))) $files[] = basename($video->thumbnail) . ' (thumbnail)'; foreach ($video->slides as $slide) { if (file_exists($slide->localPath())) $files[] = basename($slide->filename) . " (slide #{$slide->position})"; } return $files ? ['video' => $video, 'files' => $files] : null; }) ->filter(); } private function collectStuckAvatars(): \Illuminate\Support\Collection { $results = collect(); // Legacy flat dir $dir = storage_path('app/public/avatars'); if (is_dir($dir)) { $flat = collect(scandir($dir) ?: []) ->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}")) ->map(function ($filename) use ($dir) { $user = \App\Models\User::where('avatar', $filename)->first(); return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null; }) ->filter(); $results = $results->merge($flat); } // New structured dir: users/{slug}/profile/avatar.* $usersBase = storage_path('app/users'); if (is_dir($usersBase)) { foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) { $relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/'); $user = \App\Models\User::where('avatar', $relPath)->first(); if ($user) { $results->push(['user' => $user, 'file' => basename($path), 'path' => $path]); } } } return $results; } private function collectStuckBanners(): \Illuminate\Support\Collection { $results = collect(); // Legacy flat dir $dir = storage_path('app/public/banners'); if (is_dir($dir)) { $flat = collect(scandir($dir) ?: []) ->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}")) ->map(function ($filename) use ($dir) { $user = \App\Models\User::where('banner', $filename)->first(); return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null; }) ->filter(); $results = $results->merge($flat); } // New structured dir: users/{slug}/profile/cover.* $usersBase = storage_path('app/users'); if (is_dir($usersBase)) { foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) { $relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/'); $user = \App\Models\User::where('banner', $relPath)->first(); if ($user) { $results->push(['user' => $user, 'file' => basename($path), 'path' => $path]); } } } return $results; } private function collectStuckLegacyThumbnails(): \Illuminate\Support\Collection { $dir = storage_path('app/public/thumbnails'); if (! is_dir($dir)) return collect(); $results = []; foreach (scandir($dir) ?: [] as $filename) { if ($filename === '.' || $filename === '..') continue; $path = "{$dir}/{$filename}"; if (! is_file($path)) continue; $video = \App\Models\Video::where('thumbnail', $filename)->first(); if ($video) { $results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null]; continue; } $slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first(); if ($slide && $slide->video) { $results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide]; } } return collect($results); } private function pruneLocalStorageDirs(): void { // NAS-mirrored tree $nasRoot = storage_path('app/users'); if (is_dir($nasRoot)) { $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($nasRoot, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iter as $item) { if (! $item->isDir()) continue; $path = $item->getPathname(); $contents = array_diff(scandir($path) ?: [], ['.', '..']); $nonMeta = array_diff($contents, ['meta.json']); if (empty($contents)) { @rmdir($path); } elseif (empty($nonMeta)) { @unlink("{$path}/meta.json"); @rmdir($path); } } } // Flat asset dirs — remove if empty foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) { $path = storage_path("app/{$rel}"); if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) { @rmdir($path); } } } // ── NAS Disable Flow ────────────────────────────────────────────────── public function nasDisable(Request $request) { $mode = $request->input('mode'); // 'migrate' or 'fresh' if ($mode === 'migrate') { // Reset progress cache, dispatch job \Cache::put('nas_disable_progress', json_encode([ 'current' => 0, 'total' => 0, 'phase' => 'Starting...', 'done' => false, 'error' => null, ]), 3600); \App\Jobs\NasToLocalMigrationJob::dispatch() ->onQueue('video-processing') ->onConnection('database'); return response()->json(['ok' => true]); } if ($mode === 'fresh') { // Truncate all media tables, reset user avatars/banners, disable NAS $tables = [ 'videos','video_slides','video_likes','video_views','video_shares', 'video_downloads','playlist_videos','playlists','comments','comment_likes', 'posts','post_images','post_reactions','post_videos', 'coach_reviews','match_rounds','match_points', 'share_accesses','playlist_share_accesses','notifications', ]; foreach ($tables as $t) { \DB::table($t)->delete(); } \DB::table('users')->update(['avatar' => null, 'banner' => null]); Setting::set('nas_sync_enabled', 'false'); app(\App\Services\NasSyncService::class)->flushReachabilityCache(); AuditLog::record('admin.nas_disabled_fresh'); return response()->json(['ok' => true]); } return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422); } public function nasMigrateProgress() { $raw = \Cache::get('nas_disable_progress'); if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']); return response()->json(json_decode($raw, true)); } public function backupUsersSettings() { $users = \DB::table('users')->get()->map(function ($u) { return (array) $u; })->toArray(); $settings = \DB::table('settings')->get()->map(function ($s) { return (array) $s; })->toArray(); $payload = json_encode([ 'version' => '1.0', 'exported_at' => now()->toIso8601String(), 'users' => $users, 'settings' => $settings, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); return response($payload, 200, [ 'Content-Type' => 'application/json', 'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"', ]); } public function restoreUsersSettings(Request $request) { $request->validate(['backup' => 'required|file|mimes:json|max:10240']); $content = file_get_contents($request->file('backup')->getRealPath()); $data = json_decode($content, true); if (! isset($data['users']) || ! isset($data['settings'])) { return back()->with('toast_error', 'Invalid backup file.'); } // Restore settings foreach ($data['settings'] as $row) { \DB::table('settings')->updateOrInsert( ['key' => $row['key']], ['key' => $row['key'], 'value' => $row['value']] ); } // Restore users (upsert by email) $restored = 0; foreach ($data['users'] as $row) { unset($row['id']); // let DB assign new IDs to avoid PK conflicts \DB::table('users')->updateOrInsert( ['email' => $row['email']], $row ); $restored++; } AuditLog::record('admin.backup_restored', ['users' => $restored]); return back()->with('toast_success', "Backup restored: {$restored} users + settings."); } }